Skip to content

fix(kosong,agent-core-v2): watchdog and retry silent LLM stream stalls#1799

Open
askie wants to merge 3 commits into
MoonshotAI:mainfrom
askie:fix/stream-idle-watchdog
Open

fix(kosong,agent-core-v2): watchdog and retry silent LLM stream stalls#1799
askie wants to merge 3 commits into
MoonshotAI:mainfrom
askie:fix/stream-idle-watchdog

Conversation

@askie

@askie askie commented Jul 17, 2026

Copy link
Copy Markdown

Related Issue

Resolve #1798

Problem

A streaming completion whose body goes silent mid-flight blocks the whole turn forever. The OpenAI-compatible client only guards the header phase — openai/src/client.ts fetchWithTimeout wraps the fetch in try { … } finally { clearTimeout(timeout) } and the fetch promise resolves once headers arrive, so the abort timer is cleared before the body is consumed. From that point on, for await (const part of stream) has no deadline of its own; if the socket goes idle (rate-limit disconnect, LB/NAT half-close, provider-side stall) the loop never advances, no error is thrown, and the CLI status bar keeps showing an in-flight turn.

Full evidence — 11/161 loop requests hung under concurrent use, always after partial content had already streamed — is in the linked issue.

What changed

Add an inactivity watchdog around the streamed message iterator, in both stream loops shipped in the CLI bundle:

  • packages/kosong/src/generate.ts — the ACP path (kimi-code TUI / kimi acp → kimi-code-sdk → agent-core → kosong), which is where the linked issue's hangs were observed.
  • packages/agent-core-v2/src/app/llmProtocol/generate.ts — the kap-server path, which has the same failure signature.

Mechanics (identical in both):

  • Wrap stream[Symbol.asyncIterator]() with an async generator that races each next() against a timer. Default deadline is 180_000 ms, tunable via KIMI_STREAM_IDLE_TIMEOUT_MS.
  • generate() now owns a watchdog AbortController merged with the caller's signal via AbortSignal.any, and hands the merged signal to the provider. On timeout the watchdog aborts it, which providers forward to the HTTP client — so the stalled connection is actually torn down instead of leaking until the server gives up on it. (Provider stream classes expose no cancellation handle of their own, so signal-based teardown is the only path that reaches the socket.)
  • The provider iterator is additionally closed via iterator.return?.() from a finally block, so generator cleanup also runs on consumer throws and early exits. The call is deliberately not awaited: on a stalled stream return() stays pending until the transport abort settles the dangling read, and awaiting it would re-introduce the hang.
  • StreamIdleTimeoutError extends APITimeoutError, so isRetryableGenerateError recognises it and the loop's existing stepRetry plugin transparently re-drives the failed step — no new retry pathway is introduced.
  • The error message carries the elapsed stream time and the Kimi x-trace-id when the response headers exposed one, so operators can correlate a hung turn with a server-side request without patching fetch.

This is the smallest change that eliminates the silent-hang failure mode without altering the happy path (a healthy stream simply never trips the deadline) and reuses the existing retry taxonomy rather than adding a parallel one.

Tests (packages/kosong/test/stream-idle-timeout.test.ts, packages/agent-core-v2/test/app/llmProtocol/stream-idle-timeout.test.ts) cover:

  • A stalled stream after a partial think delta throws StreamIdleTimeoutError and cancels the underlying stream.
  • The error is an APITimeoutError and isRetryableGenerateError returns true, so step-retry will drive recovery.
  • A healthy stream is unaffected.
  • On timeout, the signal the provider received is aborted (with the timeout error as reason) and the provider iterator's return() is invoked.
  • A caller-supplied signal still reaches the provider through the merged signal.

Verification beyond unit tests

The patched build has been running as the reporter's daily driver. Since the rebuild, 254 streamed completions completed with zero silent hangs; every request/usage-record gap in the session logs was attributable to a user interrupt or an in-flight request, none to a stall.

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. (patch @moonshot-ai/kimi-code)
  • Ran gen-docs skill, or this PR needs no doc update.

The OpenAI-compatible client used for streaming completions clears its
request timeout as soon as response headers arrive (`openai/src/client.ts`
wraps the fetch in `try { … } finally { clearTimeout(timeout) }` and the
fetch promise resolves on header receipt). Once headers are back the
streaming body has no read timeout of its own, so a mid-stream stall
blocks `for await` forever and hangs the whole turn — including cases
where the assistant has already streamed a reasoning delta and then
falls silent, and where the process holds pooled TCP connections that a
NAT/LB has half-closed.

Add an inactivity watchdog around the streamed message iterator: after
`KIMI_STREAM_IDLE_TIMEOUT_MS` (default 180 s) without a new chunk, cancel
the underlying stream and throw `StreamIdleTimeoutError`. The error now
extends `APITimeoutError`, so `isRetryableGenerateError` recognises it
and the loop's `stepRetry` plugin re-drives the failed step automatically;
users see the same recoverable behaviour as for any transient network
timeout instead of a permanent hang. The error message carries the
elapsed stream time and, when available, the Kimi `x-trace-id` for
server-side triage.
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cd11470

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

@askie

askie commented Jul 17, 2026

Copy link
Copy Markdown
Author

Closing to run local verification first — will reopen after the fix is validated on the reporter's machine.

@askie askie closed this Jul 17, 2026

@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: 9312f94828

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// The dangling next() will reject once the stream is cancelled below;
// swallow it so it never surfaces as an unhandled rejection.
next.catch(() => {});
await cancelStream(stream);

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 Close the provider iterator on timeout

In the real provider streams (KimiStreamedMessage, OpenAILegacyStreamedMessage, etc.) cancellation is on the async iterator returned above, not on the StreamedMessage object; those classes do not expose cancel or return. When this new timeout path runs, await cancelStream(stream) is therefore a no-op, leaving the stalled iterator.next() and its HTTP socket open while retry starts another request. Please close iterator.return?.() here, and preferably from a finally so early exits are also closed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cd11470. You're right that the provider stream classes expose no cancellation handle, so cancelStream(stream) was a no-op on every real provider. generate() now owns a watchdog AbortController merged with the caller's signal via AbortSignal.any and hands the merged signal to the provider; on timeout the watchdog aborts it, which providers forward to the HTTP client — the stalled connection is actually torn down. The iterator is additionally closed via iterator.return?.() from a finally block so early exits are covered too; the call is deliberately not awaited because on a stalled stream return() stays pending until the transport abort settles the dangling read. New tests assert both the signal abort (with the timeout error as reason) and the return() call.

Comment on lines +178 to +182
/**
* Guard the gap between streamed chunks with an inactivity deadline.
*
* The provider SSE stream has no read timeout of its own — the HTTP client's
* request timeout is cleared as soon as response headers arrive (see

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 Move the watchdog explanation to the file header

packages/agent-core-v2/AGENTS.md says comments in this package must live solely in the top-of-file /** */ block and never beside functions, methods, or statements. This new body comment (and the inline timeout comment below) violates the local convention, so move any necessary module-level context into the file header or let the code/tests carry it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cd11470 — the body comments are gone; module-level context now lives in the top-of-file /** */ header per this package's convention.

The ACP path (kimi-code TUI + `kimi acp` → @moonshot-ai/kimi-code-sdk →
@moonshot-ai/agent-core → agent-core/turn/kosong-llm.ts) drives its LLM
stream through kosong.generate(), not agent-core-v2.generate(). The
earlier watchdog on agent-core-v2 fired only on the kap-server path
(@moonshot-ai/agent-core-v2), so the ACP session that hung mid-stream
still had no read-side timeout. Live proof: session
6871de3a-3499-4e94-94f7-39585a347f1b silently stalled after the first
`think` chunk at 02:13:47 on a bundle that contained the agent-core-v2
watchdog symbols — no StreamIdleTimeoutError ever surfaced.

Port the identical inter-chunk deadline guard into kosong: wrap the
`for await (const part of stream)` loop with `withStreamIdleTimeout()`
that races each iterator.next() against a `setTimeout` reject; on
timeout, cancel the stream and throw a `StreamIdleTimeoutError` (extends
kosong `APITimeoutError`) carrying the elapsed time and the
`x-trace-id`. The existing `isRetryableGenerateError()` classifies it as
retryable, so agent-core's `chatWithRetry()` re-drives the failed step
via the standard `step.retrying` event — same recovery path the
kap-server watchdog already exercises.

Watchdog window shares the same `KIMI_STREAM_IDLE_TIMEOUT_MS` knob
(default 180000 ms) so operators tune both stacks in lockstep.

Test: packages/kosong/test/stream-idle-timeout.test.ts covers cancel,
retryable classification with traceId propagation, and healthy-stream
pass-through.
@askie askie reopened this Jul 17, 2026
…atchdog fires

Provider stream classes expose no cancellation handle, so the previous
cancelStream() call was a no-op on every real provider and each stall
leaked its HTTP connection until the server gave up. The watchdog now
owns an AbortController merged with the caller's signal via
AbortSignal.any and aborts it on timeout, so the stalled request is
actually torn down. The provider iterator is additionally closed from a
finally block (fire-and-forget: awaiting return() on a stalled stream
would re-introduce the hang) so generator cleanup also runs on consumer
throws and early exits. In agent-core-v2, comments move to the
top-of-file header per that package's convention.
@askie
askie force-pushed the fix/stream-idle-watchdog branch from 4a3880d to cd11470 Compare July 17, 2026 14:56
@askie askie changed the title fix(agent-core-v2): watchdog and retry silent LLM stream stalls fix(kosong,agent-core-v2): watchdog and retry silent LLM stream stalls Jul 17, 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.

Silent infinite hang when a streaming completion stalls mid-body (0.26.0)

1 participant