Repository navigation
Truncated tool calls are misclassified and unrecoverable (finishReason: length + repairToolCall + doom loop) #18108
Description
Description
When an LLM generates a tool call whose JSON arguments exceed maxOutputTokens, the output is truncated mid-JSON. opencode misclassifies this as a generic "invalid tool call," provides no truncation signal to the model, and either silently exits the session loop or enters an unrecoverable retry cycle. There is no truncation detection or recovery mechanism anywhere in the pipeline.
This affects any tool with large string parameters (write, apply_patch, edit, bash), but is most commonly triggered by write when creating new files.
Root Cause Chain (5 failure points, all interacting)
1. Artificially low output cap wastes model capacity
OUTPUT_TOKEN_MAX = 32_000 (transform.ts:21), so maxOutputTokens(model) = Math.min(model.limit.output, OUTPUT_TOKEN_MAX) caps Claude Opus (128k output) at just 32k tokens — only 25% of the model's actual capacity. The env var OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX (#5679) exists but defaults to 32k.
2. Thinking budget competes with output tokens (no coordination)
Anthropic thinking budget (up to 16k tokens, transform.ts:542) and output share the same token pool. If thinking consumes 14k tokens, only 18k remain for output, but maxOutputTokens still claims 32k. Compaction (compaction.ts:44) reserves maxOutputTokens without subtracting thinking budget. Truncation occurs even for files well under the nominal limit.
3. experimental_repairToolCall cannot distinguish truncation from invalid tools (llm.ts:181-201)
When JSON is truncated, toolName is typically valid (e.g. "write") — only the args JSON is incomplete. But the code routes ALL parse failures to the generic "invalid" tool. Key signal being ignored: if failed.toolCall.toolName is a registered tool, it is almost certainly truncation, not an invalid tool.
4. finishReason "length" treated as normal completion — session exits silently (prompt.ts:698-699)
"length" is NOT in the exclusion list for modelFinished check, so the session loop breaks when the model is cut off mid-tool-call.
5. Doom loop detection fails on truncation, blocks legitimate retries (processor.ts:233-244)
Detection requires exact JSON.stringify match of inputs. Truncation produces varying incomplete JSON each attempt so doom loop is never detected. But if the model retries identically during chunked recovery, the doom loop fires on the 3rd attempt, blocking legitimate recovery.
Additional: doom_loop Permission Hangs Indefinitely in Sub-Agent Sessions
When doom loop IS detected in a sub-agent, it triggers PermissionNext.ask (processor.ts:246). This creates an Effect Deferred and awaits it (permission/service.ts:173) with no timeout.
In sub-agent sessions there is no TUI to answer the permission prompt. The Deferred.await blocks forever. The parent agent is stuck at await SessionPrompt.prompt (task.ts:129) with no timeout, no polling, no watchdog. The abort signal from the parent only covers the LLM stream (processor.ts:61-91), not Effect fibers running the permission check.
Result: parent agent blocks for 20-30 minutes until an external stale task poller force-cancels the session.
The same pattern exists in the Question service (question/service.ts) — any ask-type permission or question in a sub-agent can trigger infinite hangs.
The Cascade
LLM generates write(path, 40KB_content) then maxOutputTokens=32000 truncates JSON mid-string. AI SDK cannot parse JSON so calls experimental_repairToolCall. toolName="write" is valid but code does not check, routes to "invalid". InvalidTool returns generic error. Model has NO signal output was truncated, retries from scratch. Truncated again at different point, "invalid" again (doom loop inputs differ so no match).
Two possible outcomes:
- A) finishReason="length" exits session silently, tools marked "Tool execution aborted"
- B) doom_loop triggers (identical retry) then PermissionNext.ask hangs forever in sub-agents
Affected Tools
- write (content param) — Critical, most common trigger
- apply_patch (patchText param) — High, triggered during chunked recovery
- edit (oldString/newString) — Medium
- multiedit (nested array) — Medium
- bash (command) — Low
Proposed Fix (prioritized checklist)
- P0: Detect truncation in repairToolCall — When failed.toolCall.toolName is a registered tool but JSON parsing fails, return "output truncated by token limit, split into smaller operations" instead of generic "invalid." (llm.ts:181-201)
- P1: Auto-continue on finishReason "length" — Add "length" to the exclusion list in prompt.ts:698-699 so the session loop continues. Cap auto-continues per turn.
- P2: Coordinate thinking budget with output limit — Subtract thinking budget from maxOutputTokens so the system does not promise 32k when only 18k is available.
- P3: Fix doom_loop permission hang in sub-agents — Either set doom_loop to "deny" for sub-agents (auto-reject instead of hang), or add timeout to Deferred.await in permission service. Also fix abort signal propagation to cover permission/question Effect fibers.
- P4 (optional): Raise OUTPUT_TOKEN_MAX from 32k to 64k. Reduces truncation frequency but does not fix recovery. Do after P0-P1.
Related Issues
Directly related: #13102, #17471, #14087, #17750, #12716
Tangentially related: #17578, #17019, #18037
Previously fixed (context): #5679, #10995, #2976
Steps to reproduce
- Start a session with Claude Opus (or any model with maxOutputTokens effectively capped at 32k)
- Ask the agent to create a 35KB+ markdown file
- The model calls write(filePath, content) and the tool call JSON is truncated at maxOutputTokens
- Observe: experimental_repairToolCall routes to InvalidTool, model retries, truncated again, session exits with "Tool execution aborted" or silently stops
- For the sub-agent hang: run the same scenario via a task() sub-agent call and observe the parent blocks indefinitely
Activity
github-actions commentedon Mar 18, 2026
This issue might be a duplicate of existing issues. Please check:
finish: "length"+ tool execution aborted scenariofinishReason: "length")If these don't fully address your report (e.g. the doom loop hang in sub-agents and the
repairToolCallmisclassification are distinct points), please clarify what's not covered by the existing issues so maintainers can track the novel parts separately.guazi04 commentedon Mar 18, 2026
Additional Finding: No Error Signal Propagation in Async Task Chains
The truncation problem is significantly worse in async/sub-agent scenarios because error signals cannot propagate upward through the task chain.
Three-layer opacity:
Sub-agent layer: Hits truncation → tools get "Tool execution aborted" → processor returns "continue" → prompt loop keeps retrying. Sub-agent has no signal that it's being truncated, so it retries indefinitely.
Parent agent layer: Blocked at
await SessionPrompt.prompt()(task.ts:129) with zero visibility into sub-agent state. No timeout, no progress callback, no health check. Cannot detect that the sub-agent is stuck in a futile retry loop.User/orchestrator layer: Dispatched the parent agent as a background task (
run_in_background). Waiting for a completion notification that never comes because the parent is blocked waiting for the sub-agent.Result: User waits 20-30 minutes in silence until an external stale task poller detects inactivity and force-cancels the entire chain.
Proposed fix (P5): TaskTool should monitor sub-agent health. When the sub-agent's session accumulates N consecutive tool errors (e.g., 3+ "invalid" or "Tool execution aborted" within a single prompt loop), TaskTool should either:
await SessionPrompt.prompt()This is the same root cause (no truncation detection) but with compounding impact through async task delegation layers.
fix(session): detect truncated tool calls and auto-continue on length (…
fix(session): propagate all-tools-aborted error to parent (anomalyco#…
fix(agent,provider): deny doom loop for sub-agents and raise output t…
fix(session): detect near-maxOutputTokens truncation and override fin…
fix(opencode): recover from truncated tool calls instead of failing s…
thgilciffart commentedon May 20, 2026
I can confirm that I experience this as well.
Keesan12 commentedon May 20, 2026
The highest-leverage fix here feels like preserving the real stop cause all the way up the stack.
If a registered tool name parsed but args did not and
finishReason=length, that is not aninvalid toolevent, it'stool_call_truncated. Once that gets misclassified, every recovery layer is reasoning from the wrong premise.I'd propagate a typed truncation receipt with the tool name, attempt number, and whether the retry was auto-admitted or human-approved; otherwise subagents can burn budget re-proving the same truncation forever.
flyinghail commentedon Jun 2, 2026
I agree with you. I encountered this problem last weekend as well—the subagent used up my entire budget in just one night.
JustSidus commentedon Jun 12, 2026
Related: opened PR #32089 which fixes the doom loop detection scope. The truncated tool call misclassification issue you describe here is a separate problem in the repairToolCall logic that would need its own fix.
ooiyeefei commentedon Jun 27, 2026
Adding an independent reproduction from headless / unattended usage.
Setup: a reasoning model behind an OpenAI-compatible provider, with
limit.outputset low in config. Per-step output was effectively capped, so turns that combined reasoning plus a large file write exhausted the output budget before a final text part was emitted.Symptom: opencode returned no final assistant text for those turns. The turn had clearly done work (reasoning and tool calls), but it ended at the output ceiling with no committed final text, so a non-interactive caller has nothing to act on.
Evidence that the output cap is the trigger: turns that stayed under the output budget produced a final answer and succeeded; turns that hit the budget ceiling produced no final answer. Raising the configured output cap reduced the failures. This matches the root cause described here: the artificially low output cap plus no truncation detection or recovery.
Two things would each have prevented this, and they line up with what is already filed:
limit.outputinstead of the internal cap, tracked in Bug:limit.outputin config is silently capped at 32k;OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAXis a poor workaround #29363.finish_reason: "length"rather than ending the turn, tracked in [FEATURE]: Auto-continue when model hits output token limit (finish_reason: "length") #17471.Sharing in case the repro detail helps prioritize. The impact is amplified for headless usage, where there is no human to type "continue," so a truncated turn becomes a hard failure rather than a pause.
fix(selfext): lockstep the output-cap raise (streamshim) + fail loudl…
iceteaSA commentedon Aug 2, 2026
Independent reproduction of the same root cause with a different presentation: reasoning-channel truncation with no tool call at all. Your P1 finding (
lengthmissing from themodelFinishedexclusion list) is the load-bearing one here, and this variant is harder to recover from than the tool-call case because there is no malformed JSON to attempt repair on.Observed on
hf:zai-org/GLM-5.2via an OpenAI-compatible gateway, cataloglimit.output: 65536, somin(65536, 32000) = 32000.Occurrence 1 — turn 21 of a normal subagent run, no prior truncation in history:
The model entered the reasoning channel and spent the entire 32K budget there. No text part, no tool part. Because
lengthis not excluded atprompt.ts:1495, this was classified as a normal finish, the loop exited, and the run reported success with empty output.Occurrence 2 — resuming that same session, 3 hours later:
Identical shape. Both landed on exactly 32000 output tokens — two independent 4-minute streams hitting the same round number is the constant, not the model.
Why the resume repeated it. In
message-v2.ts, a storedreasoningpart is replayed as native reasoning when the request model matches the stored model, and downgraded to plain text only when they differ:Same model on resume, so the incomplete 130KB chain was replayed verbatim, and the model continued it into another truncation. Self-perpetuating: each resume replays a longer incomplete chain. A fresh-slug re-dispatch of the same task on the same model completed normally in 274s, which isolates it to the replayed history rather than the task or the lane.
That branch is correct for Anthropic, where signed thinking blocks must round-trip intact. For a signature-less OpenAI-compatible lane there is nothing to preserve, so replaying an unbounded prior chain-of-thought buys nothing.
Two notes that may be useful for the fix:
reasoning: 0in the token accounting while the part holds 130KB of reasoning text — this gateway books reasoning inside the ordinary output count. Any detection keyed on the reasoning counter reads zero on exactly the affected turns.finish == "length"is the reliable signal.okand empty output, indistinguishable from a subagent that simply had nothing to say.Supports your P1 and P4. Beyond those, a reasoning-only turn that finished with
lengthis not a healthy resume point at all — the stored chain is unrecoverable context regardless of how it is replayed.iceteaSA commentedon Aug 2, 2026
I've opened #40146 for item 4 of your root-cause chain (the P1 in your checklist —
finishReason: "length"treated as a normal completion) and a PR against it: #40142.Separated it out because the other four items in this issue are independent of it — nothing in that fix touches
repairToolCall, the thinking-budget coordination, the doom-loop detector, orOUTPUT_TOKEN_MAX. Your issue stays the umbrella; this is one strand of it, small enough to review on its own.One deviation from your P1 as written, worth flagging since it's your issue:
Unconditional auto-continue is right for your case — a truncated tool call, where the model has produced real work and needs another turn to finish emitting it. It's harmful for a case I hit from the other direction: a turn that burned its entire output budget inside the reasoning channel and emitted no text and no tool call. Continuing that replays a growing incomplete chain and truncates again at the same limit, at a full output budget per iteration.
So the PR branches on whether the truncated turn produced anything usable: continue if there are text or tool parts, surface
MessageOutputLengthErrorif there aren't. Your tool-call case takes the continue path.On the cap —
agent.stepscan't serve as one. It defaults toInfinity, and at the limit it only injectsMAX_STEPS_PROMPTrather than breaking, so it's a nudge rather than a bound. The PR bounds continuation to exactly one attempt by reading the previous turn'sfinishfrom history the loop already reloads, which needs no new state.Also worth noting for whoever picks up the rest of your chain:
MessageOutputLengthErroralready exists in the schema, in core, and in the assistant error union, andacp/service.ts:842already maps it tostopReason: "max_tokens"— it just had no producer anywhere insrc. The plumbing for this case was built and never connected.Raynos commentedon Aug 5, 2026
I've run into "4. finishReason "length" treated as normal completion — session exits silently (prompt.ts:698-699)"
As well the length finish reason, I think its a shame, and this should be fixed in the harness.
mianabdulrehman1994-boop commentedon Oct 2, 2026
Local reproduction + workaround implemented
Reproduction confirmed: The
finishReason: "length"silent exit atprompt.ts:698-699is real. Locally observed 3/17903 step-finishes with this cause (rare but confirmed). Headlessopencode runexits 0 with "FINISHED clean" — matches #17471 reports.Workaround deployed (no core fork needed):
continuation-gate.ts) injectscontinueonsession.idlewhen plan has open rows (budget: 5/session, reset on plan file change)opencode run --attach http://localhost:49374 -s <id>for V2 sessions, V1 CLI for V1 sessions — respects sharedNEEDS_USERflagRequested upstream fixes (still needed):
"length"to modelFinished exclusion list inprompt.ts:698-699finishReason: "length"(cap per turn)repairToolCalltruncation detection — whenfailed.toolCall.toolNameis registered but JSON parse fails, return "output truncated by token limit, split into smaller operations"PermissionNext.askDeferred needs timeout + auto-deny for doom_loopEvidence: V2 DB shows 63/681 failed idles (9.3%) dominated by free-tier 403/503;
opencode run --session <id> "continue"recovery works but nothing calls it (resume_attempts=0across all DBs).Willing to test PRs when available.
thdxr commentedon Oct 11, 2026
Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (
opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊