Skip to content

fix(providers): content-idle stream watchdog that keepalive pings cannot reset - #170

Closed
jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-stream-content-idle-timeout
Closed

jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-stream-content-idle-timeout

Conversation

@jasonkneen

Copy link
Copy Markdown

Problem

Today the only stream liveness guard is undici bodyTimeout/headersTimeout, set in packages/coding-agent/src/core/http-dispatcher.ts (~84) from the httpIdleTimeoutMs setting (default 300s). bodyTimeout resets on any body bytes, including SSE ping events and comment keepalives. A stalled generation that keeps pinging therefore never times out, and the turn hangs until the user aborts. StreamOptions.timeoutMs (packages/providers/src/types.ts) is only an SDK request timeout. Library users of @step-harness/providers and agent-core had no idle guard at all.

Invariant: idle timers reset on content frames only, never on keepalives. The guard fires and is covered by a test. The error names the phase and the last activity.

Fix

  • New StreamOptions.streamIdleTimeoutMs (0 or undefined disables it).
  • New withStreamIdleTimeout (packages/providers/src/utils/stream-idle-timeout.ts, exported from the package). When the value is > 0, it runs the request under a child AbortController linked to the caller's signal. It starts the timer when the request is issued and resets it on every emitted AssistantMessageEvent: start, *_start, *_delta, *_end and toolcall events. Adapters filter pings before they reach this layer; for example, iterateAnthropicEvents skips non-message SSE events. The OpenAI SDK's SSE decoder drops : ping comment lines. openai-completions pushes events only for choices with content, and openai-responses-shared pushes only on typed content events: response.created and in_progress push nothing. When the timer expires, the watchdog aborts the request. It then ends the stream right away with stopReason: "error" (not "aborted"), keeps the last partial content, and sets an error such as Stream idle timeout: no content for 300000ms (phase: streaming text_delta, last content at 2026-...Z). Before the first token the message reads phase: waiting for first token, followed by either request started at ... or last content at .... The text contains "timeout", so isRetryableAssistantError retries it like a network stall. A caller abort still produces "aborted". Timers and listeners are cleared on every terminal path. The outermost guard consumes the option, so nested dispatch layers do not add a second guard.
  • The guard is applied at every stream-dispatch entry point: createProvider (all built-in, models.json and faux providers), Models.stream/streamSimple, the compat registered-API fallback, and coding-agent's ModelRuntime.stream/streamSimple (which also covers extension providers).
  • coding-agent: sdk.ts sets streamIdleTimeoutMs from the existing httpIdleTimeoutMs setting (0 disables it; a per-request override is honoured). No new setting was added.

Test

  • packages/providers/test/stream-idle-timeout.test.ts uses fake timers and a scripted wire API behind createProvider. The API emits start and then nothing, which is how a ping-only upstream looks at the event layer. Covered cases:
    • The stream ends with stopReason "error" and the exact message naming the phase and last content time, and the error counts as retryable.
    • A mid-stream stall reports streaming text_delta and keeps the partial text.
    • The underlying signal is aborted and the option is consumed.
    • Deltas arriving every 800ms under a 1000ms timeout never trip it, and no timers are left over.
    • A caller abort still produces "aborted".
    • streamIdleTimeoutMs of 0 or undefined installs no watchdog.
    • Before the fix, the three trip tests failed with Error: Test timed out in 30000ms because the stream never ended.
  • packages/coding-agent/test/sdk-stream-options.test.ts: two new tests check that the watchdog is installed from httpIdleTimeoutMs (the provider sees a linked signal and the option is consumed) and not installed when the value is 0. Both failed without the sdk.ts change.

Risk / behaviour change

  • Reasoning models that think silently: most APIs emit thinking deltas, which reset the timer. An API that emits nothing during hidden reasoning will now time out after httpIdleTimeoutMs of silence. That is the same budget as today's bodyTimeout, so behaviour is no worse than now, except that pings no longer keep such a request alive. Users who hit this can raise httpIdleTimeoutMs or set it to 0.
  • The watchdog clock starts when the request is issued, so auth resolution and lazy module load count toward the first-token budget. These normally take milliseconds against a 300s budget.
  • When the watchdog is active, providers receive a linked child AbortSignal in place of the caller's signal, or a signal where the caller passed none.
  • Library users get the watchdog only when they set streamIdleTimeoutMs. The providers default is unchanged: no watchdog.

https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9

…not reset

Invariant: stream liveness timers reset on content events only, never on
keepalive pings.

Cause: the only liveness guard was undici bodyTimeout (httpIdleTimeoutMs,
default 300s), which resets on any body bytes including SSE pings and comment
keepalives, so a stalled generation that keeps pinging never timed out.
Library users of providers/agent-core had no idle guard at all.

Fix: add StreamOptions.streamIdleTimeoutMs and withStreamIdleTimeout, applied
at the stream-dispatch entry points (createProvider, Models, compat fallback,
coding-agent ModelRuntime). When > 0 it runs the request under a child
AbortController linked to the caller signal, resets on every emitted stream
event, and on expiry aborts the request and terminates with stopReason
"error" and "Stream idle timeout: no content for Nms (phase: ..., last
content at <iso>)", which isRetryableAssistantError treats as retryable.
Caller aborts still yield "aborted". coding-agent passes the existing
httpIdleTimeoutMs setting (0 disables).
Copilot AI lite review requested due to automatic review settings September 23, 2026 11:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Three unresolved moderate findings remain in stream-idle-timeout.ts.

Review effort: Lite
Findings: None

What changed in this PR

Adds a content-idle watchdog that ignores keepalive pings, aborts stalled streams, preserves partial output, and integrates across provider and coding-agent dispatch paths.

Changes:

  • Adds and exports streamIdleTimeoutMs and withStreamIdleTimeout.
  • Applies watchdog protection across models, compatibility APIs, SDK, and runtime providers.
  • Adds timeout, abort, cleanup, partial-content, and SDK propagation tests.
File Summary Final comments
packages/​providers/​test/​stream-idle-timeout.test.ts Tests watchdog behavior and SDK integration. None.
packages/​providers/​src/​utils/​stream-idle-timeout.ts Implements watchdog timing, abort linkage, cleanup, and timeout errors. Moderate (1 vote): Track token/delta receipt separately so start events retain the waiting for first token diagnostic. Moderate (1 vote): Caller cancellation during setup can incorrectly produce error instead of aborted. Moderate (1 vote): Convert synchronous start exceptions into terminal error events instead of rethrowing.
packages/​providers/​src/​types.ts Defines the new stream option. None.
packages/​providers/​src/​models.ts Guards model and provider stream dispatch. None.
packages/​providers/​src/​index.ts Exports the watchdog utility. None.
packages/​providers/​src/​compat.ts Guards compatibility API streams. None.
packages/​coding-agent/​test/​sdk-stream-options.test.ts Tests SDK option propagation and disabling. None.
packages/​coding-agent/​src/​core/​sdk.ts Maps the existing HTTP idle setting to the watchdog. None.
packages/​coding-agent/​src/​core/​model-runtime.ts Guards runtime and extension-provider streams. None.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@ZouR-Ma ZouR-Ma closed this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants