diff --git a/src/hooks/model-fallback/next-fallback.ts b/src/hooks/model-fallback/next-fallback.ts index dadd16654..0994f693d 100644 --- a/src/hooks/model-fallback/next-fallback.ts +++ b/src/hooks/model-fallback/next-fallback.ts @@ -32,7 +32,16 @@ function createReachabilityChecker(state: ModelFallbackState): (entry: FallbackE export function getNextReachableFallback( sessionID: string, state: ModelFallbackState, -): { providerID: string; modelID: string; variant?: string } | null { +): { + providerID: string + modelID: string + variant?: string + reasoningEffort?: string + temperature?: number + top_p?: number + maxTokens?: number + thinking?: { type: "enabled" | "disabled"; budgetTokens?: number } +} | null { const isReachable = createReachabilityChecker(state) while (state.attemptCount < state.fallbackChain.length) { @@ -63,6 +72,11 @@ export function getNextReachableFallback( providerID, modelID, variant: fallback.variant, + reasoningEffort: fallback.reasoningEffort, + temperature: fallback.temperature, + top_p: fallback.top_p, + maxTokens: fallback.maxTokens, + thinking: fallback.thinking, } } diff --git a/src/tools/delegate-task/sync-task-fallback.ts b/src/tools/delegate-task/sync-task-fallback.ts new file mode 100644 index 000000000..6ad64ef3d --- /dev/null +++ b/src/tools/delegate-task/sync-task-fallback.ts @@ -0,0 +1,68 @@ +import type { FallbackEntry } from "../../shared/model-requirements" +import type { DelegatedModelConfig } from "./types" +import type { ModelFallbackState } from "../../hooks/model-fallback/hook" +import { getNextReachableFallback } from "../../hooks/model-fallback/next-fallback" + +function toDelegatedModelConfig(fallback: NonNullable>): DelegatedModelConfig { + return { + providerID: fallback.providerID, + modelID: fallback.modelID, + variant: fallback.variant, + reasoningEffort: fallback.reasoningEffort, + temperature: fallback.temperature, + top_p: fallback.top_p, + maxTokens: fallback.maxTokens, + thinking: fallback.thinking, + } +} + +export async function retrySyncPromptWithFallbacks(input: { + sessionID: string + initialError: string + categoryModel: DelegatedModelConfig | undefined + fallbackChain: FallbackEntry[] | undefined + sendPrompt: (categoryModel: DelegatedModelConfig) => Promise +}): Promise<{ promptError: string | null; categoryModel: DelegatedModelConfig | undefined }> { + const { sessionID, initialError, categoryModel, fallbackChain, sendPrompt } = input + + if (!categoryModel || !fallbackChain || fallbackChain.length === 0) { + return { + promptError: initialError, + categoryModel, + } + } + + const fallbackState: ModelFallbackState = { + providerID: categoryModel.providerID, + modelID: categoryModel.modelID, + fallbackChain, + attemptCount: 0, + pending: true, + } + + let finalError = initialError + + while (true) { + const nextFallback = getNextReachableFallback(sessionID, fallbackState) + if (!nextFallback) { + return { + promptError: finalError, + categoryModel, + } + } + + const fallbackModel = toDelegatedModelConfig(nextFallback) + const promptError = await sendPrompt(fallbackModel) + if (!promptError) { + return { + promptError: null, + categoryModel: fallbackModel, + } + } + + finalError = promptError + fallbackState.providerID = fallbackModel.providerID + fallbackState.modelID = fallbackModel.modelID + fallbackState.pending = true + } +} diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 30c9199c3..16bc31521 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -228,6 +228,140 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(deleteCalls[0]).toBe("ses_test_12345678") }) + test("#given fallback chain set #when sendSyncPrompt fails #then retries with next model", async () => { + //#given + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = [] + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => { + attemptedModels.push(input.categoryModel) + return attemptedModels.length === 1 ? "Initial failure" : null + }, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + const initialModel = { + providerID: "anthropic", + modelID: "claude-opus-4-6", + variant: "max", + } + const fallbackChain = [ + { providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["opencode-go"], model: "kimi-k2.5" }, + ] + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", initialModel, undefined, undefined, fallbackChain, deps) + + //#then + expect(result).toContain("Task completed") + expect(result).toContain("Model: opencode-go/kimi-k2.5") + expect(attemptedModels).toEqual([ + { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" }, + { providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined }, + ]) + }) + + test("#given fallback chain exhausted #when all retries fail #then returns final error", async () => { + //#given + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = [] + const promptErrors = ["Initial failure", "Second failure", "Final failure"] + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => { + attemptedModels.push(input.categoryModel) + return promptErrors[attemptedModels.length - 1] ?? "Unexpected extra retry" + }, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + const initialModel = { + providerID: "anthropic", + modelID: "claude-opus-4-6", + variant: "max", + } + const fallbackChain = [ + { providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["opencode-go"], model: "kimi-k2.5" }, + { providers: ["openai"], model: "gpt-5.4", variant: "medium" }, + ] + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", initialModel, undefined, undefined, fallbackChain, deps) + + //#then + expect(result).toBe("Final failure") + expect(attemptedModels).toEqual([ + { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" }, + { providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined }, + { providerID: "openai", modelID: "gpt-5.4", variant: "medium" }, + ]) + }) + test("cleans up toast and subagentSessions on successful completion", async () => { const mockClient = { session: { diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index d1ee6ff31..18d99e500 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -11,6 +11,7 @@ import { formatDuration } from "./time-formatter" import { formatDetailedError } from "./error-formatting" import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps" import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook" +import { retrySyncPromptWithFallbacks } from "./sync-task-fallback" export async function executeSyncTask( args: DelegateTaskArgs, @@ -135,18 +136,43 @@ export async function executeSyncTask( storeToolMetadata(ctx.sessionID, callID, syncTaskMeta) } - const promptError = await deps.sendSyncPrompt(client, { + let effectiveCategoryModel = categoryModel + let promptError = await deps.sendSyncPrompt(client, { sessionID, agentToUse, args, systemContent, - categoryModel, + categoryModel: effectiveCategoryModel, toastManager, taskId, sisyphusAgentConfig: executorCtx.sisyphusAgentConfig, }) if (promptError) { - return promptError + const promptResult = await retrySyncPromptWithFallbacks({ + sessionID, + initialError: promptError, + categoryModel: effectiveCategoryModel, + fallbackChain, + sendPrompt: async (fallbackModel) => { + return deps.sendSyncPrompt(client, { + sessionID, + agentToUse, + args, + systemContent, + categoryModel: fallbackModel, + toastManager, + taskId, + sisyphusAgentConfig: executorCtx.sisyphusAgentConfig, + }) + }, + }) + + promptError = promptResult.promptError + effectiveCategoryModel = promptResult.categoryModel + + if (promptError) { + return promptError + } } try { @@ -168,8 +194,8 @@ export async function executeSyncTask( const duration = formatDuration(startTime) // 检测模型路由是否与父 session 不同,给用户可见的提示 - const actualModelStr = categoryModel - ? `${categoryModel.providerID}/${categoryModel.modelID}` + const actualModelStr = effectiveCategoryModel + ? `${effectiveCategoryModel.providerID}/${effectiveCategoryModel.modelID}` : undefined const parentModelStr = parentContext.model ? `${parentContext.model.providerID}/${parentContext.model.modelID}`