Skip to content

Truncated tool calls are misclassified and unrecoverable (finishReason: length + repairToolCall + doom loop) #18108

Closed as not planned

Description

@guazi04

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

  1. Start a session with Claude Opus (or any model with maxOutputTokens effectively capped at 32k)
  2. Ask the agent to create a 35KB+ markdown file
  3. The model calls write(filePath, content) and the tool call JSON is truncated at maxOutputTokens
  4. Observe: experimental_repairToolCall routes to InvalidTool, model retries, truncated again, session exits with "Tool execution aborted" or silently stops
  5. For the sub-agent hang: run the same scenario via a task() sub-agent call and observe the parent blocks indefinitely

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Mar 18, 2026
  2. github-actions commented on Mar 18, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If these don't fully address your report (e.g. the doom loop hang in sub-agents and the repairToolCall misclassification are distinct points), please clarify what's not covered by the existing issues so maintainers can track the novel parts separately.

  3. guazi04 commented on Mar 18, 2026

    @guazi04
    Author

    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:

    1. 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.

    2. 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.

    3. 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:

    • Bail out early and return an error to the parent with diagnostic info
    • Emit a progress/warning event that the parent can act on
    • At minimum, enforce a wall-clock timeout on await SessionPrompt.prompt()

    This is the same root cause (no truncation detection) but with compounding impact through async task delegation layers.

  4. added a commit that references this issue on Apr 9, 2026
    a266d02
  5. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  6. thgilciffart commented on May 20, 2026

    @thgilciffart

    I can confirm that I experience this as well.

  7. Keesan12 commented on May 20, 2026

    @Keesan12

    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 an invalid tool event, it's tool_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.

  8. flyinghail commented on Jun 2, 2026

    @flyinghail

    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 an invalid tool event, it's tool_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.

    I agree with you. I encountered this problem last weekend as well—the subagent used up my entire budget in just one night.

  9. JustSidus commented on Jun 12, 2026

    @JustSidus

    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.

  10. ooiyeefei commented on Jun 27, 2026

    @ooiyeefei

    Adding an independent reproduction from headless / unattended usage.

    Setup: a reasoning model behind an OpenAI-compatible provider, with limit.output set 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:

    1. Honor the configured limit.output instead of the internal cap, tracked in Bug: limit.output in config is silently capped at 32k; OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX is a poor workaround #29363.
    2. Auto-continue on 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.

  11. iceteaSA commented on Aug 2, 2026

    @iceteaSA

    Independent reproduction of the same root cause with a different presentation: reasoning-channel truncation with no tool call at all. Your P1 finding (length missing from the modelFinished exclusion 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.2 via an OpenAI-compatible gateway, catalog limit.output: 65536, so min(65536, 32000) = 32000.

    Occurrence 1 — turn 21 of a normal subagent run, no prior truncation in history:

    step-start   text_len=0
    reasoning    text_len=129,961
    step-finish  text_len=0        (+4m06s)
    
    tokens: input 3014, output 32000, reasoning 0, cache_read 148096
    finish: "length"
    

    The model entered the reasoning channel and spent the entire 32K budget there. No text part, no tool part. Because length is not excluded at prompt.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:

    step-start   text_len=0
    reasoning    text_len=132,948
    step-finish  text_len=0        (+4m19s)
    
    tokens: input 96571, output 32000, reasoning 0
    finish: "length"
    

    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 stored reasoning part is replayed as native reasoning when the request model matches the stored model, and downgraded to plain text only when they differ:

    if (part.type === "reasoning") {
      if (differentModel) {
        if (part.text.trim().length > 0)
          assistantMessage.parts.push({ type: "text", text: part.text })
        continue
      }
      assistantMessage.parts.push({ type: "reasoning", text: part.text, providerMetadata: part.metadata })
    }

    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: 0 in 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.
    • Truncation is invisible to a parent orchestrator today: the task completes with status ok and 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 length is not a healthy resume point at all — the stored chain is unrecoverable context regardless of how it is replayed.

  12. iceteaSA commented on Aug 2, 2026

    @iceteaSA

    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, or OUTPUT_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:

    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.

    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 MessageOutputLengthError if there aren't. Your tool-call case takes the continue path.

    On the cap — agent.steps can't serve as one. It defaults to Infinity, and at the limit it only injects MAX_STEPS_PROMPT rather than breaking, so it's a nudge rather than a bound. The PR bounds continuation to exactly one attempt by reading the previous turn's finish from history the loop already reloads, which needs no new state.

    Also worth noting for whoever picks up the rest of your chain: MessageOutputLengthError already exists in the schema, in core, and in the assistant error union, and acp/service.ts:842 already maps it to stopReason: "max_tokens" — it just had no producer anywhere in src. The plumbing for this case was built and never connected.

  13. Raynos commented on Aug 5, 2026

    @Raynos

    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.

  14. mianabdulrehman1994-boop commented on Oct 2, 2026

    @mianabdulrehman1994-boop

    Local reproduction + workaround implemented

    Reproduction confirmed: The finishReason: "length" silent exit at prompt.ts:698-699 is real. Locally observed 3/17903 step-finishes with this cause (rare but confirmed). Headless opencode run exits 0 with "FINISHED clean" — matches #17471 reports.

    Workaround deployed (no core fork needed):

    1. V2 stop-gate plugin (continuation-gate.ts) injects continue on session.idle when plan has open rows (budget: 5/session, reset on plan file change)
    2. Headless watchdog (LaunchAgent, 10min) uses opencode run --attach http://localhost:49374 -s <id> for V2 sessions, V1 CLI for V1 sessions — respects shared NEEDS_USER flag
    3. Provider fallback moved to LiteLLM proxy (VM) — avoids client-side model hop that breaks 89% cache hit

    Requested upstream fixes (still needed):

    • P0: Add "length" to modelFinished exclusion list in prompt.ts:698-699
    • P1: Bounded auto-continue on finishReason: "length" (cap per turn)
    • P2: repairToolCall truncation detection — when failed.toolCall.toolName is registered but JSON parse fails, return "output truncated by token limit, split into smaller operations"
    • P3: Sub-agent PermissionNext.ask Deferred needs timeout + auto-deny for doom_loop
    • P4: Exit code discipline — headless length-death should NOT exit 0

    Evidence: 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=0 across all DBs).

    Willing to test PRs when available.

  15. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    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 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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

Metadata

Metadata

Assignees

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