Skip to content

fix(agent-core-v2): detect and retry stalled LLM response streams - #3073

Draft
7Sageer wants to merge 8 commits into
mainfrom
fix/v2-llm-stream-stall-watchdog
Draft

fix(agent-core-v2): detect and retry stalled LLM response streams#3073
7Sageer wants to merge 8 commits into
mainfrom
fix/v2-llm-stream-stall-watchdog

Conversation

@7Sageer

@7Sageer 7Sageer commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Related Issue

None — the problem is explained below. See also #1917, which covers the sibling problem of SDK-internal hidden retries.

Problem

When a provider stops producing bytes mid-stream — or never sends the first byte — a model request can hang forever:

  • The provider SDKs' default request timeout clears as soon as response headers arrive; nothing bounds a stalled SSE body afterwards.
  • generate() only checks the abort signal at part boundaries, so a stalled stream never settles.
  • No layer detects "no progress": the UI keeps the thinking spinner running, and the only way out is the user interrupting the turn. Observed in the wild: a request sat unsettled for 11+ minutes while the CLI appeared to be running normally.

What changed

Adds an application-layer liveness watchdog for model requests in agent-core-v2:

  • createIdleTimeoutAbortSignal (_base/utils/abort.ts): a resettable idle-timeout signal that links to the parent signal and aborts only its own internal controller on expiry.
  • ModelRequesterImpl.runRequest wraps each generate() attempt (inside the auth-refresh callback, so a 401 resend gets fresh timers): a first-output timeout runs until the first part arrives, then a stream-idle timeout resets on every part. On expiry it aborts only the internal controller and rethrows a typed, retryable LLMStreamStalledError carrying phase / elapsed / idle diagnostics. A user cancel always wins the race, and when both thresholds are 0 the watchdog is bypassed entirely — the provider receives the original signal object.
  • AgentStepRetryService gives stall errors their own bounded budget (max_stall_attempts_per_step, default 3) instead of the generic 10 attempts; the existing backoff and turn.step.retrying pipeline is reused unchanged.
  • Config: new loop_control fields first_output_timeout_ms (default 180000), stream_idle_timeout_ms (default 120000), and max_stall_attempts_per_step (default 3), with KIMI_LOOP_* env bindings. Thresholds flow from AgentLLMRequesterService down through ModelRequestParams.
flowchart TD
    A[AgentLLMRequesterService<br/>reads loop_control thresholds] --> B[ModelRequesterImpl.runRequest]
    B --> C{watchdog armed?<br/>threshold &gt; 0}
    C -- no --> D[generate with original signal]
    C -- yes --> E[generate with idle-timeout signal]
    E -->|part arrives| E
    E -->|idle timeout| F[abort internal controller<br/>throw LLMStreamStalledError]
    F --> G[AgentStepRetryService<br/>bounded budget: 3 attempts]
    G --> B
    E -->|user cancel| H[cancelled, never retried]
Loading

Tests cover: first-output expiry, per-part idle reset, the typed stall error (retryable, never an AbortError), user-cancel precedence, full timer cleanup on every path, the 0-threshold bypass, the separate stall budget, and the config/env wiring. User docs (config-files, env-vars, both locales) are updated in this PR.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3b2c6e9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@7Hanrui

7Hanrui commented Aug 19, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 075d0633f4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

{
...options,
auth,
signal: watchdog?.signal ?? signal,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make the watchdog interrupt Google streaming reads

When the Google GenAI provider has returned its stream but the iterator then stops yielding, passing the watchdog signal here does not settle generate(): google-genai.ts races the signal only while acquiring generateContentStream() (lines 797–806), while its subsequent for await loop checks the signal only after another chunk arrives (lines 593–605). Consequently the timeout aborts its controller but no error reaches this catch, so no LLMStreamStalledError or retry occurs and Google streaming requests can still hang indefinitely; race each pending iterator read against the signal or otherwise make that provider's stream consumption abort-aware.

Useful? React with 👍 / 👎.

@7Hanrui

7Hanrui commented Aug 19, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b28a44822

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +118 to +122
timeout = setTimeout(() => {
timeout = undefined;
didIdleTimeout = true;
controller.abort(abortError());
}, currentTimeoutMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clamp watchdog delays to the runtime timer maximum

When either new timeout is configured above 2147483647 ms, Node clamps this setTimeout delay to 1 ms, so a user attempting to allow a long-running request instead causes nearly every request to be classified as stalled immediately. Both config fields currently accept any non-negative integer; reject oversized values or use the existing setClampedTimeout helper.

Useful? React with 👍 / 👎.

Comment on lines +123 to +126
const maxAttempts = Math.max(
this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.maxAttemptsPerStep ??
DEFAULT_MAX_RETRY_ATTEMPTS,
error instanceof LLMStreamStalledError
? loopControl?.maxStallAttemptsPerStep ?? DEFAULT_MAX_STALL_ATTEMPTS_PER_STEP
: loopControl?.maxAttemptsPerStep ?? DEFAULT_MAX_RETRY_ATTEMPTS,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count stall attempts independently of other failures

When a step encounters mixed transient failures, failedAttempts includes every prior retryable error before this smaller stall limit is applied. For example, after two 5xx failures, the first subsequent stall makes the shared count 3 and exhausts the default stall budget immediately, so that stalled request is not retried at all despite the separate stall budget. Track stall failures separately while continuing to enforce the overall attempt cap.

Useful? React with 👍 / 👎.

@7Hanrui

7Hanrui commented Aug 19, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11e58591ae

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

throw convertGoogleGenAIError(error);
} finally {
try {
void iterator.return?.()?.catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Abort the Google transport instead of abandoning its iterator

When the stalled operation is the pending iterator.next() call, the new race lets the caller fail but this fire-and-forget return() does not cancel the read: native async generators queue return() behind the unresolved next(). Because generateContentStream(params) is not supplied the watchdog signal, the underlying Google HTTP stream can therefore remain open indefinitely, and each automatic retry can leave another request and connection behind. Make the transport itself abort-aware or use a cancellation primitive that can interrupt the pending read.

Useful? React with 👍 / 👎.

@7Hanrui

7Hanrui commented Aug 19, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b1b98feaa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

compactionTriggerRatio: z.number().min(0.5).max(0.99).optional(),
firstOutputTimeoutMs: z.number().int().min(0).max(MAX_TIMER_DELAY_MS).optional(),
streamIdleTimeoutMs: z.number().int().min(0).max(MAX_TIMER_DELAY_MS).optional(),
maxStallAttemptsPerStep: z.number().int().min(0).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cap the stall-attempt budget before allocating backoff arrays

When max_stall_attempts_per_step is configured to a very large integer, the first stall passes that value to retryBackoffDelays(attemptBudget), which eagerly allocates attemptBudget - 1 entries even though the overall max_attempts_per_step limit may still be only 10. Values such as 1000000000 are accepted by both the schema and environment parser, so a single stalled request can exhaust memory before retrying; bound this field, clamp it to the effective overall budget, or calculate only the current delay.

Useful? React with 👍 / 👎.

@7Hanrui

7Hanrui commented Aug 19, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: cfe470ed5e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@7Hanrui

7Hanrui commented Aug 19, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cfe470ed5e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const maxAttempts = Math.max(loopControl?.maxAttemptsPerStep ?? DEFAULT_MAX_RETRY_ATTEMPTS, 1);
let failedAttempt = this.failedAttempts;
let attemptBudget = maxAttempts;
if (error instanceof LLMStreamStalledError) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Discard streamed output from a stalled attempt before retrying

When a request emits assistant or thinking parts and then exceeds stream_idle_timeout_ms, this branch classifies it for retry after those parts have already been published as deltas. The retry path emits turn.step.retrying and starts another numbered step, but the kap-server transcript projector only updates the old step header and never deletes its existing frames, so the incomplete first attempt remains visible—often as a permanently running step—beside the successful retry output. Retract or explicitly terminate the stalled attempt's streamed frames before requeueing the driver.

Useful? React with 👍 / 👎.

Comment on lines +301 to +303
beforeEach(() => {
vi.useFakeTimers();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Drive watchdog time through an explicit clock seam

The new watchdog suite globally enables vi.useFakeTimers() (and the added abort, retry, and requester suites do likewise), violating the agent-core-v2 test skill's boundary-discipline rule that timers be controlled through documented knobs, an injected clock, or a manual tick rather than fake timers. This is particularly important here because the implementation depends on real Node timer clamping and abort/event-loop ordering, which virtual timers can model differently; expose a scheduler/clock seam and drive these tests through that public control instead.

Useful? React with 👍 / 👎.

@7Hanrui

7Hanrui commented Aug 19, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 3b2c6e98b7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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.

2 participants