From 2baf6c105e61bb7a032e454ac57a6cd7b93bfdca Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 17:21:50 +0900 Subject: [PATCH 1/2] revert: remove the stream throughput watchdog and the live tok/s working line The rate guard shipped in #1743 fails 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 8 tok/s floor. The verdict is also the retry classifier's token, so it cannot be softened where it is produced, and it aborts the request controller, discarding the partial answer instead of delivering it slowly. The live rate segment on the working line existed only to make that verdict observable while it was being measured, so it goes with the watchdog; end-of-turn rate is still reported by the builtin TPS extension. Reverts #1743 (ced1dadeb1, d04674187c) and its follow-up test stub #1748 (e5ab39dba8), including the settings knobs, the docs rows, the changelog entry and the fork-tracking notes, so neither feature leaves a trace. Silence stalls (#1740) are untouched: that is a different class with its own bounds and its own copy. --- packages/agent/src/agent-loop.ts | 35 +-- packages/agent/src/agent.ts | 7 - packages/agent/src/changes.md | 23 -- packages/agent/src/index.ts | 12 - .../agent/src/stream-throughput-watchdog.ts | 219 ---------------- packages/agent/src/types.ts | 10 - .../agent-loop-stream-start-timeout.test.ts | 5 - .../agent-loop-throughput-watchdog.test.ts | 238 ------------------ packages/ai/src/changes.md | 17 -- packages/ai/src/utils/retry.ts | 24 -- packages/coding-agent/CHANGELOG.md | 2 - packages/coding-agent/docs/settings.md | 20 +- .../coding-agent/src/core/agent-session.ts | 64 +---- packages/coding-agent/src/core/changes.md | 22 -- .../src/core/retry-fallback/settings.ts | 3 - packages/coding-agent/src/core/sdk.ts | 1 - .../coding-agent/src/core/settings-manager.ts | 24 +- .../src/modes/interactive/changes.md | 19 -- .../src/modes/interactive/interactive-mode.ts | 48 +--- .../src/modes/interactive/working-status.ts | 30 +-- .../test/interactive-mode-status.test.ts | 8 - .../interactive-mode-working-status.test.ts | 12 - .../test/settings-manager.test.ts | 29 --- packages/coding-agent/test/suite/harness.ts | 8 - ...retry-fallback-throughput-degraded.test.ts | 102 -------- 25 files changed, 12 insertions(+), 970 deletions(-) delete mode 100644 packages/agent/src/stream-throughput-watchdog.ts delete mode 100644 packages/agent/test/agent-loop-throughput-watchdog.test.ts delete mode 100644 packages/coding-agent/test/suite/retry-fallback-throughput-degraded.test.ts diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 5e87c9b333..1fb3d6c5a4 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 07295387fb..af80613b3a 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 de2d185849..7a16366c53 100644 --- a/packages/agent/src/changes.md +++ b/packages/agent/src/changes.md @@ -1,26 +1,3 @@ -## 2026-09-16 - Stream throughput watchdog for in-progress provider streams (#1739) - -### 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`). - -### 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. - -### 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. - -### 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`. - ## 2026-09-16 - Forward thinking live in the empty-assistant recovery wrapper (#1733) ### What changed diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0a6cd5fcfa..e58c449787 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 2d8f1ebe8a..0000000000 --- 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 ae171b2a2d..a21f5f5197 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 01aff7969e..7ca2a94f21 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 b4f353cb80..0000000000 --- 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 d139a008ea..0ef6c3f628 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -16,23 +16,6 @@ - LOW: one appended block at the end of the stall-classifier section in `packages/ai/src/utils/retry.ts`; no existing line changes. -## Throughput-degraded provider streams classified apart from silence stalls (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. - -### 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. - -### 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. - -### Expected merge conflict zones - -- LOW: one alternation in the retryable list plus one new exported predicate in `packages/ai/src/utils/retry.ts`. ## 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 5c5b64e3f2..cdf9900901 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 6c09973198..1116bf2589 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 89acd82304..8ddfb6e251 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 e8c63cafdf..556d773309 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 bd1fac8d89..d96916161a 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -18,28 +18,6 @@ - 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 - Throughput-degraded streams skip same-model retries and fail over (senpi#1739) - -### 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`. - -### 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. - -### 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. - -### 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. - ## 2026-09-16 - /rename session command ### What changed diff --git a/packages/coding-agent/src/core/retry-fallback/settings.ts b/packages/coding-agent/src/core/retry-fallback/settings.ts index f8a643ff1c..98f353c6b4 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 2ad4933fd7..ee2650f19f 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 bda5ea6c77..0362881088 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 8af8736348..1f8831dd57 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -16,25 +16,6 @@ - LOW: the `case "error"` arm of `createAssistantRenderDescriptors` and one import block. -## 2026-09-16 - Live tok/s on the working line and the throughput-degraded notice (#1739) - -### 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."). - -### 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. - -### 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. - -### Expected merge conflict zones - -- LOW: the working-status suffix helper and the `message_start` / `message_update` cases in `interactive-mode.ts`. - ## 2026-09-16 - /rename session command ### What changed diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4c7aebd9e0..686a4eb4a5 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 6f354a3e55..7b03379aa0 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 1406f08e77..f4cef49bf1 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 aafce92ef0..e95aa7d6d3 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 7b7c035ea6..0773132d47 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 4d91affd1f..c919e93611 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 }, - ]); - }); -}); From fedd1ab594fd54c0dba334dd92a972128f781164 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 17:31:46 +0900 Subject: [PATCH 2/2] docs(changes): record the throughput-guard withdrawal in the fork trackers The changes.md trackers must cover every production path a PR touches, so the removal needs an entry in each affected tracker saying these files are back to their pre-guard shape. Without it the next upstream merge would still expect fork-local throughput code that no longer exists. --- packages/agent/src/changes.md | 22 +++++++++++++++++++ packages/ai/src/changes.md | 18 +++++++++++++++ packages/coding-agent/src/core/changes.md | 22 +++++++++++++++++++ .../src/modes/interactive/changes.md | 19 ++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/packages/agent/src/changes.md b/packages/agent/src/changes.md index 7a16366c53..f9c97506da 100644 --- a/packages/agent/src/changes.md +++ b/packages/agent/src/changes.md @@ -1,3 +1,25 @@ +## 2026-09-16 - Stream throughput guard withdrawn; the loop bounds silence only (senpi#1759) + +### What changed + +- `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 + +- 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 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 + +- 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) ### What changed diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 0ef6c3f628..641e3c3130 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,21 @@ +## Throughput-degraded classification withdrawn (2026-09-16) + +### What changed + +- `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 + +- 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 + +- 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: the retryable list and the stall-classifier block in `packages/ai/src/utils/retry.ts` are back to carrying silence classes only. + ## Plain-language provider-stall copy (2026-09-16) ### What changed diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index d96916161a..9d24e96166 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,27 @@ # changes +## 2026-09-16 - Throughput retry branch and its settings withdrawn (senpi#1759) + +### What changed + +- `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 + +- 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 + +- 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 + +- 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 - Stalled turns end with recovery guidance (senpi#1740) ### What changed diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index 1f8831dd57..e6da461217 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -1,3 +1,22 @@ +## 2026-09-16 - Live tok/s removed from the working line (senpi#1759) + +### What changed + +- `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 + +- 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 + +- 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 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 - Stall transcripts read as stalls (senpi#1740) ### What changed