Skip to content

[bug] Chat times out at 25s on the tool loop's second round — a 4096-token cap under a 25s non-streaming ceiling is an impossible combination #427

Description

@serge-ivo

Not transient, and not the deploy — the tool loop's second round cannot fit a 25s ceiling

Reported live: chat showing AI request timed out (25s).

What actually happened — production, reconstructed from the trace + message history

05:02:36 → 05:04:37   Deploy API Worker (success)
05:04:01              turn OK — "Issue #112 is live"
05:05:49              503 "The service is updating"      ← the deploy, a SEPARATE event
05:08:52              chat 504  AI request timed out (25s)
05:15:38              chat 504  AI request timed out (25s)
05:20:39              turn OK — github_list_pulls → answered

Message history for bd43f4de-… shows the failing turn, twice, with the same shape:

user   | "Start implementing"
system | ✅ github_read_issue { "number": 111, "title": "Feature: Masters Games Training section", … }
system | Error: AI request timed out (25s)

The tool call succeeded. The generation that consumed its result did not. Round 1 (decide to call github_read_issue) returned fast; round 2 — same request, now carrying issue #111's full JSON on top of an already-large coding context — exceeded 25s. The user retried and got the identical failure 6 minutes later.

So: deterministic, reproducible from the same message, and unrelated to the 05:05:49 deploy 503. The chat recovered by itself only because the next turn (github_list_pulls → empty array → one-line answer) was short.

The defect: two constants that contradict each other

lib/user-ai.ts:147-167 — the Anthropic call is not streamed:

const timeoutMs = body.timeoutMs ?? 25_000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
res = await fetch("https://api.anthropic.com/v1/messages", {, signal: controller.signal });

const data = await res.json();

There is no stream: true anywhere in user-ai.ts. So the 25s must cover the entire generation, not time-to-first-token.

lib/reply-truncation.ts:45export const CHAT_MAX_TOKENS = 4096;, passed at agent-think.ts:700.

These cannot both hold. A 4096-token completion from claude-sonnet-4-6 needs roughly 30-70s at realistic throughput. The system is configured to permit a reply it is configured not to wait for. Any turn that runs long fails by construction — retrying reproduces it exactly, which is what happened here.

And the tool loop makes it worse in the precise place users feel it: round 2+ carries the tool result, so it is simultaneously the longest prompt and the round the user reaches after the agent has already done real work (created an issue, read a repo). The side effects land; the answer does not.

Note also that up to 3 rounds × 25s = 75s of provider time inside one request, so the 25s is not protecting a 30s request budget the way a single-call reading of it suggests.

What to do

1. Stream the Anthropic call (the real fix). With stream: true, the deadline becomes time-to-first token plus an inter-chunk stall timeout, instead of total generation time. A long reply then succeeds, and the 4096-token cap stops being a trap. It also removes the dead air the user currently stares at.

This is the same class as #421 — a non-streaming request under a wall-clock ceiling, where the ceiling measures the wrong thing. Worth fixing with the same shape in both places.

2. Until then, make the two constants consistent. Either raise timeoutMs to something a 4096-token reply can actually finish in, or lower CHAT_MAX_TOKENS to what fits 25s. Shipping them contradictory is the bug; either direction is defensible, silently permitting an impossible combination is not.

3. Cap tool results fed back into the prompt. github_read_issue returned a full issue JSON verbatim into round 2. A size cap on tool results before they re-enter the context (with a "truncated" marker) directly shortens the round most likely to time out. Grep found no such cap.

4. Say something actionable. AI request timed out (25s) tells the user nothing they can act on, and "try again" is precisely the wrong advice for a deterministic failure — they took it, and it failed identically. It should say the reply was too long to complete and offer the action that works: ask for a shorter answer, or narrow the request.

5. Do not lose the turn. The user's message and the tool result both survived here (good — better than #420's behaviour). Keep that, and make the failure resumable: the tool result is already in hand, so a retry should be able to re-run only the generation rather than re-calling the tool.

Alternatives considered and rejected

  • Treat it as transient and auto-retry. Rejected — this is deterministic. An automatic retry burns the user's own Anthropic credit on a call that cannot succeed, twice as slowly.
  • Raise timeoutMs alone. Insufficient on its own: with 3 tool rounds it multiplies, and it does nothing about the dead air. Acceptable only as the stopgap in (2).
  • Blame the deploy. The 503 at 05:05:49 is real and is a genuinely different failure (isTransientInfraError → "The service is updating"). Conflating them would send the implementer to the wrong subsystem — the stack is unambiguous: UserAiProviderError … at runAnthropic.

Acceptance criteria

  • A turn whose reply approaches CHAT_MAX_TOKENS completes instead of timing out.
  • A tool-loop round carrying a large tool result completes, or the result is capped before it re-enters the prompt.
  • The timeout and the output cap are consistent, with a test that fails if one is changed without the other.
  • The user-facing message names a next action that works, and does not suggest a bare retry for a deterministic failure.

Regression risk

  • Streaming changes the response contract for /chat and every caller of runAnthropic (chat, loop-decide, co-pilot, overseer, workflows). Worth doing behind the existing function signature so callers are untouched — collect the stream server-side, keep returning a whole message, and only the timeout semantics change. That gets the fix with none of the client work.
  • A tool-result cap can hide information the model needed. Truncate with an explicit marker and keep the head, so the model can ask for more rather than silently reasoning over a fragment.

Related: #421 (the same non-streaming-under-a-deadline shape, in the voice path), #424 (this failure logged correctly — source:"chat" from agent-do.ts:549 — which is why it was reconstructible), #423 (the log noise that made it harder to spot).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions