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); + }); +});