diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 5e87c9b33..1fb3d6c5a 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -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, @@ -499,7 +494,6 @@ async function streamAssistantResponse( (error) => requestAbortController.abort(error), config.streamStartTimeoutMs, response, - createStreamThroughputWatchdog(config.streamThroughput), ); try { while (true) { @@ -660,12 +654,6 @@ 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, timeoutMs: number | undefined, @@ -673,7 +661,6 @@ function createAssistantEventReader( onIdleTimeout?: (error: Error) => void, streamStartTimeoutMs?: number, stream?: Pick, - throughput?: StreamThroughputWatchdog, ): AssistantEventReader { const idleTimeoutMs = normalizeTimeoutMs(timeoutMs); const startTimeoutMs = normalizeTimeoutMs(streamStartTimeoutMs); @@ -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, @@ -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?.(), diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 07295387f..af80613b3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -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, @@ -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; @@ -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. */ @@ -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 ?? {}; @@ -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, diff --git a/packages/agent/src/changes.md b/packages/agent/src/changes.md index de2d18584..f9c97506d 100644 --- a/packages/agent/src/changes.md +++ b/packages/agent/src/changes.md @@ -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) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0a6cd5fcf..e58c44978 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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"; diff --git a/packages/agent/src/stream-throughput-watchdog.ts b/packages/agent/src/stream-throughput-watchdog.ts deleted file mode 100644 index 2d8f1ebe8..000000000 --- a/packages/agent/src/stream-throughput-watchdog.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Rate guard for an in-progress provider stream. - * - * Every other guard on a live stream is a SILENCE detector: the stream-start - * bound stops applying once the first event arrived, and the inter-event idle - * bound is re-armed by every event. A provider that keeps answering at ~2 tok/s - * therefore trips nothing at all, while the session is unusable — the reported - * `gpt-6-astra` symptom (#1739). This measures the RATE of streamed text and - * thinking units so a trickle becomes a first-class, retryable failure instead - * of a healthy-looking turn. - * - * Deliberately not a wall-clock turn budget: tool-using turns legitimately last - * many minutes. Only time spent waiting on the provider counts, and time the - * provider spends executing local work (Cursor's exec channel) is excluded. - */ - -/** Sustained floor in streamed units per second; `0` disables the watchdog. */ -export const DEFAULT_STREAM_THROUGHPUT_FLOOR_TOKENS_PER_SECOND = 8; -/** Observation window; the verdict needs a full window of measured streaming. */ -export const DEFAULT_STREAM_THROUGHPUT_WINDOW_MS = 20_000; -/** First-token jitter and a single long reasoning pause must not fire. */ -export const DEFAULT_STREAM_THROUGHPUT_GRACE_MS = 5_000; -/** - * Minimum streamed units inside the window before a rate is judged at all, so a - * two-token heartbeat is never divided into a verdict. - */ -export const STREAM_THROUGHPUT_MIN_UNITS = 16; -/** Live rate needs this much measured streaming before it means anything. */ -const MIN_RATE_SAMPLE_MS = 1_000; - -export interface StreamThroughputOptions { - /** Sustained floor in units per second; `0` or negative disables the watchdog. */ - floorTokensPerSecond?: number; - /** Observation window in milliseconds; `0` or negative disables the watchdog. */ - windowMs?: number; - /** Milliseconds after the first stream event that are never measured. */ - graceMs?: number; -} - -/** - * One unit approximates one token. Providers that emit a delta per token give - * one unit per delta; gateways that batch several tokens into one delta are - * measured by length instead of by event count, so batching cannot be mistaken - * for a trickle. - */ -export function estimateStreamedUnits(text: string | undefined): number { - if (!text) return 0; - return Math.max(1, Math.ceil(text.length / 4)); -} - -function formatRate(value: number): string { - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} - -function formatWindowSeconds(windowMs: number): string { - const seconds = windowMs / 1000; - return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1); -} - -/** - * The wording is part of the contract: `packages/ai/src/utils/retry.ts` - * classifies it as a retryable, throughput-degraded failure (distinct from the - * silence stalls), and the session routes it straight to the fallback chain. - */ -export function formatStreamThroughputDegradedMessage( - tokensPerSecond: number, - floorTokensPerSecond: number, - windowMs: number, -): string { - return ( - `Provider stream throughput degraded: ${formatRate(tokensPerSecond)} tok/s over ` + - `${formatWindowSeconds(windowMs)}s (floor ${formatRate(floorTokensPerSecond)} tok/s) ` + - `(lower or disable with retry.provider.minThroughputTokensPerSecond in senpi settings; 0 disables)` - ); -} - -export class StreamThroughputDegradedError extends Error { - readonly tokensPerSecond: number; - readonly floorTokensPerSecond: number; - readonly windowMs: number; - - constructor(tokensPerSecond: number, floorTokensPerSecond: number, windowMs: number) { - super(formatStreamThroughputDegradedMessage(tokensPerSecond, floorTokensPerSecond, windowMs)); - this.name = "StreamThroughputDegradedError"; - this.tokensPerSecond = tokensPerSecond; - this.floorTokensPerSecond = floorTokensPerSecond; - this.windowMs = windowMs; - } -} - -/** - * Sliding-window counter of streamed units over the time actually spent - * waiting on the provider. Shared by the watchdog and by the interactive - * working status, so the rate a user sees is the rate that gets judged. - */ -export class StreamRateMeter { - private readonly windowMs: number; - private readonly now: () => number; - private samples: { at: number; units: number }[] = []; - private unitsInWindow = 0; - private excludedMs = 0; - private originMs: number | undefined; - - constructor(windowMs: number, now: () => number = Date.now) { - this.windowMs = windowMs; - this.now = now; - } - - /** Wall clock minus the spans excluded from measurement; monotonic. */ - measuredNow(): number { - return this.now() - this.excludedMs; - } - - /** Measured time since the first stream event, or undefined before it. */ - measuredElapsedMs(): number | undefined { - return this.originMs === undefined ? undefined : this.measuredNow() - this.originMs; - } - - /** Anchor the measurement at the first stream event. Idempotent. */ - start(): void { - if (this.originMs === undefined) this.originMs = this.measuredNow(); - } - - /** Drop a span of wall-clock time (provider-local tool work) from the measurement. */ - exclude(elapsedMs: number): void { - if (elapsedMs > 0) this.excludedMs += elapsedMs; - } - - record(units: number): void { - if (units <= 0) return; - this.start(); - const at = this.measuredNow(); - this.samples.push({ at, units }); - this.unitsInWindow += units; - this.prune(at); - } - - /** Streamed units inside the trailing window. */ - units(): number { - this.prune(this.measuredNow()); - return this.unitsInWindow; - } - - /** - * Units per second over the trailing window, or undefined until enough - * measured streaming exists for the number to mean anything. - */ - ratePerSecond(): number | undefined { - const elapsedMs = this.measuredElapsedMs(); - if (elapsedMs === undefined) return undefined; - const spanMs = Math.min(this.windowMs, elapsedMs); - if (spanMs < MIN_RATE_SAMPLE_MS) return undefined; - const units = this.units(); - if (units <= 0) return 0; - return units / (spanMs / 1000); - } - - reset(): void { - this.samples = []; - this.unitsInWindow = 0; - this.excludedMs = 0; - this.originMs = undefined; - } - - private prune(at: number): void { - const cutoff = at - this.windowMs; - let dropped = 0; - while (dropped < this.samples.length && this.samples[dropped].at < cutoff) { - this.unitsInWindow -= this.samples[dropped].units; - dropped++; - } - if (dropped > 0) this.samples = this.samples.slice(dropped); - } -} - -export interface StreamThroughputWatchdog { - /** Marks the first stream event; starts the grace clock. Idempotent. */ - start(): void; - /** Records streamed units and returns the verdict when the floor is breached. */ - record(units: number): StreamThroughputDegradedError | undefined; - /** Drops a span of wall-clock time (provider-local tool work) from the measurement. */ - exclude(elapsedMs: number): void; - /** Live rate over the window, or undefined while it is still meaningless. */ - ratePerSecond(): number | undefined; -} - -/** - * Returns undefined when the watchdog is disabled (a floor or window of `0`), - * so the caller can skip the measurement entirely. - */ -export function createStreamThroughputWatchdog( - options: StreamThroughputOptions | undefined, - now: () => number = Date.now, -): StreamThroughputWatchdog | undefined { - const floorTokensPerSecond = options?.floorTokensPerSecond ?? DEFAULT_STREAM_THROUGHPUT_FLOOR_TOKENS_PER_SECOND; - const windowMs = options?.windowMs ?? DEFAULT_STREAM_THROUGHPUT_WINDOW_MS; - const graceMs = Math.max(0, options?.graceMs ?? DEFAULT_STREAM_THROUGHPUT_GRACE_MS); - if (!Number.isFinite(floorTokensPerSecond) || floorTokensPerSecond <= 0) return undefined; - if (!Number.isFinite(windowMs) || windowMs <= 0) return undefined; - - const meter = new StreamRateMeter(windowMs, now); - return { - start: () => meter.start(), - exclude: (elapsedMs: number) => meter.exclude(elapsedMs), - ratePerSecond: () => meter.ratePerSecond(), - record: (units: number) => { - meter.record(units); - const elapsedMs = meter.measuredElapsedMs(); - // Judge only on a full window of measured streaming that starts after - // the grace period; anything earlier is jitter, not a sustained rate. - if (elapsedMs === undefined || elapsedMs < graceMs + windowMs) return undefined; - const unitsInWindow = meter.units(); - if (unitsInWindow < STREAM_THROUGHPUT_MIN_UNITS) return undefined; - const tokensPerSecond = unitsInWindow / (windowMs / 1000); - if (tokensPerSecond >= floorTokensPerSecond) return undefined; - return new StreamThroughputDegradedError(tokensPerSecond, floorTokensPerSecond, windowMs); - }, - }; -} diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index ae171b2a2..a21f5f519 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -16,7 +16,6 @@ import type { Usage, } from "@earendil-works/pi-ai"; import type { Static, TSchema } from "typebox"; -import type { StreamThroughputOptions } from "./stream-throughput-watchdog.ts"; /** * Stream function used by the agent loop. `Models.streamSimple` satisfies @@ -180,15 +179,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions { */ streamStartTimeoutMs?: number; - /** - * Sustained-throughput guard for an in-progress stream. The start bound stops - * applying once the first event arrives and the idle bound is re-armed by - * every event, so a provider answering at a uselessly low rate trips neither. - * Unset fields fall back to the shipped defaults (floor 8 units/s measured - * over 20s after a 5s grace); a floor or window of `0` disables the guard. - */ - streamThroughput?: StreamThroughputOptions; - /** Provider/SDK timeout override for only the first request in this loop invocation. */ initialRequestTimeoutMs?: number; diff --git a/packages/agent/test/agent-loop-stream-start-timeout.test.ts b/packages/agent/test/agent-loop-stream-start-timeout.test.ts index 01aff7969..7ca2a94f2 100644 --- a/packages/agent/test/agent-loop-stream-start-timeout.test.ts +++ b/packages/agent/test/agent-loop-stream-start-timeout.test.ts @@ -193,11 +193,6 @@ describe("agent loop stream-start timeout", () => { expect(assistantMessage?.errorMessage).toBe("Idle timeout waiting for provider stream after 60ms"); }); - // The gap stays above the throughput floor as well (a 6-character delta after - // 40ms is ~50 tok/s), so this pins the start bound alone: an inter-event gap - // longer than the start timeout is not a failure. Sustained trickle below the - // floor is the throughput watchdog's contract, in - // agent-loop-throughput-watchdog.test.ts. it("lets slow-but-alive streams finish despite gaps above the start bound", async () => { const config: AgentLoopConfig = { model: createModel(), diff --git a/packages/agent/test/agent-loop-throughput-watchdog.test.ts b/packages/agent/test/agent-loop-throughput-watchdog.test.ts deleted file mode 100644 index b4f353cb8..000000000 --- a/packages/agent/test/agent-loop-throughput-watchdog.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { - type AssistantMessage, - type AssistantMessageEvent, - EventStream, - type Message, - type Model, -} from "@earendil-works/pi-ai"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { agentLoop } from "../src/agent-loop.ts"; -import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage } from "../src/types.ts"; - -/** - * Regression coverage for the stream throughput watchdog (#1739). - * - * Every other guard on a live provider stream is a SILENCE detector: the - * stream-start bound stops applying once the first event arrived and the - * inter-event idle bound is re-armed by every event. A provider that keeps - * answering at ~2 tok/s therefore looked perfectly healthy while the session - * was unusable (reported for `gpt-6-astra`, September 2026). The watchdog - * measures the RATE of streamed text/thinking units and aborts the in-flight - * request with its own error when the sustained rate stays below the floor. - */ - -class AssistantEventStream extends EventStream { - constructor() { - super( - (event) => event.type === "done" || event.type === "error", - (event) => { - if (event.type === "done") return event.message; - if (event.type === "error") return event.error; - throw new Error("Unexpected event type"); - }, - ); - } -} - -/** - * Emits `deltaCount` text deltas of `chunk`, one every `gapMs`, then finishes. - * `chunk.length / 4` is the streamed-unit estimate the watchdog measures, so - * the emitted rate is `(chunk.length / 4) / (gapMs / 1000)` units per second. - */ -class PacedTextStream extends AssistantEventStream { - private readonly gapMs: number; - private readonly deltaCount: number; - private readonly chunk: string; - private text = ""; - - constructor(gapMs: number, deltaCount: number, chunk: string) { - super(); - this.gapMs = gapMs; - this.deltaCount = deltaCount; - this.chunk = chunk; - } - - override async *[Symbol.asyncIterator](): AsyncIterator { - const partial = createAssistantMessage([{ type: "text", text: "" }]); - yield { type: "start", partial }; - for (let index = 0; index < this.deltaCount; index++) { - await new Promise((resolve) => setTimeout(resolve, this.gapMs)); - this.text += this.chunk; - partial.content = [{ type: "text", text: this.text }]; - yield { type: "text_delta", contentIndex: 0, delta: this.chunk, partial }; - } - yield { type: "done", reason: "stop", message: this.finalMessage() }; - } - - override result(): Promise { - return Promise.resolve(this.finalMessage()); - } - - private finalMessage(): AssistantMessage { - return createAssistantMessage([{ type: "text", text: this.text }]); - } -} - -function createUsage() { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; -} - -function createModel(): Model<"openai-responses"> { - return { - id: "mock", - name: "mock", - api: "openai-responses", - provider: "openai", - baseUrl: "https://example.invalid", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 2048, - }; -} - -function createAssistantMessage( - content: AssistantMessage["content"], - stopReason: AssistantMessage["stopReason"] = "stop", -): AssistantMessage { - return { - role: "assistant", - content, - api: "openai-responses", - provider: "openai", - model: "mock", - usage: createUsage(), - stopReason, - timestamp: Date.now(), - }; -} - -function createUserMessage(text: string): AgentMessage { - return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() }; -} - -const identityConverter = (messages: AgentMessage[]): Message[] => messages as unknown as Message[]; - -function createContext(): AgentContext { - return { systemPrompt: "You are helpful.", messages: [], tools: [] }; -} - -function findAssistant(messages: AgentMessage[]): AssistantMessage | undefined { - return messages.find((message): message is AssistantMessage => message.role === "assistant"); -} - -async function runPacedLoop( - config: AgentLoopConfig, - makeStream: () => AssistantEventStream, - advanceMs: number, -): Promise<{ assistant: AssistantMessage | undefined; requestSignal: AbortSignal | undefined }> { - let requestSignal: AbortSignal | undefined; - const stream = agentLoop([createUserMessage("Hello")], createContext(), config, undefined, (_m, _c, options) => { - requestSignal = options?.signal; - return makeStream(); - }); - const collected = (async () => { - const events: AgentEvent[] = []; - for await (const event of stream) events.push(event); - return stream.result(); - })(); - await vi.advanceTimersByTimeAsync(advanceMs); - const messages = await collected; - return { assistant: findAssistant(messages), requestSignal }; -} - -describe("agent loop stream throughput watchdog", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("aborts a stream that keeps trickling below the throughput floor", async () => { - const config: AgentLoopConfig = { - model: createModel(), - convertToLlm: identityConverter, - streamStartTimeoutMs: 300_000, - timeoutMs: 300_000, - }; - - // 2 tok/s: one 4-character delta every 500ms, for longer than the - // 5s grace plus the 20s observation window. - const { assistant, requestSignal } = await runPacedLoop( - config, - () => new PacedTextStream(500, 80, "word"), - 45_000, - ); - - expect(assistant?.stopReason).toBe("error"); - expect(assistant?.errorMessage).toMatch(/Provider stream throughput degraded: \d+(?:\.\d+)? tok\/s/); - expect(assistant?.errorMessage).toContain("floor 8 tok/s"); - expect(assistant?.errorMessage).toContain("over 20s"); - expect(requestSignal?.aborted).toBe(true); - expect(String(requestSignal?.reason)).toContain("Provider stream throughput degraded"); - }); - - it("lets a healthy stream finish even when deltas are batched", async () => { - const config: AgentLoopConfig = { - model: createModel(), - convertToLlm: identityConverter, - streamStartTimeoutMs: 300_000, - timeoutMs: 300_000, - }; - - // 40 tok/s delivered as 5 units per delta every 125ms, over the same - // observation window as the degraded case. - const { assistant, requestSignal } = await runPacedLoop( - config, - () => new PacedTextStream(125, 240, "0123456789abcdefghij"), - 45_000, - ); - - expect(assistant?.stopReason).toBe("stop"); - expect(assistant?.errorMessage).toBeUndefined(); - // The loop always tears its own request controller down at the end of the - // turn; that teardown must not carry a throughput verdict. - expect(String(requestSignal?.reason ?? "")).not.toContain("Provider stream throughput degraded"); - }); - - it("does not judge a stream that ends before the observation window closes", async () => { - const config: AgentLoopConfig = { - model: createModel(), - convertToLlm: identityConverter, - streamStartTimeoutMs: 300_000, - timeoutMs: 300_000, - }; - - // Same 2 tok/s trickle, but the answer completes after 20s of streaming - // (5s grace + 15s of window): too early to judge, so it must finish. - const { assistant } = await runPacedLoop(config, () => new PacedTextStream(500, 40, "word"), 45_000); - - expect(assistant?.stopReason).toBe("stop"); - expect(assistant?.errorMessage).toBeUndefined(); - }); - - it("can be disabled with a zero floor", async () => { - const config: AgentLoopConfig = { - model: createModel(), - convertToLlm: identityConverter, - streamStartTimeoutMs: 300_000, - timeoutMs: 300_000, - streamThroughput: { floorTokensPerSecond: 0 }, - }; - - const { assistant } = await runPacedLoop(config, () => new PacedTextStream(500, 80, "word"), 60_000); - - expect(assistant?.stopReason).toBe("stop"); - expect(assistant?.errorMessage).toBeUndefined(); - }); -}); diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index d139a008e..641e3c313 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,38 +1,39 @@ -## Plain-language provider-stall copy (2026-09-16) +## Throughput-degraded classification withdrawn (2026-09-16) ### What changed -- `packages/ai/src/utils/retry.ts`: adds `describeProviderStallForUser(errorMessage, options)` and the `ProviderStallDescriptionOptions` type next to `PROVIDER_STREAM_STALL_ERROR_PATTERN`. It turns any of the four stall watchdog wordings (stream-start, idle, WebSocket liveness, Responses completion) into one user-facing sentence naming the model, what the provider failed to do, and the bound it blew; with `attempts` it adds the same-model retry count, and with `recovery` it adds the next step (`/fallback`, resend, or the matching `retry.provider.*` setting). Anything that is not a stall returns `undefined` so callers keep their verbatim error. The classifier patterns and every existing export are untouched. +- `packages/ai/src/utils/retry.ts`: the `"provider stream throughput degraded"` alternation is removed from `RETRYABLE_PROVIDER_ERROR_PATTERN`, and `isProviderStreamThroughputDegradedError` with its anchored pattern is deleted. The silence-stall classifiers and `describeProviderStallForUser` are untouched. ### Why -- senpi#1740: the watchdog's own `Error.message` is a classifier token (`isProviderStreamStallError`, the turn-retry gate) that also leaked to users as the answer to a stalled turn (`Provider stream start timed out after 180000ms`). The wording therefore cannot change, and the replacement has to live next to the patterns it mirrors so the two never drift - the coding-agent session, the interactive transcript and print mode all read this one definition. +- The agent loop no longer produces that verdict (senpi#1759): the rate guard that raised it failed healthy turns and was withdrawn, so a classifier for a message that can no longer occur is dead weight. ### Why an extension could not handle it -- The stall wording is produced inside the agent loop and consumed by the retry classifier in this package; an extension sees the assistant message only after the host has already decided what to print. +- Retry and fallback admission is decided from this classifier inside `AgentSession`; an extension observes the turn only after that decision. ### Expected merge conflict zones -- LOW: one appended block at the end of the stall-classifier section in `packages/ai/src/utils/retry.ts`; no existing line changes. +- LOW: the retryable list and the stall-classifier block in `packages/ai/src/utils/retry.ts` are back to carrying silence classes only. -## Throughput-degraded provider streams classified apart from silence stalls (2026-09-16) +## Plain-language provider-stall copy (2026-09-16) ### What changed -- `packages/ai/src/utils/retry.ts`: `RETRYABLE_PROVIDER_ERROR_PATTERN` accepts the agent-loop throughput verdict ("provider stream throughput degraded"), and a new anchored `isProviderStreamThroughputDegradedError(message)` matches the full wording `Provider stream throughput degraded: tok/s over s (floor tok/s)` plus its optional settings hint. It is deliberately NOT part of `PROVIDER_STREAM_STALL_ERROR_PATTERN` / `isProviderStreamStallError`, and not a provider timeout. +- `packages/ai/src/utils/retry.ts`: adds `describeProviderStallForUser(errorMessage, options)` and the `ProviderStallDescriptionOptions` type next to `PROVIDER_STREAM_STALL_ERROR_PATTERN`. It turns any of the four stall watchdog wordings (stream-start, idle, WebSocket liveness, Responses completion) into one user-facing sentence naming the model, what the provider failed to do, and the bound it blew; with `attempts` it adds the same-model retry count, and with `recovery` it adds the next step (`/fallback`, resend, or the matching `retry.provider.*` setting). Anything that is not a stall returns `undefined` so callers keep their verbatim error. The classifier patterns and every existing export are untouched. ### Why -- senpi#1739: a stall is silence, which a same-model retry can genuinely fix; a degraded stream is an upstream that answers too slowly, where replaying the same payload cannot raise the rate. `AgentSession` needs the two classes separated so the degraded one can skip the same-model budget and go straight to the fallback chain while staying retryable. +- senpi#1740: the watchdog's own `Error.message` is a classifier token (`isProviderStreamStallError`, the turn-retry gate) that also leaked to users as the answer to a stalled turn (`Provider stream start timed out after 180000ms`). The wording therefore cannot change, and the replacement has to live next to the patterns it mirrors so the two never drift - the coding-agent session, the interactive transcript and print mode all read this one definition. ### Why an extension could not handle it -- Retry and fallback admission is decided inside `AgentSession` from this classifier; an extension observes the turn only after that decision. +- The stall wording is produced inside the agent loop and consumed by the retry classifier in this package; an extension sees the assistant message only after the host has already decided what to print. ### Expected merge conflict zones -- LOW: one alternation in the retryable list plus one new exported predicate in `packages/ai/src/utils/retry.ts`. +- LOW: one appended block at the end of the stall-classifier section in `packages/ai/src/utils/retry.ts`; no existing line changes. + ## Shared empty-response error texts, forwarded empty stops admitted to the turn retry (2026-09-16) ### What changed diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index 5c5b64e3f..cdf990090 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -143,11 +143,6 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ escapeRegExp(FORWARDED_EMPTY_RESPONSE_ERROR), escapeRegExp(FORWARDED_EMPTY_TOOL_USE_ERROR), - // Agent-loop throughput watchdog verdict (#1739). The upstream is answering, - // just uselessly slowly, so the turn must move - to the fallback chain, not - // through the same-model budget (see isProviderStreamThroughputDegradedError). - "provider stream throughput degraded", - // gRPC based providers (e.g. NVIDIA NIM) "ResourceExhausted", @@ -499,25 +494,6 @@ export function describeProviderStallForUser( return undefined; } -/** - * Matches the agent-loop throughput watchdog verdict ("Provider stream - * throughput degraded: tok/s over s (floor tok/s)", optionally - * followed by the settings hint). Deliberately NOT part of the stall pattern - * above: a stall is silence, which a same-model retry can genuinely fix, while - * a degraded stream is an upstream that answers too slowly for replaying the - * same payload to help. Callers use this to skip the same-model retry budget - * and consult the fallback chain immediately. - */ -const PROVIDER_STREAM_THROUGHPUT_DEGRADED_ERROR_PATTERN = - /^Provider stream throughput degraded: \d+(?:\.\d+)? tok\/s over \d+(?:\.\d+)?s \(floor \d+(?:\.\d+)? tok\/s\)(?: \([^)]*\))?$/i; - -export function isProviderStreamThroughputDegradedError(message: AssistantMessage): boolean { - return ( - message.stopReason === "error" && - PROVIDER_STREAM_THROUGHPUT_DEGRADED_ERROR_PATTERN.test(message.errorMessage ?? "") - ); -} - /** * Classifies timeout failures that originate from the provider stream or its * transport. Transport timeouts may arrive as `aborted`; stream watchdog diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c0997319..1116bf258 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -37,8 +37,6 @@ - A provider that accepts a request and never starts streaming no longer ends the turn with the watchdog's own message (`Provider stream start timed out after 180000ms ...`). The stall is still retried on the same model with the configured stream-start bound, and still hands the turn to the next model in a configured `retry.fallbackChains` entry whose answer becomes the turn result. What changed is what you read: the transcript (and `senpi -p`) describes the stall in plain language, and when nothing can take the turn over the final line names the stalled model, the attempts spent and the next step - `/fallback`, resending, or raising `retry.provider.streamStartTimeoutMs` (`0` disables). The wording on the assistant message is unchanged, so retry classification and fallback routing behave exactly as before ([#1740](https://github.com/code-yeongyu/senpi/issues/1740)). -- A provider that keeps streaming at a uselessly low rate is now detected instead of looking healthy forever. Every previous guard on a live stream watched for silence (the stream-start bound stops applying at the first event; the idle bound is re-armed by every event), so a turn crawling at ~2 tok/s never failed, never retried and never walked a fallback chain. After the first stream event senpi now ignores `retry.provider.throughputGraceMs` (default 5000) of streaming and then measures streamed text and thinking units over a trailing `retry.provider.throughputWindowMs` (default 20000); a full window carrying at least 16 units whose sustained rate is below `retry.provider.minThroughputTokensPerSecond` (default 8, `0` disables) aborts the request with `Provider stream throughput degraded: tok/s over s (floor tok/s)`. That failure is retryable but spends no same-model attempts - replaying the payload cannot make the upstream faster - so it goes straight to the configured fallback chain, and with no candidate the turn ends on that error with the usual "No fallback chain configured - set one with /fallback." guidance instead of continuing to crawl. Time the provider spends running local tools is excluded from the measurement, and the interactive working line now shows the live rate (`Working (1m 12s - 2.1 tok/s - esc to interrupt)`) so a degraded turn is visible while it runs ([#1739](https://github.com/code-yeongyu/senpi/issues/1739)). - - A session no longer dead-ends when the compaction summarizer is killed by a provider or credential failure. Such a stream used to end required compaction with the raw internal `senpi:no-turn-retry:` marker, which also disabled auto-retry and model fallback; because compaction never applied, the context stayed above the threshold and every following prompt failed identically. Those failures now apply the deterministic compaction checkpoint (retained-suffix safety checks unchanged) and the turn continues, with one plain warning saying a provider summary could not be completed, that a checkpoint was applied and older detail was dropped, and that it is safe to continue. Provider refusals, missing credentials and user aborts still surface loudly instead of reducing context. One compaction is also bounded at 15 minutes total across every attempt and retry regardless of input size (an explicit `compaction.summarizationMaxDurationMs` above that still wins), and the summary stream's final settlement now happens inside the watchdog, so a provider whose stream ends without a terminal event can no longer park compaction with no timer armed ([#1741](https://github.com/code-yeongyu/senpi/issues/1741)). ### Removed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 89acd8230..8ddfb6e25 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -258,9 +258,6 @@ See [compaction.md](compaction.md) for trigger and summarization behavior. | `retry.provider.timeoutMs` | number | `300000` | Provider/SDK request timeout and stream idle timeout in milliseconds | | `retry.provider.streamStartTimeoutMs` | number | `300000` | Maximum wait for the first provider stream event; `0` disables | | `retry.provider.streamRetryTimeoutMs` | number | `30000` | First-request liveness cap after a known provider stream/transport timeout; `0` disables the cap | -| `retry.provider.minThroughputTokensPerSecond` | number | `8` | Sustained streamed-units floor for an in-progress stream; `0` disables the watchdog | -| `retry.provider.throughputWindowMs` | number | `20000` | Observation window for that floor; `0` disables the watchdog | -| `retry.provider.throughputGraceMs` | number | `5000` | Streaming after the first event that is never measured; `0` measures immediately | | `retry.provider.maxRetries` | number | `0` | Provider/SDK retry attempts | | `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay honored on the same model before the fallback chain engages (60s) | @@ -272,18 +269,6 @@ After an exact provider stream/transport timeout, `retry.provider.streamRetryTim provider request and defers queued user input from that request. The cap applies only to stream guards that are already enabled, never turns a disabled guard back on, and restores configured timeouts for later requests. -`streamStartTimeoutMs` bounds the wait for the FIRST event and stops applying once it arrives; `timeoutMs` then -bounds silence between events. Both are silence detectors, so a provider that keeps answering at a uselessly low -rate trips neither. The throughput watchdog is the rate half: after the first stream event it ignores -`retry.provider.throughputGraceMs` of streaming, then measures streamed text and thinking units (about one unit -per token) over a trailing `retry.provider.throughputWindowMs`. A full window carrying at least 16 units whose -sustained rate is below `retry.provider.minThroughputTokensPerSecond` aborts the in-flight request with -`Provider stream throughput degraded: tok/s over s (floor tok/s)`. Time the provider spends executing -local tools does not count against the window, and the interactive working line shows the live rate -(`Working (1m 12s • 2.1 tok/s • esc to interrupt)`) while the turn runs. That failure is retryable but never -spends same-model attempts — replaying the payload cannot make the upstream faster — so it goes straight to the -fallback chain; with no chain candidate the turn ends with that error instead of continuing to crawl. - Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explicitly needed. Setting it above `0` can make SDK/provider retries handle out-of-usage-limit errors before senpi sees them, which may block the agent until the provider quota resets in some circumstances. ```json @@ -297,9 +282,6 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic "timeoutMs": 3600000, "streamStartTimeoutMs": 300000, "streamRetryTimeoutMs": 30000, - "minThroughputTokensPerSecond": 8, - "throughputWindowMs": 20000, - "throughputGraceMs": 5000, "maxRetries": 0, "maxRetryDelayMs": 60000 } @@ -309,7 +291,7 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic #### Model fallback chains -`retry.fallbackChains` maps a primary-model selector to an ordered list of fallback selectors. A selector is `provider/model` with an optional `:thinking-level` suffix, or a bare `model` id that applies to every provider serving that model family. Bare selectors expand against the models you actually have: providers holding an OAuth credential are preferred, then a fixed precedence order, and OpenRouter is never chosen by expansion. Fallback chains are explicit user configuration only: Senpi ships no default and no wildcard lane, so a model without a configured chain never enters an implicit fallback lane and a failure that would fall back ends the turn instead. Set a key to `[]` to opt out of a chain you configured at a broader granularity, or set one `provider/claude-fable-5-1` key to override just that provider. For example, this switches Fable 5.1 to Kimi K3 at `max` thinking when an eligible failure occurs: +`retry.fallbackChains` maps a primary-model selector to an ordered list of fallback selectors. A selector is `provider/model` with an optional `:thinking-level` suffix, or a bare `model` id that applies to every provider serving that model family. Bare selectors expand against the models you actually have: providers holding an OAuth credential are preferred, then a fixed precedence order, and OpenRouter is never chosen by expansion. Senpi ships bare default chains for `claude-fable-5-1` and `claude-fable-5`, so Fable 5.1 and Fable 5 keep a fallback chain whichever provider serves them; set a key to `[]` to opt out entirely, or set one `provider/claude-fable-5-1` key to override just that provider. For example, this switches Fable 5.1 to Kimi K3 at `max` thinking when an eligible failure occurs: ```json { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e8c63cafd..556d77330 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -62,7 +62,6 @@ import { isCursorQuotaResourceExhausted, isCursorZeroTokenResourceExhausted, isProviderStreamStallError, - isProviderStreamThroughputDegradedError, isProviderTimeoutError, isRecoverableLength, isRetryableAssistantError, @@ -565,17 +564,6 @@ export type AgentSessionEvent = to: string; chainConfigured: boolean; } - /** - * A turn ended on the throughput watchdog's verdict without a fallback model - * to take it over. `chainConfigured` mirrors `server_fallback_aborted`: the - * no-chain case has no other signal to offer the UI. - */ - | { - type: "stream_throughput_degraded"; - model: string; - errorMessage: string; - chainConfigured: boolean; - } // Auth login flow (task 13) is additive with event-only completion. The // login_start command responds immediately, then the OAuth URL and the // terminal result arrive here, because an interactive browser round-trip @@ -1801,13 +1789,11 @@ export class AgentSession { if (event.type === "message_end" && event.message.role === "assistant") { const message = event.message as AssistantMessage; if (message.stopReason !== "error") return; - const kind = isProviderStreamThroughputDegradedError(message) - ? "throughput" - : isProviderStreamStallError(message) - ? "stall" - : isProviderTimeoutError(message) - ? "timeout" - : "error"; + const kind = isProviderStreamStallError(message) + ? "stall" + : isProviderTimeoutError(message) + ? "timeout" + : "error"; this._sessionLogger.warn("provider_error", { kind, error: message.errorMessage, @@ -8280,46 +8266,6 @@ export class AgentSession { return "not-handled"; } this._retryAttempt++; - } else if (isProviderStreamThroughputDegradedError(message)) { - // The upstream IS answering, just uselessly slowly (#1739). Replaying the - // same payload on the same model cannot raise its rate, so - unlike the - // silence stalls - this class spends no same-model attempts and consults - // the chain immediately. The slow selector still gets the ordinary - // transient cooldown inside tryFallback. - switchedFallback = await tryFallback("transient", { errorMessage }); - if (switchedFallback) { - // The fallback model starts with a fresh budget, as on every other hop. - this._retryAttempt = 1; - } else { - const exhaustedChainKey = this._retryFallback.exhaustedChainKey; - if (exhaustedChainKey) { - this._emit({ - type: "retry_fallback_exhausted", - chainKey: exhaustedChainKey, - lastError: errorMessage, - }); - } - // No candidate: end the turn on the measured rate instead of sitting on - // the trickle, and tell the UI whether a chain exists at all. - this._emit({ - type: "stream_throughput_degraded", - model: this.model ? formatSelector(this.model) : message.model, - errorMessage, - chainConfigured: this._retryFallback.hasConfiguredChain(), - }); - if (this._retryAttempt > 0) { - this._emit({ - type: "auto_retry_end", - success: false, - attempt: this._retryAttempt, - finalError: message.errorMessage, - }); - } - this._retryAttempt = 0; - this._resetHintTierState(); - this._resolveRetry(); - return "not-handled"; - } } else { // A provider-stream stall is an ordinary transient failure: it consumes // the same bounded same-model budget (the resolved profile's turn diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index bd1fac8d8..9d24e9616 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,44 +1,44 @@ # changes -## 2026-09-16 - Stalled turns end with recovery guidance (senpi#1740) +## 2026-09-16 - Throughput retry branch and its settings withdrawn (senpi#1759) ### What changed -- `packages/coding-agent/src/core/agent-session.ts` adds the private `_terminalFailureText(message, attempts)` and uses it for the `auto_retry_end.finalError` of an exhausted transient retry. A provider-stream stall is rewritten through `describeProviderStallForUser` (imported from `@earendil-works/pi-ai/compat`) with the stalled model selector, the attempts spent and a recovery hint chosen from `RetryFallbackController.hasConfiguredChain()` (`chain-exhausted` vs `no-fallback-configured`); every other failure keeps `message.errorMessage` verbatim. The assistant message itself is left untouched, so `isProviderStreamStallError` and the retry/fallback routing are unchanged. +- `packages/coding-agent/src/core/agent-session.ts`: the `isProviderStreamThroughputDegradedError` branch in `_handleRetryableError`, the `stream_throughput_degraded` session event and the `"throughput"` arm of the provider-error log kind are removed. Stalls, refusals and the 429 tiers are unchanged. +- `packages/coding-agent/src/core/settings-manager.ts`: `getAgentStreamThroughputOptions()` removed. +- `packages/coding-agent/src/core/retry-fallback/settings.ts`: `minThroughputTokensPerSecond`, `throughputWindowMs` and `throughputGraceMs` removed from `ProviderRetrySettings`. +- `packages/coding-agent/src/core/sdk.ts`: the `streamThroughput` wiring next to `timeoutMs` / `streamStartTimeoutMs` removed. ### Why -- senpi#1740: when a provider accepted a request and never streamed a first event, the session's visible outcome was the watchdog's interpolated message (`Provider stream start timed out after 180000ms`). It names no cause and no next step, and the same string has to stay on the message because the retry classifier matches on it - so the rewrite belongs at the event the UI renders, not at the message. +- The agent-loop rate guard those knobs configured aborted healthy turns and was withdrawn (senpi#1759). With no such verdict reaching the session, the retry branch is unreachable and the settings configure nothing. ### Why an extension could not handle it -- `auto_retry_end` is emitted by the session at the moment it gives the turn up; only the session knows the attempts spent and whether a fallback chain existed. +- Retry budget, fallback chain and turn termination live in `AgentSession`, and the settings surface is host-owned; an extension can neither add nor remove either. ### Expected merge conflict zones -- LOW: one import specifier, one new private method before `_degradeRateLimitedWithoutFallback`, and one `finalError:` line in the generic transient-exhaustion branch of `_handleRetryableError`. +- MEDIUM: the retry class chain in `_handleRetryableError` is back to stall / refusal / 429 tiers only, so an upstream edit there applies without the fork-local throughput arm. +- LOW: the settings getter and the `ProviderRetrySettings` fields. -## 2026-09-16 - Throughput-degraded streams skip same-model retries and fail over (senpi#1739) +## 2026-09-16 - Stalled turns end with recovery guidance (senpi#1740) ### What changed -- `packages/coding-agent/src/core/agent-session.ts`: `_handleRetryableError` gains a branch for `isProviderStreamThroughputDegradedError` ahead of the generic transient path. It spends no same-model attempts, calls `tryFallback("transient")` immediately (which still notes the slow selector's cooldown), and, when no candidate exists, emits the new `stream_throughput_degraded` session event (`model`, `errorMessage`, `chainConfigured`) plus the usual `retry_fallback_exhausted` / `auto_retry_end` bookkeeping before ending the turn on that error. The `provider_error` session log gains the `throughput` kind. -- `packages/coding-agent/src/core/settings-manager.ts`: `getAgentStreamThroughputOptions()` forwards `retry.provider.minThroughputTokensPerSecond`, `retry.provider.throughputWindowMs` and `retry.provider.throughputGraceMs` to the agent loop, returning undefined when nothing is configured so the agent defaults apply; a configured `0` floor or window is forwarded and disables the guard. -- `packages/coding-agent/src/core/retry-fallback/settings.ts`: the three knobs on `ProviderRetrySettings`. -- `packages/coding-agent/src/core/sdk.ts`: wires `streamThroughput` into the `Agent` next to `timeoutMs` / `streamStartTimeoutMs`. +- `packages/coding-agent/src/core/agent-session.ts` adds the private `_terminalFailureText(message, attempts)` and uses it for the `auto_retry_end.finalError` of an exhausted transient retry. A provider-stream stall is rewritten through `describeProviderStallForUser` (imported from `@earendil-works/pi-ai/compat`) with the stalled model selector, the attempts spent and a recovery hint chosen from `RetryFallbackController.hasConfiguredChain()` (`chain-exhausted` vs `no-fallback-configured`); every other failure keeps `message.errorMessage` verbatim. The assistant message itself is left untouched, so `isProviderStreamStallError` and the retry/fallback routing are unchanged. ### Why -- senpi#1739: a provider that keeps streaming at ~2 tok/s produced no error at all, so retry and fallback never ran and the session looked healthy. With the agent loop now failing such a stream, the session must route it: replaying the payload on the same model cannot make the upstream faster, and the stall policy's full same-model budget would waste minutes before the chain is consulted. +- senpi#1740: when a provider accepted a request and never streamed a first event, the session's visible outcome was the watchdog's interpolated message (`Provider stream start timed out after 180000ms`). It names no cause and no next step, and the same string has to stay on the message because the retry classifier matches on it - so the rewrite belongs at the event the UI renders, not at the message. ### Why an extension could not handle it -- Retry budget, fallback chain and turn termination all live in `AgentSession`; extensions observe the turn after those decisions and cannot skip the same-model budget. +- `auto_retry_end` is emitted by the session at the moment it gives the turn up; only the session knows the attempts spent and whether a fallback chain existed. ### Expected merge conflict zones -- MEDIUM: the retry class chain in `_handleRetryableError` (`packages/coding-agent/src/core/agent-session.ts`), which upstream also edits for 429 tiers and stalls. Keep the throughput branch BEFORE the generic transient branch. -- LOW: the settings getter and the `ProviderRetrySettings` fields. +- LOW: one import specifier, one new private method before `_degradeRateLimitedWithoutFallback`, and one `finalError:` line in the generic transient-exhaustion branch of `_handleRetryableError`. ## 2026-09-16 - /rename session command diff --git a/packages/coding-agent/src/core/retry-fallback/settings.ts b/packages/coding-agent/src/core/retry-fallback/settings.ts index f8a643ff1..98f353c6b 100644 --- a/packages/coding-agent/src/core/retry-fallback/settings.ts +++ b/packages/coding-agent/src/core/retry-fallback/settings.ts @@ -4,9 +4,6 @@ export interface ProviderRetrySettings { timeoutMs?: number; streamStartTimeoutMs?: number; streamRetryTimeoutMs?: number; // retry-continuation watchdog cap after a provider timeout; reconciled to max(cap, streamStartTimeoutMs) so a granted stream-start budget is never cut short; default: 30000, 0 disables - minThroughputTokensPerSecond?: number; // sustained streamed-units floor for an in-progress stream; default: 8, 0 disables the watchdog - throughputWindowMs?: number; // observation window for that floor; default: 20000, 0 disables the watchdog - throughputGraceMs?: number; // streaming after the first event that is never measured; default: 5000, 0 measures immediately maxRetries?: number; maxRetryDelayMs?: number; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 2ad4933fd..ee2650f19 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -493,7 +493,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingBudgets: settingsManager.getThinkingBudgets(), timeoutMs: settingsManager.getAgentStreamIdleTimeoutMs(), streamStartTimeoutMs: settingsManager.getAgentStreamStartTimeoutMs(), - streamThroughput: settingsManager.getAgentStreamThroughputOptions(), maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs, cursorExecHandlers: (runSignal: AbortSignal) => createSessionCursorExecBridge(sessionRef, () => agent, runSignal), }); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index bda5ea6c7..036288108 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,4 +1,4 @@ -import type { StreamThroughputOptions, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, type Transport } from "@earendil-works/pi-ai"; import { SENPI_DEFAULT_RETRY_PROFILE } from "@earendil-works/pi-ai/utils/retry-profile/profiles"; import type { @@ -1633,28 +1633,6 @@ export class SettingsManager { return Math.min(DEFAULT_STREAM_START_TIMEOUT_MS, idleTimeoutMs); } - /** - * Sustained-throughput guard for an in-progress provider stream. The - * stream-start bound stops applying at the first event and the idle bound is - * re-armed by every event, so a provider that keeps answering at a uselessly - * low rate trips neither (#1739). `retry.provider.minThroughputTokensPerSecond` - * (floor, `0` disables), `retry.provider.throughputWindowMs` (`0` disables) - * and `retry.provider.throughputGraceMs` override the agent defaults; an - * unset knob keeps the shipped default. Returns undefined when nothing is - * configured, so the agent loop applies its own defaults. - */ - getAgentStreamThroughputOptions(): StreamThroughputOptions | undefined { - const provider = this.settings.retry?.provider; - const options: StreamThroughputOptions = { - ...(provider?.minThroughputTokensPerSecond === undefined - ? {} - : { floorTokensPerSecond: provider.minThroughputTokensPerSecond }), - ...(provider?.throughputWindowMs === undefined ? {} : { windowMs: provider.throughputWindowMs }), - ...(provider?.throughputGraceMs === undefined ? {} : { graceMs: provider.throughputGraceMs }), - }; - return Object.keys(options).length === 0 ? undefined : options; - } - getWebSocketConnectTimeoutMs(): number | undefined { return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs"); } diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index 8af873634..e6da46121 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -1,39 +1,39 @@ -## 2026-09-16 - Stall transcripts read as stalls (senpi#1740) +## 2026-09-16 - Live tok/s removed from the working line (senpi#1759) ### What changed -- `packages/coding-agent/src/modes/interactive/components/assistant-render-descriptors.ts`: the `error` stop-reason branch routes `message.errorMessage` through `describeProviderStallForUser` first and prints that sentence for a provider-stream stall, falling back to the previous `Error: ` line for everything else. The branch is now a block with two early `break`s (tool calls, server-fallback diagnostic) instead of one negated condition; the descriptors it emits are unchanged in kind and order. No recovery advice is printed here - the turn may still be retrying. +- `packages/coding-agent/src/modes/interactive/working-status.ts`: the optional live-rate parameter and `formatWorkingRateSegment` are removed; the suffix is again `( - to interrupt)`. +- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: the `StreamRateMeter` field, its rebuild at assistant `message_start`, the `message_update` unit recording, `getWorkingTokensPerSecond()` and the `stream_throughput_degraded` notice box are removed. ### Why -- senpi#1740: the transcript printed `Error: Provider stream start timed out after 180000ms (raise streamStartTimeoutMs ...)` for every stalled attempt, including attempts a retry or a fallback model later recovered, so the watchdog wording was what the user read as the answer. +- The readout existed only to make the agent-loop rate verdict observable while it was measured. That guard aborted healthy turns and was withdrawn (senpi#1759), leaving a per-delta rate as noise on every turn; end-of-turn rate is still reported by the builtin TPS extension. ### Why an extension could not handle it -- Assistant bubbles are built by the host renderer; an extension cannot rewrite a descriptor the host already emitted. +- The working line and its animation frames are owned by interactive mode; extensions can only post notifications after the turn ends. ### Expected merge conflict zones -- LOW: the `case "error"` arm of `createAssistantRenderDescriptors` and one import block. +- LOW: the working-status suffix helper and the `message_start` / `message_update` cases in `interactive-mode.ts` are back to their pre-guard shape. -## 2026-09-16 - Live tok/s on the working line and the throughput-degraded notice (#1739) +## 2026-09-16 - Stall transcripts read as stalls (senpi#1740) ### What changed -- `packages/coding-agent/src/modes/interactive/working-status.ts`: `formatWorkingStatusSuffix` is now the single suffix builder and takes an optional live rate, so `formatWorkingStatusMessage` and `formatWorkingStatusMessageFrame` render `Working (1m 12s - 2.1 tok/s - esc to interrupt)` when a rate exists and the previous string when it does not. -- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: a `StreamRateMeter` (pi-agent-core) is rebuilt at every assistant `message_start` with the configured throughput window, fed from `message_update` text/thinking deltas, and read only while an assistant message is streaming, so tool time never dilutes the displayed rate. A `stream_throughput_degraded` session event renders an error notice naming the measured rate and reuses the configured-chain-absent copy ("No fallback chain configured - set one with /fallback."). +- `packages/coding-agent/src/modes/interactive/components/assistant-render-descriptors.ts`: the `error` stop-reason branch routes `message.errorMessage` through `describeProviderStallForUser` first and prints that sentence for a provider-stream stall, falling back to the previous `Error: ` line for everything else. The branch is now a block with two early `break`s (tool calls, server-fallback diagnostic) instead of one negated condition; the descriptors it emits are unchanged in kind and order. No recovery advice is printed here - the turn may still be retrying. ### Why -- senpi#1739: a crawling turn was indistinguishable from a healthy one - the working line showed elapsed time only, and the builtin TPS extension reports tok/s only after `agent_end`, far too late to route. The rate must be visible while the turn crawls, and the no-fallback termination needs the same explanation the server-fallback abort already gives. +- senpi#1740: the transcript printed `Error: Provider stream start timed out after 180000ms (raise streamStartTimeoutMs ...)` for every stalled attempt, including attempts a retry or a fallback model later recovered, so the watchdog wording was what the user read as the answer. ### Why an extension could not handle it -- The working line and its animation frames are owned by interactive mode; extensions can only post notifications after the turn ends. +- Assistant bubbles are built by the host renderer; an extension cannot rewrite a descriptor the host already emitted. ### Expected merge conflict zones -- LOW: the working-status suffix helper and the `message_start` / `message_update` cases in `interactive-mode.ts`. +- LOW: the `case "error"` arm of `createAssistantRenderDescriptors` and one import block. ## 2026-09-16 - /rename session command diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4c7aebd9e..686a4eb4a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6,13 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { - type AgentMessage, - DEFAULT_STREAM_THROUGHPUT_WINDOW_MS, - estimateStreamedUnits, - StreamRateMeter, - type ThinkingLevel, -} from "@earendil-works/pi-agent-core"; +import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type AuthEvent, type AuthPrompt, contentText, modelsAreEqual } from "@earendil-works/pi-ai"; import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@earendil-works/pi-ai/compat"; import type { @@ -946,15 +940,6 @@ export class InteractiveMode { private workingVisible = true; private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined; private workingStartedAt: number | undefined = undefined; - /** - * Live streamed-units rate for the working line, over the same window the - * throughput watchdog judges, so a crawling turn is visible while it crawls - * (#1739). Rebuilt at every assistant message start (field initializers run - * before the runtime host is bound, so the configured window is read there); - * only observed while an assistant message streams, so tool time never - * dilutes the rate. - */ - private streamRateMeter = new StreamRateMeter(DEFAULT_STREAM_THROUGHPUT_WINDOW_MS); private readonly defaultWorkingMessage = "Working"; private readonly defaultHiddenThinkingLabel = "Thinking..."; private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; @@ -3153,12 +3138,6 @@ export class InteractiveMode { return this.workingMessage ?? this.defaultWorkingMessage; } - /** Live tok/s once the provider's first stream event arrived, else undefined. */ - private getWorkingTokensPerSecond(): number | undefined { - if (this.streamingMessage === undefined) return undefined; - return this.streamRateMeter.ratePerSecond(); - } - private refreshWorkingLoaderMessage(): void { if (this.activeStatusIndicator?.kind === "working") { this.activeStatusIndicator.setMessage(this.getWorkingLoaderMessage()); @@ -3197,7 +3176,6 @@ export class InteractiveMode { shimmer: formatWorkingStatusShimmerText, suffix: (text) => theme.fg("dim", text), }, - this.getWorkingTokensPerSecond(), ), messageIntervalMs: largeSessionWorkingStatusInterval( sessionEntryCount, @@ -5099,10 +5077,6 @@ export class InteractiveMode { this.updatePendingMessagesDisplay(); this.ui.requestRender(); } else if (event.message.role === "assistant") { - this.streamRateMeter = new StreamRateMeter( - this.settingsManager.getAgentStreamThroughputOptions()?.windowMs || - DEFAULT_STREAM_THROUGHPUT_WINDOW_MS, - ); this.streamingComponent = new AssistantMessageComponent( undefined, this.hideThinkingBlock, @@ -5123,12 +5097,6 @@ export class InteractiveMode { break; case "message_update": - if ( - event.assistantMessageEvent.type === "text_delta" || - event.assistantMessageEvent.type === "thinking_delta" - ) { - this.streamRateMeter.record(estimateStreamedUnits(event.assistantMessageEvent.delta)); - } if (this.streamingComponent && event.message.role === "assistant") { this.streamingMessage = event.message; this.streamingReveal.setTarget(assistantStreamingHeadMessage(event.message)); @@ -5559,20 +5527,6 @@ export class InteractiveMode { }); break; - case "stream_throughput_degraded": - // The turn ended on the measured rate with no model left to take it - // over; same copy as the configured-chain-absent server fallback. - this.showNoticeBox({ - title: `✕ Provider stream throughput degraded · ${event.model}`, - tone: "error", - why: `${event.errorMessage} ${ - event.chainConfigured - ? "Every fallback candidate for this model was already tried." - : "No fallback chain configured — set one with /fallback." - }`, - }); - break; - case "auto_retry_start": { // During retry waits, isStreaming flips false between attempts. The main Esc handler // keys off both isStreaming and retryAttempt so we keep the same close-out path here; diff --git a/packages/coding-agent/src/modes/interactive/working-status.ts b/packages/coding-agent/src/modes/interactive/working-status.ts index 6f354a3e5..7b03379aa 100644 --- a/packages/coding-agent/src/modes/interactive/working-status.ts +++ b/packages/coding-agent/src/modes/interactive/working-status.ts @@ -44,31 +44,8 @@ export function formatWorkingElapsedSeconds(elapsedSeconds: number): string { return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`; } -/** - * Live streaming rate for the working line. A crawling turn used to look - * exactly like a healthy one (elapsed time only), so a 2 tok/s session stayed - * "Working (10m 00s • Esc to interrupt)" for ten minutes (#1739). - */ -function formatWorkingRateSegment(tokensPerSecond: number | undefined): string { - if (tokensPerSecond === undefined || !Number.isFinite(tokensPerSecond) || tokensPerSecond < 0) return ""; - return ` • ${tokensPerSecond.toFixed(1)} tok/s`; -} - -export function formatWorkingStatusSuffix( - elapsedSeconds: number, - interruptKey: string, - tokensPerSecond?: number, -): string { - return ` (${formatWorkingElapsedSeconds(elapsedSeconds)}${formatWorkingRateSegment(tokensPerSecond)} • ${interruptKey} to interrupt)`; -} - -export function formatWorkingStatusMessage( - message: string, - elapsedSeconds: number, - interruptKey: string, - tokensPerSecond?: number, -): string { - return `${message}${formatWorkingStatusSuffix(elapsedSeconds, interruptKey, tokensPerSecond)}`; +export function formatWorkingStatusMessage(message: string, elapsedSeconds: number, interruptKey: string): string { + return `${message} (${formatWorkingElapsedSeconds(elapsedSeconds)} • ${interruptKey} to interrupt)`; } export type ToolHookStatusHookName = "PreToolUse" | "PostToolUse"; @@ -186,9 +163,8 @@ export function formatWorkingStatusMessageFrame( interruptKey: string, animationElapsedMs: number, style: WorkingStatusMessageFrameStyle, - tokensPerSecond?: number, ): string { - const suffix = formatWorkingStatusSuffix(elapsedSeconds, interruptKey, tokensPerSecond); + const suffix = ` (${formatWorkingElapsedSeconds(elapsedSeconds)} • ${interruptKey} to interrupt)`; return `${formatWorkingStatusTextFrame(message, animationElapsedMs, style)}${style.suffix(suffix)}`; } diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 1406f08e7..f4cef49bf 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -728,11 +728,7 @@ describe("InteractiveMode.getWorkingIndicatorOptions", () => { workingIndicatorOptions: undefined, sessionManager: { getEntries: () => [], getEntryCount: () => 0 }, getWorkingElapsedSeconds: () => 7, - // No stream is in flight, so the borrowed rate reader answers undefined - // without touching the meter: the status line carries no tok/s segment. - streamingMessage: undefined, }; - fakeThis.getWorkingTokensPerSecond = (InteractiveMode as any).prototype.getWorkingTokensPerSecond.bind(fakeThis); // When const options = (InteractiveMode as any).prototype.getWorkingIndicatorOptions.call(fakeThis); @@ -767,11 +763,7 @@ describe("InteractiveMode.getWorkingIndicatorOptions", () => { workingIndicatorOptions: undefined, sessionManager: { getEntries: () => [], getEntryCount: () => 0 }, getWorkingElapsedSeconds: () => 7, - // No stream is in flight, so the borrowed rate reader answers undefined - // without touching the meter: the status line carries no tok/s segment. - streamingMessage: undefined, }; - fakeThis.getWorkingTokensPerSecond = (InteractiveMode as any).prototype.getWorkingTokensPerSecond.bind(fakeThis); // When const options = (InteractiveMode as any).prototype.getWorkingIndicatorOptions.call(fakeThis); diff --git a/packages/coding-agent/test/interactive-mode-working-status.test.ts b/packages/coding-agent/test/interactive-mode-working-status.test.ts index aafce92ef..e95aa7d6d 100644 --- a/packages/coding-agent/test/interactive-mode-working-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-working-status.test.ts @@ -43,18 +43,6 @@ describe("formatWorkingStatusMessage", () => { test("combines message, elapsed time, and interrupt hint", () => { expect(formatWorkingStatusMessage("Working", 427, "esc")).toBe("Working (7m 07s • esc to interrupt)"); }); - - // #1739: a crawling turn must be visible while it crawls, not only in the - // post-turn TPS toast. - test("shows the live streaming rate once one is measured", () => { - expect(formatWorkingStatusMessage("Working", 72, "esc", 2.1)).toBe( - "Working (1m 12s • 2.1 tok/s • esc to interrupt)", - ); - }); - - test("omits the rate before the first stream event", () => { - expect(formatWorkingStatusMessage("Working", 7, "esc", undefined)).toBe("Working (7s • esc to interrupt)"); - }); }); describe("formatToolHookStatusMessage", () => { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 7b7c035ea..0773132d4 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -422,35 +422,6 @@ describe("SettingsManager", () => { expect(whenManager.getProviderStreamRetryTimeoutMs()).toBeUndefined(); }); - // #1739: the throughput watchdog's knobs are agent-side defaults; the - // settings layer only forwards what the user actually configured. - it("should leave the stream throughput guard at the agent defaults when unconfigured", () => { - writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark" })); - - const whenManager = SettingsManager.create(projectDir, agentDir); - - expect(whenManager.getAgentStreamThroughputOptions()).toBeUndefined(); - }); - - it("should forward configured stream throughput knobs, including a disabling zero", () => { - writeFileSync( - join(agentDir, "settings.json"), - JSON.stringify({ - retry: { - provider: { minThroughputTokensPerSecond: 0, throughputWindowMs: 30_000, throughputGraceMs: 0 }, - }, - }), - ); - - const whenManager = SettingsManager.create(projectDir, agentDir); - - expect(whenManager.getAgentStreamThroughputOptions()).toEqual({ - floorTokensPerSecond: 0, - windowMs: 30_000, - graceMs: 0, - }); - }); - it("should default the agent stream idle timeout to httpIdleTimeoutMs", () => { const givenSettingsPath = join(agentDir, "settings.json"); writeFileSync(givenSettingsPath, JSON.stringify({ theme: "dark" })); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 4d91affd1..c919e9361 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -65,10 +65,6 @@ export interface HarnessOptions { models?: FauxModelDefinition[]; api?: string; provider?: string; - /** Faux streaming rate; paces every delta by its estimated token count. */ - tokensPerSecond?: number; - /** Faux delta size in estimated tokens (4 characters each). */ - tokenSize?: { min?: number; max?: number }; settings?: Partial; systemPrompt?: string; tools?: AgentTool[]; @@ -129,8 +125,6 @@ export async function createHarness(options: HarnessOptions = {}): Promise entry.modelId); -} - -function failedAssistantErrors(harness: Harness): string[] { - return harness.session.messages - .filter((message): message is AssistantMessage => message.role === "assistant") - .map((message) => message.errorMessage) - .filter((errorMessage): errorMessage is string => errorMessage !== undefined); -} - -describe("throughput-degraded streams skip same-model retries and fall back", () => { - const harnesses: Harness[] = []; - afterEach(() => { - while (harnesses.length) harnesses.pop()?.cleanup(); - }); - - it("applies the fallback chain after a single crawling attempt", async () => { - const harness = await createHarness({ - models: [{ id: "faux-1" }, { id: "faux-2" }], - tokensPerSecond: 2, - tokenSize: { min: 1, max: 1 }, - settings: { - retry: { - enabled: true, - maxRetries: 3, - baseDelayMs: 1, - fallbackChains: { [primary]: [fallback] }, - provider: throughputSettings, - }, - }, - }); - harnesses.push(harness); - harness.setResponses([fauxAssistantMessage(SLOW_ANSWER), fauxAssistantMessage("ok")]); - - await harness.session.prompt("hello"); - - expect(harness.eventsOfType("retry_fallback_applied")).toMatchObject([ - { from: primary, to: fallback, chainKey: primary, reason: "transient" }, - ]); - // The crawling model is asked exactly once: no same-model retry burn. - expect(calledModelIds(harness)).toEqual(["faux-1", "faux-2"]); - expect(getAssistantTexts(harness).join("\n")).toContain("ok"); - }); - - it("ends the turn with the measured rate when no fallback model exists", async () => { - const harness = await createHarness({ - models: [{ id: "faux-1" }, { id: "faux-2" }], - tokensPerSecond: 2, - tokenSize: { min: 1, max: 1 }, - settings: { - retry: { - enabled: true, - maxRetries: 3, - baseDelayMs: 1, - modelFallback: false, - provider: throughputSettings, - }, - }, - }); - harnesses.push(harness); - harness.setResponses([fauxAssistantMessage(SLOW_ANSWER), fauxAssistantMessage("never reached")]); - - await harness.session.prompt("hello"); - - expect(calledModelIds(harness)).toEqual(["faux-1"]); - expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); - expect(failedAssistantErrors(harness).join("\n")).toMatch( - /Provider stream throughput degraded: \d+(?:\.\d+)? tok\/s over 8s \(floor 8 tok\/s\)/, - ); - expect(harness.eventsOfType("stream_throughput_degraded")).toMatchObject([ - { model: primary, chainConfigured: false }, - ]); - }); -});