Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/server/responses/terminal-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"你刚才只描述了计划,没有执行任何工具。不要再次解释计划,现在立即调用必要工具执行用户任务。" +
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions tests/terminal-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "我接下来会修改。" },
Expand Down
Loading