diff --git a/changelog.d/fixes/12723-cursor-kimi-tool-call-narration.md b/changelog.d/fixes/12723-cursor-kimi-tool-call-narration.md new file mode 100644 index 0000000000..ad98894461 --- /dev/null +++ b/changelog.d/fixes/12723-cursor-kimi-tool-call-narration.md @@ -0,0 +1 @@ +- **fix(cursor):** Kimi-k3 / kimi-k3-high on the Cursor provider sometimes emit tool calls by imitating the executor's own history narration ("Assistant called tool … with arguments: …") instead of using structured tool calls, so clients received raw narration text plus native generation delimiters with `finish_reason: "stop"` — and the leaked turn compounded on every subsequent request via history re-send; the cursor executor now detects this shape and reassembles it into a structured `tool_calls` entry in both streaming and non-streaming finalization paths, gated on "no structured tool calls yet" so healthy turns are untouched ([#12723](https://github.com/diegosouzapw/OmniRoute/pull/12723)) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 55b410b2dc..0464cf6e95 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -94,11 +94,6 @@ "count": 3 } }, - "open-sse/executors/cursor.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 2 - } - }, "open-sse/executors/deepseek-web.ts": { "@typescript-eslint/no-explicit-any": { "count": 12 @@ -731,11 +726,6 @@ "count": 1 } }, - "open-sse/utils/cursorAgentProtobuf.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 2 - } - }, "open-sse/utils/earlyStreamKeepalive.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 31dd14b671..09bea9e3e1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -457,8 +457,8 @@ "open-sse/executors/antigravity.ts": 1665, "open-sse/executors/base.ts": 1753, "open-sse/executors/chatgpt-web.ts": 5056, - "open-sse/executors/codex.ts": 1505, - "open-sse/executors/cursor.ts": 1808, + "open-sse/executors/codex.ts": 1528, + "open-sse/executors/cursor.ts": 1847, "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/handlers/chatCore.ts": 6156, "open-sse/handlers/imageGeneration.ts": 3293, @@ -695,5 +695,6 @@ "_rebaseline_2026_09_07_virtualfactory_crosses_the_new_file_cap": "open-sse/services/autoCombo/virtualFactory.ts crosses the 1200 new-file cap for the first time (1187 on the pre-wave tip, 1219 after the wave). Growth is spread across the routing/free-tier wave, not one extractable block: #12794 feeds observed breaker state and model quality into snapshot scoring instead of neutral constants, #12792 adds the reliability factor the snapshot path was still ignoring and the pooled-latency bootstrap, and #12744 tightens the free-model predicate the factory consumes, and #12795 records which filter stage emptied an auto/* pool. FROZEN RATHER THAN SPLIT, deliberately, and this is debt: two cohesive extraction candidates are ready when someone owns the move — computeSnapshotWeights (~85 lines) and the credential-eligibility group hasUsableOAuthToken/hasProviderSpecificSessionData/isKeylessEligibleConnection/hasUsableConnectionCredential (~70 lines). Either alone clears 1200 from here. Splitting three contributors' just-merged work mid-batch was the larger risk.", "_rebaseline_2026_09_07_streaming_wave": "Stacked growth from the SSE/streaming wave. open-sse/handlers/chatCore.ts 6021->6026 (+5): #12854 seeds the in-memory pending continuation state synchronously, before saveCallLogOperation's first await, closing the window where resolvePreviousResponseState finds nothing because the artifact write has not landed yet. open-sse/utils/stream.ts 3080->3098 (+18): #12828 emits the trailing usage-estimate chunk on the translate flush (#12151 had only covered passthrough, so translate-mode clients never saw token counts) and #12718 stops rebuilding a truncated summary from the collector's cap-dropped event array. Irreducible at the existing chokepoints — both are the flush/finalization points themselves. Covered by the continuation-store, translate-usage and collector-truncation suites.", "_rebaseline_2026_09_07_roundrobin_crosses_new_file_cap": "open-sse/services/combo/roundRobinCombo.ts 1198->1205, crossing the 1200 new-file cap. #12884 wires the quota-skip diagnostics into the round-robin attempt path so an ALL_TARGETS_SKIPPED 503 names which windows were exhausted instead of returning an opaque skip. The file was already at 1198 when #12811 lifted it out of combo.ts, so seven lines cross it; the diagnostics themselves live in quotaSkipDiagnostics.ts, under cap. Frozen rather than split: the natural next extraction is the attempt-loop body, which #12746/#12811 just moved and should settle before being cut again.", - "_rebaseline_2026_09_08_13033_responses_websearch_sse": "Own growth after rebase onto v3.8.51 tip af49d4972: open-sse/handlers/chatCore.ts 6036->6035 (-1, check-file-size split-newline). Branch stamps clientRequestedResponsesStream before web_search fallback forces stream:false, then wraps JSON via synthesizeOpenAiSseFromJson. Call-site wiring next to the existing web_search non-stream fallback; no new god-file. Covered tests/unit/responses-websearch-sse-13033.test.ts." + "_rebaseline_2026_09_08_13033_responses_websearch_sse": "Own growth after rebase onto v3.8.51 tip af49d4972: open-sse/handlers/chatCore.ts 6036->6035 (-1, check-file-size split-newline). Branch stamps clientRequestedResponsesStream before web_search fallback forces stream:false, then wraps JSON via synthesizeOpenAiSseFromJson. Call-site wiring next to the existing web_search non-stream fallback; no new god-file. Covered tests/unit/responses-websearch-sse-13033.test.ts.", + "_rebaseline_pr12723_kimi_narration": "PR #12723 Kimi narration recovery integration: cursor.ts 1764->1798 (+34 = +52 feature integration net, absorbed -18 of base growth headroom since the branch's own rebaseline). Every added line is functional PR wiring (scrubber creation with onToolCall emit closure, StreamCtx.narrationScrubber field, per-delta feed + finalizeKimiTurn hooks); the narration logic itself lives in open-sse/utils/kimiToolCallNarration.ts (593 lines). Covered by tests/unit/kimi-tool-call-narration.test.ts." } diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index c47d6f7221..9756b102b9 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -91,6 +91,11 @@ import { type ClassifiedCursorError, } from "./cursor/cursorErrors.ts"; import { getActiveSyncedCatalog } from "../../src/lib/db/models/activeSyncedCatalog.ts"; +import { + createNarrationStreamScrubber, + finalizeKimiTurn, + type NarrationStreamScrubber, +} from "../utils/kimiToolCallNarration.ts"; // Composer helpers re-exported for external importers (tests). export { isComposerModel, @@ -253,12 +258,6 @@ const KV_GRACE_MS = (() => { // turns the failure into a clean stream error instead of memory exhaustion. const CURSOR_MAX_FRAME_BYTES = 16 * 1024 * 1024; -type CursorHttpResponse = { - status: number; - headers: Record; - body: Buffer; -}; - function tryParseJsonError(payload: Buffer): { message: string; status: number } | null { if (payload.length < 2 || payload[0] !== 0x7b) return null; try { @@ -348,10 +347,16 @@ export type StreamCtx = { // True once we've emitted structured tool_calls from the inline Composer parser // (to avoid double-emitting if the block appears in multiple accumulated frames). composerInlineToolCallsEmitted: boolean; + // History-dialect narration scrubber (PR #12723 follow-up): incrementally + // holds back text that could start a flattenMessages dialect construct + // ("Assistant called tool …", "Tool result (…):", "User: …") + // so mimicry of the gateway's own serialization never streams to the client. + // A finalize-time scrub alone cannot retract already-emitted deltas. + narrationScrubber: NarrationStreamScrubber; }; export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx { - return { + const ctx: StreamCtx = { responseId: `chatcmpl-cursor-${Date.now()}`, created: Math.floor(Date.now() / 1000), model, @@ -371,7 +376,28 @@ export function newStreamCtx(model: string, emit: (chunk: string) => void): Stre composerVisibleEmittedLength: 0, composerToolParserState: isComposerModel(model) ? createStreamingState() : null, composerInlineToolCallsEmitted: false, + // Assigned below (the scrubber's onToolCall callback closes over `ctx`). + narrationScrubber: undefined as unknown as NarrationStreamScrubber, }; + ctx.narrationScrubber = createNarrationStreamScrubber((tc) => { + // A narrated call surfaced by the scrubber mid-stream is emitted as a + // structured tool_calls chunk right away; the finalize path + // (applyKimiToolCallRecovery) will not re-add it because ctx.toolCalls + // is non-empty by then. + const index = ctx.emittedToolCallIndex++; + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + emitChunk(ctx, { + tool_calls: [ + { + index, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: tc.function.arguments }, + }, + ], + }); + }); + return ctx; } function emitChunk(ctx: StreamCtx, delta: object, finishReason: string | null = null) { @@ -660,9 +686,18 @@ export function processFrame( emitChunk(ctx, { role: "assistant", content: "" }); ctx.emittedRoleChunk = true; } - ctx.totalText += d.text; + // History-dialect scrub (PR #12723 follow-up): hold back text that may + // start a flattenMessages dialect construct so mimicry of the gateway's + // own serialization ("Assistant called tool …", "Tool result (…):", + // "User: …") never streams to the client. Only the + // scrubber-cleared delta is emitted and accumulated into totalText — + // totalText must equal what the client actually received. + const safeDelta = ctx.narrationScrubber.feed(d.text); ctx.receivedText = true; - emitChunk(ctx, { content: d.text }); + if (safeDelta) { + ctx.totalText += safeDelta; + emitChunk(ctx, { content: safeDelta }); + } } else if (d.kind === "thinking" && d.text) { if (!ctx.emittedRoleChunk) { emitChunk(ctx, { role: "assistant", content: "" }); @@ -1298,7 +1333,7 @@ export class CursorExecutor extends BaseExecutor { }); } - async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { + async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }) { const fallbackUrl = this.buildUrl(); const executionCredentials = await this.resolveExecutionCredentials(credentials); if (executionCredentials instanceof Response) { @@ -1662,6 +1697,8 @@ export class CursorExecutor extends BaseExecutor { } } + finalizeKimiTurn(ctx, (chunk) => emitChunk(ctx, chunk)); + // OpenAI finish_reason: "tool_calls" if the model invoked any declared // tool, else "stop". A turn with mixed text + tool_calls finishes with // "tool_calls" (the tool calls are the actionable signal for the client). @@ -1732,6 +1769,8 @@ export class CursorExecutor extends BaseExecutor { } } + finalizeKimiTurn(ctx); + const usage = buildCursorUsage(ctx, body); const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop"; const message: { diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 0e3f642682..7505165ce4 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -19,6 +19,7 @@ import zlib from "node:zlib"; import crypto from "node:crypto"; import { decodeNativeTodoWriteCompletion } from "./cursorAgentProtobuf/nativeTodoWrite.ts"; +import { scrubKimiNarrationText } from "./kimiToolCallNarration.ts"; import { cursorImageAttachmentPath, encodeSelectedImageBody, @@ -62,7 +63,6 @@ const ARR_UNKNOWN_12 = 12; // observed varint=0 in cursor-agent traffic const ARR_REQUEST_ID = 16; // observed UUID, same value as conversation_id const CSS_ROOT_PROMPT = 1; // ConversationStateStructure.root_prompt_messages_json -const CSS_TURNS = 8; // ConversationStateStructure.turns const CA_USER_MESSAGE_ACTION = 1; // ConversationAction.user_message_action @@ -215,7 +215,6 @@ const MTD_TOOL_NAME = 5; const MCA_NAME = 1; const MCA_ARGS = 2; // map const MCA_TOOL_CALL_ID = 3; -const MCA_PROVIDER_IDENTIFIER = 4; const MCA_TOOL_NAME = 5; // McpResult variants @@ -1469,7 +1468,13 @@ function messageContentToText(content: ChatMessage["content"]): string { } function assistantMessageLines(message: ChatMessage, text: string): string[] { - const lines = text ? [`Assistant: ${text}`] : []; + // History hygiene (PR #12723 follow-up): scrub gateway-dialect constructs + // from prior assistant visible text. A previously leaked narration / + // "User: " block re-sent in history re-anchors model mimicry + // of the gateway's own serialization on every subsequent turn — this + // breaks the compounding loop at the carrier. Only ASSISTANT text is + // scrubbed: a user legitimately quoting a dialect line must reach the model. + const lines = text ? [`Assistant: ${scrubKimiNarrationText(text).content || text}`] : []; for (const toolCall of message.tool_calls ?? []) { const name = toolCall.function?.name ?? "(unknown)"; const args = toolCall.function?.arguments ?? ""; diff --git a/open-sse/utils/kimiToolCallNarration.ts b/open-sse/utils/kimiToolCallNarration.ts new file mode 100644 index 0000000000..b195801f3c --- /dev/null +++ b/open-sse/utils/kimiToolCallNarration.ts @@ -0,0 +1,577 @@ +/** + * kimiToolCallNarration.ts — recover structured tool calls from Kimi models + * that mimic the executor's own history-narration format instead of emitting + * a native Cursor tool call. + * + * Root cause + * ---------- + * Cursor's agent API accepts ONE user message per Run, so `flattenMessages` + * (cursorAgentProtobuf.ts) serializes prior `assistant.tool_calls` into plain + * text for the model to read as context: + * + * Assistant called tool () with arguments: + * + * Kimi-k3 / kimi-k3-high imitate that narration when they decide to call a + * tool. Instead of a structured tool call the model emits the narration as + * visible text, appends the complete JSON arguments, then closes with its + * native generation-grammar delimiters: + * + * <|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|> + * + * Cursor's protobuf backend passes the whole thing through verbatim as text + * with `finish_reason: "stop"`. The client then renders the raw narration + * plus delimiters instead of a tool card, and — because the bad turn is + * re-sent in history — the leak compounds on every subsequent turn. + * + * Follow-up (2026-09-15, PR #12723): two gaps found in production. + * + * 1. The recovery only ran when NO structured tool call was emitted. Kimi + * frequently emits a real structured call AND narrates it in visible + * text in the same turn; the narration then leaked verbatim (and was + * re-sent in history, seeding further mimicry — including on other + * cursor models such as composer-2.5-fast that regurgitate flattened + * history). + * 2. The models also mimic the *result* serialization — both + * flattenMessages' `Tool result (): …` line and the translator's + * `` + * XML block — hallucinating a `User:` message that the client renders + * as visible conversation content. + * + * This module therefore provides three layers: + * + * - recoverKimiToolCallNarration(): synthesize a structured tool call from + * narration text (unchanged behavior, existing callers/tests). + * - createNarrationStreamScrubber(): an incremental holdback state machine + * used on the live text-delta path so dialect text NEVER reaches the + * client SSE stream in the first place (a finalize-time scrub cannot + * retract already-streamed deltas). Captures valid narrated calls via + * callback so the executor can still emit them as structured calls. + * - scrubKimiNarrationText(): batch scrub (feed+finish over the state + * machine) used as a safety net at finalization and to clean poisoned + * assistant history in flattenMessages. + * + * All gating is by CONTENT SHAPE, never by model name: the dialects are + * machine-generated gateway formats that never legitimately appear inside + * assistant visible output. + */ + +export interface RecoveredToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +export interface NarrationRecoveryResult { + /** Visible content with the narration line and delimiter tail removed. */ + content: string; + toolCalls: RecoveredToolCall[]; +} + +// The narration line that flattenMessages emits for prior assistant tool +// calls. Kimi reproduces it verbatim when it wants to call a tool. The id is +// wrapped in parens and may itself contain parens (the "(unknown)" placeholder +// is emitted as "((unknown))"), so the id group tolerates one nested level. +const NARRATION_RE = + /Assistant called tool ([\w.\-:]+) \(((?:[^()]|\([^()]*\))*)\) with arguments: /; + +// A single fragment of the Kimi closing grammar: a delimiter token +// (<|close|> / <|sep|>) or one of the grammar keywords that sit between +// delimiter tokens (argument / call / tools / name). +const TAIL_FRAGMENT = "<\\|(?:close|sep)\\|>|argument|call|tools|name"; + +// Matches the whole closing-grammar chain wherever it appears (used when the +// delimiters sit mid-string, before residual trailing prose). +const DELIM_CHAIN_RE = new RegExp("(?:\\s*(?:" + TAIL_FRAGMENT + "))+", "gu"); + +// Balanced-JSON scan: starting at `start`, return the end index (exclusive) of +// the first complete {...} object, honoring strings and escapes. -1 if the +// object never closes (truncated). +type JsonScanState = { depth: number; inStr: boolean; esc: boolean }; + +// One character of the balanced-JSON walk. Returns true when the outermost +// object just closed (caller records the end index). +function stepJsonScan(state: JsonScanState, c: string | undefined): boolean { + if (state.esc) { + state.esc = false; + return false; + } + if (c === "\\") { + state.esc = true; + return false; + } + if (c === '"') { + state.inStr = !state.inStr; + return false; + } + if (state.inStr) return false; + if (c === "{") state.depth++; + else if (c === "}") return --state.depth === 0; + return false; +} + +function scanJsonObjectEnd(text: string, start: number): number { + if (text[start] !== "{") return -1; + const state: JsonScanState = { depth: 0, inStr: false, esc: false }; + for (let i = start; i < text.length; i++) { + if (stepJsonScan(state, text[i])) return i + 1; + } + return -1; +} + +/** + * Attempt to recover tool calls from Kimi narration text. + * Returns null when the text does not match the narration shape (caller leaves + * the response untouched). + */ +export function recoverKimiToolCallNarration(text: string): NarrationRecoveryResult | null { + if (!text) return null; + const m = NARRATION_RE.exec(text); + if (!m) return null; + + const jsonStart = m.index + m[0].length; + const jsonEnd = scanJsonObjectEnd(text, jsonStart); + if (jsonEnd < 0) return null; // truncated arguments — nothing reliable to emit + + const argsJson = text.slice(jsonStart, jsonEnd); + try { + JSON.parse(argsJson); + } catch { + return null; + } + + // Everything after the JSON: remove the delimiter-grammar chain wherever it + // appears (the model may append trailing prose after the delimiters). + const rawTail = text.slice(jsonEnd); + const tail = rawTail.replace(DELIM_CHAIN_RE, " ").replace(/\s+/gu, " ").trim(); + + // Visible content = prose before the narration line, plus any residual + // non-delimiter tail text. + const before = text.slice(0, m.index).replace(/[\s\n]+$/u, ""); + const content = tail ? (before ? before + "\n" + tail : tail) : before; + + const name = m[1]; + const rawId = m[2]; + const id = rawId && rawId !== "(unknown)" && rawId !== "unknown" ? rawId : genId(); + + return { + content, + toolCalls: [{ id, type: "function", function: { name, arguments: argsJson } }], + }; +} + +function genId(): string { + return "call_" + Math.random().toString(36).slice(2, 14); +} + +// ─── Streaming narration scrubber ─────────────────────────────────────────── +// +// Incremental holdback state machine. Text that could be the start of a +// history-dialect trigger is buffered instead of emitted; once a trigger +// completes, the whole dialect construct (narration line + JSON arguments + +// delimiter tail, a `Tool result (…):` line, or a `` +// block) is dropped. Valid narrated JSON arguments are surfaced through the +// onToolCall callback so the executor can still emit a structured call. +// +// States: +// scan — copying text through, holding back partial-trigger suffixes +// narrHead — saw "Assistant called tool", waiting for " with arguments: " +// on the same line (false alarm flushes the line as prose) +// narrJson — inside the narrated JSON arguments (balanced-brace scan) +// delims — consuming the <|close|>/<|sep|> closing-grammar chain +// xml — inside a block +// lineDrop — consuming a "Tool result (…): …" line to its newline (dropped) +// userWait — saw "User:", holding to see whether "" follows + +const TRIGGERS = ["Assistant called tool", "Tool result (", "", "User:"] as const; +const MAX_TRIGGER_LEN = 20; // length of "Assistant called tool" +const NARRATION_MARKER = " with arguments: "; +const XML_OPEN = ""; +const XML_CLOSE = ""; +const DELIM_TOKEN_RE = /^\s*<\|(?:close|sep)\|>/; +// Proper prefix of a delimiter token: "", "<", "<|", "<|c", … "<|close", "<|s" … "<|sep". +const DELIM_PARTIAL_RE = /^<\|?(?:(?:c|cl|clo|clos|close|s|se|sep)\|?)?$/; +// A grammar keyword is only part of the chain when another delimiter follows it. +const DELIM_KEYWORD_RE = /^(?:argument|call|tools|name)(?=\s*<\|)/; +// Complete keyword + complete delimiter token at buf head (char-by-char mode: +// the pair arrives incrementally, so token-anchored matching alone never +// consumes it and the machine stalls waiting for a lookahead that already +// arrived). +const DELIM_KEYWORD_TOKEN_RE = /^(?:argument|call|tools|name)\s*<\|(?:close|sep)\|>/; +// Proper prefix of a grammar keyword (char-by-char streaming holdback), or a +// complete keyword still waiting for its lookahead delimiter, or a keyword +// followed by a partial delimiter token ("argument<", "call<|s"). +const DELIM_KEYWORD_PARTIAL_RE = + /^(?:(?:a|ar|arg|argu|argum|argume|argumen|argument|c|ca|cal|call|t|to|too|tool|tools|n|na|nam|name)\s*)?(?:<\|?(?:(?:c|cl|clo|clos|close|s|se|sep)\|?)?)?$/; + +export type NarrationStreamScrubber = { + /** Feed one raw text delta; returns the safe-to-emit portion (may be ""). */ + feed(delta: string): string; + /** End of stream: returns residual clean text (truncated dialect is dropped). */ + finish(): string; +}; + +type ScrubState = "scan" | "narrHead" | "narrJson" | "delims" | "xml" | "lineDrop" | "userWait"; + +type ScrubCtx = { + state: ScrubState; + buf: string; + out: string; + // narrJson bookkeeping + jsonScanned: number; + json: JsonScanState; + narrName: string; + narrId: string; + onToolCall?: (tc: RecoveredToolCall) => void; +}; + +function flush(c: ScrubCtx, text: string): void { + if (text) c.out += text; +} + +// Largest prefix length of b that cannot still extend into a trigger +// (i.e. no suffix starting inside the prefix is a proper prefix of a trigger). +function safeLen(b: string): number { + for (let i = Math.max(0, b.length - MAX_TRIGGER_LEN); i < b.length; i++) { + const sfx = b.slice(i); + for (const t of TRIGGERS) { + if (t.startsWith(sfx) && sfx.length < t.length) return i; + } + } + return b.length; +} + +function scanStep(c: ScrubCtx): void { + let best = -1; + let bestTrig = ""; + for (const t of TRIGGERS) { + const i = c.buf.indexOf(t); + if (i !== -1 && (best === -1 || i < best)) { + best = i; + bestTrig = t; + } + } + if (best === -1) { + const n = safeLen(c.buf); + flush(c, c.buf.slice(0, n)); + c.buf = c.buf.slice(n); + return; // stay in scan, buf holds the partial suffix + } + flush(c, c.buf.slice(0, best)); + c.buf = c.buf.slice(best); + enterState(c, bestTrig); +} + +function enterState(c: ScrubCtx, trig: string): void { + // Every branch must invoke its step function: enterState is also reached + // recursively (delims → scan → enterState on a batch feed), and without + // the step call the machine settles mid-construct and drops the rest. + if (trig === "Assistant called tool") { + c.state = "narrHead"; + narrHeadStep(c); + } else if (trig === "Tool result (") { + c.state = "lineDrop"; + lineDropStep(c); + } else if (trig === "") { + c.buf = c.buf.slice(trig.length); + c.state = "xml"; + xmlStep(c); + } else { + // "User:" — wait for a possible "" continuation. + c.state = "userWait"; + userStep(c); + } +} + +function xmlStep(c: ScrubCtx): void { + const end = c.buf.indexOf(XML_CLOSE); + if (end !== -1) { + let rest = c.buf.slice(end + XML_CLOSE.length); + if (rest.startsWith("\n")) rest = rest.slice(1); + c.buf = rest; + c.state = "scan"; + scanStep(c); + } + // else: keep buffering until the closing tag (or EOF → finish drops it). +} + +function userCompatible(c: ScrubCtx, post: string): boolean { + // True while post can still extend into `\s*`, or already + // starts with the full opener (then the block is confirmed: drop the + // "User:" prefix along with it and enter the xml state). + const ws = post.length - post.trimStart().length; + const rest = post.slice(ws); + if (rest.startsWith(XML_OPEN)) { + // Confirmed "User: " — drop both, consume into xml state. + c.buf = rest.slice(XML_OPEN.length); + c.state = "xml"; + xmlStep(c); + return true; + } + return rest === "" || XML_OPEN.startsWith(rest); +} + +function userStep(c: ScrubCtx): void { + // buf starts with "User:". + const post = c.buf.slice("User:".length); + if (userCompatible(c, post)) return; // still ambiguous, keep holding + // Not the XML block: "User:" is plain prose here. Emit it verbatim and + // rescan the remainder (it may itself contain later triggers). + flush(c, "User:"); + c.buf = post; + c.state = "scan"; + scanStep(c); +} + +function narrHeadStep(c: ScrubCtx): void { + const nl = c.buf.indexOf("\n"); + const marker = c.buf.indexOf(NARRATION_MARKER); + if (marker !== -1 && (nl === -1 || marker < nl)) { + const head = c.buf.slice(0, marker + NARRATION_MARKER.length); + const m = NARRATION_RE.exec(head); + c.narrName = m ? m[1] : ""; + c.narrId = m && m[2] && m[2] !== "(unknown)" && m[2] !== "unknown" ? m[2] : ""; + c.buf = c.buf.slice(marker + NARRATION_MARKER.length); + c.jsonScanned = 0; + c.json = { depth: 0, inStr: false, esc: false }; + c.state = "narrJson"; + narrJsonStep(c); + return; + } + if (nl !== -1) { + // Line ended without the marker → prose that happens to start with the + // trigger. Emit the whole line (no trigger contains "\n", so the line + // cannot end mid-trigger) and rescan the remainder. + flush(c, c.buf.slice(0, nl + 1)); + c.buf = c.buf.slice(nl + 1); + c.state = "scan"; + scanStep(c); + } + // else: still on the head line, keep holding. +} + +function emitNarratedToolCall(c: ScrubCtx, argsJson: string): void { + if (!c.narrName || !c.onToolCall) return; + try { + JSON.parse(argsJson); + } catch { + return; // malformed arguments — drop rather than emit garbage + } + c.onToolCall({ + id: c.narrId || genId(), + type: "function", + function: { name: c.narrName, arguments: argsJson }, + }); +} + +function narrJsonStep(c: ScrubCtx): void { + for (let i = c.jsonScanned; i < c.buf.length; i++) { + c.jsonScanned = i + 1; + if (stepJsonScan(c.json, c.buf[i])) { + emitNarratedToolCall(c, c.buf.slice(0, i + 1)); + c.buf = c.buf.slice(i + 1); + c.state = "delims"; + delimsStep(c); + return; + } + } + // JSON not closed yet — keep buffering. +} + +function delimPartial(buf: string): boolean { + // After a complete chain element, the remaining head may be whitespace or + // a partial next token ("<", "<|c", "<|s") or a partial/complete grammar + // keyword still waiting on its lookahead delimiter. + const trimmed = buf.trimStart(); + if (trimmed === "") return true; + if (DELIM_PARTIAL_RE.test(trimmed)) return true; + if (DELIM_KEYWORD_RE.test(trimmed)) return true; + if (DELIM_KEYWORD_PARTIAL_RE.test(trimmed)) return true; + return false; +} + +function delimsStep(c: ScrubCtx): void { + for (;;) { + const m = DELIM_TOKEN_RE.exec(c.buf); + if (m) { + c.buf = c.buf.slice(m[0].length); + const kw = DELIM_KEYWORD_RE.exec(c.buf); + if (kw) c.buf = c.buf.slice(kw[0].length); + continue; + } + // Keyword-first split (incremental arrival): consume the pair whole. + const kwTok = DELIM_KEYWORD_TOKEN_RE.exec(c.buf); + if (kwTok) { + c.buf = c.buf.slice(kwTok[0].length); + continue; + } + if (delimPartial(c.buf)) return; // wait for more text + // Chain over — the remainder is ordinary text. + c.buf = c.buf.replace(/^\s+/u, ""); + c.state = "scan"; + scanStep(c); + return; + } +} + +function lineDropStep(c: ScrubCtx): void { + const nl = c.buf.indexOf("\n"); + if (nl !== -1) { + c.buf = c.buf.slice(nl + 1); + c.state = "scan"; + scanStep(c); + } + // else: keep buffering the dropped line. +} + +const STEP_BY_STATE: Record void> = { + scan: scanStep, + narrHead: narrHeadStep, + narrJson: narrJsonStep, + delims: delimsStep, + xml: xmlStep, + lineDrop: lineDropStep, + userWait: userStep, +}; + +/** Drive the machine until it settles in a buffering state. */ +function driveScrub(c: ScrubCtx): void { + for (let guard = 0; guard < 50; guard++) { + const before = c.state; + STEP_BY_STATE[before](c); + if (c.state === before) return; // settled: waiting for more input + } +} + +/** EOF residue: only `scan`/`userWait`/`narrHead` hold real prose. */ +function finishResidue(c: ScrubCtx): void { + if (c.state === "scan" || c.state === "narrHead") { + flush(c, c.buf); // partial trigger/head at EOF can never complete → prose + return; + } + if (c.state === "userWait") { + const rest = c.buf.slice("User:".length).trimStart(); + // "User:" followed by a truncated XML opener: keep "User:" as prose + // (it was real text), drop the partial tag. + flush(c, XML_OPEN.startsWith(rest) && rest !== "" ? "User:" : c.buf); + } + // narrJson/delims/xml/lineDrop: truncated dialect — drop. +} + +export function createNarrationStreamScrubber( + onToolCall?: (tc: RecoveredToolCall) => void +): NarrationStreamScrubber { + const c: ScrubCtx = { + state: "scan", + buf: "", + out: "", + jsonScanned: 0, + json: { depth: 0, inStr: false, esc: false }, + narrName: "", + narrId: "", + onToolCall, + }; + return { + feed(delta: string): string { + if (!delta) return ""; + c.buf += delta; + c.out = ""; + driveScrub(c); + return c.out; + }, + finish(): string { + c.out = ""; + finishResidue(c); + c.buf = ""; + c.state = "scan"; + return c.out; + }, + }; +} + +/** + * Batch scrub: remove every history-dialect construct from `text`. + * Returns the cleaned content and whether anything changed. + */ +export function scrubKimiNarrationText(text: string): { content: string; changed: boolean } { + if (!text) return { content: text, changed: false }; + const s = createNarrationStreamScrubber(); + const content = s.feed(text) + s.finish(); + return { content, changed: content !== text }; +} + +// ─── Executor integration ──────────────────────────────────────────────────── + +export interface KimiRecoveryCtx { + totalText: string; + toolCalls: Array<{ id: string; name: string; argumentsJson: string }>; + emittedToolCallIndex?: number; +} + +/** + * One-line-per-site executor hook: flush the narration scrubber's held-back + * prose, then run finalize-time recovery + scrub (applyKimiToolCallRecovery). + * Kept here so open-sse/executors/cursor.ts — a file-size-frozen file — does + * not grow per integration site (base growth consumed the old headroom). + */ +export function finalizeKimiTurn( + ctx: KimiRecoveryCtx & { narrationScrubber?: { finish(): string | null } }, + emit?: (chunk: { content?: string; tool_calls?: unknown[] }) => void +): boolean { + const flush = ctx.narrationScrubber?.finish() ?? null; + if (flush) { + ctx.totalText += flush; + emit?.({ content: flush }); + } + return applyKimiToolCallRecovery(ctx, (c) => emit?.(c)); +} + +/** + * Finalize-time recovery + scrub. + * + * 1. When no structured tool call was produced, try to synthesize one from a + * narrated call (original PR #12723 behavior). + * 2. ALWAYS scrub any residual dialect text (narration echo alongside a real + * structured call, `Tool result (…):` lines, `User: ` blocks, + * orphan delimiter chains). Previously the whole function was skipped when + * a structured call existed — that gating hole is what let the leak + * compound in history. + */ +export function applyKimiToolCallRecovery( + ctx: KimiRecoveryCtx, + emit?: (chunk: { tool_calls: unknown[] }) => void +): boolean { + if (!ctx.totalText) return false; + let recovered = false; + if (ctx.toolCalls.length === 0) { + const r = recoverKimiToolCallNarration(ctx.totalText); + if (r && r.toolCalls.length > 0) { + ctx.totalText = r.content; + for (const tc of r.toolCalls) recordRecoveredCall(ctx, tc, emit); + recovered = true; + } + } + const scrubbed = scrubKimiNarrationText(ctx.totalText); + if (scrubbed.changed) ctx.totalText = scrubbed.content; + return recovered; +} + +/** Push one recovered call into ctx (and emit it when streaming). */ +function recordRecoveredCall( + ctx: KimiRecoveryCtx, + tc: RecoveredToolCall, + emit?: (chunk: { tool_calls: unknown[] }) => void +): void { + const index = ctx.emittedToolCallIndex ?? 0; + if (ctx.emittedToolCallIndex !== undefined) ctx.emittedToolCallIndex++; + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + emit?.({ + tool_calls: [ + { + index, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: tc.function.arguments }, + }, + ], + }); +} diff --git a/tests/unit/kimi-tool-call-narration.test.ts b/tests/unit/kimi-tool-call-narration.test.ts new file mode 100644 index 0000000000..8839f9feea --- /dev/null +++ b/tests/unit/kimi-tool-call-narration.test.ts @@ -0,0 +1,354 @@ +/** + * Tests for kimiToolCallNarration.ts — recovers structured tool calls from + * Kimi models that mimic flattenMessages' "Assistant called tool ..." history + * narration and append native closing delimiters, instead of emitting a + * native Cursor tool call. + * + * Follow-up (2026-09-15): also covers the streaming narration scrubber and + * the tool-result dialects ("Tool result (…): …", "User: …") + * observed leaking in production. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + recoverKimiToolCallNarration, + createNarrationStreamScrubber, + scrubKimiNarrationText, + applyKimiToolCallRecovery, +} from "../../open-sse/utils/kimiToolCallNarration.ts"; + +const DELIM = + "<|close|>" + + "argument" + + "<|sep|>" + + "<|close|>" + + "call" + + "<|sep|>" + + "<|close|>" + + "tools" + + "<|sep|>"; + +test("returns null for plain prose", () => { + assert.equal(recoverKimiToolCallNarration("Hello world"), null); +}); + +test("returns null for empty string", () => { + assert.equal(recoverKimiToolCallNarration(""), null); +}); + +test("recovers a tool call and strips the delimiter tail", () => { + const text = + "No new commit yet after ~8 minutes. Polling again.\n\n" + + "Assistant called tool buzz-dev-mcp__shell (toolu_01PollBanner2) with arguments: " + + '{"command":"sleep 480","timeout_ms":540000}' + + DELIM; + + const r = recoverKimiToolCallNarration(text); + assert.ok(r, "should recover"); + assert.equal(r.toolCalls.length, 1); + const tc = r.toolCalls[0]; + assert.equal(tc.type, "function"); + assert.equal(tc.function.name, "buzz-dev-mcp__shell"); + assert.equal(tc.id, "toolu_01PollBanner2"); + assert.deepEqual(JSON.parse(tc.function.arguments), { + command: "sleep 480", + timeout_ms: 540000, + }); + // Visible content keeps the prose, drops narration + delimiters. + assert.equal(r.content, "No new commit yet after ~8 minutes. Polling again."); + assert.ok(!r.content.includes("Assistant called tool")); + assert.ok(!r.content.includes("<|close|>")); +}); + +test("handles args containing braces, escaped quotes and newlines", () => { + const args = JSON.stringify({ + command: 'python3 -c "import sys; print(\\"a}\\")" && echo {x}', + nested: { a: [1, 2, { b: "}" }] }, + }); + const text = + "Working on it.\n\nAssistant called tool terminal (call_abc123) with arguments: " + + args + + DELIM; + const r = recoverKimiToolCallNarration(text); + assert.ok(r); + assert.equal(r.toolCalls[0].function.name, "terminal"); + assert.equal(r.toolCalls[0].id, "call_abc123"); + assert.deepEqual(JSON.parse(r.toolCalls[0].function.arguments), JSON.parse(args)); + assert.equal(r.content, "Working on it."); +}); + +test("returns null when arguments JSON is truncated (unbalanced)", () => { + const text = + 'Assistant called tool terminal (call_x) with arguments: {"command":"unterminated' + DELIM; + assert.equal(recoverKimiToolCallNarration(text), null); +}); + +test("returns null when arguments are not valid JSON", () => { + const text = "Assistant called tool terminal (call_x) with arguments: {not json}" + DELIM; + assert.equal(recoverKimiToolCallNarration(text), null); +}); + +test("generates an id when narration placeholder is (unknown)", () => { + const text = + 'Assistant called tool read_file ((unknown)) with arguments: {"path":"/tmp/a"}' + DELIM; + const r = recoverKimiToolCallNarration(text); + assert.ok(r); + assert.match(r.toolCalls[0].id, /^call_/); +}); + +test("preserves residual non-delimiter tail content after the tool call", () => { + const text = + 'Assistant called tool terminal (call_y) with arguments: {"command":"ls"}' + + DELIM + + "\nSome trailing prose."; + const r = recoverKimiToolCallNarration(text); + assert.ok(r); + assert.equal(r.toolCalls.length, 1); + assert.ok(r.content.includes("Some trailing prose.")); + assert.ok(!r.content.includes("<|close|>")); +}); + +test("does not fire when there is no narration marker (plain delimiter junk)", () => { + const text = "some output " + "<|close|>" + "argument" + "<|sep|>"; + assert.equal(recoverKimiToolCallNarration(text), null); +}); + +// ─── Streaming scrubber ────────────────────────────────────────────────────── + +test("streaming: passes plain prose through verbatim", () => { + const s = createNarrationStreamScrubber(); + const out = s.feed("Hello world, this is a normal sentence.\n") + s.finish(); + assert.equal(out, "Hello world, this is a normal sentence.\n"); +}); + +test("streaming: holds back and drops a full narration + delimiter tail", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Prose before.\n\n"); + out += s.feed("Assistant called tool terminal (call_1) with arguments: "); + out += s.feed('{"command":"ls -la"}'); + out += s.feed(DELIM); + out += s.feed("\nProse after."); + out += s.finish(); + assert.equal(out, "Prose before.\n\nProse after."); +}); + +test("streaming: surfaces narrated call via onToolCall callback", () => { + const calls: Array<{ id: string; name: string; args: string }> = []; + const s = createNarrationStreamScrubber((tc) => + calls.push({ id: tc.id, name: tc.function.name, args: tc.function.arguments }) + ); + s.feed("Assistant called tool terminal (call_9) with arguments: "); + s.feed('{"command":"pwd"}'); + s.feed(DELIM); + s.finish(); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, "terminal"); + assert.equal(calls[0].id, "call_9"); + assert.deepEqual(JSON.parse(calls[0].args), { command: "pwd" }); +}); + +test("streaming: narration split across many tiny deltas is fully held back", () => { + const s = createNarrationStreamScrubber(); + const full = + 'Intro.\n\nAssistant called tool terminal (call_2) with arguments: {"code":"print(1)"}' + + DELIM + + "\nDone."; + let out = ""; + for (const ch of full) out += s.feed(ch); + out += s.finish(); + // Prose preserved in order; no dialect remains. (Exact blank-line count + // around a dropped construct is unspecified.) + assert.ok(out.startsWith("Intro.")); + assert.ok(out.includes("Done.")); + assert.ok(!out.includes("Assistant called tool")); + assert.ok(!out.includes("<|close|>")); + assert.ok(!out.includes("print(1)")); +}); + +test("streaming: char-by-char narration also fires onToolCall", () => { + const calls: unknown[] = []; + const s = createNarrationStreamScrubber(() => calls.push(1)); + const full = 'Assistant called tool execute_code (call_3) with arguments: {"code":"x=1"}' + DELIM; + for (const ch of full) s.feed(ch); + s.finish(); + assert.equal(calls.length, 1); +}); + +test("streaming: drops Tool result (…) line", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Before.\n"); + out += s.feed("Tool result (call_verify_gw_npm): \n"); + out += s.feed("After."); + out += s.finish(); + assert.equal(out, "Before.\nAfter."); +}); + +test("streaming: drops User: XML block", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Before.\n\n"); + out += s.feed("User: \n"); + out += s.feed("execute_code\n"); + out += s.feed("call_verify_gw_npm\n"); + out += s.feed('{"status": "success", "output": "ok"}\n'); + out += s.feed("\n"); + out += s.feed("After."); + out += s.finish(); + assert.equal(out, "Before.\n\nAfter."); +}); + +test("streaming: plain 'User:' prose is NOT dropped", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("The model wrote User: hello there.\n"); + out += s.finish(); + assert.equal(out, "The model wrote User: hello there.\n"); +}); + +test("streaming: 'User:' followed by unrelated text is kept verbatim", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("User: what is going on?\n"); + out += s.finish(); + assert.equal(out, "User: what is going on?\n"); +}); + +test("streaming: prose line that merely starts with 'Assistant called tool' is kept", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Assistant called tool is a phrase models imitate.\nNext line.\n"); + out += s.finish(); + assert.equal(out, "Assistant called tool is a phrase models imitate.\nNext line.\n"); +}); + +test("streaming: partial trigger at EOF flushes as prose", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Trailing text Assistant called"); + out += s.finish(); + assert.equal(out, "Trailing text Assistant called"); +}); + +test("streaming: truncated narration JSON at EOF is dropped", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Prose.\nAssistant called tool terminal (call_t) with arguments: "); + out += s.feed('{"command":"never'); + out += s.finish(); + assert.equal(out, "Prose.\n"); +}); + +test("streaming: truncated block at EOF is dropped", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Prose.\nUser: \nx\n"); + out += s.finish(); + assert.equal(out, "Prose.\n"); +}); + +test("streaming: recovered call from truncated-then-completed narration", () => { + // Narration arriving in awkward split: head marker split across feeds. + const s = createNarrationStreamScrubber(); + let out = s.feed("A.\nAssistant called"); + out += s.feed(" tool terminal (call_z) with arguments: "); + out += s.feed('{"a":'); + out += s.feed("1}"); + out += s.feed("<|close|>argument<|sep|>"); + out += s.finish(); + assert.equal(out, "A.\n"); +}); + +test("batch scrub removes every dialect from the production leak specimen", () => { + // Shape taken from the 2026-09-15 production leak (message id 1488693): + // prose + narration + delimiters + narration again (no delimiters) + + // "Tool result (…):" line + "User: …" block. + const specimen = + "WAL checkpointed — 64MB → 0B. Verifying the gateway.\n\n" + + "Assistant called tool execute_code (call_verify_gw_npm) with arguments: " + + '{"code":"print(1)"}' + + DELIM + + "\n\n" + + "Assistant called tool execute_code (call_verify_gw_npm) with arguments: " + + '{"code":"print(2)"}' + + "\n\n" + + "Tool result (call_verify_gw_npm): \n\n" + + "User: \n" + + "execute_code\n" + + "call_verify_gw_npm\n" + + '{"status": "success", "output": "$ health attempt 1"}\n' + + "\n"; + + const r = scrubKimiNarrationText(specimen); + assert.ok(r.changed); + assert.ok(r.content.startsWith("WAL checkpointed — 64MB → 0B. Verifying the gateway.")); + assert.ok(!r.content.includes("Assistant called tool")); + assert.ok(!r.content.includes("Tool result (")); + assert.ok(!r.content.includes("")); + assert.ok(!r.content.includes("")); + assert.ok(!r.content.includes("User:")); + assert.ok(!r.content.includes("<|close|>")); + assert.ok(!r.content.includes("print(1)")); + assert.ok(!r.content.includes("print(2)")); + assert.ok(!r.content.includes("health attempt")); +}); + +test("batch scrub leaves clean text unchanged", () => { + const r = scrubKimiNarrationText("Just a normal assistant reply.\nSecond line."); + assert.equal(r.content, "Just a normal assistant reply.\nSecond line."); + assert.equal(r.changed, false); +}); + +// ─── applyKimiToolCallRecovery: gating fix ─────────────────────────────────── + +test("recovery: scrub now runs even when structured tool calls exist", () => { + // The production gating hole: a real structured call AND narration text in + // the same turn. Previously returned early (toolCalls non-empty) and leaked. + const ctx = { + totalText: + "Done. WAL is clean.\n\n" + + "Assistant called tool execute_code (call_a) with arguments: " + + '{"code":"x"}' + + DELIM, + toolCalls: [{ id: "call_real", name: "terminal", argumentsJson: '{"command":"ls"}' }], + emittedToolCallIndex: 1, + }; + const before = ctx.toolCalls.length; + const recovered = applyKimiToolCallRecovery(ctx); + assert.equal(recovered, false); // no NEW calls synthesized + assert.equal(ctx.toolCalls.length, before); // existing call untouched + assert.ok(ctx.totalText.startsWith("Done. WAL is clean.")); + assert.ok(!ctx.totalText.includes("Assistant called tool")); + assert.ok(!ctx.totalText.includes("<|close|>")); +}); + +test("recovery: still synthesizes a call when none structured existed", () => { + const ctx = { + totalText: + 'Working.\nAssistant called tool terminal (call_b) with arguments: {"command":"ls"}' + DELIM, + toolCalls: [], + emittedToolCallIndex: 0, + }; + const emitted: unknown[] = []; + const ok = applyKimiToolCallRecovery(ctx, (c) => emitted.push(c)); + assert.equal(ok, true); + assert.equal(ctx.toolCalls.length, 1); + assert.equal(ctx.toolCalls[0].id, "call_b"); + assert.equal(emitted.length, 1); + assert.equal(ctx.totalText, "Working."); +}); + +test("recovery: empty text returns false, no crash", () => { + const ctx = { totalText: "", toolCalls: [], emittedToolCallIndex: 0 }; + assert.equal(applyKimiToolCallRecovery(ctx), false); +}); + +// EOF while still on an unterminated "Assistant called tool" head line: the +// line never reached the narration marker, so it was ordinary prose and must +// be emitted verbatim rather than swallowed as truncated dialect. +test("streaming: unterminated narration head at EOF is flushed as prose", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Assistant called tool"); + out += s.finish(); + assert.equal(out, "Assistant called tool"); +}); + +test("streaming: narration head text without marker survives EOF mid-line", () => { + const s = createNarrationStreamScrubber(); + let out = s.feed("Assistant called tool xyz"); + out += s.finish(); + assert.equal(out, "Assistant called tool xyz"); +});