From a266d02acc20911e492868e9820ec146b099ef24 Mon Sep 17 00:00:00 2001 From: Nick Veenhof Date: Thu, 9 Apr 2026 14:54:21 +0200 Subject: [PATCH] fix(opencode): recover from truncated tool calls instead of failing silently When a model's output hits the token limit mid-tool-call, the JSON arguments are truncated and tool parsing fails with 'expected string, received undefined'. Two fixes: 1. experimental_repairToolCall now detects truncation: when the tool name is a valid registered tool but args failed to parse, return an actionable error telling the model to split its operation into smaller pieces. Previously all parse failures were routed to a generic 'invalid tool' handler with no recovery guidance. 2. The session loop now handles finishReason 'length': instead of silently exiting when the model is cut off by the token limit, inject a synthetic continuation message so the model can resume where it left off. Fixes #18108 Fixes #17471 Refs #14519, #13102 --- packages/opencode/src/session/llm.ts | 22 +++++++++++++++++++ packages/opencode/src/session/prompt.ts | 29 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index d55424f91ede..fd11de3cc64a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -329,6 +329,28 @@ export namespace LLM { toolName: lower, } } + // When the tool name is valid but args failed to parse, the output + // was likely truncated by the token limit. Give the model a clear + // signal so it can retry with smaller input instead of looping. + if (tools[failed.toolCall.toolName] || tools[lower]) { + l.warn("truncated tool call detected", { + tool: failed.toolCall.toolName, + error: failed.error.message, + }) + return { + ...failed.toolCall, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: + "Your output was truncated because it exceeded the token limit. " + + "The tool arguments were cut off and could not be parsed. " + + "Split your operation into smaller pieces and try again. " + + "For file writes, use the Edit tool for targeted changes or write smaller sections. " + + "For bash commands, break long heredocs into multiple shorter commands.", + }), + toolName: "invalid", + } + } return { ...failed.toolCall, input: JSON.stringify({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e9bd5bcd5605..00207237e6c0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1376,6 +1376,35 @@ NOTE: At any point in time through this workflow you should feel free to ask the const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false + // When the model hit the output token limit (finishReason: "length"), + // its response was truncated. Instead of silently exiting, inject a + // continuation message so the model can finish its work. + if (lastAssistant?.finish === "length" && lastUser.id < lastAssistant.id) { + log.warn("output truncated by token limit, auto-continuing", { sessionID }) + const continuationMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + } + yield* sessions.updateMessage(continuationMsg) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: continuationMsg.id, + sessionID, + type: "text", + text: + "Your previous response was truncated because it exceeded the output token limit. " + + "If you were writing a file, split it into smaller pieces. " + + "If you were using a tool, use smaller arguments. " + + "Continue where you left off.", + synthetic: true, + }) + continue + } + if ( lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) &&