From 050502f8d040ab5192b118bd438edd0d51357694 Mon Sep 17 00:00:00 2001 From: Jim Park Date: Sat, 4 Apr 2026 21:57:37 -0700 Subject: [PATCH] fix(background-agent): retry with fallback agent on Agent not found error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/features/background-agent/manager.ts | 59 ++++-- src/features/background-agent/spawner.test.ts | 178 ++++++++++++++++++ src/features/background-agent/spawner.ts | 101 +++++++--- 3 files changed, 293 insertions(+), 45 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index b66659abf..c4c86b23c 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -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 diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index f223aa300..214e15e4f 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -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") diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index e8fc49e32..c1e3ccf7e 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -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))) })