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
35 changes: 1 addition & 34 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ import {
shouldTerminateAssistantTurn,
} from "./assistant-terminal-state.ts";
import { getDefaultStreamFn, withEmptyAssistantRecovery } from "./stream-fn.ts";
import {
createStreamThroughputWatchdog,
estimateStreamedUnits,
type StreamThroughputWatchdog,
} from "./stream-throughput-watchdog.ts";
import type {
AgentContext,
AgentEvent,
Expand Down Expand Up @@ -499,7 +494,6 @@ async function streamAssistantResponse(
(error) => requestAbortController.abort(error),
config.streamStartTimeoutMs,
response,
createStreamThroughputWatchdog(config.streamThroughput),
);
try {
while (true) {
Expand Down Expand Up @@ -660,20 +654,13 @@ function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined {
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
}

/** Streamed units carried by one assistant event; only text and thinking count. */
function streamedUnitsOf(event: AssistantMessageEvent): number {
if (event.type === "text_delta" || event.type === "thinking_delta") return estimateStreamedUnits(event.delta);
return 0;
}

function createAssistantEventReader(
iterator: AsyncIterator<AssistantMessageEvent>,
timeoutMs: number | undefined,
signal: AbortSignal | undefined,
onIdleTimeout?: (error: Error) => void,
streamStartTimeoutMs?: number,
stream?: Pick<AssistantMessageEventStream, "hasPendingLocalWork">,
throughput?: StreamThroughputWatchdog,
): AssistantEventReader {
const idleTimeoutMs = normalizeTimeoutMs(timeoutMs);
const startTimeoutMs = normalizeTimeoutMs(streamStartTimeoutMs);
Expand Down Expand Up @@ -706,10 +693,6 @@ function createAssistantEventReader(
const makeTimeoutError = useStartBound
? (ms: number) => new StreamStartTimeoutError(ms)
: (ms: number) => new StreamIdleTimeoutError(ms);
// A provider executing a server-requested tool locally (Cursor's exec
// channel) is not streaming; that span must not count against the rate.
const localWorkPending = throughput !== undefined && stream?.hasPendingLocalWork?.() === true;
const waitStartedAt = localWorkPending ? Date.now() : 0;
const result = await readNextAssistantEvent(
iterator,
readTimeoutMs,
Expand All @@ -719,23 +702,7 @@ function createAssistantEventReader(
stream,
signal,
);
if (localWorkPending) throughput?.exclude(Date.now() - waitStartedAt);
if (!result.done) {
sawFirstEvent = true;
if (throughput !== undefined) {
// The rate clock starts at the first event, exactly where the
// stream-start bound stops applying.
throughput.start();
const degraded = throughput.record(streamedUnitsOf(result.value));
if (degraded !== undefined) {
closeAssistantIterator(iterator);
// Abort before rejecting: the caller inspects the request signal's
// reason, and the crawling upstream must be torn down either way.
onIdleTimeout?.(degraded);
throw degraded;
}
}
}
if (!result.done) sawFirstEvent = true;
return result;
},
dispose: () => removeAbortListener?.(),
Expand Down
7 changes: 0 additions & 7 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
} from "./agent-loop.ts";
import { ProviderRetryWatchdogAbortError } from "./assistant-terminal-state.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type { StreamThroughputOptions } from "./stream-throughput-watchdog.ts";
import type {
AfterToolCallContext,
AfterToolCallResult,
Expand Down Expand Up @@ -129,8 +128,6 @@ export interface AgentOptions {
transport?: Transport;
timeoutMs?: number;
streamStartTimeoutMs?: number;
/** Sustained-throughput guard; see {@link AgentLoopConfig.streamThroughput}. */
streamThroughput?: StreamThroughputOptions;
maxRetryDelayMs?: number;
toolExecution?: ToolExecutionMode;
removedToolHints?: Record<string, string>;
Expand Down Expand Up @@ -249,8 +246,6 @@ export class Agent {
public timeoutMs?: number;
/** Optional bound on the wait for the first provider stream event. */
public streamStartTimeoutMs?: number;
/** Optional sustained-throughput guard for an in-progress stream. */
public streamThroughput?: StreamThroughputOptions;
/** Optional cap for provider-requested retry delays. */
public maxRetryDelayMs?: number;
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
Expand Down Expand Up @@ -294,7 +289,6 @@ export class Agent {
this.transport = runtimeOptions.transport ?? "auto";
this.timeoutMs = runtimeOptions.timeoutMs;
this.streamStartTimeoutMs = runtimeOptions.streamStartTimeoutMs;
this.streamThroughput = runtimeOptions.streamThroughput;
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
this.removedToolHints = runtimeOptions.removedToolHints ?? {};
Expand Down Expand Up @@ -599,7 +593,6 @@ export class Agent {
thinkingBudgets: this.thinkingBudgets,
timeoutMs: this.timeoutMs,
streamStartTimeoutMs: this.streamStartTimeoutMs,
streamThroughput: this.streamThroughput,
initialRequestTimeoutMs: options.initialRequestTimeoutMs,
initialRequestStreamStartTimeoutMs: options.initialRequestStreamStartTimeoutMs,
maxRetryDelayMs: this.maxRetryDelayMs,
Expand Down
19 changes: 9 additions & 10 deletions packages/agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
## 2026-09-16 - Stream throughput watchdog for in-progress provider streams (#1739)
## 2026-09-16 - Stream throughput guard withdrawn; the loop bounds silence only (senpi#1759)

### What changed

- `packages/agent/src/stream-throughput-watchdog.ts` (new): `StreamThroughputDegradedError`, `formatStreamThroughputDegradedMessage`, `estimateStreamedUnits`, the sliding-window `StreamRateMeter`, `createStreamThroughputWatchdog` and the shipped defaults (floor 8 units/s, 20s window, 5s grace, 16-unit minimum). One streamed unit is ~4 characters of a text or thinking delta, so a gateway that batches several tokens per delta is measured by volume rather than by event count.
- `packages/agent/src/agent-loop.ts`: the assistant event reader creates the watchdog from `config.streamThroughput`, anchors it at the first stream event, records units from `text_delta` / `thinking_delta`, and excludes any wait that began while the stream reported pending local work (Cursor exec). A verdict closes the iterator, aborts the request controller with the error and rejects the read, so the turn ends as `stopReason: "error"` with that message and the request signal carries it.
- `packages/agent/src/types.ts`: `AgentLoopConfig.streamThroughput` (floor / window / grace; a `0` floor or window disables the guard).
- `packages/agent/src/agent.ts`: `AgentOptions.streamThroughput` and the matching public field, forwarded into every loop config so hosts can retune it per session.
- `packages/agent/src/index.ts`: exports the watchdog module's public surface (the coding agent's interactive working line reuses `StreamRateMeter` and `estimateStreamedUnits`).
- `packages/agent/src/stream-throughput-watchdog.ts` is deleted.
- `packages/agent/src/agent-loop.ts`: the assistant event reader no longer builds a rate watchdog, records streamed units or aborts the request controller on a rate verdict. It is back to the two silence bounds - the stream-start bound until the first event, and the inter-event idle bound.
- `packages/agent/src/types.ts`: `AgentLoopConfig.streamThroughput` removed.
- `packages/agent/src/agent.ts`: `AgentOptions.streamThroughput`, the public field and its forwarding into every loop config removed.
- `packages/agent/src/index.ts`: the watchdog module's exports removed.

### Why

- Every other guard on a live stream detects SILENCE: the stream-start bound stops applying once the first event arrives (`useStartBound = !sawFirstEvent`) and the idle bound is re-armed by every event. A provider answering at ~2 tok/s therefore tripped nothing while the session was unusable (senpi#1739, reported for `gpt-6-astra`). Compaction already bounds this class with a wall-clock budget; the main turn cannot use a wall clock because tool-using turns are legitimately long, so the guard measures rate over a trailing window instead.
- The floor failed healthy turns: a stream measured at 6.1 tok/s over the 20s window had its request aborted mid tool call, and thinking-heavy models and gateways that batch several tokens into one delta routinely sustain rates under the shipped 8 tok/s floor. Aborting the controller also discarded the partial answer instead of delivering it slowly. The guard is withdrawn rather than retuned, so these files match their pre-guard shape again.

### Why an extension could not handle it

- The measurement has to happen between the provider iterator and the loop, on the same controller that can abort the in-flight request. No extension hook sits there, and an extension cannot fail the turn with a retryable error the session router understands.
- The bound lived inside the agent loop's stream reader, which no extension can observe or replace; removing it likewise has to happen here.

### Expected merge conflict zones

- MEDIUM: `packages/agent/src/agent-loop.ts` around `createAssistantEventReader` / `readNextAssistantEvent`, which upstream also edits for the idle and start bounds. Keep the split: silence -> start/idle errors, sustained low rate -> `StreamThroughputDegradedError`.
- LOW: the new option field in `packages/agent/src/types.ts` and `packages/agent/src/agent.ts`.
- LOW: `createAssistantEventReader` / `readNextAssistantEvent` in `packages/agent/src/agent-loop.ts` are back to the upstream shape, so an upstream edit to the start or idle bounds now applies cleanly.

## 2026-09-16 - Forward thinking live in the empty-assistant recovery wrapper (#1733)

Expand Down
12 changes: 0 additions & 12 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,4 @@ export * from "./harness/utils/truncate.ts";
export * from "./proxy.ts";
export * from "./search/index.ts";
export { setDefaultStreamFn } from "./stream-fn.ts";
export type { StreamThroughputOptions, StreamThroughputWatchdog } from "./stream-throughput-watchdog.ts";
export {
createStreamThroughputWatchdog,
DEFAULT_STREAM_THROUGHPUT_FLOOR_TOKENS_PER_SECOND,
DEFAULT_STREAM_THROUGHPUT_GRACE_MS,
DEFAULT_STREAM_THROUGHPUT_WINDOW_MS,
estimateStreamedUnits,
formatStreamThroughputDegradedMessage,
STREAM_THROUGHPUT_MIN_UNITS,
StreamRateMeter,
StreamThroughputDegradedError,
} from "./stream-throughput-watchdog.ts";
export * from "./types.ts";
219 changes: 0 additions & 219 deletions packages/agent/src/stream-throughput-watchdog.ts

This file was deleted.

Loading