Skip to content
Merged
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
69 changes: 65 additions & 4 deletions src/server/responses/terminal-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"你刚才只描述了计划,没有执行任何工具。不要再次解释计划,现在立即调用必要工具执行用户任务。" +
Expand Down Expand Up @@ -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<AdapterEvent> {
const maxContinuations = Math.max(0, Math.min(2, Math.floor(options.maxAutoContinuations ?? 1)));
let parsed = options.parsed;
Expand All @@ -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
Expand All @@ -216,19 +233,25 @@ 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";
if (normalStop && analysis.decision === "continue" && continuations < maxContinuations) {
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;
Expand All @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions structure/transports/byte-accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update every mapped server contract document

Because this commit changes src/server/responses/terminal-guard.ts, updating only structure/transports/byte-accounting.md leaves the other documents mapped to src/server/ in structure/INDEX.md:125 untouched. Update those mapped contract documents in the same change, or correct the ownership mapping if they do not actually describe this area; repository rules explicitly require every listed document to be synchronized whenever an owned source area changes.

AGENTS.md reference: structure/AGENTS.md:L44-L50

Useful? React with 👍 / 👎.

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.
Loading
Loading