fix(background-agent): retry with fallback agent on Agent not found error

When a model/mode switch happens while a background task is in-flight,
the oh-my-openagent agent registry can be rebuilt without custom agents
(e.g., Sisyphus-Junior). The SDK then rejects the promptAsync call with
"Agent not found", killing the task.

This adds retry logic: when promptAsync fails with "Agent not found",
retry with the "general" agent (always available in opencode). The
original prompt, model, and skill content are preserved — only the
agent routing changes.

Fixes both the spawner (startTask/resumeTask) and manager (inline
launch) code paths. Also improves the error message detection in
manager.ts to recognize "Agent not found" alongside the existing
"agent.name"/"undefined" checks.

Related: #2052, #2875, #2882

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jim Park
2026-04-04 21:57:37 -07:00
parent 7243bfa483
commit 050502f8d0
3 changed files with 293 additions and 45 deletions
+41 -18
View File
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { isAgentNotFoundError } from "./spawner"
import type {
BackgroundTask,
LaunchInput,
@@ -546,32 +547,54 @@ export class BackgroundManager {
applySessionPromptParams(sessionID, input.model)
}
const FALLBACK_AGENT = "general"
const promptBody = {
agent: input.agent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: (() => {
const tools = {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [createInternalAgentTextPart(input.prompt)],
}
promptWithModelSuggestionRetry(this.client, {
path: { id: sessionID },
body: {
agent: input.agent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: (() => {
const tools = {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [createInternalAgentTextPart(input.prompt)],
},
body: promptBody,
}).catch(async (error) => {
// Retry with fallback agent if the original agent was unregistered (e.g., after a model switch)
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
log("[background-agent] Agent not found, retrying with fallback agent", {
original: input.agent,
fallback: FALLBACK_AGENT,
taskId: task.id,
})
try {
await promptWithModelSuggestionRetry(this.client, {
path: { id: sessionID },
body: { ...promptBody, agent: FALLBACK_AGENT },
})
return
} catch (retryError) {
log("[background-agent] Fallback agent also failed:", retryError)
}
}
log("[background-agent] promptAsync error:", error)
const existingTask = this.findBySession(sessionID)
if (existingTask) {
existingTask.status = "interrupt"
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined") || isAgentNotFoundError(error)) {
existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
} else {
existingTask.error = errorMessage
@@ -6,6 +6,184 @@ import {
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
describe("background-agent spawner agent-not-found fallback", () => {
afterEach(() => {
clearSessionPromptParams("session-fallback")
})
test("retries with 'general' agent when promptAsync fails with Agent not found", async () => {
//#given
const promptCalls: any[] = []
let callCount = 0
const client = {
session: {
get: async () => ({ data: { directory: "/tmp/test" } }),
create: async () => ({ data: { id: "session-fallback" } }),
promptAsync: async (args: any) => {
callCount++
promptCalls.push({ body: { ...args.body }, path: { ...args.path } })
if (callCount === 1) {
throw new Error('Agent not found: "Sisyphus-Junior". Available agents: build, explore, general, plan')
}
return { data: {} }
},
},
} as any
const onTaskError = mock(() => {})
const task = createTask({
description: "Implement feature",
prompt: "Please implement the break-even analysis",
agent: "Sisyphus-Junior",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
},
}
const ctx = {
client,
directory: "/tmp/test",
concurrencyManager: { release: () => {} },
tmuxEnabled: false,
onTaskError,
}
//#when
await startTask(item as any, ctx as any)
// Wait for the fire-and-forget prompt chain to settle
await new Promise(resolve => setTimeout(resolve, 50))
//#then
// Should have called promptAsync twice: once with original agent, once with fallback
expect(promptCalls).toHaveLength(2)
expect(promptCalls[0].body.agent).toBe("Sisyphus-Junior")
expect(promptCalls[1].body.agent).toBe("general")
// Original prompt content preserved in fallback
expect(promptCalls[1].body.parts).toEqual(promptCalls[0].body.parts)
// Task should not have errored
expect(onTaskError).not.toHaveBeenCalled()
})
test("does not retry for non-agent-not-found errors", async () => {
//#given
const promptCalls: any[] = []
const client = {
session: {
get: async () => ({ data: { directory: "/tmp/test" } }),
create: async () => ({ data: { id: "session-fallback" } }),
promptAsync: async (args: any) => {
promptCalls.push(args)
throw new Error("Connection timeout")
},
},
} as any
const onTaskError = mock(() => {})
const task = createTask({
description: "Implement feature",
prompt: "Do work",
agent: "Sisyphus-Junior",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
},
}
const ctx = {
client,
directory: "/tmp/test",
concurrencyManager: { release: () => {} },
tmuxEnabled: false,
onTaskError,
}
//#when
await startTask(item as any, ctx as any)
await new Promise(resolve => setTimeout(resolve, 50))
//#then
// Only one attempt — no retry for non-agent errors
expect(promptCalls).toHaveLength(1)
expect(onTaskError).toHaveBeenCalled()
})
test("calls onTaskError if fallback agent also fails", async () => {
//#given
const client = {
session: {
get: async () => ({ data: { directory: "/tmp/test" } }),
create: async () => ({ data: { id: "session-fallback" } }),
promptAsync: async () => {
throw new Error('Agent not found: "Sisyphus-Junior". Available agents: build, explore, general, plan')
},
},
} as any
const onTaskError = mock(() => {})
const task = createTask({
description: "Implement feature",
prompt: "Do work",
agent: "Sisyphus-Junior",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
},
}
const ctx = {
client,
directory: "/tmp/test",
concurrencyManager: { release: () => {} },
tmuxEnabled: false,
onTaskError,
}
//#when
await startTask(item as any, ctx as any)
await new Promise(resolve => setTimeout(resolve, 50))
//#then
expect(onTaskError).toHaveBeenCalled()
})
})
describe("background-agent spawner fallback model promotion", () => {
afterEach(() => {
clearSessionPromptParams("session-123")
+74 -27
View File
@@ -8,6 +8,13 @@ import { getTaskToastManager } from "../task-toast-manager"
import { isInsideTmux } from "../../shared/tmux"
import type { ConcurrencyManager } from "./concurrency"
const FALLBACK_AGENT = "general"
export function isAgentNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.includes("Agent not found")
}
export interface SpawnerContext {
client: OpencodeClient
directory: string
@@ -138,22 +145,42 @@ export async function startTask(
applySessionPromptParams(sessionID, input.model)
const promptBody = {
agent: input.agent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
},
parts: [createInternalAgentTextPart(input.prompt)],
}
promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: {
agent: input.agent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
},
parts: [createInternalAgentTextPart(input.prompt)],
},
}).catch((error) => {
body: promptBody,
}).catch(async (error) => {
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
log("[background-agent] Agent not found, retrying with fallback agent", {
original: input.agent,
fallback: FALLBACK_AGENT,
taskId: task.id,
})
try {
await promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: { ...promptBody, agent: FALLBACK_AGENT },
})
return
} catch (retryError) {
log("[background-agent] Fallback agent also failed:", retryError)
onTaskError(task, retryError instanceof Error ? retryError : new Error(String(retryError)))
return
}
}
log("[background-agent] promptAsync error:", error)
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
})
@@ -228,21 +255,41 @@ export async function resumeTask(
applySessionPromptParams(task.sessionID, task.model)
const resumeBody = {
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(task.agent),
},
parts: [createInternalAgentTextPart(input.prompt)],
}
client.session.promptAsync({
path: { id: task.sessionID },
body: {
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(task.agent),
},
parts: [createInternalAgentTextPart(input.prompt)],
},
}).catch((error) => {
body: resumeBody,
}).catch(async (error) => {
if (isAgentNotFoundError(error) && task.agent !== FALLBACK_AGENT) {
log("[background-agent] Resume agent not found, retrying with fallback agent", {
original: task.agent,
fallback: FALLBACK_AGENT,
taskId: task.id,
})
try {
await client.session.promptAsync({
path: { id: task.sessionID! },
body: { ...resumeBody, agent: FALLBACK_AGENT },
})
return
} catch (retryError) {
log("[background-agent] Resume fallback agent also failed:", retryError)
onTaskError(task, retryError instanceof Error ? retryError : new Error(String(retryError)))
return
}
}
log("[background-agent] resume prompt error:", error)
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
})