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
21 changes: 14 additions & 7 deletions src/server/responses/terminal-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ export interface GuardedEventStreamOptions {
maxAutoContinuations?: number;
}

/**
* Events that are useful to the downstream stream consumer but carry no state used by the
* terminal-continuation decision or its rebuilt request. Keep them out of the retained event
* list so adapter liveness markers and arbitrarily large tool-argument fragments cannot make
* the guard's per-turn memory grow without adding any continuation semantics.
*/
export function isTerminalGuardPassthroughOnly(event: AdapterEvent): boolean {
return event.type === "heartbeat" || event.type === "tool_call_delta";
}

function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined {
if (!first) return second;
if (!second) return first;
Expand Down Expand Up @@ -193,13 +203,10 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
const seen: AdapterEvent[] = [];
let terminalSeen = false;
for await (const event of source) {
// A heartbeat is adapter liveness, not turn content: it exists so the bridge watchdog
// can tell a buffering adapter from a hung one. Retaining it here would put an
// unbounded number of empty markers into `seen`, which feeds both the continuation
// analysis and the rebuilt request — and the openai-chat adapter now emits one per
// tool-call delta, so a long argument payload alone could grow this array without
// limit. The empty-completion guard already passes them through unretained; match it.
if (event.type === "heartbeat") {
// Liveness markers and tool argument fragments are passed through to the bridge, but
// neither analyzeTerminalTurn nor buildContinuationRequest consumes them. Retaining the
// fragments would duplicate arbitrarily large argument payloads in `seen` for no effect.
if (isTerminalGuardPassthroughOnly(event)) {
yield event;
continue;
}
Expand Down
11 changes: 11 additions & 0 deletions tests/terminal-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
analyzeTerminalTurn,
buildContinuationRequest,
guardTerminalEventStream,
isTerminalGuardPassthroughOnly,
} from "../src/server/responses/terminal-guard";
import { buildResponseJSON } from "../src/bridge";
import type { AdapterEvent, OcxParsedRequest } from "../src/types";
Expand Down Expand Up @@ -236,6 +237,16 @@ describe("terminal guard", () => {
expect(actual.filter(event => event.type === "done")).toHaveLength(1);
});

test("does not retain passthrough-only liveness or tool argument fragments", () => {
expect(isTerminalGuardPassthroughOnly({ type: "heartbeat" })).toBe(true);
expect(isTerminalGuardPassthroughOnly({
type: "tool_call_delta",
arguments: "x".repeat(1024 * 1024),
})).toBe(true);
expect(isTerminalGuardPassthroughOnly({ type: "tool_call_start", id: "call_1", name: "exec_command" })).toBe(false);
expect(isTerminalGuardPassthroughOnly({ type: "text_delta", text: "working" })).toBe(false);
});

test("stops after the configured continuation bound", async () => {
let continuations = 0;
const actual: AdapterEvent[] = [];
Expand Down
Loading