Merge pull request #2005 from code-yeongyu/fix/1803-session-recovery-unavailable-tool

fix(session-recovery): handle unavailable_tool (dummy_tool) errors
This commit is contained in:
YeonGyu-Kim 2026-02-21 05:40:32 +09:00 committed by GitHub
commit 92c3d3917b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 251 additions and 39 deletions

View File

@ -1,4 +1,4 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import * as originalExecutor from "./executor" import * as originalExecutor from "./executor"
import * as originalParser from "./parser" import * as originalParser from "./parser"
@ -81,6 +81,10 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
parseAnthropicTokenLimitErrorMock.mockClear() parseAnthropicTokenLimitErrorMock.mockClear()
}) })
afterEach(() => {
mock.restore()
})
test("cancels pending timer when session.idle handles compaction first", async () => { test("cancels pending timer when session.idle handles compaction first", async () => {
//#given //#given
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks() const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()

View File

@ -1,6 +1,6 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import { describe, expect, it } from "bun:test" import { describe, expect, it } from "bun:test"
import { detectErrorType, extractMessageIndex } from "./detect-error-type" import { detectErrorType, extractMessageIndex, extractUnavailableToolName } from "./detect-error-type"
describe("detectErrorType", () => { describe("detectErrorType", () => {
it("#given a tool_use/tool_result error #when detecting #then returns tool_result_missing", () => { it("#given a tool_use/tool_result error #when detecting #then returns tool_result_missing", () => {
@ -101,6 +101,56 @@ describe("detectErrorType", () => {
//#then //#then
expect(result).toBe("tool_result_missing") expect(result).toBe("tool_result_missing")
}) })
it("#given a dummy_tool unavailable tool error #when detecting #then returns unavailable_tool", () => {
//#given
const error = { message: "model tried to call unavailable tool 'invalid'" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("unavailable_tool")
})
it("#given a no such tool error #when detecting #then returns unavailable_tool", () => {
//#given
const error = { message: "No such tool: grepppp" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("unavailable_tool")
})
it("#given a NoSuchToolError token #when detecting #then returns unavailable_tool", () => {
//#given
const error = { message: "NoSuchToolError: no such tool invalid" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("unavailable_tool")
})
it("#given a dummy_tool token in nested error #when detecting #then returns unavailable_tool", () => {
//#given
const error = {
data: {
error: {
message: "dummy_tool Model tried to call unavailable tool 'invalid'",
},
},
}
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("unavailable_tool")
})
}) })
describe("extractMessageIndex", () => { describe("extractMessageIndex", () => {
@ -127,3 +177,38 @@ describe("extractMessageIndex", () => {
expect(result).toBeNull() expect(result).toBeNull()
}) })
}) })
describe("extractUnavailableToolName", () => {
it("#given unavailable tool error with quoted tool name #when extracting #then returns tool name", () => {
//#given
const error = { message: "model tried to call unavailable tool 'invalid'" }
//#when
const result = extractUnavailableToolName(error)
//#then
expect(result).toBe("invalid")
})
it("#given error without unavailable tool name #when extracting #then returns null", () => {
//#given
const error = { message: "dummy_tool appeared without tool name" }
//#when
const result = extractUnavailableToolName(error)
//#then
expect(result).toBeNull()
})
it("#given no such tool error with colon format #when extracting #then returns tool name", () => {
//#given
const error = { message: "No such tool: invalid_tool" }
//#when
const result = extractUnavailableToolName(error)
//#then
expect(result).toBe("invalid_tool")
})
})

View File

@ -3,6 +3,7 @@ export type RecoveryErrorType =
| "thinking_block_order" | "thinking_block_order"
| "thinking_disabled_violation" | "thinking_disabled_violation"
| "assistant_prefill_unsupported" | "assistant_prefill_unsupported"
| "unavailable_tool"
| null | null
function getErrorMessage(error: unknown): string { function getErrorMessage(error: unknown): string {
@ -43,6 +44,16 @@ export function extractMessageIndex(error: unknown): number | null {
} }
} }
export function extractUnavailableToolName(error: unknown): string | null {
try {
const message = getErrorMessage(error)
const match = message.match(/(?:unavailable tool|no such tool)[:\s'"]+([^'".\s]+)/)
return match ? match[1] : null
} catch {
return null
}
}
export function detectErrorType(error: unknown): RecoveryErrorType { export function detectErrorType(error: unknown): RecoveryErrorType {
try { try {
const message = getErrorMessage(error) const message = getErrorMessage(error)
@ -74,6 +85,16 @@ export function detectErrorType(error: unknown): RecoveryErrorType {
return "tool_result_missing" return "tool_result_missing"
} }
if (
message.includes("dummy_tool") ||
message.includes("unavailable tool") ||
message.includes("model tried to call unavailable") ||
message.includes("nosuchtoolerror") ||
message.includes("no such tool")
) {
return "unavailable_tool"
}
return null return null
} catch { } catch {
return null return null

View File

@ -5,6 +5,7 @@ import { detectErrorType } from "./detect-error-type"
import type { RecoveryErrorType } from "./detect-error-type" import type { RecoveryErrorType } from "./detect-error-type"
import type { MessageData } from "./types" import type { MessageData } from "./types"
import { recoverToolResultMissing } from "./recover-tool-result-missing" import { recoverToolResultMissing } from "./recover-tool-result-missing"
import { recoverUnavailableTool } from "./recover-unavailable-tool"
import { recoverThinkingBlockOrder } from "./recover-thinking-block-order" import { recoverThinkingBlockOrder } from "./recover-thinking-block-order"
import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation" import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation"
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume" import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
@ -79,12 +80,14 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
const toastTitles: Record<RecoveryErrorType & string, string> = { const toastTitles: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Tool Crash Recovery", tool_result_missing: "Tool Crash Recovery",
unavailable_tool: "Tool Recovery",
thinking_block_order: "Thinking Block Recovery", thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery", thinking_disabled_violation: "Thinking Strip Recovery",
"assistant_prefill_unsupported": "Prefill Unsupported", "assistant_prefill_unsupported": "Prefill Unsupported",
} }
const toastMessages: Record<RecoveryErrorType & string, string> = { const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...", tool_result_missing: "Injecting cancelled tool results...",
unavailable_tool: "Recovering from unavailable tool call...",
thinking_block_order: "Fixing message structure...", thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...", thinking_disabled_violation: "Stripping thinking blocks...",
"assistant_prefill_unsupported": "Prefill not supported; continuing without recovery.", "assistant_prefill_unsupported": "Prefill not supported; continuing without recovery.",
@ -105,6 +108,8 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
if (errorType === "tool_result_missing") { if (errorType === "tool_result_missing") {
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "unavailable_tool") {
success = await recoverUnavailableTool(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") { } else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
if (success && experimental?.auto_resume) { if (success && experimental?.auto_resume) {

View File

@ -0,0 +1,108 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import { extractUnavailableToolName } from "./detect-error-type"
import { readParts } from "./storage"
import type { MessageData } from "./types"
import { normalizeSDKResponse } from "../../shared"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
type Client = ReturnType<typeof createOpencodeClient>
interface ToolResultPart {
type: "tool_result"
tool_use_id: string
content: string
}
interface PromptWithToolResultInput {
path: { id: string }
body: { parts: ToolResultPart[] }
}
interface ToolUsePart {
type: "tool_use"
id: string
name: string
}
interface MessagePart {
type: string
id?: string
name?: string
}
function extractToolUseParts(parts: MessagePart[]): ToolUsePart[] {
return parts.filter(
(part): part is ToolUsePart =>
part.type === "tool_use" && typeof part.id === "string" && typeof part.name === "string"
)
}
async function readPartsFromSDKFallback(
client: Client,
sessionID: string,
messageID: string
): Promise<MessagePart[]> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
const target = messages.find((message) => message.info?.id === messageID)
if (!target?.parts) return []
return target.parts.map((part) => ({
type: part.type === "tool" ? "tool_use" : part.type,
id: "callID" in part ? (part as { callID?: string }).callID : part.id,
name: "name" in part && typeof part.name === "string" ? part.name : ("tool" in part && typeof (part as { tool?: unknown }).tool === "string" ? (part as { tool: string }).tool : undefined),
}))
} catch {
return []
}
}
export async function recoverUnavailableTool(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
let parts = failedAssistantMsg.parts || []
if (parts.length === 0 && failedAssistantMsg.info?.id) {
if (isSqliteBackend()) {
parts = await readPartsFromSDKFallback(client, sessionID, failedAssistantMsg.info.id)
} else {
const storedParts = readParts(failedAssistantMsg.info.id)
parts = storedParts.map((part) => ({
type: part.type === "tool" ? "tool_use" : part.type,
id: "callID" in part ? (part as { callID?: string }).callID : part.id,
name: "tool" in part && typeof part.tool === "string" ? part.tool : undefined,
}))
}
}
const toolUseParts = extractToolUseParts(parts)
if (toolUseParts.length === 0) {
return false
}
const unavailableToolName = extractUnavailableToolName(failedAssistantMsg.info?.error)
const matchingToolUses = unavailableToolName
? toolUseParts.filter((part) => part.name.toLowerCase() === unavailableToolName)
: []
const targetToolUses = matchingToolUses.length > 0 ? matchingToolUses : toolUseParts
const toolResultParts = targetToolUses.map((part) => ({
type: "tool_result" as const,
tool_use_id: part.id,
content: '{"status":"error","error":"Tool not available. Please continue without this tool."}',
}))
try {
const promptInput: PromptWithToolResultInput = {
path: { id: sessionID },
body: { parts: toolResultParts },
}
const promptAsync = client.session.promptAsync as (...args: never[]) => unknown
await Reflect.apply(promptAsync, client.session, [promptInput])
return true
} catch {
return false
}
}

View File

@ -82,7 +82,7 @@ export async function applyAgentConfig(params: {
migratedDisabledAgents, migratedDisabledAgents,
params.pluginConfig.agents, params.pluginConfig.agents,
params.ctx.directory, params.ctx.directory,
undefined, currentModel,
params.pluginConfig.categories, params.pluginConfig.categories,
params.pluginConfig.git_master, params.pluginConfig.git_master,
allDiscoveredSkills, allDiscoveredSkills,

View File

@ -1277,12 +1277,15 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
}) })
describe("disable_omo_env pass-through", () => { describe("disable_omo_env pass-through", () => {
test("omits <omo-env> in generated sisyphus prompt when disable_omo_env is true", async () => { test("passes disable_omo_env=true to createBuiltinAgents", async () => {
//#given //#given
;(agents.createBuiltinAgents as any)?.mockRestore?.() const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
;(shared.fetchAvailableModels as any).mockResolvedValue( mockResolvedValue: (value: Record<string, unknown>) => void
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"]) mock: { calls: unknown[][] }
) }
createBuiltinAgentsMock.mockResolvedValue({
sisyphus: { name: "sisyphus", prompt: "without-env", mode: "primary" },
})
const pluginConfig: OhMyOpenCodeConfig = { const pluginConfig: OhMyOpenCodeConfig = {
experimental: { disable_omo_env: true }, experimental: { disable_omo_env: true },
@ -1304,18 +1307,21 @@ describe("disable_omo_env pass-through", () => {
await handler(config) await handler(config)
//#then //#then
const agentResult = config.agent as Record<string, { prompt?: string }> const lastCall =
const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
expect(sisyphusPrompt).toBeDefined() expect(lastCall).toBeDefined()
expect(sisyphusPrompt).not.toContain("<omo-env>") expect(lastCall?.[12]).toBe(true)
}) })
test("keeps <omo-env> in generated sisyphus prompt when disable_omo_env is omitted", async () => { test("passes disable_omo_env=false to createBuiltinAgents when omitted", async () => {
//#given //#given
;(agents.createBuiltinAgents as any)?.mockRestore?.() const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
;(shared.fetchAvailableModels as any).mockResolvedValue( mockResolvedValue: (value: Record<string, unknown>) => void
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"]) mock: { calls: unknown[][] }
) }
createBuiltinAgentsMock.mockResolvedValue({
sisyphus: { name: "sisyphus", prompt: "with-env", mode: "primary" },
})
const pluginConfig: OhMyOpenCodeConfig = {} const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
@ -1335,9 +1341,9 @@ describe("disable_omo_env pass-through", () => {
await handler(config) await handler(config)
//#then //#then
const agentResult = config.agent as Record<string, { prompt?: string }> const lastCall =
const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
expect(sisyphusPrompt).toBeDefined() expect(lastCall).toBeDefined()
expect(sisyphusPrompt).toContain("<omo-env>") expect(lastCall?.[12]).toBe(false)
}) })
}) })

View File

@ -66,7 +66,7 @@ describe("createChatHeadersHandler", () => {
sessionID: "ses_1", sessionID: "ses_1",
provider: { id: "openai" }, provider: { id: "openai" },
message: { message: {
id: "msg_1", id: "msg_2",
role: "user", role: "user",
}, },
}, },

View File

@ -15,7 +15,6 @@ import {
createInteractiveBashSessionHook, createInteractiveBashSessionHook,
createRalphLoopHook, createRalphLoopHook,
createEditErrorRecoveryHook, createEditErrorRecoveryHook,
createJsonErrorRecoveryHook,
createDelegateTaskRetryHook, createDelegateTaskRetryHook,
createTaskResumeInfoHook, createTaskResumeInfoHook,
createStartWorkHook, createStartWorkHook,
@ -51,7 +50,6 @@ export type SessionHooks = {
interactiveBashSession: ReturnType<typeof createInteractiveBashSessionHook> | null interactiveBashSession: ReturnType<typeof createInteractiveBashSessionHook> | null
ralphLoop: ReturnType<typeof createRalphLoopHook> | null ralphLoop: ReturnType<typeof createRalphLoopHook> | null
editErrorRecovery: ReturnType<typeof createEditErrorRecoveryHook> | null editErrorRecovery: ReturnType<typeof createEditErrorRecoveryHook> | null
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook> | null delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook> | null
startWork: ReturnType<typeof createStartWorkHook> | null startWork: ReturnType<typeof createStartWorkHook> | null
prometheusMdOnly: ReturnType<typeof createPrometheusMdOnlyHook> | null prometheusMdOnly: ReturnType<typeof createPrometheusMdOnlyHook> | null
@ -212,10 +210,6 @@ export function createSessionHooks(args: {
? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx)) ? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx))
: null : null
const jsonErrorRecovery = isHookEnabled("json-error-recovery")
? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx))
: null
const delegateTaskRetry = isHookEnabled("delegate-task-retry") const delegateTaskRetry = isHookEnabled("delegate-task-retry")
? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx)) ? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx))
: null : null
@ -268,7 +262,6 @@ export function createSessionHooks(args: {
interactiveBashSession, interactiveBashSession,
ralphLoop, ralphLoop,
editErrorRecovery, editErrorRecovery,
jsonErrorRecovery,
delegateTaskRetry, delegateTaskRetry,
startWork, startWork,
prometheusMdOnly, prometheusMdOnly,

View File

@ -12,7 +12,6 @@ import {
createTasksTodowriteDisablerHook, createTasksTodowriteDisablerHook,
createWriteExistingFileGuardHook, createWriteExistingFileGuardHook,
createHashlineReadEnhancerHook, createHashlineReadEnhancerHook,
createHashlineEditDiffEnhancerHook,
} from "../../hooks" } from "../../hooks"
import { import {
getOpenCodeVersion, getOpenCodeVersion,
@ -32,7 +31,6 @@ export type ToolGuardHooks = {
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
hashlineEditDiffEnhancer: ReturnType<typeof createHashlineEditDiffEnhancerHook> | null
} }
export function createToolGuardHooks(args: { export function createToolGuardHooks(args: {
@ -101,10 +99,6 @@ export function createToolGuardHooks(args: {
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } })) ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
: null : null
const hashlineEditDiffEnhancer = isHookEnabled("hashline-edit-diff-enhancer")
? safeHook("hashline-edit-diff-enhancer", () => createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
: null
return { return {
commentChecker, commentChecker,
toolOutputTruncator, toolOutputTruncator,
@ -115,6 +109,5 @@ export function createToolGuardHooks(args: {
tasksTodowriteDisabler, tasksTodowriteDisabler,
writeExistingFileGuard, writeExistingFileGuard,
hashlineReadEnhancer, hashlineReadEnhancer,
hashlineEditDiffEnhancer,
} }
} }

View File

@ -40,11 +40,9 @@ export function createToolExecuteAfterHandler(args: {
await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output) await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output)
await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output) await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output)
await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output) await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output)
await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output)
await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output)
await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output) await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
await hooks.hashlineEditDiffEnhancer?.["tool.execute.after"]?.(input, output)
} }
} }

View File

@ -29,7 +29,6 @@ export function createToolExecuteBeforeHandler(args: {
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
await hooks.hashlineEditDiffEnhancer?.["tool.execute.before"]?.(input, output)
if (input.tool === "task") { if (input.tool === "task") {
const argsObject = output.args const argsObject = output.args
const category = typeof argsObject.category === "string" ? argsObject.category : undefined const category = typeof argsObject.category === "string" ? argsObject.category : undefined