diff --git a/README.md b/README.md index 163228dc86..66e195229b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > [!TIP] > -> [![The Orchestrator is now available in beta.](./.github/assets/orchestrator-sisyphus.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.1) +> [![The Orchestrator is now available in beta.](./.github/assets/orchestrator-sisyphus.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.6) > > **The Orchestrator is now available in beta. Use `oh-my-opencode@3.0.0-beta.6` to install it.** > > Be with us! diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index b215a7c81d..15ff28371d 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -102,6 +102,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -228,6 +244,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -354,6 +386,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -480,6 +528,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -606,6 +670,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -732,6 +812,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -858,6 +954,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -984,6 +1096,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1110,6 +1238,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1236,6 +1380,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1362,6 +1522,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1488,6 +1664,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1614,6 +1806,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1740,6 +1948,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1866,6 +2090,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, @@ -1999,6 +2239,22 @@ "model": { "type": "string" }, + "fallback": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackDelayMs": { + "type": "number", + "minimum": 100, + "maximum": 30000 + }, + "fallbackRetryCount": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, "variant": { "type": "string" }, diff --git a/src/config/schema.ts b/src/config/schema.ts index d5fad7e014..5b5e9d6226 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -96,12 +96,12 @@ export const BuiltinCommandNameSchema = z.enum([ ]) export const AgentOverrideConfigSchema = z.object({ - /** @deprecated Use `category` instead. Model is inherited from category defaults. */ model: z.string().optional(), + fallback: z.array(z.string()).optional(), + fallbackDelayMs: z.number().min(100).max(30000).optional(), + fallbackRetryCount: z.number().min(1).max(5).optional(), variant: z.string().optional(), - /** Category name to inherit model and other settings from CategoryConfig */ category: z.string().optional(), - /** Skill names to inject into agent prompt */ skills: z.array(z.string()).optional(), temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), @@ -155,6 +155,9 @@ export const SisyphusAgentConfigSchema = z.object({ export const CategoryConfigSchema = z.object({ model: z.string(), + fallback: z.array(z.string()).optional(), + fallbackDelayMs: z.number().min(100).max(30000).optional(), + fallbackRetryCount: z.number().min(1).max(5).optional(), variant: z.string().optional(), temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 5860258fa4..b4715b2f3a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -66,6 +66,7 @@ export class BackgroundManager { log("[background-agent] launch() called with:", { agent: input.agent, model: input.model, + modelChainLength: input.modelChain?.length, description: input.description, parentSessionID: input.parentSessionID, }) @@ -153,17 +154,19 @@ export class BackgroundManager { sessionID, agent: input.agent, model: input.model, + modelChainLength: input.modelChain?.length, hasSkillContent: !!input.skillContent, promptLength: input.prompt.length, }) // Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget) - // Include model if caller provided one (e.g., from Sisyphus category configs) + // Include model or modelChain if caller provided one (e.g., from Sisyphus category configs) this.client.session.prompt({ path: { id: sessionID }, body: { agent: input.agent, ...(input.model ? { model: input.model } : {}), + ...(input.modelChain && input.modelChain.length > 0 ? { modelChain: input.modelChain } : {}), system: input.skillContent, tools: { task: false, @@ -178,7 +181,7 @@ export class BackgroundManager { if (existingTask) { existingTask.status = "error" const errorMessage = error instanceof Error ? error.message : String(error) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { + if (errorMessage.includes("agent.name")) { 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/types.ts b/src/features/background-agent/types.ts index 8c384211b4..5411a68691 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -47,6 +47,7 @@ export interface LaunchInput { parentModel?: { providerID: string; modelID: string } parentAgent?: string model?: { providerID: string; modelID: string; variant?: string } + modelChain?: Array<{ providerID: string; modelID: string; variant?: string }> skills?: string[] skillContent?: string } diff --git a/src/features/model-fallback/index.test.ts b/src/features/model-fallback/index.test.ts new file mode 100644 index 0000000000..3227f29632 --- /dev/null +++ b/src/features/model-fallback/index.test.ts @@ -0,0 +1,521 @@ +import { describe, test, expect, mock } from "bun:test" +import { + parseModelString, + modelSpecToString, + buildModelChain, + isModelError, + withModelFallback, + formatRetryErrors, + type ModelSpec, + type RetryResult, +} from "./index" + +describe("model-fallback", () => { + describe("parseModelString", () => { + test("parses valid provider/model string", () => { + // #given + const modelString = "anthropic/claude-opus-4-5" + + // #when + const result = parseModelString(modelString) + + // #then + expect(result).toBeDefined() + expect(result?.providerID).toBe("anthropic") + expect(result?.modelID).toBe("claude-opus-4-5") + }) + + test("parses model string with nested path", () => { + // #given + const modelString = "a-llm-nextologies/anthropic/claude-opus-4-5" + + // #when + const result = parseModelString(modelString) + + // #then + expect(result).toBeDefined() + expect(result?.providerID).toBe("a-llm-nextologies") + expect(result?.modelID).toBe("anthropic/claude-opus-4-5") + }) + + test("returns undefined for single segment string", () => { + // #given + const modelString = "claude-opus" + + // #when + const result = parseModelString(modelString) + + // #then + expect(result).toBeUndefined() + }) + + test("returns undefined for empty string", () => { + // #given + const modelString = "" + + // #when + const result = parseModelString(modelString) + + // #then + expect(result).toBeUndefined() + }) + }) + + describe("modelSpecToString", () => { + test("converts ModelSpec to string", () => { + // #given + const spec: ModelSpec = { + providerID: "anthropic", + modelID: "claude-opus-4-5", + } + + // #when + const result = modelSpecToString(spec) + + // #then + expect(result).toBe("anthropic/claude-opus-4-5") + }) + + test("handles nested modelID", () => { + // #given + const spec: ModelSpec = { + providerID: "a-llm-nextologies", + modelID: "anthropic/claude-opus-4-5", + } + + // #when + const result = modelSpecToString(spec) + + // #then + expect(result).toBe("a-llm-nextologies/anthropic/claude-opus-4-5") + }) + }) + + describe("buildModelChain", () => { + test("builds chain with primary model only", () => { + // #given + const primary = "anthropic/claude-opus-4-5" + + // #when + const chain = buildModelChain(primary) + + // #then + expect(chain).toHaveLength(1) + expect(chain[0].providerID).toBe("anthropic") + expect(chain[0].modelID).toBe("claude-opus-4-5") + }) + + test("builds chain with primary and fallback models", () => { + // #given + const primary = "anthropic/claude-opus-4-5" + const fallback = ["anthropic/claude-sonnet-4-5", "openai/gpt-5.2"] + + // #when + const chain = buildModelChain(primary, fallback) + + // #then + expect(chain).toHaveLength(3) + expect(chain[0].providerID).toBe("anthropic") + expect(chain[0].modelID).toBe("claude-opus-4-5") + expect(chain[1].providerID).toBe("anthropic") + expect(chain[1].modelID).toBe("claude-sonnet-4-5") + expect(chain[2].providerID).toBe("openai") + expect(chain[2].modelID).toBe("gpt-5.2") + }) + + test("skips invalid model strings in fallback", () => { + // #given + const primary = "anthropic/claude-opus-4-5" + const fallback = ["invalid-model", "openai/gpt-5.2"] + + // #when + const chain = buildModelChain(primary, fallback) + + // #then + expect(chain).toHaveLength(2) + expect(chain[0].modelID).toBe("claude-opus-4-5") + expect(chain[1].modelID).toBe("gpt-5.2") + }) + + test("returns empty chain for invalid primary", () => { + // #given + const primary = "invalid" + + // #when + const chain = buildModelChain(primary) + + // #then + expect(chain).toHaveLength(0) + }) + + test("handles empty fallback array", () => { + // #given + const primary = "anthropic/claude-opus-4-5" + const fallback: string[] = [] + + // #when + const chain = buildModelChain(primary, fallback) + + // #then + expect(chain).toHaveLength(1) + }) + + test("handles undefined fallback", () => { + // #given + const primary = "anthropic/claude-opus-4-5" + + // #when + const chain = buildModelChain(primary, undefined) + + // #then + expect(chain).toHaveLength(1) + }) + }) + + describe("isModelError", () => { + test("returns true for rate limit error", () => { + // #given + const error = new Error("Rate limit exceeded") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for 429 error", () => { + // #given + const error = new Error("Request failed with status 429") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for 503 error", () => { + // #given + const error = new Error("Service unavailable: 503") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for 502 error", () => { + // #given + const error = new Error("Bad gateway: 502") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for timeout error", () => { + // #given + const error = new Error("Request timeout") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for unavailable error", () => { + // #given + const error = new Error("Model is unavailable") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for overloaded error", () => { + // #given + const error = new Error("Server is overloaded") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for capacity error", () => { + // #given + const error = new Error("No capacity available") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for ECONNREFUSED error", () => { + // #given + const error = new Error("connect ECONNREFUSED") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns true for ENOTFOUND error", () => { + // #given + const error = new Error("getaddrinfo ENOTFOUND") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(true) + }) + + test("returns false for non-model errors", () => { + // #given + const error = new Error("Invalid input parameter") + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(false) + }) + + test("returns false for non-Error objects", () => { + // #given + const error = "string error" + + // #when + const result = isModelError(error) + + // #then + expect(result).toBe(false) + }) + + test("returns false for null", () => { + // #given / #when + const result = isModelError(null) + + // #then + expect(result).toBe(false) + }) + }) + + describe("withModelFallback", () => { + test("succeeds on first model", async () => { + // #given + const chain = buildModelChain("anthropic/claude-opus-4-5", ["openai/gpt-5.2"]) + const operation = mock(async (model: ModelSpec) => `success:${model.modelID}`) + + // #when + const result = await withModelFallback(chain, operation) + + // #then + expect(result.success).toBe(true) + expect(result.result).toBe("success:claude-opus-4-5") + expect(result.attempts).toBe(1) + expect(result.errors).toHaveLength(0) + expect(operation).toHaveBeenCalledTimes(1) + }) + + test("falls back on model error", async () => { + // #given + const chain = buildModelChain("anthropic/claude-opus-4-5", ["openai/gpt-5.2"]) + let callCount = 0 + const operation = mock(async (model: ModelSpec) => { + callCount++ + if (callCount === 1) { + throw new Error("Rate limit exceeded") + } + return `success:${model.modelID}` + }) + + // #when + const result = await withModelFallback(chain, operation, { retryConfig: { delayMs: 10 } }) + + // #then + expect(result.success).toBe(true) + expect(result.result).toBe("success:gpt-5.2") + expect(result.attempts).toBe(2) + expect(result.errors).toHaveLength(1) + expect(result.errors[0].error).toContain("Rate limit") + }) + + test("does not retry on non-model errors", async () => { + // #given + const chain = buildModelChain("anthropic/claude-opus-4-5", ["openai/gpt-5.2"]) + const operation = mock(async (_model: ModelSpec) => { + throw new Error("Invalid prompt format") + }) + + // #when + const result = await withModelFallback(chain, operation) + + // #then + expect(result.success).toBe(false) + expect(result.attempts).toBe(1) + expect(result.errors).toHaveLength(1) + expect(operation).toHaveBeenCalledTimes(1) + }) + + test("exhausts all models on persistent failures", async () => { + // #given + const chain = buildModelChain("anthropic/claude-opus-4-5", [ + "openai/gpt-5.2", + "google/gemini-3-flash", + ]) + const operation = mock(async (_model: ModelSpec) => { + throw new Error("Service unavailable") + }) + + // #when + const result = await withModelFallback(chain, operation, { retryConfig: { delayMs: 10 } }) + + // #then + expect(result.success).toBe(false) + expect(result.attempts).toBe(3) + expect(result.errors).toHaveLength(3) + expect(operation).toHaveBeenCalledTimes(3) + }) + + test("respects maxAttempts config", async () => { + // #given + const chain = buildModelChain("anthropic/claude-opus-4-5", [ + "openai/gpt-5.2", + "google/gemini-3-flash", + ]) + const operation = mock(async (_model: ModelSpec) => { + throw new Error("Rate limit exceeded") + }) + + // #when + const result = await withModelFallback(chain, operation, { + retryConfig: { maxAttempts: 2, delayMs: 10 }, + }) + + // #then + expect(result.success).toBe(false) + expect(result.attempts).toBe(2) + expect(operation).toHaveBeenCalledTimes(2) + }) + + test("handles NaN maxAttempts by falling back to chain length", async () => { + // #given - chain with 3 models, all fail with retryable error + const chain = buildModelChain("anthropic/claude-opus-4-5", [ + "openai/gpt-5.2", + "google/gemini-3-flash", + ]) + const operation = mock(async (_model: ModelSpec) => { + throw new Error("Rate limit exceeded") + }) + + // #when - NaN maxAttempts should fall back to chain.length (3) + const result = await withModelFallback(chain, operation, { + retryConfig: { maxAttempts: NaN, delayMs: 10 }, + }) + + // #then - all 3 models should be tried (proves maxAttempts=3, not 1) + expect(result.success).toBe(false) + expect(result.attempts).toBe(3) + expect(Number.isNaN(result.attempts)).toBe(false) + expect(operation).toHaveBeenCalledTimes(3) + expect(result.errors).toHaveLength(3) + }) + + test("returns error for empty model chain", async () => { + // #given + const chain: ModelSpec[] = [] + const operation = mock(async (_model: ModelSpec) => "success") + + // #when + const result = await withModelFallback(chain, operation) + + // #then + expect(result.success).toBe(false) + expect(result.attempts).toBe(0) + expect(result.errors).toHaveLength(1) + expect(result.errors[0].error).toContain("No models configured") + expect(operation).not.toHaveBeenCalled() + }) + + test("records usedModel on success", async () => { + // #given + const chain = buildModelChain("anthropic/claude-opus-4-5") + const operation = mock(async (_model: ModelSpec) => "result") + + // #when + const result = await withModelFallback(chain, operation) + + // #then + expect(result.usedModel).toBeDefined() + expect(result.usedModel?.modelID).toBe("claude-opus-4-5") + }) + + test("records usedModel on failure", async () => { + // #given + const chain = buildModelChain("anthropic/claude-opus-4-5") + const operation = mock(async (_model: ModelSpec) => { + throw new Error("Some error") + }) + + // #when + const result = await withModelFallback(chain, operation) + + // #then + expect(result.usedModel).toBeDefined() + expect(result.usedModel?.modelID).toBe("claude-opus-4-5") + }) + }) + + describe("formatRetryErrors", () => { + test("returns 'No errors' for empty array", () => { + // #given + const errors: Array<{ model: string; error: string }> = [] + + // #when + const result = formatRetryErrors(errors) + + // #then + expect(result).toBe("No errors") + }) + + test("formats single error inline", () => { + // #given + const errors = [{ model: "anthropic/claude-opus-4-5", error: "Rate limit exceeded" }] + + // #when + const result = formatRetryErrors(errors) + + // #then + expect(result).toBe("anthropic/claude-opus-4-5: Rate limit exceeded") + }) + + test("formats multiple errors as numbered list", () => { + // #given + const errors = [ + { model: "anthropic/claude-opus-4-5", error: "Rate limit exceeded" }, + { model: "openai/gpt-5.2", error: "Service unavailable" }, + ] + + // #when + const result = formatRetryErrors(errors) + + // #then + expect(result).toContain("1. anthropic/claude-opus-4-5: Rate limit exceeded") + expect(result).toContain("2. openai/gpt-5.2: Service unavailable") + }) + }) +}) diff --git a/src/features/model-fallback/index.ts b/src/features/model-fallback/index.ts new file mode 100644 index 0000000000..83d6f394ea --- /dev/null +++ b/src/features/model-fallback/index.ts @@ -0,0 +1,119 @@ +import { log } from "../../shared/logger" + +export interface ModelSpec { + providerID: string + modelID: string + variant?: string +} + +export function parseModelString(model: string): ModelSpec | undefined { + const parts = model.split("/") + if (parts.length >= 2) { + const providerID = parts[0].trim() + const modelID = parts.slice(1).join("/").trim() + if (providerID.length === 0 || modelID.length === 0) { + return undefined + } + return { providerID, modelID } + } + return undefined +} + +export function modelSpecToString(spec: ModelSpec): string { + return `${spec.providerID}/${spec.modelID}` +} + +export function buildModelChain(primary: string, fallback?: string[]): ModelSpec[] { + const chain: ModelSpec[] = [] + const primarySpec = parseModelString(primary) + if (primarySpec) chain.push(primarySpec) + if (fallback) { + for (const fb of fallback) { + const spec = parseModelString(fb) + if (spec) chain.push(spec) + } + } + return chain +} + +export function isModelError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const msg = error.message.toLowerCase() + return ( + msg.includes("rate limit") || + msg.includes("rate_limit") || + msg.includes("429") || + msg.includes("503") || + msg.includes("502") || + msg.includes("unavailable") || + msg.includes("overloaded") || + msg.includes("capacity") || + msg.includes("timeout") || + msg.includes("econnrefused") || + msg.includes("enotfound") + ) +} + +export interface RetryResult { + success: boolean + result?: T + usedModel?: ModelSpec + attempts: number + errors: Array<{ model: string; error: string }> +} + +export interface RetryConfig { + delayMs?: number + maxAttempts?: number +} + +export async function withModelFallback( + modelChain: ModelSpec[], + operation: (model: ModelSpec) => Promise, + options?: { retryConfig?: RetryConfig; logPrefix?: string } +): Promise> { + const rawMaxAttempts = options?.retryConfig?.maxAttempts + const maxAttempts = Math.max(1, Number.isFinite(rawMaxAttempts) ? rawMaxAttempts : modelChain.length) + const delayMs = options?.retryConfig?.delayMs ?? 1000 + const prefix = options?.logPrefix ?? "[model-fallback]" + const errors: Array<{ model: string; error: string }> = [] + + if (modelChain.length === 0) { + return { success: false, attempts: 0, errors: [{ model: "none", error: "No models configured" }] } + } + + for (let i = 0; i < Math.min(maxAttempts, modelChain.length); i++) { + const model = modelChain[i] + const modelStr = modelSpecToString(model) + + try { + if (i > 0) { + log(`${prefix} Trying fallback model: ${modelStr}`, { attempt: i + 1, maxAttempts }) + } + const result = await operation(model) + if (i > 0) { + log(`${prefix} Fallback succeeded: ${modelStr}`, { attempt: i + 1 }) + } + return { success: true, result, usedModel: model, attempts: i + 1, errors } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + errors.push({ model: modelStr, error: errorMsg }) + log(`${prefix} Model ${modelStr} failed: ${errorMsg}`, { attempt: i + 1 }) + + const shouldRetry = isModelError(error) && i < Math.min(maxAttempts, modelChain.length) - 1 + if (!shouldRetry) { + return { success: false, usedModel: model, attempts: i + 1, errors } + } + + await new Promise(resolve => setTimeout(resolve, delayMs)) + } + } + + return { success: false, attempts: Math.min(maxAttempts, modelChain.length), errors } +} + +export function formatRetryErrors(errors: Array<{ model: string; error: string }>): string { + if (errors.length === 0) return "No errors" + if (errors.length === 1) return `${errors[0].model}: ${errors[0].error}` + return errors.map((e, i) => ` ${i + 1}. ${e.model}: ${e.error}`).join("\n") +} diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index ee73fed95b..87f6508686 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -1,7 +1,7 @@ # TOOLS KNOWLEDGE BASE ## OVERVIEW -Custom tools extending agent capabilities: LSP (11 tools), AST-aware search/replace, background tasks, and multimodal analysis. +Custom tools extending agent capabilities: LSP (7 tools), AST-aware search/replace, background tasks, and multimodal analysis. ## STRUCTURE ``` diff --git a/src/tools/lsp/tools.ts b/src/tools/lsp/tools.ts index b0120c9815..7adb4c0cab 100644 --- a/src/tools/lsp/tools.ts +++ b/src/tools/lsp/tools.ts @@ -147,7 +147,7 @@ export const lsp_symbols: ToolDefinition = tool({ } const total = result.length - const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) + const limit = Math.max(1, Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)) const truncated = total > limit const limited = truncated ? result.slice(0, limit) : result @@ -156,9 +156,9 @@ export const lsp_symbols: ToolDefinition = tool({ lines.push(`Found ${total} symbols (showing first ${limit}):`) } - if ("range" in limited[0]) { + if (limited.length > 0 && "range" in limited[0]) { lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s))) - } else { + } else if (limited.length > 0) { lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo)) } return lines.join("\n") diff --git a/src/tools/sisyphus-task/constants.ts b/src/tools/sisyphus-task/constants.ts index 4919b65565..5bc2a051f4 100644 --- a/src/tools/sisyphus-task/constants.ts +++ b/src/tools/sisyphus-task/constants.ts @@ -186,30 +186,37 @@ The more explicit your prompt, the better the results. export const DEFAULT_CATEGORIES: Record = { "visual-engineering": { model: "google/gemini-3-pro-preview", + fallback: ["anthropic/claude-sonnet-4-5", "google/gemini-3-flash-preview"], temperature: 0.7, }, ultrabrain: { model: "openai/gpt-5.2", + fallback: ["anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-5"], temperature: 0.1, }, artistry: { model: "google/gemini-3-pro-preview", + fallback: ["anthropic/claude-sonnet-4-5"], temperature: 0.9, }, quick: { model: "anthropic/claude-haiku-4-5", + fallback: ["google/gemini-3-flash-preview", "anthropic/claude-sonnet-4-5"], temperature: 0.3, }, "most-capable": { model: "anthropic/claude-opus-4-5", + fallback: ["openai/gpt-5.2", "anthropic/claude-sonnet-4-5"], temperature: 0.1, }, writing: { model: "google/gemini-3-flash-preview", + fallback: ["anthropic/claude-haiku-4-5"], temperature: 0.5, }, general: { model: "anthropic/claude-sonnet-4-5", + fallback: ["anthropic/claude-haiku-4-5", "google/gemini-3-flash-preview"], temperature: 0.3, }, } diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index b8a519ef9c..8280875ba8 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -12,6 +12,7 @@ import { getTaskToastManager } from "../../features/task-toast-manager" import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" +import { isModelError, type ModelSpec, type RetryConfig, buildModelChain } from "../../features/model-fallback" type OpencodeClient = PluginInput["client"] @@ -29,6 +30,11 @@ function parseModelString(model: string): { providerID: string; modelID: string function getMessageDir(sessionID: string): string | null { if (!existsSync(MESSAGE_STORAGE)) return null + // Validate sessionID to prevent path traversal + if (!sessionID || sessionID.includes("..") || sessionID.includes("/") || sessionID.includes("\\")) { + return null + } + const directPath = join(MESSAGE_STORAGE, sessionID) if (existsSync(directPath)) return directPath @@ -59,6 +65,14 @@ type ToolContextWithMetadata = { metadata?: (input: { title?: string; metadata?: Record }) => void } +interface ResolvedCategoryConfig { + config: CategoryConfig + promptAppend: string + model: string | undefined + modelChain: ModelSpec[] + retryConfig: RetryConfig +} + function resolveCategoryConfig( categoryName: string, options: { @@ -66,7 +80,7 @@ function resolveCategoryConfig( parentModelString?: string systemDefaultModel?: string } -): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null { +): ResolvedCategoryConfig | null { const { userCategories, parentModelString, systemDefaultModel } = options const defaultConfig = DEFAULT_CATEGORIES[categoryName] const userConfig = userCategories?.[categoryName] @@ -85,6 +99,14 @@ function resolveCategoryConfig( model, } + const fallbackList = userConfig?.fallback ?? defaultConfig?.fallback + const modelChain = buildModelChain(config.model, fallbackList) + + const retryConfig: RetryConfig = { + delayMs: userConfig?.fallbackDelayMs ?? defaultConfig?.fallbackDelayMs ?? 1000, + maxAttempts: userConfig?.fallbackRetryCount ?? defaultConfig?.fallbackRetryCount ?? modelChain.length, + } + let promptAppend = defaultPromptAppend if (userConfig?.prompt_append) { promptAppend = defaultPromptAppend @@ -92,7 +114,7 @@ function resolveCategoryConfig( : userConfig.prompt_append } - return { config, promptAppend, model } + return { config, promptAppend, model, modelChain, retryConfig } } export interface SisyphusTaskToolOptions { @@ -347,6 +369,8 @@ ${textContent || "(No text output)"}` let agentToUse: string let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined let categoryPromptAppend: string | undefined + let modelChain: ModelSpec[] = [] + let retryConfig: RetryConfig = { delayMs: 1000 } const parentModelString = parentModel ? `${parentModel.providerID}/${parentModel.modelID}` @@ -400,6 +424,8 @@ ${textContent || "(No text output)"}` : parsedModel) : undefined categoryPromptAppend = resolved.promptAppend || undefined + modelChain = resolved.modelChain + retryConfig = resolved.retryConfig } else { if (!args.subagent_type?.trim()) { return `❌ Agent name cannot be empty.` @@ -515,30 +541,75 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id metadata: { sessionId: sessionID, category: args.category, sync: true }, }) - try { - await client.session.prompt({ - path: { id: sessionID }, - body: { - agent: agentToUse, - system: systemContent, - tools: { - task: false, - sisyphus_task: false, - call_omo_agent: true, + const modelsToTry = modelChain.length > 0 ? modelChain : (categoryModel ? [categoryModel] : []) + const maxAttempts = Math.max(1, retryConfig.maxAttempts ?? modelsToTry.length) + const delayMs = retryConfig.delayMs ?? 1000 + let promptSuccess = false + let usedModel: ModelSpec | undefined = categoryModel + const failedModels: Array<{ model: string; error: string }> = [] + + for (let attempt = 0; attempt < Math.min(Math.max(modelsToTry.length, 1), maxAttempts); attempt++) { + const currentModel = modelsToTry[attempt] + const modelStr = currentModel ? `${currentModel.providerID}/${currentModel.modelID}` : "default" + + try { + if (attempt > 0) { + log(`[sisyphus_task] Retrying with fallback model: ${modelStr}`, { attempt: attempt + 1, sessionID }) + } + + await client.session.prompt({ + path: { id: sessionID }, + body: { + agent: agentToUse, + system: systemContent, + tools: { + task: false, + sisyphus_task: false, + call_omo_agent: true, + }, + parts: [{ type: "text", text: args.prompt }], + ...(currentModel ? { model: currentModel } : {}), }, - parts: [{ type: "text", text: args.prompt }], - ...(categoryModel ? { model: categoryModel } : {}), - }, - }) - } catch (promptError) { + }) + promptSuccess = true + usedModel = currentModel + if (attempt > 0) { + log(`[sisyphus_task] Fallback succeeded with: ${modelStr}`, { attempt: attempt + 1 }) + } + break + } catch (promptError) { + const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) + failedModels.push({ model: modelStr, error: errorMessage }) + + if (errorMessage.includes("agent.name")) { + if (toastManager && taskId !== undefined) { + toastManager.removeTask(taskId) + } + subagentSessions.delete(sessionID) + return `❌ Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\nSession ID: ${sessionID}` + } + + const attemptsRemaining = Math.min(modelsToTry.length, maxAttempts) - attempt - 1 + if (!isModelError(promptError) || attemptsRemaining <= 0) { + if (toastManager && taskId !== undefined) { + toastManager.removeTask(taskId) + } + subagentSessions.delete(sessionID) + const allErrors = failedModels.map((f, i) => ` ${i + 1}. ${f.model}: ${f.error}`).join("\n") + return `❌ Failed to send prompt (tried ${failedModels.length} model${failedModels.length > 1 ? "s" : ""}):\n${allErrors}\n\nSession ID: ${sessionID}` + } + + log(`[sisyphus_task] Model ${modelStr} failed, trying fallback...`, { error: errorMessage }) + await new Promise(resolve => setTimeout(resolve, delayMs)) + } + } + + if (!promptSuccess) { if (toastManager && taskId !== undefined) { toastManager.removeTask(taskId) } - const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { - return `❌ Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\nSession ID: ${sessionID}` - } - return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}` + subagentSessions.delete(sessionID) + return `❌ All models failed.\n\nSession ID: ${sessionID}` } // Poll for session completion with stability detection @@ -644,9 +715,15 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id subagentSessions.delete(sessionID) + const usedModelStr = usedModel ? `${usedModel.providerID}/${usedModel.modelID}` : "default" + const fallbackNote = failedModels.length > 0 + ? `\n⚠️ Model fallback occurred: Primary model failed, used ${usedModelStr} instead.\n Failed models: ${failedModels.map(f => f.model).join(", ")}` + : "" + return `Task completed in ${duration}. Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} +Model: ${usedModelStr}${fallbackNote} Session ID: ${sessionID} ---