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:
@@ -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 * as originalExecutor from "./executor"
|
||||
import * as originalParser from "./parser"
|
||||
@@ -81,6 +81,10 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
|
||||
parseAnthropicTokenLimitErrorMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("cancels pending timer when session.idle handles compaction first", async () => {
|
||||
//#given
|
||||
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { detectErrorType, extractMessageIndex } from "./detect-error-type"
|
||||
import { detectErrorType, extractMessageIndex, extractUnavailableToolName } from "./detect-error-type"
|
||||
|
||||
describe("detectErrorType", () => {
|
||||
it("#given a tool_use/tool_result error #when detecting #then returns tool_result_missing", () => {
|
||||
@@ -101,6 +101,56 @@ describe("detectErrorType", () => {
|
||||
//#then
|
||||
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", () => {
|
||||
@@ -127,3 +177,38 @@ describe("extractMessageIndex", () => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ export type RecoveryErrorType =
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| "assistant_prefill_unsupported"
|
||||
| "unavailable_tool"
|
||||
| null
|
||||
|
||||
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 {
|
||||
try {
|
||||
const message = getErrorMessage(error)
|
||||
@@ -74,6 +85,16 @@ export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
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
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -5,6 +5,7 @@ import { detectErrorType } from "./detect-error-type"
|
||||
import type { RecoveryErrorType } from "./detect-error-type"
|
||||
import type { MessageData } from "./types"
|
||||
import { recoverToolResultMissing } from "./recover-tool-result-missing"
|
||||
import { recoverUnavailableTool } from "./recover-unavailable-tool"
|
||||
import { recoverThinkingBlockOrder } from "./recover-thinking-block-order"
|
||||
import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation"
|
||||
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
|
||||
@@ -79,12 +80,14 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
||||
|
||||
const toastTitles: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
unavailable_tool: "Tool Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
"assistant_prefill_unsupported": "Prefill Unsupported",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
unavailable_tool: "Recovering from unavailable tool call...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
"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") {
|
||||
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") {
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
||||
if (success && experimental?.auto_resume) {
|
||||
|
||||
108
src/hooks/session-recovery/recover-unavailable-tool.ts
Normal file
108
src/hooks/session-recovery/recover-unavailable-tool.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export async function applyAgentConfig(params: {
|
||||
migratedDisabledAgents,
|
||||
params.pluginConfig.agents,
|
||||
params.ctx.directory,
|
||||
undefined,
|
||||
currentModel,
|
||||
params.pluginConfig.categories,
|
||||
params.pluginConfig.git_master,
|
||||
allDiscoveredSkills,
|
||||
|
||||
@@ -1277,12 +1277,15 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
|
||||
})
|
||||
|
||||
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
|
||||
;(agents.createBuiltinAgents as any)?.mockRestore?.()
|
||||
;(shared.fetchAvailableModels as any).mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"])
|
||||
)
|
||||
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
|
||||
mockResolvedValue: (value: Record<string, unknown>) => void
|
||||
mock: { calls: unknown[][] }
|
||||
}
|
||||
createBuiltinAgentsMock.mockResolvedValue({
|
||||
sisyphus: { name: "sisyphus", prompt: "without-env", mode: "primary" },
|
||||
})
|
||||
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
experimental: { disable_omo_env: true },
|
||||
@@ -1304,18 +1307,21 @@ describe("disable_omo_env pass-through", () => {
|
||||
await handler(config)
|
||||
|
||||
//#then
|
||||
const agentResult = config.agent as Record<string, { prompt?: string }>
|
||||
const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt
|
||||
expect(sisyphusPrompt).toBeDefined()
|
||||
expect(sisyphusPrompt).not.toContain("<omo-env>")
|
||||
const lastCall =
|
||||
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
|
||||
expect(lastCall).toBeDefined()
|
||||
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
|
||||
;(agents.createBuiltinAgents as any)?.mockRestore?.()
|
||||
;(shared.fetchAvailableModels as any).mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"])
|
||||
)
|
||||
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
|
||||
mockResolvedValue: (value: Record<string, unknown>) => void
|
||||
mock: { calls: unknown[][] }
|
||||
}
|
||||
createBuiltinAgentsMock.mockResolvedValue({
|
||||
sisyphus: { name: "sisyphus", prompt: "with-env", mode: "primary" },
|
||||
})
|
||||
|
||||
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||
const config: Record<string, unknown> = {
|
||||
@@ -1335,9 +1341,9 @@ describe("disable_omo_env pass-through", () => {
|
||||
await handler(config)
|
||||
|
||||
//#then
|
||||
const agentResult = config.agent as Record<string, { prompt?: string }>
|
||||
const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt
|
||||
expect(sisyphusPrompt).toBeDefined()
|
||||
expect(sisyphusPrompt).toContain("<omo-env>")
|
||||
const lastCall =
|
||||
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
|
||||
expect(lastCall).toBeDefined()
|
||||
expect(lastCall?.[12]).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("createChatHeadersHandler", () => {
|
||||
sessionID: "ses_1",
|
||||
provider: { id: "openai" },
|
||||
message: {
|
||||
id: "msg_1",
|
||||
id: "msg_2",
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
createInteractiveBashSessionHook,
|
||||
createRalphLoopHook,
|
||||
createEditErrorRecoveryHook,
|
||||
createJsonErrorRecoveryHook,
|
||||
createDelegateTaskRetryHook,
|
||||
createTaskResumeInfoHook,
|
||||
createStartWorkHook,
|
||||
@@ -51,7 +50,6 @@ export type SessionHooks = {
|
||||
interactiveBashSession: ReturnType<typeof createInteractiveBashSessionHook> | null
|
||||
ralphLoop: ReturnType<typeof createRalphLoopHook> | null
|
||||
editErrorRecovery: ReturnType<typeof createEditErrorRecoveryHook> | null
|
||||
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
|
||||
delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook> | null
|
||||
startWork: ReturnType<typeof createStartWorkHook> | null
|
||||
prometheusMdOnly: ReturnType<typeof createPrometheusMdOnlyHook> | null
|
||||
@@ -212,10 +210,6 @@ export function createSessionHooks(args: {
|
||||
? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx))
|
||||
: null
|
||||
|
||||
const jsonErrorRecovery = isHookEnabled("json-error-recovery")
|
||||
? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx))
|
||||
: null
|
||||
|
||||
const delegateTaskRetry = isHookEnabled("delegate-task-retry")
|
||||
? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx))
|
||||
: null
|
||||
@@ -268,7 +262,6 @@ export function createSessionHooks(args: {
|
||||
interactiveBashSession,
|
||||
ralphLoop,
|
||||
editErrorRecovery,
|
||||
jsonErrorRecovery,
|
||||
delegateTaskRetry,
|
||||
startWork,
|
||||
prometheusMdOnly,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
createTasksTodowriteDisablerHook,
|
||||
createWriteExistingFileGuardHook,
|
||||
createHashlineReadEnhancerHook,
|
||||
createHashlineEditDiffEnhancerHook,
|
||||
} from "../../hooks"
|
||||
import {
|
||||
getOpenCodeVersion,
|
||||
@@ -32,7 +31,6 @@ export type ToolGuardHooks = {
|
||||
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
|
||||
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
|
||||
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
||||
hashlineEditDiffEnhancer: ReturnType<typeof createHashlineEditDiffEnhancerHook> | null
|
||||
}
|
||||
|
||||
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 } }))
|
||||
: null
|
||||
|
||||
const hashlineEditDiffEnhancer = isHookEnabled("hashline-edit-diff-enhancer")
|
||||
? safeHook("hashline-edit-diff-enhancer", () => createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
|
||||
: null
|
||||
|
||||
return {
|
||||
commentChecker,
|
||||
toolOutputTruncator,
|
||||
@@ -115,6 +109,5 @@ export function createToolGuardHooks(args: {
|
||||
tasksTodowriteDisabler,
|
||||
writeExistingFileGuard,
|
||||
hashlineReadEnhancer,
|
||||
hashlineEditDiffEnhancer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,9 @@ export function createToolExecuteAfterHandler(args: {
|
||||
await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.interactiveBashSession?.["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.atlasHook?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.hashlineEditDiffEnhancer?.["tool.execute.after"]?.(input, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.sisyphusJuniorNotepad?.["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") {
|
||||
const argsObject = output.args
|
||||
const category = typeof argsObject.category === "string" ? argsObject.category : undefined
|
||||
|
||||
Reference in New Issue
Block a user