From 789a5747679634efb7152d36af752c7fbd420b93 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 10:25:22 -0500 Subject: [PATCH 01/11] feat(config): add fallback, fallbackDelayMs, fallbackRetryCount schema fields Add new optional fields to AgentOverrideConfigSchema and CategoryConfigSchema: - fallback: string[] - array of fallback model strings - fallbackDelayMs: number (100-30000) - delay between retry attempts - fallbackRetryCount: number (1-5) - maximum models to try These fields enable automatic model fallback when primary model fails due to rate limits, service unavailability, or other transient errors. --- assets/oh-my-opencode.schema.json | 256 ++++++++++++++++++++++++++++++ src/config/schema.ts | 9 +- 2 files changed, 262 insertions(+), 3 deletions(-) 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(), From 83c0d00799840f26bb6d29d66f770da3ed5f6800 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 10:26:44 -0500 Subject: [PATCH 02/11] feat(model-fallback): add utility module for automatic model fallback Implement core model fallback utilities in src/features/model-fallback/: - parseModelString(): Parse 'provider/model' format into ModelSpec - modelSpecToString(): Convert ModelSpec back to string - buildModelChain(): Build ordered list of models from primary + fallbacks - isModelError(): Detect retryable errors (rate limits, 429, 503, timeouts) - withModelFallback(): Generic retry wrapper for simple use cases - formatRetryErrors(): Format error list for user-friendly display The module detects these transient errors for automatic retry: - Rate limits (rate_limit, 429) - Service unavailable (503, 502, unavailable, overloaded, capacity) - Network errors (timeout, ECONNREFUSED, ENOTFOUND) Non-model errors (auth failures, invalid input) are NOT retried. --- src/features/model-fallback/index.ts | 113 +++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/features/model-fallback/index.ts diff --git a/src/features/model-fallback/index.ts b/src/features/model-fallback/index.ts new file mode 100644 index 0000000000..46ab6a1286 --- /dev/null +++ b/src/features/model-fallback/index.ts @@ -0,0 +1,113 @@ +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) { + return { providerID: parts[0], modelID: parts.slice(1).join("/") } + } + 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 maxAttempts = options?.retryConfig?.maxAttempts ?? 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") +} From 93d224f85315baa4c83f2a2156b7230fa3d18c21 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 10:26:58 -0500 Subject: [PATCH 03/11] feat(sisyphus-task): integrate model fallback with user notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update sisyphus-task to use the model fallback system: Integration changes: - Import parseModelString, isModelError, RetryConfig from model-fallback - Update resolveCategoryConfig() to return modelChain and retryConfig - Use configurable fallbackDelayMs and fallbackRetryCount from config - Build model chain from primary model + fallback array User notification on fallback: - Show which model was actually used in task completion message - Display warning when fallback occurred with list of failed models - Example: '⚠️ Model fallback occurred: Primary model failed, used anthropic/claude-haiku-4-5 instead. Failed models: deepseek/deepseek-v3-2' Special handling: - Agent configuration errors (agent.name) do NOT trigger fallback - Only transient model errors (rate limits, timeouts) retry with fallback - Inline retry logic preserved for session lifecycle management --- src/tools/sisyphus-task/constants.ts | 25 +++--- src/tools/sisyphus-task/tools.ts | 112 +++++++++++++++++++++------ 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/tools/sisyphus-task/constants.ts b/src/tools/sisyphus-task/constants.ts index 4919b65565..294ec48bf7 100644 --- a/src/tools/sisyphus-task/constants.ts +++ b/src/tools/sisyphus-task/constants.ts @@ -185,31 +185,38 @@ The more explicit your prompt, the better the results. export const DEFAULT_CATEGORIES: Record = { "visual-engineering": { - model: "google/gemini-3-pro-preview", - temperature: 0.7, + model: "a-llm-nextologies/gemini-3-pro-preview", + fallback: ["a-llm-nextologies/claude-sonnet-4-5", "a-llm-nextologies/gemini-3-flash-preview"], + temperature: 0.5, }, ultrabrain: { - model: "openai/gpt-5.2", + model: "a-llm-nextologies/gpt-5.2", + fallback: ["a-llm-nextologies/deepseek-r1", "a-llm-nextologies/claude-opus-4-5"], temperature: 0.1, }, artistry: { - model: "google/gemini-3-pro-preview", + model: "a-llm-nextologies/gemini-3-pro-preview", + fallback: ["a-llm-nextologies/claude-sonnet-4-5"], temperature: 0.9, }, quick: { - model: "anthropic/claude-haiku-4-5", - temperature: 0.3, + model: "a-llm-nextologies/gemini-3-flash-preview", + fallback: ["a-llm-nextologies/gemini-2.5-flash", "a-llm-nextologies/deepseek-v3.2"], + temperature: 0.2, }, "most-capable": { - model: "anthropic/claude-opus-4-5", + model: "a-llm-nextologies/claude-opus-4-5", + fallback: ["a-llm-nextologies/gpt-5.2", "a-llm-nextologies/claude-sonnet-4-5"], temperature: 0.1, }, writing: { - model: "google/gemini-3-flash-preview", + model: "a-llm-nextologies/gemini-3-flash-preview", + fallback: ["a-llm-nextologies/deepseek-v3.2", "a-llm-nextologies/claude-haiku-4-5"], temperature: 0.5, }, general: { - model: "anthropic/claude-sonnet-4-5", + model: "a-llm-nextologies/deepseek-v3.2", + fallback: ["a-llm-nextologies/claude-haiku-4-5", "a-llm-nextologies/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..ca3f06b322 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"] @@ -59,6 +60,13 @@ type ToolContextWithMetadata = { metadata?: (input: { title?: string; metadata?: Record }) => void } +interface ResolvedCategoryConfig { + config: CategoryConfig + promptAppend: string + modelChain: ModelSpec[] + retryConfig: RetryConfig +} + function resolveCategoryConfig( categoryName: string, options: { @@ -66,7 +74,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 +93,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 +108,7 @@ function resolveCategoryConfig( : userConfig.prompt_append } - return { config, promptAppend, model } + return { config, promptAppend, modelChain, retryConfig } } export interface SisyphusTaskToolOptions { @@ -347,6 +363,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 +418,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 +535,72 @@ 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 = 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") || errorMessage.includes("undefined")) { + if (toastManager && taskId !== undefined) { + toastManager.removeTask(taskId) + } + 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) + } + 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}` + return `❌ All models failed.\n\nSession ID: ${sessionID}` } // Poll for session completion with stability detection @@ -644,9 +706,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} --- From b521e9598d1563b15fe0ac2671efd6e4ade9d88e Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 10:27:15 -0500 Subject: [PATCH 04/11] test(model-fallback): add comprehensive unit tests Add 36 unit tests covering all model-fallback functions: parseModelString() tests: - Valid provider/model strings - Nested paths (provider/sub/model) - Invalid single-segment strings - Empty string handling buildModelChain() tests: - Primary model only - Primary with fallback array - Invalid models filtered out - Empty/undefined fallback handling isModelError() tests: - Rate limit detection (rate_limit, 429) - Service errors (503, 502, unavailable, overloaded, capacity) - Network errors (timeout, ECONNREFUSED, ENOTFOUND) - Non-model errors return false - Non-Error objects return false withModelFallback() tests: - Success on first model - Fallback on model error - No retry on non-model errors - Exhausts all models on persistent failures - Respects maxAttempts config - Empty model chain handling - Records usedModel on success/failure formatRetryErrors() tests: - Empty array handling - Single error formatting - Multiple errors as numbered list --- src/features/model-fallback/index.test.ts | 498 ++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 src/features/model-fallback/index.test.ts diff --git a/src/features/model-fallback/index.test.ts b/src/features/model-fallback/index.test.ts new file mode 100644 index 0000000000..041ac86188 --- /dev/null +++ b/src/features/model-fallback/index.test.ts @@ -0,0 +1,498 @@ +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("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") + }) + }) +}) From 9adb8f366d42ff656a6c82e05fdd02671e64b890 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 15:09:43 -0500 Subject: [PATCH 05/11] fix: address cubic-dev-ai P1/P2 review feedback - Fix P1: Remove duplicate session.prompt calls in manager.ts that caused double execution before task registration. Synced with origin/dev and properly integrated modelChain support. - Fix P2: parseModelString now rejects empty provider/model segments, preventing malformed model specs from entering the fallback chain. - Remove pr_790_body.txt which belonged to a different PR (HTTP transport). --- src/features/background-agent/manager.ts | 7 +++++-- src/features/model-fallback/index.ts | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) 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/model-fallback/index.ts b/src/features/model-fallback/index.ts index 46ab6a1286..81885c4b11 100644 --- a/src/features/model-fallback/index.ts +++ b/src/features/model-fallback/index.ts @@ -9,7 +9,12 @@ export interface ModelSpec { export function parseModelString(model: string): ModelSpec | undefined { const parts = model.split("/") if (parts.length >= 2) { - return { providerID: parts[0], modelID: parts.slice(1).join("/") } + 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 } From 4f32b7e40c6eba7701b4d8b23ed9ab7202fe4e64 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 15:24:36 -0500 Subject: [PATCH 06/11] fix: restore standard provider names and fix type annotations - Restore constants.ts to use standard provider names (google/, openai/) instead of custom nextologies provider - Add modelChain and retryConfig to resolveCategoryConfig return type --- src/tools/sisyphus-task/constants.ts | 25 +++++++++---------------- src/tools/sisyphus-task/tools.ts | 3 ++- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/tools/sisyphus-task/constants.ts b/src/tools/sisyphus-task/constants.ts index 294ec48bf7..4919b65565 100644 --- a/src/tools/sisyphus-task/constants.ts +++ b/src/tools/sisyphus-task/constants.ts @@ -185,38 +185,31 @@ The more explicit your prompt, the better the results. export const DEFAULT_CATEGORIES: Record = { "visual-engineering": { - model: "a-llm-nextologies/gemini-3-pro-preview", - fallback: ["a-llm-nextologies/claude-sonnet-4-5", "a-llm-nextologies/gemini-3-flash-preview"], - temperature: 0.5, + model: "google/gemini-3-pro-preview", + temperature: 0.7, }, ultrabrain: { - model: "a-llm-nextologies/gpt-5.2", - fallback: ["a-llm-nextologies/deepseek-r1", "a-llm-nextologies/claude-opus-4-5"], + model: "openai/gpt-5.2", temperature: 0.1, }, artistry: { - model: "a-llm-nextologies/gemini-3-pro-preview", - fallback: ["a-llm-nextologies/claude-sonnet-4-5"], + model: "google/gemini-3-pro-preview", temperature: 0.9, }, quick: { - model: "a-llm-nextologies/gemini-3-flash-preview", - fallback: ["a-llm-nextologies/gemini-2.5-flash", "a-llm-nextologies/deepseek-v3.2"], - temperature: 0.2, + model: "anthropic/claude-haiku-4-5", + temperature: 0.3, }, "most-capable": { - model: "a-llm-nextologies/claude-opus-4-5", - fallback: ["a-llm-nextologies/gpt-5.2", "a-llm-nextologies/claude-sonnet-4-5"], + model: "anthropic/claude-opus-4-5", temperature: 0.1, }, writing: { - model: "a-llm-nextologies/gemini-3-flash-preview", - fallback: ["a-llm-nextologies/deepseek-v3.2", "a-llm-nextologies/claude-haiku-4-5"], + model: "google/gemini-3-flash-preview", temperature: 0.5, }, general: { - model: "a-llm-nextologies/deepseek-v3.2", - fallback: ["a-llm-nextologies/claude-haiku-4-5", "a-llm-nextologies/gemini-3-flash-preview"], + model: "anthropic/claude-sonnet-4-5", temperature: 0.3, }, } diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index ca3f06b322..6917fb5f89 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -63,6 +63,7 @@ type ToolContextWithMetadata = { interface ResolvedCategoryConfig { config: CategoryConfig promptAppend: string + model: string | undefined modelChain: ModelSpec[] retryConfig: RetryConfig } @@ -108,7 +109,7 @@ function resolveCategoryConfig( : userConfig.prompt_append } - return { config, promptAppend, modelChain, retryConfig } + return { config, promptAppend, model, modelChain, retryConfig } } export interface SisyphusTaskToolOptions { From 9a2614bbadc7e415ed9a82b484fcb9f528b86146 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 15:50:56 -0500 Subject: [PATCH 07/11] fix: address auto-review bot findings Security & Correctness Fixes: - P1: Fix path traversal vulnerability in getMessageDir by validating sessionID - P2: Fix lsp_symbols array access when limit <= 0 by enforcing min limit of 1 - P2: Add fallback chains to all DEFAULT_CATEGORIES to enable automatic model fallback Documentation Fixes: - P2: Update README.md beta release link from v3.0.0-beta.1 to v3.0.0-beta.6 - P3: Fix AGENTS.md LSP tool count from 11 to 7 to match actual count All fixes verified with full test suite (1048/1048 passing). --- README.md | 2 +- src/tools/AGENTS.md | 2 +- src/tools/lsp/tools.ts | 6 +++--- src/tools/sisyphus-task/constants.ts | 7 +++++++ src/tools/sisyphus-task/tools.ts | 5 +++++ 5 files changed, 17 insertions(+), 5 deletions(-) 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/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 6917fb5f89..aa68c1db1a 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -30,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 From 7ef053b5ac6187d35e32d549e4cd1d70a2cdebdc Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 15:56:43 -0500 Subject: [PATCH 08/11] fix: add modelChain to LaunchInput type --- src/features/background-agent/types.ts | 1 + 1 file changed, 1 insertion(+) 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 } From 836e686a86879b2430b154ee8108f129f29c8e85 Mon Sep 17 00:00:00 2001 From: Aleksey Bragin Date: Wed, 14 Jan 2026 16:39:00 -0500 Subject: [PATCH 09/11] fix: address model fallback review issues - Fix maxAttempts accepting 0/negative values causing zero retry attempts in both withModelFallback() and sisyphus_task retry loop - Remove over-broad "undefined" check in agent-not-found detection that could misclassify unrelated errors and abort fallbacks prematurely - Add missing subagentSessions.delete() calls on early error returns to prevent memory leaks in session tracking Fixes issues reported by cubic-dev-ai review: - P1: No prompt sent for subagent requests when maxAttempts=0 - P2: maxAttempts accepts 0/negative values - P2: Over-broad agent-not-found detection - P2: Session tracking leak on early failures Co-Authored-By: Claude Opus 4.5 --- src/features/model-fallback/index.ts | 2 +- src/tools/sisyphus-task/tools.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/features/model-fallback/index.ts b/src/features/model-fallback/index.ts index 81885c4b11..8b68142390 100644 --- a/src/features/model-fallback/index.ts +++ b/src/features/model-fallback/index.ts @@ -72,7 +72,7 @@ export async function withModelFallback( operation: (model: ModelSpec) => Promise, options?: { retryConfig?: RetryConfig; logPrefix?: string } ): Promise> { - const maxAttempts = options?.retryConfig?.maxAttempts ?? modelChain.length + const maxAttempts = Math.max(1, options?.retryConfig?.maxAttempts ?? modelChain.length) const delayMs = options?.retryConfig?.delayMs ?? 1000 const prefix = options?.logPrefix ?? "[model-fallback]" const errors: Array<{ model: string; error: string }> = [] diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index aa68c1db1a..8280875ba8 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -542,7 +542,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id }) const modelsToTry = modelChain.length > 0 ? modelChain : (categoryModel ? [categoryModel] : []) - const maxAttempts = retryConfig.maxAttempts ?? modelsToTry.length + const maxAttempts = Math.max(1, retryConfig.maxAttempts ?? modelsToTry.length) const delayMs = retryConfig.delayMs ?? 1000 let promptSuccess = false let usedModel: ModelSpec | undefined = categoryModel @@ -581,10 +581,11 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) failedModels.push({ model: modelStr, error: errorMessage }) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { + 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}` } @@ -593,6 +594,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id 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}` } @@ -606,6 +608,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id if (toastManager && taskId !== undefined) { toastManager.removeTask(taskId) } + subagentSessions.delete(sessionID) return `❌ All models failed.\n\nSession ID: ${sessionID}` } From 00e2dfacfbbef1c34886b1fb8cf56befe226a212 Mon Sep 17 00:00:00 2001 From: Aleksey Bragin Date: Wed, 14 Jan 2026 16:46:34 -0500 Subject: [PATCH 10/11] fix: handle NaN maxAttempts in model fallback Use Number.isFinite() to validate maxAttempts before using it. If maxAttempts is NaN, Infinity, or not a number, fall back to using the model chain length instead. Adds test case for NaN input. Co-Authored-By: Claude Opus 4.5 --- src/features/model-fallback/index.test.ts | 17 +++++++++++++++++ src/features/model-fallback/index.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/features/model-fallback/index.test.ts b/src/features/model-fallback/index.test.ts index 041ac86188..09ed03c41a 100644 --- a/src/features/model-fallback/index.test.ts +++ b/src/features/model-fallback/index.test.ts @@ -412,6 +412,23 @@ describe("model-fallback", () => { expect(operation).toHaveBeenCalledTimes(2) }) + test("handles NaN maxAttempts by using chain length", 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, { + retryConfig: { maxAttempts: NaN, delayMs: 10 }, + }) + + // #then + expect(result.success).toBe(true) + expect(result.attempts).toBe(1) + expect(Number.isNaN(result.attempts)).toBe(false) + expect(operation).toHaveBeenCalledTimes(1) + }) + test("returns error for empty model chain", async () => { // #given const chain: ModelSpec[] = [] diff --git a/src/features/model-fallback/index.ts b/src/features/model-fallback/index.ts index 8b68142390..83d6f394ea 100644 --- a/src/features/model-fallback/index.ts +++ b/src/features/model-fallback/index.ts @@ -72,7 +72,8 @@ export async function withModelFallback( operation: (model: ModelSpec) => Promise, options?: { retryConfig?: RetryConfig; logPrefix?: string } ): Promise> { - const maxAttempts = Math.max(1, options?.retryConfig?.maxAttempts ?? modelChain.length) + 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 }> = [] From 746034090ea5dee5f2ff070cf4763d4bd7e81c1f Mon Sep 17 00:00:00 2001 From: Aleksey Bragin Date: Wed, 14 Jan 2026 16:53:57 -0500 Subject: [PATCH 11/11] test: improve NaN maxAttempts test to verify chain-length fallback The previous test succeeded on first attempt, so it couldn't distinguish maxAttempts=1 from maxAttempts=chainLength. Now uses 3 models that all fail, verifying all 3 are tried (proving maxAttempts correctly falls back to chain length, not 1). Co-Authored-By: Claude Opus 4.5 --- src/features/model-fallback/index.test.ts | 24 ++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/features/model-fallback/index.test.ts b/src/features/model-fallback/index.test.ts index 09ed03c41a..3227f29632 100644 --- a/src/features/model-fallback/index.test.ts +++ b/src/features/model-fallback/index.test.ts @@ -412,21 +412,27 @@ describe("model-fallback", () => { expect(operation).toHaveBeenCalledTimes(2) }) - test("handles NaN maxAttempts by using chain length", async () => { - // #given - const chain = buildModelChain("anthropic/claude-opus-4-5", ["openai/gpt-5.2"]) - const operation = mock(async (model: ModelSpec) => `success:${model.modelID}`) + 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 + // #when - NaN maxAttempts should fall back to chain.length (3) const result = await withModelFallback(chain, operation, { retryConfig: { maxAttempts: NaN, delayMs: 10 }, }) - // #then - expect(result.success).toBe(true) - expect(result.attempts).toBe(1) + // #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(1) + expect(operation).toHaveBeenCalledTimes(3) + expect(result.errors).toHaveLength(3) }) test("returns error for empty model chain", async () => {