You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Long single LLM completions fail with 'Failed after 3 attempts. Last error: fetch failed' (node default 300s idle timeout on non-streaming requests) #38
Long single LLM completions fail with "Failed after 3 attempts. Last error: fetch failed"
What happens
runQuery and runMutation (packages/core/src/agent/agent.ts) use the Vercel AI SDK's generateText, i.e. non-streaming requests. The client POSTs to the configured OpenAI-compatible endpoint and then waits for a single JSON body. Node's built-in fetch (undici) enforces default idle timeouts of 300 s (headersTimeout/bodyTimeout) — on a non-streaming request, no bytes arrive until the entire completion is finished. So any single model completion taking longer than 5 minutes aborts the request with TypeError: fetch failed, which the AI SDK retries twice by default, and the run dies after ~16 min with:
answer: "Failed after 3 attempts. Last error: fetch failed"
outcome: "failed"
Reproduction (local model)
Stack: local Qwen3.8-27b behind a llama-swap gateway, reached through a small OpenAI-compatible proxy. Observed via the traces under <bundle>/.traces/:
run
duration
result
mutation
15.6 min
failed — fetch failed after 3 attempts (~5 min each)
mutation
17.7 min
failed — fetch failed after 3 attempts (~5 min each)
mutation
13.9 min
success — every single completion happened to stay under 5 min
The model was healthy throughout; runs #1/#2 lost entirely to the 300 s idle default, run #3 succeeded by luck of per-completion duration. Local models make single >5-min completions routine, so this effectively makes understory unreliable on slow hardware unless completions happen to be short.
Second symptom: no cancellation path for dead MCP clients
While a mutation runs, the tool call is held as a single pending JSON-RPC response. If the MCP client gives up (request timeout), nothing cancels the internal agent — LLM calls continue against the configured endpoint for minutes after the caller is gone, and the writes (concepts, log.md, trace) land only when the run finally ends. That makes failures hard to diagnose: the client reports an error, yet the bundle is silently updated 8–14 min later, and a live chat session on the same model gets starved by the zombie run.
Suggested fixes
Stream in runQuery/runMutation — use streamText (SSE) so bytes flow continuously. This makes idle timeouts moot at every layer including any proxy between understory and the model, and it is what a llama.cpp/llama-swap backend does best anyway.
Make the HTTP client timeouts configurable (e.g. LLM_TIMEOUT_MS / undici dispatcher with raised headersTimeout/bodyTimeout) so users on slow local models are not stuck at Node's 300 s default even if authN/authZ #1 is out of scope.
Honor MCP client disconnection: abort the internal agent loop (and the in-flight LLM call) when the requesting session/request closes, so a dead caller stops wasting model time.
Closing as a duplicate of #32, which reports the same 300 s limit in Node's fetch. The fix for both is in #48: LLM requests get a transport without that limit, and I measured a 320 s request failing at 302 s on main and completing on the branch.
The second symptom here, the agent carrying on after the MCP client has gone, was fixed by #24. A client that disconnects now aborts its in-flight model call.
Streaming the agent runs (your first suggestion) is not part of #48. A long prefill sends no bytes whether or not the request streams, so it would not have removed the limit on its own.
Activity
thecodacus commentedon Oct 13, 2026
Closing as a duplicate of #32, which reports the same 300 s limit in Node's fetch. The fix for both is in #48: LLM requests get a transport without that limit, and I measured a 320 s request failing at 302 s on main and completing on the branch.
The second symptom here, the agent carrying on after the MCP client has gone, was fixed by #24. A client that disconnects now aborts its in-flight model call.
Streaming the agent runs (your first suggestion) is not part of #48. A long prefill sends no bytes whether or not the request streams, so it would not have removed the limit on its own.