diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 2badf4772f1..0703a334ab4 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -47,7 +47,7 @@ import { messagesToChatFormat } from "./openai-chat/messages"; import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; -import { freeformToolsByWireName, type FreeformToolIdentity, reconcileSerializedToolCallEvents, reconcileStructuredToolCall, reconcileStructuredToolCalls, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content"; +import { containsSerializedToolCallBlock, freeformToolsByWireName, type FreeformToolIdentity, reconcileSerializedToolCallEvents, reconcileStructuredToolCall, reconcileStructuredToolCalls, recoverStandaloneBareBlocks, SerializedToolCallContentBuffer, stripStandaloneBareBlocks } from "./openai-chat/serialized-tool-call-content"; export { stripBracketedModelSuffix } from "./openai-chat/wire"; export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; @@ -301,6 +301,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } const pendingToolCalls: PendingToolCall[] = []; const toolCallContent = new SerializedToolCallContentBuffer(budget); + toolCallContent.holdMidLine = /(?:^|[/-])mimo-v2\.6-pro(?:\.|$)/i.test(lastRequestedModelId ?? ""); const heldText = (): AdapterEvent[] => toolCallContent.drain([]); let toolCallSeq = 0; const closeToolCalls = (): PendingToolCall[] => { @@ -338,6 +339,46 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // Held markup is released only now, as one batch per response: the doubled-input repair needs every call. const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current(), !toolCallContent.releasedAnswerText && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? "")); calls.forEach((call, index) => { call.args = references[index]!.argumentsText; }); + // MiMo can put a whole freeform call only in the visible text and never send the + // structured `delta.tool_calls` payload (#7214 follow-up). Recovered bare blocks + // that name a declared freeform tool become synthetic calls so the client executes + // one call instead of rendering raw markup. + if (calls.length === 0 && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? "")) { + const recovered = recoverStandaloneBareBlocks(toolCallContent.current(), freeformTools); + if (recovered.length > 0) { + const syntheticReferences = recovered.map(block => ({ + names: new Set([block.name]), + argumentsText: JSON.stringify({ input: block.input }), + freeformTool: freeformTools.get(block.name), + })); + yield* toolCallContent.drain(syntheticReferences); + for (const block of recovered) { + yield { type: "tool_call_start", id: `call_${++toolCallSeq}`, name: toolNames.restore(block.name) }; + yield { type: "tool_call_delta", arguments: JSON.stringify({ input: block.input }) }; + yield { type: "tool_call_end" }; + } + return "continue"; + } + // Codex Desktop harness sessions declare no tools and send no structured + // payload; the client-side harness executes these blocks. The proxy's job is + // only to keep the markup out of the visible answer text. + // Also covers the one-declared-tool case: MiMo declares exec as freeform but + // sends the whole call only in the visible text, mid-line. + { + const heldText = toolCallContent.current(); + const stripped = stripStandaloneBareBlocks(heldText); + if (stripped !== heldText) { + const events = toolCallContent.drain([]); + for (const event of events) { + if (event.type === "text_delta") { + const clean = stripStandaloneBareBlocks(event.text); + if (clean.length > 0) yield { type: "text_delta", text: clean }; + } else yield event; + } + return "continue"; + } + } + } yield* toolCallContent.drain(references); for (const call of calls) { if (!call.id) call.id = `call_${++toolCallSeq}`; @@ -351,7 +392,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd event: Extract, ): Generator { closeToolCalls(); - yield* heldText(); // Pending tools are not dispatched, so held text stays visible. + // Pending tools are not dispatched, so held text stays visible — but a MiMo echo + // block that exactly matches one of those undelivered calls is still duplicate + // markup, not answer text. Reconcile against the closed calls before releasing. + const closedCalls = [...pendingToolCalls]; + const references = reconcileStructuredToolCalls(closedCalls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current(), false); + yield* toolCallContent.drain(references); yield event; return "terminate"; }; @@ -802,6 +848,35 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }, delta, { type: "tool_call_end" }); } } + // Same MiMo standalone-bare-block recovery as the streamed path: with no structured + // payload, qualifying blocks become synthetic calls so the markup is suppressed once. + if (structuredCalls.length === 0 && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? "")) { + const recovered = recoverStandaloneBareBlocks(answerText, freeformTools); + if (recovered.length > 0) { + recovered.forEach((block, blockIndex) => { + const id = `call_${blockIndex + 1}`; + const delta: Extract = { type: "tool_call_delta", arguments: JSON.stringify({ input: block.input }) }; + structuredCalls.push({ wireName: block.name, restoredName: toolNames.restore(block.name), argumentsText: delta.arguments, freeformTool: freeformTools.get(block.name), delta }); + events.push({ type: "tool_call_start", id, name: toolNames.restore(block.name) }, delta, { type: "tool_call_end" }); + }); + } + // Same no-tools harness path as the streamed adapter: strip the bare markup + // from the visible text events when nothing else claimed it. + if (freeformTools.size === 0) { + const stripped = stripStandaloneBareBlocks(answerText); + if (stripped !== answerText) { + const replacement: AdapterEvent[] = []; + for (let i = contentStart; i < contentEnd; i++) { + const event = events[i]!; + if (event.type === "text_delta") { + const clean = stripStandaloneBareBlocks(event.text); + if (clean.length > 0) replacement.push({ type: "text_delta", text: clean }); + } else replacement.push(event); + } + events.splice(contentStart, contentEnd - contentStart, ...replacement); + } + } + } const references = reconcileStructuredToolCalls(structuredCalls, answerText, /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? "")); structuredCalls.forEach((call, index) => { call.delta.arguments = references[index]!.argumentsText; }); reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget); diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 48a7bca52e2..552e4648892 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -5,6 +5,10 @@ import { repairFreeformToolInput } from "../../responses/apply-patch-envelope"; const OPEN_TAG = ""; const CLOSE_TAG = ""; const FUNCTION_TAG = "?/; /** * Streaming bounds (ingestStreaming only; buffered reconciliation already knows its structured calls). * MAX_HELD_BYTES is a runaway guard on held text plus queued events, far below the translator budget, @@ -42,6 +46,128 @@ export function freeformToolsByWireName tool.freeform).map(tool => [wireName(tool), { name: tool.name, namespace: tool.namespace }] as const) ?? []); } +/** One bare block recovered as an executable call when MiMo sends no structured payload. */ +export interface RecoveredBareBlock { + name: string; + input: string; +} + +/** + * Bare blocks a declared freeform tool can own, for the observed MiMo shape where the + * whole call lives only in the visible text and `delta.tool_calls` never arrives. + * A block qualifies only when its function name matches a declared freeform tool's + * wire name and its body survives the same safety rules as the empty-input recovery: + * non-empty, no nested parameter wrappers, no nested block headers. Fenced examples, + * prose-only text, and blocks naming undeclared tools return nothing, so ordinary + * commentary cannot lose bytes. + */ +export function recoverStandaloneBareBlocks( + text: string, + freeformTools: ReadonlyMap, + context: TextContext = { fence: null, lineStart: true }, +): RecoveredBareBlock[] { + if (freeformTools.size === 0) return []; + return callsIn(text, context).flatMap(call => { + if (!freeformTools.has(call.name)) return []; + const wrappedBody = freeformBody(call.body); + const hasParameterOpener = wrappedBody.startsWith("/.test(wrappedBody)) + || body.includes("")) return []; + return [{ name: call.name, input: unwrappedBody }]; + }); +} + +/** + * The ranges of bare serialized blocks to hide from visible text when no structured + * payload and no declared freeform tools exist (the Codex Desktop harness path: the + * client-side harness parses and executes these blocks, so the proxy must only keep + * them out of the assistant's visible answer). Used by the adapter's flush paths when + * the routed model is a dotted MiMo V2 ID and nothing else claimed the markup. + */ +export function standaloneBareBlockRanges( + text: string, + context: TextContext = { fence: null, lineStart: true }, +): { start: number; end: number }[] { + return [...callsIn(text, context), ...midLineCallsIn(text, context)] + .map(call => ({ start: call.start, end: call.end })); +} + +/** Removes bare serialized blocks from visible text when no structured call claims them. */ +export function stripStandaloneBareBlocks( + text: string, + context: TextContext = { fence: null, lineStart: true }, +): string { + let result = ""; + let cursor = 0; + for (const range of standaloneBareBlockRanges(text, context)) { + result += text.slice(cursor, range.start); + cursor = range.end; + } + return result + text.slice(cursor); +} + +/** True when text carries a serialized tool-call block (used as an emission gate). */ +export function containsSerializedToolCallBlock(text: string): boolean { + return /\s* { + const split = splitAtPossibleSerializedToolCall(text, initialContext, final); + if (split.hasOpenTag) return split; + // Find the first mid-line header; skip when the text carries an open fence/code span. + const MID_LINE_HEADER = /\s*\r\n]+>/g; + for (let m = MID_LINE_HEADER.exec(text); m !== null; m = MID_LINE_HEADER.exec(text)) { + const at = m.index; + // Hold from the header unless the prose before it leaves a fence or inline-code span + // open (fenced examples are user-visible, not control markup). + const fenceCheck = splitAtPossibleSerializedToolCall(text.slice(0, at).replaceAll(OPEN_TAG, ""), { fence: initialContext.fence, lineStart: initialContext.lineStart, inlineTicks: initialContext.inlineTicks }, true); + if (fenceCheck.context.fence !== null || fenceCheck.context.inlineTicks !== undefined) continue; + return { emit: text.slice(0, at), defer: text.slice(at), hasOpenTag: true, context: { fence: null, lineStart: false } }; + } + // A partial `` prefix at the end of the chunk must also be held so the + // complete header can be recognized when the next delta arrives. + if (!final) { + for (let cut = Math.min(text.length, OPEN_TAG.length); cut > 0; cut--) { + if (text.endsWith(OPEN_TAG.slice(0, cut))) { + // Only hold the partial when the preceding text is not inside a fence. + const before = text.slice(0, text.length - cut); + const fenceCheck = splitAtPossibleSerializedToolCall(before, initialContext, true); + if (fenceCheck.context.fence !== null || fenceCheck.context.inlineTicks !== undefined) break; + return { emit: text.slice(0, text.length - cut), defer: OPEN_TAG.slice(0, cut), hasOpenTag: false, context: fenceCheck.context }; + } + } + // Same for a partial header after a complete `` opener. + const lastOpen = text.lastIndexOf(OPEN_TAG); + if (lastOpen >= 0) { + const afterOpen = text.slice(lastOpen + OPEN_TAG.length); + if (afterOpen.length > 0 && !afterOpen.includes("\n") + && (FUNCTION_TAG.startsWith(afterOpen.trimStart()) + || (afterOpen.trimStart().startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(afterOpen.trimStart().slice(FUNCTION_TAG.length))))) { + return { emit: text.slice(0, lastOpen), defer: text.slice(lastOpen), hasOpenTag: false, context: { fence: null, lineStart: false } }; + } + } + } + return split; +} + export interface StructuredToolCallReference { names: ReadonlySet; argumentsText: string; @@ -54,6 +180,7 @@ const BLOCK_HEADER = /\s*\r\n]+)>/y; const NEXT_BLOCK_HEADER = /\n\s*\r\n]+>/g; const FUNCTION_CLOSE = ""; const PARAMETER_CLOSE = ""; +const INVOKE_CLOSE = ""; function trimmedEnd(text: string, from: number, to: number): number { while (to > from && /\s/.test(text[to - 1]!)) to--; @@ -82,24 +209,72 @@ function blockAt(text: string, offset: number): SerializedToolCall | undefined { 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); + for ( + let close = nextBlockClose(text, bodyStart); + close.index >= 0 && (close.index < limit || !unclosed); + close = nextBlockClose(text, close.end) + ) { + const { index: closeIndex, end: closeEnd, tag: closeTag } = close; + let bodyEnd = trimmedEnd(text, bodyStart, closeIndex); const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE); if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length); + // MiMo's echo can also end with the Command Code alternate closer + // ... or ... instead of + // . Treat the alternate close as a completed block only when + // a known function closer immediately precedes it, so prose containing the tag alone + // cannot terminate a block. + if (closeTag === FUNCTION_CALLS_CLOSE) { + const inner = trimmedEnd(text, bodyStart, closeIndex); + const hasFn = endsWithAt(text, bodyStart, inner, FUNCTION_CLOSE); + const hasInvoke = endsWithAt(text, bodyStart, inner, INVOKE_CLOSE); + const hasFnName = endsWithAt(text, bodyStart, inner, FUNCTION_NAME_CLOSE); + if (!hasFn && !hasInvoke && !hasFnName) continue; + let altEnd = inner; + if (hasFn) altEnd = trimmedEnd(text, bodyStart, altEnd - FUNCTION_CLOSE.length); + else if (hasInvoke) altEnd = trimmedEnd(text, bodyStart, altEnd - INVOKE_CLOSE.length); + else if (hasFnName) altEnd = trimmedEnd(text, bodyStart, altEnd - FUNCTION_NAME_CLOSE.length); + if (endsWithAt(text, bodyStart, altEnd, PARAMETER_CLOSE)) altEnd -= PARAMETER_CLOSE.length; + return { + name: header[1]!.trim(), + body: text.slice(bodyStart, altEnd), + start: offset, + end: closeEnd, + }; + } + // MiMo's echo can also carry a stray `` close (the Command Code grammar's + // alternate function close) between `` and ``. Strip tail + // closers from the end inward — first, then — so the body + // comparison sees the same text the structured arguments carried (#7260 follow-up). + while (endsWithAt(text, bodyStart, trimmedEnd(text, bodyStart, bodyEnd), INVOKE_CLOSE)) { + bodyEnd = trimmedEnd(text, bodyStart, bodyEnd) - INVOKE_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, + end: closeEnd, }; if (closed) return call; - if (close < limit) unclosed ??= call; + if (closeIndex < limit) unclosed ??= call; } return unclosed; } +/** The next candidate block close: `` or MiMo's alternate ``. */ +function nextBlockClose(text: string, from: number): { index: number; end: number; tag: string } { + const a = text.indexOf(CLOSE_TAG, from); + const b = text.indexOf(FUNCTION_CALLS_CLOSE, from); + const c = text.indexOf(FUNCTION_NAME_CLOSE, from); + const candidates = [ + a >= 0 ? { index: a, end: a + CLOSE_TAG.length, tag: CLOSE_TAG } : undefined, + b >= 0 ? { index: b, end: b + FUNCTION_CALLS_CLOSE.length, tag: FUNCTION_CALLS_CLOSE } : undefined, + c >= 0 ? { index: c, end: c + FUNCTION_NAME_CLOSE.length, tag: FUNCTION_NAME_CLOSE } : undefined, + ].filter((x): x is { index: number; end: number; tag: string } => x !== undefined) + .sort((x, y) => x.index - y.index); + return candidates[0] ?? { index: -1, end: -1, tag: CLOSE_TAG }; +} + /** 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 calls: SerializedToolCall[] = []; @@ -235,6 +410,9 @@ export class SerializedToolCallContentBuffer { private context: TextContext = { fence: null, lineStart: true }; private queued: { offset: number; event: AdapterEvent }[] = []; + /** When true, ingest also holds mid-line serialized blocks (MiMo emission shape). */ + holdMidLine = false; + constructor(private readonly budget: TranslatorBudget) {} /** Remembers visible text released before a later structured call can be reconciled. */ @@ -312,7 +490,9 @@ export class SerializedToolCallContentBuffer { this.append(delta); return ""; } - const split = splitAtPossibleSerializedToolCall(this.text + delta, this.context); + const split = this.holdMidLine + ? splitAtPossibleSerializedToolCallIncludingMidLine(this.text + delta, this.context) + : splitAtPossibleSerializedToolCall(this.text + delta, this.context); this.replace(split.defer, split.hasOpenTag); this.context = split.context; if (!split.hasOpenTag) this.continuationBreak = deferredContinuationBreak(split.defer, split.context); @@ -465,6 +645,27 @@ function hasDoubledInput(structured: StructuredToolCallReference, repeated: Seri } /** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */ +/** Mid-line bare blocks (MiMo emits them right after a prose sentence with no newline). */ +function midLineCallsIn(text: string, context?: TextContext): SerializedToolCall[] { + const midLineCalls: SerializedToolCall[] = []; + const lineStartSpans = new Set(callsIn(text, context ?? { fence: null, lineStart: true }).map(call => `${call.start}:${call.end}`)); + const MID_LINE_HEADER = /\s*\r\n]+>/g; + MID_LINE_HEADER.lastIndex = 0; + for (let header = MID_LINE_HEADER.exec(text); header !== null; header = MID_LINE_HEADER.exec(text)) { + const at = header.index; + if (context?.fence) { + // Skip blocks that sit inside a fence; the splitter's fence tracking already covers + // line-start cases, so only a cheap before-text check is needed here. + const before = text.slice(0, at); + const fences = (before.match(/^ {0,3}(`{3,}|~{3,})/gm) ?? []).length; + if (fences % 2 === 1) continue; + } + const call = blockAt(text, at); + if (call && !lineStartSpans.has(`${call.start}:${call.end}`)) midLineCalls.push(call); + } + return midLineCalls; +} + function duplicatedSerializedToolCallRanges( text: string, structuredCalls: readonly StructuredToolCallReference[], @@ -473,18 +674,49 @@ function duplicatedSerializedToolCallRanges( if (structuredCalls.length === 0) return []; const repeated = repeatedCallIn(text, context); if (repeated) { - // A doubled call beside an agreeing call leaves the pair ambiguous. If reduction was - // refused, keep the markup too: otherwise the visible text and executable call disagree. const matching = structuredCalls.filter(structured => agreesWithRepeatedBlock(structured, repeated)); const doubled = structuredCalls.some(structured => structured !== matching[0] && hasDoubledInput(structured, repeated)); return matching.length === 1 && !doubled ? [{ start: repeated.start, end: repeated.end }] : []; } - return callsIn(text, context).filter(call => { - const body = freeformBody(call.body); + const lineStartCalls = callsIn(text, context); + const midLineCalls = midLineCallsIn(text, context); + return [...lineStartCalls, ...midLineCalls].filter(call => { + let body = freeformBody(call.body); + // Two wrapper families: + // A) script — a complete tag; strip the whole tag. + // B) const r=await...): the script restarts with its own + // `const` declaration after the tag — the tag is a complete wrapper; strip it whole. + // Shape B ( { const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText, structured.freeformTool) : undefined; - if (input === undefined) return false; - const normalized = freeformBody(input); + // MiMo can send a function tool's arguments as raw JS text instead of JSON and echo + // the same body as a bare block (#7260 follow-up). A raw-arguments string that equals + // the block body is the strongest possible agreement between the two representations, + // so it authorizes suppression exactly like a parsed `input` match. + let normalized: string | undefined = input !== undefined ? freeformBody(input) : undefined; + if (normalized === undefined && structured.argumentsText !== "") { + // JSON-parsable arguments keep the original bridge-unwrap contract; only a non-JSON + // payload whose entire text equals the block body authorizes the raw-text match. + // A text that merely looks like JSON (starts with { or [) stays protected: it may + // be a truncated JSON payload, not the raw-text shape MiMo sends. + const looksLikeJson = /^\s*[[{]/.test(structured.argumentsText); + try { JSON.parse(structured.argumentsText); } catch { + if (!looksLikeJson && freeformBody(structured.argumentsText) === body) normalized = body; + } + } + if (normalized === undefined) return false; return normalized === body || (structured.recoveredParameterWrapper === true && body.startsWith(", + repeated: SerializedToolCall, +): string | undefined { + if (!functionNames.has(repeated.name)) return undefined; + const body = freeformBody(repeated.body); + if (body === "" || argumentsText === body) return undefined; + return argumentsText === body + body || argumentsText === body + "\n" + body + ? body : undefined; +} + /** Removes a malformed argument prefix only when a bare block and the JSON suffix prove identical input. */ export function repairArgumentsDuplicatedBesideSerializedCall( argumentsText: string, diff --git a/structure/decisions/ADR-5724-serialized-tool-call-content.md b/structure/decisions/ADR-5724-serialized-tool-call-content.md index 923b74900d1..e7df2a214e3 100644 --- a/structure/decisions/ADR-5724-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5724-serialized-tool-call-content.md @@ -11,3 +11,4 @@ - 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. Streaming retention scans each new delta with fixed delimiter overlap and carries trailing-prose state; it never searches the whole retained block per delta. See [ADR-0102](ADR-0102-incremental-stream-accounting.md). +- Follow-up (261016): MiMo V2 Chat models (`mimo-v2` and dotted `mimo-v2.*` IDs) can emit the whole freeform call only as a standalone bare block with no structured `delta.tool_calls` payload; the client then renders raw markup as visible text (#7214 follow-up). When no structured call exists, qualifying bare blocks that name a declared freeform tool's wire name are recovered as synthetic `input` calls, streamed and buffered. A block qualifies only with a non-empty body free of nested parameter wrappers and nested block headers — the same safety rules as the empty-input recovery — so prose, fenced examples, undeclared tools, and hyphenated MiMo IDs keep the original match-only behavior. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 082ac326a82..9baf59fbde6 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -345,6 +345,12 @@ prefix, and wrapper's `input` value all agree. Mismatched markup and arguments r MiMo V2 Chat IDs of `mimo-v2` or `mimo-v2.*` can send `{}` for a freeform call and put its input in one bare block; hyphenated IDs (`mimo-v2-pro`, `mimo-v2-omni`) are excluded. With no other text, one call and an exact wire-tool match, the adapter restores `input` and removes the block. Prose, fences, ordinary functions, multiple calls/blocks, and nonempty arguments remain inert. A malformed `