diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts
index 5bcd579df9f..3a46c3ebbac 100644
--- a/src/adapters/openai-chat/serialized-tool-call-content.ts
+++ b/src/adapters/openai-chat/serialized-tool-call-content.ts
@@ -32,25 +32,69 @@ export interface StructuredToolCallReference {
argumentsText: string;
}
+const BLOCK_HEADER = /\s*\r\n]+)>/y;
+/** A separate bare block starts a line (see `splitAtPossibleSerializedToolCall`); a header mid-line is body text. */
+const NEXT_BLOCK_HEADER = /\n\s*\r\n]+>/g;
+const FUNCTION_CLOSE = "";
+const PARAMETER_CLOSE = "";
+
+function trimmedEnd(text: string, from: number, to: number): number {
+ while (to > from && /\s/.test(text[to - 1]!)) to--;
+ return to;
+}
+
+function endsWithAt(text: string, from: number, to: number, suffix: string): boolean {
+ return to - suffix.length >= from && text.startsWith(suffix, to - suffix.length);
+}
+
+/**
+ * The block starting at `offset`, read by delimiter scan so an unterminated block costs linear time.
+ * MiMo's echo may close a freeform body with a stray `` and may omit ``
+ * (#5724), the grammar the Command Code reader accepts too. The first `` preceded by
+ * `` closes the block, so a body can still carry a literal `` or header;
+ * with none before the next line-start block header, the first `` does. That header only
+ * bounds an unclosed candidate: with no close at all before it, it is body text, and a closed
+ * `` after it still ends the block.
+ */
+function blockAt(text: string, offset: number): SerializedToolCall | undefined {
+ BLOCK_HEADER.lastIndex = offset;
+ const header = BLOCK_HEADER.exec(text);
+ if (!header) return undefined;
+ const bodyStart = offset + header[0].length;
+ NEXT_BLOCK_HEADER.lastIndex = bodyStart;
+ const next = NEXT_BLOCK_HEADER.exec(text);
+ const limit = next ? next.index + 1 : text.length;
+ let unclosed: SerializedToolCall | undefined;
+ for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && (close < limit || !unclosed);
+ close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) {
+ let bodyEnd = trimmedEnd(text, bodyStart, close);
+ const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE);
+ if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length);
+ if (endsWithAt(text, bodyStart, bodyEnd, PARAMETER_CLOSE)) bodyEnd -= PARAMETER_CLOSE.length;
+ const call = {
+ name: header[1]!.trim(),
+ body: text.slice(bodyStart, bodyEnd),
+ start: offset,
+ end: close + CLOSE_TAG.length,
+ };
+ if (closed) return call;
+ if (close < limit) unclosed ??= call;
+ }
+ return unclosed;
+}
+
/** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */
function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] {
- const pattern = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y;
const calls: SerializedToolCall[] = [];
let offset = 0;
while (offset < text.length) {
const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true);
offset += split.emit.length;
if (!split.hasOpenTag) break;
- pattern.lastIndex = offset;
- const match = pattern.exec(text);
+ const match = blockAt(text, offset);
if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call.
- calls.push({
- name: match[1]!.trim(),
- body: match[2]!,
- start: match.index,
- end: match.index + match[0].length,
- });
- offset = pattern.lastIndex;
+ calls.push(match);
+ offset = match.end;
context = { fence: null, lineStart: false };
}
return calls;
@@ -279,6 +323,11 @@ function inputFromArguments(argumentsText: string): string | undefined {
}
}
+/** One wrapping newline after the function header is template layout, not input (vLLM `_trim_wrapping_newlines`). */
+function freeformBody(value: string): string {
+ return value.replace(/^\r?\n/, "").trimEnd();
+}
+
/** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */
function duplicatedSerializedToolCallRanges(
text: string,
@@ -287,9 +336,11 @@ function duplicatedSerializedToolCallRanges(
): { start: number; end: number }[] {
if (structuredCalls.length === 0) return [];
return callsIn(text, context).filter(call => {
- const body = call.body.trimEnd();
- return structuredCalls.some(structured =>
- structured.names.has(call.name) && inputFromArguments(structured.argumentsText)?.trimEnd() === body);
+ const body = freeformBody(call.body);
+ return structuredCalls.some(structured => {
+ const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText) : undefined;
+ return input !== undefined && freeformBody(input) === body;
+ });
});
}
diff --git a/structure/decisions/ADR-5724-serialized-tool-call-content.md b/structure/decisions/ADR-5724-serialized-tool-call-content.md
new file mode 100644
index 00000000000..34bb5bf1079
--- /dev/null
+++ b/structure/decisions/ADR-5724-serialized-tool-call-content.md
@@ -0,0 +1,13 @@
+# ADR-5724 — decision recorded under "Serialized tool-call content"
+
+- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#serialized-tool-call-content)
+
+## Decision record
+
+- Intent: Remove MiMo's duplicated tool-call echo on OpenAI Chat routes when it arrives in the shapes the model actually emits, not only the canonical one (#5724).
+- Prior constraint: ADR-5548 suppresses a block only when its function name and freeform body agree with a structured call in the same response; mismatched markup stays byte-exact.
+- Observed shapes: MiMo's echo can omit `` (`BODY`) and can put a template newline after the function header. The Command Code reader already accepts both (#5637); the Chat reconciler did not, so those echoes stayed visible beside the call that ran.
+- Alternatives considered: Keep two regular expressions and try the closed form first (backtracks quadratically on a long unterminated body, and rejecting a closed match on any inner `` hides a body that merely contains that string); drop anything shaped like a tool call (discards real text); restore calls from markup when no structured call exists (a new behaviour this route has no evidence for).
+- Choice: Read each block by delimiter scan. The first `` preceded by `` closes the block; if none appears before the next block header at the start of a line (`` followed by `` does. A stray `` before the close is markup, and one leading newline in the body is template layout.
+- Why: It accepts the same grammar on both MiMo routes, keeps a body that contains literal tool-call tags (even a full header) intact, and costs linear time. The agreement rule from ADR-5548 is unchanged, so no new text can disappear without a matching structured call.
+- Consequences: The two echo shapes are removed when they duplicate a structured call, streamed and buffered. Markup with no structured call is still shown and still runs nothing.
diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md
index b6ebdcef87e..46cb7064238 100644
--- a/structure/providers/chat-compat.md
+++ b/structure/providers/chat-compat.md
@@ -325,7 +325,13 @@ entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bar
start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged.
It holds a possible serialized block, resumes ordinary text delivery when the header cannot match,
and removes the block only when its function name and
-freeform body match a structured call's parsed `input` in the same response. If the gateway also prefixes the structured call's JSON
+freeform body match a structured call's parsed `input` in the same response.
+A block may close a freeform body with a stray `` and may omit ``, and one
+newline after the function header is template layout, so MiMo's echoes of those shapes match too
+(#5724). Blocks are read by delimiter scan in linear time: the first `` preceded by
+`` closes the block, and only when none appears before the next block header at the start
+of a line does the first `` close it, so a body can still carry literal tool-call tags.
+If the gateway also prefixes the structured call's JSON
arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body,
prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact.
Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures
@@ -339,6 +345,7 @@ matching and repair rules; regression coverage enters through `/v1/responses` in
`tests/responses/responses-chat-tool-call-content.test.ts`.
> Decision record: [ADR-5548](../decisions/ADR-5548-serialized-tool-call-content.md)
+> Decision record: [ADR-5724](../decisions/ADR-5724-serialized-tool-call-content.md)
## Kimi Coding Plan prompt-cache affinity
diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts
index e1e64287dfb..7faf3a8572b 100644
--- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts
+++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts
@@ -1,7 +1,8 @@
-import { expect, test } from "bun:test";
+import { describe, expect, test } from "bun:test";
import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat";
import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content";
-import { createTestTranslatorBudget } from "../../helpers/translator-budget";
+import type { AdapterEvent } from "../../../src/types";
+import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget";
const provider = { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", apiKey: "key" } as const;
@@ -61,3 +62,75 @@ test("an open serialized block charges only its appended bytes", () => {
expect(buffer.flush([])).toBe(open + body);
expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 0 });
});
+describe("MiMo echo variants (#5724)", () => {
+ const script = 'const r = await tools.exec_command({cmd:"Get-Content a.txt"}); text(r.output);';
+ const call = (input: string) => ({ index: 0, id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input }) } });
+
+ async function streamed(content: string, input: string): Promise {
+ const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider));
+ adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } });
+ const frames = [
+ { choices: [{ delta: { content: content.slice(0, 30) } }] },
+ { choices: [{ delta: { content: content.slice(30) } }] },
+ { choices: [{ delta: { tool_calls: [call(input)] } }] },
+ { choices: [{ delta: {}, finish_reason: "tool_calls" }] },
+ ];
+ const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n";
+ const events: AdapterEvent[] = [];
+ for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event);
+ return events;
+ }
+ async function buffered(content: string, input: string): Promise {
+ return createOpenAIChatAdapter(provider).parseResponse!(Response.json({
+ choices: [{ message: { content, tool_calls: [call(input)] }, finish_reason: "tool_calls" }],
+ }), createTestTranslatorBudget());
+ }
+ const visible = (events: AdapterEvent[]): string => events
+ .map(event => (event.type === "text_delta" ? event.text : ""))
+ .join("");
+
+ test.each([
+ ["the header is followed by a template newline", `\n${script}\n`],
+ ["the echo omits ", `${script}`],
+ ])("a matching block is removed when %s", async (_label, block) => {
+ for (const events of [await streamed(`Reading.\n${block}`, script), await buffered(`Reading.\n${block}`, script)]) {
+ expect(visible(events)).toBe("Reading.\n");
+ expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(1);
+ }
+ });
+
+ test("an unclosed block with a different body stays visible", async () => {
+ const block = "text('other');";
+ for (const events of [await streamed(block, script), await buffered(block, script)]) {
+ expect(visible(events)).toBe(block);
+ }
+ });
+
+ test("a closed block whose body carries literal tool-call tags is still matched whole", async () => {
+ for (const input of [
+ "text('');",
+ "text('');",
+ 'text("");',
+ 'const s = `\n`;\ntext(s);',
+ ]) {
+ const block = `${input}`;
+ for (const events of [await streamed(block, input), await buffered(block, input)]) {
+ expect(visible(events)).toBe("");
+ }
+ }
+ });
+
+ test("an unclosed block followed by a closed block is read as two blocks", async () => {
+ const first = "text('a');";
+ const second = "text('b');";
+ const content = `${first}\n${second}`;
+ const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({
+ choices: [{
+ message: { content, tool_calls: [call(first), { ...call(second), index: 1, id: "call_exec_2" }] },
+ finish_reason: "tool_calls",
+ }],
+ }), createTestTranslatorBudget());
+ expect(visible(events)).toBe("\n");
+ expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(2);
+ });
+});