Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,79 @@ export namespace ProviderTransform {
})
}

/**
* Defensive validation: ensures every tool_use (tool-call) in an assistant message
* has a corresponding tool_result in the immediately following tool message.
* If orphaned tool_use blocks are found, injects synthetic error tool_results.
* This prevents Anthropic API errors:
* "tool_use ids were found without tool_result blocks immediately after"
*/
function repairOrphanedToolUse(msgs: ModelMessage[]): ModelMessage[] {
const result: ModelMessage[] = []

for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
result.push(msg)

// Only check assistant messages with tool-call parts
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue
const toolCallIds = msg.content
.filter((p): p is Extract<typeof p, { type: "tool-call" }> => p.type === "tool-call")
.map((p) => p.toolCallId)
if (toolCallIds.length === 0) continue

// Collect tool-result IDs from the next tool message
const next = msgs[i + 1]
const existingResultIds = new Set<string>()
if (next && next.role === "tool" && Array.isArray(next.content)) {
for (const part of next.content) {
if (part.type === "tool-result") {
existingResultIds.add(part.toolCallId)
}
}
}

// Find orphaned tool calls (no matching tool result)
const orphaned = toolCallIds.filter((id) => !existingResultIds.has(id))
if (orphaned.length === 0) continue

// If there's already a tool message, inject missing results into it
if (next && next.role === "tool" && Array.isArray(next.content)) {
const syntheticResults = orphaned.map((id) => ({
type: "tool-result" as const,
toolCallId: id,
content: "[Tool execution was interrupted]",
isError: true as const,
}))
const patched = {
...next,
content: [...next.content, ...syntheticResults],
}
// Replace the next message with the patched version
i++ // skip the original next message
result.push(patched as typeof next)
} else {
// No tool message follows - insert a synthetic one
const syntheticResults = orphaned.map((id) => ({
type: "tool-result" as const,
toolCallId: id,
content: "[Tool execution was interrupted]",
isError: true as const,
}))
result.push({
role: "tool" as const,
content: syntheticResults,
} as ModelMessage)
}
}

return result
}

export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
msgs = repairOrphanedToolUse(msgs)
if (
(model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export namespace LLM {
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
/** Optional callback to rebuild messages from DB on retry, ensuring orphaned tool_use blocks are cleaned up */
rebuildMessages?: () => Promise<ModelMessage[]>
}

export type StreamOutput = StreamTextResult<ToolSet, unknown>
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,32 @@ export namespace SessionProcessor {
}
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
// Clean up orphaned tool calls before retrying - any pending/running tools
// from the failed stream must be marked as errors, otherwise the retry will
// send orphaned tool_use blocks without matching tool_result blocks, causing
// Anthropic API errors ("tool_use ids were found without tool_result blocks")
const orphanedParts = await MessageV2.parts(input.assistantMessage.id)
for (const part of orphanedParts) {
if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
await Session.updatePart({
...part,
state: {
...part.state,
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
},
})
}
}
// Rebuild messages from DB to reflect the cleaned-up orphaned tool calls.
// Without this, the retry would send stale messages with orphaned tool_use blocks.
if (streamInput.rebuildMessages) {
streamInput.messages = await streamInput.rebuildMessages()
}
attempt++
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
SessionStatus.set(input.sessionID, {
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,21 @@ export namespace SessionPrompt {
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
// Rebuild messages from DB on retry to pick up orphaned tool_use cleanup
rebuildMessages: async () => {
const fresh = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
return [
...MessageV2.toModelMessages(fresh, model),
...(isLastStep
? [
{
role: "assistant" as const,
content: MAX_STEPS,
},
]
: []),
]
},
})

// If structured output was captured, save it and exit immediately
Expand Down
12 changes: 11 additions & 1 deletion packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ export namespace SessionRetry {
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""

// invalid_request_error should never be retried - these are structural issues
// with the request (e.g., orphaned tool_use blocks without tool_result) that
// will fail identically on every retry attempt, causing an infinite loop
if (json.type === "error" && json.error?.type === "invalid_request_error") {
return undefined
}

if (json.type === "error" && json.error?.type === "too_many_requests") {
return "Too Many Requests"
}
Expand All @@ -93,7 +100,10 @@ export namespace SessionRetry {
if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
return "Rate Limited"
}
return JSON.stringify(json)
// Only retry errors that are explicitly recognized as transient.
// The previous catch-all `return JSON.stringify(json)` caused infinite retry
// loops for non-transient errors like invalid_request_error.
return undefined
} catch {
return undefined
}
Expand Down
Loading