diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index dffe315609..aa4a870645 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -13,6 +13,9 @@ 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; +// JavaScript string code units, not UTF-8 bytes or a process-wide memory limit. +const MAX_RETAINED_CONTENT_CHARS = 64 * 1_024; export const TERMINAL_GUARD_NUDGE = "你刚才只描述了计划,没有执行任何工具。不要再次解释计划,现在立即调用必要工具执行用户任务。" + @@ -195,7 +198,17 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): }; } -/** Preserve normal terminals, but withhold one suspicious no-tool terminal for a bounded re-ask. */ +/** + * Forward adapter events and re-ask only short, suspicious no-tool completions. + * Retention is bounded per turn; tools or overflow disable analysis without truncating output. + * Reported usage from completed legs survives a continuation-factory failure. Unreported + * usage stays absent, and source-iteration failures propagate to the caller's transport handler. + * + * @param options Initial stream, parsed history, and continuation callback. The caller owns + * provider opt-in; the continuation limit defaults to one and is clamped to at most two. + * @yields Unchanged content events, internal assistant boundaries, and terminal events with + * accumulated reported usage when available. Returning the iterator closes its active source. + */ export async function* guardTerminalEventStream(options: GuardedEventStreamOptions): AsyncGenerator { const maxContinuations = Math.max(0, Math.min(2, Math.floor(options.maxAutoContinuations ?? 1))); let parsed = options.parsed; @@ -205,6 +218,10 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio while (true) { const seen: AdapterEvent[] = []; + let retainedContentChars = 0; + let retainedText = ""; + let analysisEnabled = (options.adapterName === "anthropic" || options.adapterName === "openai-chat") + && continuations < maxContinuations; let terminalSeen = false; for await (const event of source) { // Liveness markers and tool argument fragments are passed through to the bridge, but @@ -216,7 +233,7 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio } if (event.type === "done") { terminalSeen = true; - const analysis = (options.adapterName === "anthropic" || options.adapterName === "openai-chat") + const analysis = analysisEnabled ? analyzeTerminalTurn(parsed, seen) : { decision: "pass" as const }; const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter"; @@ -224,11 +241,17 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio accumulatedUsage = mergeUsage(accumulatedUsage, event.usage); continuations += 1; parsed = buildContinuationRequest(parsed, seen); + seen.length = 0; + retainedText = ""; yield { type: "assistant_boundary" }; try { source = await options.continuation(parsed); } catch (error) { - yield { type: "error", message: error instanceof Error ? error.message : String(error) }; + yield { + type: "error", + message: error instanceof Error ? error.message : String(error), + ...(accumulatedUsage ? { usage: accumulatedUsage } : {}), + }; return; } break; @@ -243,7 +266,45 @@ 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 permanently rules out a no-tool continuation for this turn. + analysisEnabled = false; + } 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; + if ( + seen.length >= MAX_RETAINED_EVENTS + || content.length > MAX_RETAINED_CONTENT_CHARS - retainedContentChars + ) { + analysisEnabled = false; + } else { + retainedContentChars += content.length; + if (event.type === "text_delta") retainedText += content; + // Match analyzeTerminalTurn's trimmed-text semantics, including split padding. + if (retainedText.trim().length > MAX_ANNOUNCEMENT_CHARS) { + analysisEnabled = false; + } else { + seen.push(event); + } + } + } + if (!analysisEnabled) { + // Never rebuild a continuation from truncated thinking or a partial turn. + seen.length = 0; + retainedText = ""; + } + } yield event; } if (!terminalSeen) return; diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index b9b3980bf9..738120ae1a 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -41,3 +41,27 @@ Translated audio/file admission follows the [final-adapter input contract](../ad Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. Upstream API-key usage follows the [physical-attempt account attribution contract](../gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations. + +## Terminal-continuation retention + +`src/server/responses/terminal-guard.ts` retains at most 1,024 text/thinking/signature/redacted +content events and 65,536 aggregate JavaScript string code units per guarded turn. These are +semantic-retention limits, not UTF-8 byte accounting or a process-wide memory cap. Heartbeats, +tool-argument fragments, and events unused by continuation analysis/rebuilding pass through +without being retained or spending that allowance. + +A real tool start, a limit overflow, or text exceeding 280 characters after trimming disables +analysis for the rest of the turn and clears the retained history. Overflow never produces a +continuation from truncated reasoning. Consumer events, terminal reasons, and usage still pass +through unchanged except for existing cross-continuation usage aggregation. Each permitted +continuation has fresh counters; unsupported adapters and exhausted continuation allowances +retain no content. Anthropic behavior and the caller's OpenAI Chat opt-in gate remain scoped as +before. `tests/server/terminal-guard.test.ts` covers inclusive limits, split whitespace, passthrough, +reasoning replay, analysis shutdown, usage aggregation, and unsuccessful or absent terminals. + +If creating a continuation throws or rejects, its error event carries usage already reported by +completed legs. Unknown usage stays absent rather than becoming a measured zero. This does not +invent usage for an unreported failed send, retry a failed factory, or turn failure into success. +Source-iteration exceptions still propagate to the caller. Returning the guard iterator closes +its active source; cancellation at an assistant boundary does not start the continuation callback. +The same focused tests cover these lifecycle paths and Unicode code-unit limit boundaries. diff --git a/tests/server/terminal-guard.test.ts b/tests/server/terminal-guard.test.ts index c8d60d1ce6..61234f80a2 100644 --- a/tests/server/terminal-guard.test.ts +++ b/tests/server/terminal-guard.test.ts @@ -361,3 +361,357 @@ describe("terminal guard", () => { expect((response.output as { type: string }[]).map(item => item.type)).toEqual(["message", "function_call"]); }); }); + +describe("terminal guard bounded retention", () => { + const announcement: AdapterEvent = { type: "text_delta", text: "Let me check." }; + const done: AdapterEvent = { type: "done", usage: { inputTokens: 10, outputTokens: 2 } }; + const contentLimit = 64 * 1_024; + + /** + * Collect one guarded fixture and the continuation requests it actually makes. + * @param events Adapter events supplied in their original order. + * @param adapterName Adapter whose existing guard policy is exercised. + * @param maxAutoContinuations Allowed internal re-asks for this fixture. + * @returns Forwarded events and captured requests, without mutating the input events. + */ + async function run(events: AdapterEvent[], adapterName: string, maxAutoContinuations = 1) { + const actual: AdapterEvent[] = []; + const requests: OcxParsedRequest[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), + adapterName, + maxAutoContinuations, + firstEvents: (async function* () { yield* events; })(), + continuation: next => { + requests.push(next); + return (async function* (): AsyncGenerator { + yield { type: "done", usage: { inputTokens: 20, outputTokens: 3 } }; + })(); + }, + })) actual.push(event); + return { actual, requests }; + } + + for (const adapterName of ["anthropic", "openai-chat"]) { + describe(adapterName, () => { + for (const count of [1_024, 1_025]) { + test(`retained event count ${count} respects the inclusive limit`, async () => { + const events: AdapterEvent[] = [announcement]; + for (let i = 1; i < count; i += 1) events.push({ type: "text_delta", text: "" }); + events.push(done); + const { actual, requests } = await run(events, adapterName); + expect(requests).toHaveLength(count === 1_024 ? 1 : 0); + // Each input content event reaches the consumer unchanged, even beyond the cap. + for (let i = 0; i < count; i += 1) expect(actual[i]).toBe(events[i]); + expect(actual.filter(event => event.type === "done")).toHaveLength(1); + }); + } + + const reasoningEvents: Array<[string, (content: string) => AdapterEvent]> = [ + ["thinking", thinking => ({ type: "thinking_delta", thinking })], + ["signature", signature => ({ type: "thinking_signature", signature })], + ["redacted", data => ({ type: "redacted_thinking", data })], + ]; + for (const [name, makeEvent] of reasoningEvents) { + for (const extra of [0, 1]) { + test(`${name} content limit plus ${extra} never replays a truncated prefix`, async () => { + const payload = makeEvent("x".repeat(contentLimit - "Let me check.".length + extra)); + const { actual, requests } = await run([announcement, payload, done], adapterName); + expect(requests).toHaveLength(extra === 0 ? 1 : 0); + expect(actual[0]).toBe(announcement); + expect(actual[1]).toBe(payload); + expect(actual.at(-1)).toMatchObject({ + type: "done", usage: extra === 0 + ? { inputTokens: 30, outputTokens: 5, totalTokens: 35 } + : { inputTokens: 10, outputTokens: 2 }, + }); + }); + } + } + + test("content accounting adds different reasoning kinds together", async () => { + const { actual, requests } = await run([ + announcement, + { type: "thinking_delta", thinking: "x".repeat(32 * 1_024) }, + { type: "thinking_signature", signature: "s".repeat(16 * 1_024) }, + { type: "redacted_thinking", data: "r".repeat(16 * 1_024) }, + done, + ], adapterName); + expect(requests).toHaveLength(0); + expect(actual).toHaveLength(5); + }); + + test("text length follows trimmed announcement semantics across split whitespace", async () => { + for (const length of [280, 281]) { + const { requests } = await run([ + { type: "text_delta", text: " \n".repeat(200) }, + { type: "text_delta", text: "Let me check. " + "x".repeat(length - 14) }, + { type: "text_delta", text: "\t ".repeat(200) }, + done, + ], adapterName); + expect(requests).toHaveLength(length === 280 ? 1 : 0); + } + }); + + test("passthrough-only events do not spend the retention allowance", async () => { + const events: AdapterEvent[] = [announcement]; + for (let i = 0; i < 1_100; i += 1) { + events.push({ type: "heartbeat" }); + events.push({ type: "tool_call_delta", arguments: "x".repeat(100) }); + } + events.push(done); + const { actual, requests } = await run(events, adapterName); + expect(requests).toHaveLength(1); + for (let i = 0; i < events.length - 1; i += 1) expect(actual[i]).toBe(events[i]); + }); + + const disablingEvents: Array<[string, AdapterEvent]> = [ + ["tool start", { type: "tool_call_start", id: "call_1", name: "exec_command" }], + ["long text", { type: "text_delta", text: "x".repeat(281) }], + ["oversized reasoning", { type: "thinking_delta", thinking: "x".repeat(contentLimit + 1) }], + ]; + for (const [name, disablingEvent] of disablingEvents) { + test(`${name} permanently stops payload analysis while forwarding later events`, async () => { + let reads = 0; + const probe: AdapterEvent = { + type: "text_delta", + get text() { reads += 1; return "Let me check again."; }, + }; + const events: AdapterEvent[] = [announcement, disablingEvent]; + for (let i = 0; i < 2_000; i += 1) events.push(probe); + events.push(done); + const { actual, requests } = await run(events, adapterName); + expect(reads).toBe(0); + expect(requests).toHaveLength(0); + expect(actual).toHaveLength(events.length); + for (let i = 0; i < events.length - 1; i += 1) expect(actual[i]).toBe(events[i]); + // Terminal usage is preserved through the existing shallow-copy path. + expect(actual.at(-1)).toEqual(done); + }); + } + + const terminals: Array<[string, AdapterEvent | undefined]> = [ + ["EOF", undefined], + ["max tokens", { type: "done", stopReason: "max_tokens" }], + ["content filter", { type: "done", stopReason: "content_filter" }], + ["incomplete", { type: "incomplete", reason: "content_filter", retryable: false }], + ["error", { type: "error", message: "upstream failed", retryable: false }], + ]; + for (const [name, terminal] of terminals) { + test(`overflow preserves ${name} without manufacturing a successful terminal`, async () => { + const events: AdapterEvent[] = [announcement, { type: "thinking_delta", thinking: "x".repeat(contentLimit) }]; + if (terminal) events.push(terminal); + const { actual, requests } = await run(events, adapterName); + expect(requests).toHaveLength(0); + expect(actual).toHaveLength(events.length); + for (let i = 0; i < events.length; i += 1) expect(actual[i]).toBe(events[i]); + }); + } + + test("bounded continuation replays complete thinking, signature and redacted data", async () => { + const { requests } = await run([ + { type: "thinking_delta", thinking: "reasoning" }, + { type: "thinking_signature", signature: "signature" }, + { type: "redacted_thinking", data: "redacted" }, + announcement, done, + ], adapterName); + expect(requests).toHaveLength(1); + expect(requests[0]?.context.messages.at(-2)).toMatchObject({ + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning", signature: "signature", redacted: ["redacted"] }, + { type: "text", text: "Let me check." }, + ], + }); + }); + + test("each allowed continuation gets fresh retention counters and preserves usage", async () => { + let continuations = 0; + const actual: AdapterEvent[] = []; + const turn = async function* (): AsyncGenerator { + yield announcement; + yield { type: "thinking_delta", thinking: "x".repeat(40 * 1_024) }; + for (let i = 0; i < 600; i += 1) yield { type: "text_delta", text: "" }; + yield done; + }; + for await (const event of guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), adapterName, maxAutoContinuations: 2, + firstEvents: turn(), continuation: () => { continuations += 1; return turn(); }, + })) actual.push(event); + expect(continuations).toBe(2); + expect(actual.filter(event => event.type === "assistant_boundary")).toHaveLength(2); + expect(actual.filter(event => event.type === "done")).toHaveLength(1); + expect(actual.at(-1)).toMatchObject({ usage: { inputTokens: 30, outputTokens: 6, totalTokens: 36 } }); + }); + + test("an exhausted continuation allowance does not inspect content", async () => { + let reads = 0; + const probe: AdapterEvent = { type: "text_delta", get text() { reads += 1; return "Let me check."; } }; + const { actual, requests } = await run([probe, done], adapterName, 0); + expect(reads).toBe(0); + expect(requests).toHaveLength(0); + expect(actual[0]).toBe(probe); + }); + }); + } +}); + +describe("terminal guard lifecycle and accounting", () => { + const announcement: AdapterEvent = { type: "text_delta", text: "Let me check." }; + + for (const adapterName of ["anthropic", "openai-chat"]) { + describe(adapterName, () => { + for (const asynchronous of [false, true]) { + test(`${asynchronous ? "async" : "sync"} continuation startup failure preserves reported usage`, async () => { + const usage = { + inputTokens: 10, outputTokens: 2, cachedInputTokens: 3, + cacheReadInputTokens: 3, cacheCreationInputTokens: 1, + reasoningOutputTokens: 1, estimated: true, + }; + const failure = new Error("continuation setup failed"); + const actual: AdapterEvent[] = []; + let calls = 0; + for await (const event of guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), adapterName, + firstEvents: (async function* (): AsyncGenerator { + yield announcement; + yield { type: "done", usage }; + })(), + continuation: () => { + calls += 1; + if (asynchronous) return Promise.reject(failure); + throw failure; + }, + })) actual.push(event); + expect(calls).toBe(1); + expect(actual).toEqual([ + announcement, { type: "assistant_boundary" }, + { type: "error", message: failure.message, usage }, + ]); + }); + } + + test("startup failure after two completed legs keeps their aggregate usage", async () => { + let calls = 0; + const actual: AdapterEvent[] = []; + const turn = async function* (): AsyncGenerator { + yield announcement; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2, cachedInputTokens: 3 } }; + }; + for await (const event of guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), adapterName, maxAutoContinuations: 2, + firstEvents: turn(), + continuation: () => { + calls += 1; + if (calls === 1) return turn(); + throw new Error("second continuation setup failed"); + }, + })) actual.push(event); + expect(calls).toBe(2); + expect(actual.filter(event => event.type === "assistant_boundary")).toHaveLength(2); + expect(actual.filter(event => event.type === "done")).toHaveLength(0); + expect(actual.at(-1)).toEqual({ + type: "error", message: "second continuation setup failed", + usage: { inputTokens: 20, outputTokens: 4, totalTokens: 24, cachedInputTokens: 6 }, + }); + }); + + test("startup failure does not fabricate unknown usage", async () => { + const actual: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), adapterName, + firstEvents: (async function* (): AsyncGenerator { + yield announcement; + yield { type: "done" }; + })(), + continuation: () => { throw "continuation unavailable"; }, + })) actual.push(event); + expect(actual.at(-1)).toEqual({ type: "error", message: "continuation unavailable" }); + expect(Object.hasOwn(actual.at(-1)!, "usage")).toBe(false); + expect(actual.filter(event => event.type === "done")).toHaveLength(0); + }); + + for (const atBoundary of [false, true]) { + test(`consumer cancellation ${atBoundary ? "at boundary" : "during content"} closes the source without a continuation`, async () => { + let closed = false; + let calls = 0; + const stream = guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), adapterName, + firstEvents: (async function* (): AsyncGenerator { + try { + yield announcement; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2 } }; + } finally { + closed = true; + } + })(), + continuation: () => { + calls += 1; + return (async function* (): AsyncGenerator { yield { type: "done" }; })(); + }, + }); + expect((await stream.next()).value).toBe(announcement); + if (atBoundary) expect((await stream.next()).value).toEqual({ type: "assistant_boundary" }); + expect((await stream.return(undefined)).done).toBe(true); + expect(closed).toBe(true); + expect(calls).toBe(0); + }); + } + + test("source iteration exceptions propagate without manufacturing success", async () => { + const failure = new Error("source read failed"); + const actual: AdapterEvent[] = []; + let caught: unknown; + let calls = 0; + let closed = false; + try { + for await (const event of guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), adapterName, + firstEvents: (async function* (): AsyncGenerator { + try { + yield announcement; + throw failure; + } finally { + closed = true; + } + })(), + continuation: () => { + calls += 1; + return (async function* (): AsyncGenerator { yield { type: "done" }; })(); + }, + })) actual.push(event); + } catch (error) { + caught = error; + } + expect(caught).toBe(failure); + expect(actual).toEqual([announcement]); + expect(closed).toBe(true); + expect(calls).toBe(0); + }); + + for (const extra of [0, 1]) { + test(`Unicode content limit plus ${extra} counts code units rather than UTF-8 bytes`, async () => { + const length = 64 * 1_024 - "Let me check.".length + extra; + const thinking = "😀".repeat(Math.floor(length / 2)) + (length % 2 ? "x" : ""); + let calls = 0; + for await (const _event of guardTerminalEventStream({ + parsed: parsed("Check and fix this code"), adapterName, + firstEvents: (async function* (): AsyncGenerator { + yield announcement; + yield { type: "thinking_delta", thinking }; + yield { type: "done" }; + })(), + continuation: () => { + calls += 1; + return (async function* (): AsyncGenerator { yield { type: "done" }; })(); + }, + })) { + // Consume the stream without retaining its content in the test. + } + expect(thinking.length).toBe(length); + expect(calls).toBe(extra === 0 ? 1 : 0); + }); + } + }); + } +});