diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index b66659abf..2c58dda06 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, FALLBACK_AGENT, buildFallbackBody } from "./spawner" import type { BackgroundTask, LaunchInput, @@ -546,32 +547,55 @@ export class BackgroundManager { applySessionPromptParams(sessionID, input.model) } + 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 { + const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT) + setSessionTools(sessionID, fallbackBody.tools as Record) + await promptWithModelSuggestionRetry(this.client, { + path: { id: sessionID }, + body: fallbackBody, + }) + task.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 @@ -1202,6 +1226,16 @@ export class BackgroundManager { }): Promise { const { task, errorInfo, errorMessage, errorName } = args + // Agent-not-found errors are handled by the prompt catch block with agent fallback. + // Do not also trigger model fallback retry — that would race with the agent retry. + if (isAgentNotFoundError({ message: errorInfo.message } as Error)) { + log("[background-agent] Skipping session.error fallback for agent-not-found (handled by prompt catch)", { + taskId: task.id, + errorMessage: errorInfo.message?.slice(0, 100), + }) + return + } + if (await this.tryFallbackRetry(task, errorInfo, "session.error")) { return } diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index f223aa300..d3896e55f 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -6,6 +6,321 @@ 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) + // Tool restrictions recomputed for fallback agent (general has no restrictions) + expect(promptCalls[1].body.tools).toEqual({ + task: false, + call_omo_agent: true, + question: false, + }) + // Task agent identity updated to reflect fallback + expect(task.agent).toBe("general") + // 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 + let callCount = 0 + const client = { + session: { + get: async () => ({ data: { directory: "/tmp/test" } }), + create: async () => ({ data: { id: "session-fallback" } }), + promptAsync: async () => { + callCount++ + 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 + // Verify retry was attempted (2 calls: original + fallback) + expect(callCount).toBe(2) + expect(onTaskError).toHaveBeenCalled() + }) + + test("retries on agent.name/undefined error variant", 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 } }) + if (callCount === 1) { + throw new Error("Cannot read properties of undefined (reading 'agent.name')") + } + return { data: {} } + }, + }, + } as any + + const onTaskError = mock(() => {}) + + const task = createTask({ + description: "Test task", + 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, + 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) + await new Promise(resolve => setTimeout(resolve, 50)) + + //#then + expect(promptCalls).toHaveLength(2) + expect(promptCalls[0].body.agent).toBe("Sisyphus-Junior") + expect(promptCalls[1].body.agent).toBe("general") + expect(onTaskError).not.toHaveBeenCalled() + }) + + test("detects agent error from plain object with message field", 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 } }) + if (callCount === 1) { + throw { message: 'Agent not found: "Custom-Agent"', name: "UnknownError" } + } + return { data: {} } + }, + }, + } as any + + const onTaskError = mock(() => {}) + + const task = createTask({ + description: "Test task", + prompt: "Do work", + agent: "Custom-Agent", + 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) + await new Promise(resolve => setTimeout(resolve, 50)) + + //#then + expect(promptCalls).toHaveLength(2) + expect(promptCalls[1].body.agent).toBe("general") + expect(onTaskError).not.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..3c2fd7e73 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -8,6 +8,39 @@ import { getTaskToastManager } from "../task-toast-manager" import { isInsideTmux } from "../../shared/tmux" import type { ConcurrencyManager } from "./concurrency" +export const FALLBACK_AGENT = "general" + +export function isAgentNotFoundError(error: unknown): boolean { + const message = + typeof error === "string" + ? error + : error instanceof Error + ? error.message + : typeof error === "object" && error !== null && typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : String(error) + return ( + message.includes("Agent not found") || + message.includes("agent.name") + ) +} + +export function buildFallbackBody( + originalBody: Record, + fallbackAgent: string, +): Record { + return { + ...originalBody, + agent: fallbackAgent, + tools: { + task: false, + call_omo_agent: true, + question: false, + ...getAgentToolRestrictions(fallbackAgent), + }, + } +} + export interface SpawnerContext { client: OpencodeClient directory: string @@ -138,22 +171,43 @@ 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: buildFallbackBody(promptBody, FALLBACK_AGENT), + }) + task.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 +282,42 @@ 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 promptWithModelSuggestionRetry(client, { + path: { id: task.sessionID! }, + body: buildFallbackBody(resumeBody, FALLBACK_AGENT), + }) + task.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))) })