From 51723d6ec5d30740b000f96de18bbb10ec806440 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 8 Aug 2026 15:36:08 +0900 Subject: [PATCH] fix(anthropic): bound terminal guard buffering --- src/server/responses/terminal-guard.ts | 42 ++++++++++++++++++++++++-- tests/terminal-guard.test.ts | 22 ++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index aa2104b4e1..8719d2cf24 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -13,6 +13,8 @@ const PLAN_OR_COMPLETION_RE = /(?:\b(?:i(?:'|’)m going to|i will|i(?:'|’)ll| const WAITING_FOR_USER_RE = /(?:[??]\s*$|需要我|请(?:确认|选择|提供)|是否|要不要|可以吗|\b(?:do you want|should i|which file|please confirm|please provide)\b)/iu; const EXPLICIT_CONTINUE_RE = /^(?:继续|接着|往下|go on|continue|proceed|keep going)\s*[.!。!]?$/iu; const MAX_ANNOUNCEMENT_CHARS = 280; +const MAX_RETAINED_EVENTS = 1_024; +const MAX_RETAINED_CONTENT_CHARS = 64 * 1_024; export const TERMINAL_GUARD_NUDGE = "你刚才只描述了计划,没有执行任何工具。不要再次解释计划,现在立即调用必要工具执行用户任务。" + @@ -191,11 +193,14 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio while (true) { const seen: AdapterEvent[] = []; + let retainedContentChars = 0; + let retainedTextChars = 0; + let analysisEnabled = true; let terminalSeen = false; for await (const event of source) { if (event.type === "done") { terminalSeen = true; - const analysis = options.adapterName === "anthropic" + const analysis = options.adapterName === "anthropic" && analysisEnabled ? analyzeTerminalTurn(parsed, seen) : { decision: "pass" as const }; const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter"; @@ -222,7 +227,40 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio yield usage ? { ...event, usage } : event; return; } - seen.push(event); + if (analysisEnabled) { + if (event.type === "tool_call_start") { + // A real tool call makes this turn ineligible for a no-tool continuation. + analysisEnabled = false; + seen.length = 0; + } else if ( + event.type === "text_delta" + || event.type === "thinking_delta" + || event.type === "thinking_signature" + || event.type === "redacted_thinking" + ) { + const content = event.type === "text_delta" + ? event.text + : event.type === "thinking_delta" + ? event.thinking + : event.type === "thinking_signature" + ? event.signature + : event.data; + retainedContentChars += content.length; + if (event.type === "text_delta") retainedTextChars += event.text.length; + if ( + retainedTextChars > MAX_ANNOUNCEMENT_CHARS + || retainedContentChars > MAX_RETAINED_CONTENT_CHARS + || seen.length >= MAX_RETAINED_EVENTS + ) { + // The guard only acts on short, no-tool announcements. Once that is impossible, + // return to bounded pass-through instead of retaining the rest of the stream. + analysisEnabled = false; + seen.length = 0; + } else { + seen.push(event); + } + } + } yield event; } if (!terminalSeen) return; diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index 4cda0b1f69..968ca94598 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -212,6 +212,28 @@ describe("terminal guard", () => { expect(actual.filter(event => event.type === "done")).toHaveLength(1); }); + test("stops retaining events once a streamed answer cannot be a short announcement", async () => { + let continuations = 0; + let forwarded = 0; + for await (const _event of guardTerminalEventStream({ + parsed: parsed("请检查这个问题并修复代码"), + firstEvents: (async function* () { + yield { type: "text_delta", text: "我接下来会修改。" } as AdapterEvent; + yield { type: "thinking_delta", thinking: "x".repeat(64 * 1_024 + 1) } as AdapterEvent; + for (let i = 0; i < 2_000; i += 1) yield { type: "text_delta", text: "" } as AdapterEvent; + yield { type: "done" } as AdapterEvent; + })(), + continuation: () => { + continuations += 1; + return (async function* () {})(); + }, + adapterName: "anthropic", + })) forwarded += 1; + + expect(continuations).toBe(0); + expect(forwarded).toBe(2_003); + }); + test("serializes the guarded boundary as separate assistant output items", () => { const response = buildResponseJSON([ { type: "text_delta", text: "我接下来会修改。" },