diff --git a/docs/settings-reference.md b/docs/settings-reference.md index fc731c505..a493216ad 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -38,7 +38,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `defaultModelId` | `string` | `undefined` | Default AI model ID. | | `fallbackProvider` | `string` | `undefined` | Fallback provider when the primary default model hits transient provider failures. | | `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). | -| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. | +| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. | | `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. | | `ntfyTopic` | `string` | `undefined` | ntfy topic name. | | `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. | diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 95fee6b7f..2ed6edf9b 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -993,6 +993,36 @@ describe("createFnAgent", () => { })); }); + it("continues session creation when setting thinking level hits reasoning conflict", async () => { + const { piLog } = await import("../logger.js"); + const warnSpy = vi.spyOn(piLog, "warn").mockImplementation(() => {}); + const setThinkingLevel = vi.fn(() => { + throw new Error("400 cannot specify both 'thinking' and 'reasoning_effort'"); + }); + + createAgentSessionMock.mockResolvedValueOnce({ + session: { + prompt: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel, + }, + }); + + const { createFnAgent } = await import("../pi.js"); + + await expect(createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultThinkingLevel: "high", + })).resolves.toBeTruthy(); + + expect(setThinkingLevel).toHaveBeenCalledWith("high"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Disabling explicit thinking level")); + warnSpy.mockRestore(); + }); + describe("skill selection", () => { beforeEach(() => { // Reset modules to ensure fresh imports for each test diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index b626b8a7d..93a95c0a3 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -662,11 +662,53 @@ describe("session failure diagnostics", () => { await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to dispose session during model fallback swap: dispose failed"), + expect.stringContaining("Failed to dispose session during swap: dispose failed"), ); warnSpy.mockRestore(); }); + + it("retries prompt on thinking/reasoning conflict without switching fallback models", async () => { + const createAgentSessionMock = vi.mocked(createAgentSession); + + const firstSession = { + model: { provider: "test", id: "primary-model" }, + prompt: vi.fn().mockRejectedValue(new Error("400 cannot specify both 'thinking' and 'reasoning_effort'")), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: vi.fn(), + sessionFile: undefined, + } as unknown as AgentSession; + + const retrySession = { + model: { provider: "test", id: "primary-model" }, + prompt: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: vi.fn(), + sessionFile: undefined, + } as unknown as AgentSession; + + createAgentSessionMock.mockReset(); + createAgentSessionMock + .mockResolvedValueOnce({ session: firstSession } as any) + .mockResolvedValueOnce({ session: retrySession } as any); + + const { session } = await createFnAgent({ + cwd: "/test/project", + systemPrompt: "Test thinking compatibility", + defaultProvider: "test", + defaultModelId: "primary-model", + fallbackProvider: "test", + fallbackModelId: "fallback-model", + defaultThinkingLevel: "high", + }); + + await expect((session as any).promptWithFallback("Run review")).resolves.toBeUndefined(); + + expect(createAgentSessionMock).toHaveBeenCalledTimes(2); + expect((retrySession.setThinkingLevel as any).mock.calls.length).toBe(0); + }); }); describe("piLog structured diagnostics", () => { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 2c4dad35f..a17ec5104 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -130,6 +130,10 @@ function clearSessionStateError(session: AgentSession): void { } } +function isThinkingReasoningConflictError(message: string): boolean { + return /cannot specify both\s+['"]?thinking['"]?\s+and\s+['"]?reasoning_effort['"]?/i.test(message); +} + async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise { clearSessionStateError(session); if (options === undefined) { @@ -1283,21 +1287,83 @@ export async function createFnAgent(options: AgentOptions): Promise piLog.log("Fallback session created successfully"); } - const { session } = sessionResult; - installToolResultContentGuard(session as AgentToolHookSession); - installMessageContentGuard(session as AgentToolHookSession, sessionManager as unknown as SessionManagerLike); - (session as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; - const promptableSession = session as PromptableSession; + let activeSession = sessionResult.session; + installToolResultContentGuard(activeSession as AgentToolHookSession); + installMessageContentGuard(activeSession as AgentToolHookSession, sessionManager as unknown as SessionManagerLike); + (activeSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; + const promptableSession = activeSession as PromptableSession; + + let thinkingCompatibilityDisabled = false; + const applyThinkingLevelIfSupported = (targetSession: AgentSession, sourceModel: string): void => { + if (!options.defaultThinkingLevel || thinkingCompatibilityDisabled) { + return; + } + try { + (targetSession as PromptableSession).setThinkingLevel(options.defaultThinkingLevel as any); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (!isThinkingReasoningConflictError(message)) { + throw err; + } + thinkingCompatibilityDisabled = true; + piLog.warn(`Disabling explicit thinking level for model ${sourceModel}: ${message}`); + } + }; + + const wireFallbackHooks = (targetSession: PromptableSession): void => { + installToolResultContentGuard(targetSession as unknown as AgentToolHookSession); + installMessageContentGuard( + targetSession as unknown as AgentToolHookSession, + sessionManager as unknown as SessionManagerLike, + ); + (targetSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; + targetSession.subscribe((event) => { + if (event.type === "message_update") { + const msgEvent = event.assistantMessageEvent; + if (msgEvent.type === "text_delta") { + options.onText?.(msgEvent.delta); + } else if (msgEvent.type === "thinking_delta") { + options.onThinking?.(msgEvent.delta); + } + } + if (event.type === "tool_execution_start") { + options.onToolStart?.(event.toolName, event.args as Record | undefined); + } + if (event.type === "tool_execution_end") { + options.onToolEnd?.(event.toolName, event.isError, event.result); + } + }); + }; + + const swapPromptSession = async (modelToUse: typeof selectedModel): Promise => { + if (!modelToUse) { + throw new Error("Cannot swap session without a resolved model"); + } + try { + activeSession.dispose(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + piLog.warn(`Failed to dispose session during swap: ${msg}`); + } + const next = (await createSessionWithModel(modelToUse)).session as PromptableSession; + wireFallbackHooks(next); + applyThinkingLevelIfSupported(next, `${modelToUse.provider}/${modelToUse.id}`); + Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(next)); + Object.assign(promptableSession, next); + promptableSession.promptWithFallback = next.promptWithFallback ?? promptableSession.promptWithFallback; + activeSession = next; + return next; + }; promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => { try { - await promptSessionAndCheck(session, prompt, promptOptions); + await promptSessionAndCheck(activeSession, prompt, promptOptions); return; } catch (err: any) { const errorMessage = err?.message || ""; if (isContextLimitError(errorMessage)) { // Context limit error — attempt auto-compaction and retry once - const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions); + const promptMemoryRetry = await retryWithCompactedPromptMemory(activeSession, prompt, promptOptions); if (promptMemoryRetry.recovered) { return; } @@ -1309,12 +1375,12 @@ export async function createFnAgent(options: AgentOptions): Promise } piLog.warn("promptWithFallback: context limit error — attempting auto-compaction"); - await flushMemoryBeforeSessionCompaction(session); - const compactResult = await compactSessionContext(session); + await flushMemoryBeforeSessionCompaction(activeSession); + const compactResult = await compactSessionContext(activeSession); if (compactResult) { piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`); try { - await promptSessionAndCheck(session, prompt, promptOptions); + await promptSessionAndCheck(activeSession, prompt, promptOptions); return; } catch (retryErr: any) { const retryErrorMessage = retryErr?.message || ""; @@ -1328,52 +1394,21 @@ export async function createFnAgent(options: AgentOptions): Promise } } + if (!usingFallback && options.defaultThinkingLevel && !thinkingCompatibilityDisabled && isThinkingReasoningConflictError(errorMessage)) { + thinkingCompatibilityDisabled = true; + piLog.warn(`Prompt failed with thinking/reasoning conflict; retrying without explicit thinking level: ${errorMessage}`); + const recoveredSession = await swapPromptSession(selectedModel); + await promptSessionAndCheck(recoveredSession, prompt, promptOptions); + return; + } + if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) { throw err; } usingFallback = true; - try { - session.dispose(); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - piLog.warn(`Failed to dispose session during model fallback swap: ${msg}`); - } - - const fallbackSessionResult = await createSessionWithModel(fallbackModel); + const fallbackSession = await swapPromptSession(fallbackModel); await emitFallbackUsed("prompt-time"); - const fallbackSession = fallbackSessionResult.session as PromptableSession; - installToolResultContentGuard(fallbackSession as unknown as AgentToolHookSession); - installMessageContentGuard( - fallbackSession as unknown as AgentToolHookSession, - sessionManager as unknown as SessionManagerLike, - ); - (fallbackSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; - - if (options.defaultThinkingLevel) { - fallbackSession.setThinkingLevel(options.defaultThinkingLevel as any); - } - - fallbackSession.subscribe((event) => { - if (event.type === "message_update") { - const msgEvent = event.assistantMessageEvent; - if (msgEvent.type === "text_delta") { - options.onText?.(msgEvent.delta); - } else if (msgEvent.type === "thinking_delta") { - options.onThinking?.(msgEvent.delta); - } - } - if (event.type === "tool_execution_start") { - options.onToolStart?.(event.toolName, event.args as Record | undefined); - } - if (event.type === "tool_execution_end") { - options.onToolEnd?.(event.toolName, event.isError, event.result); - } - }); - - Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(fallbackSession)); - Object.assign(promptableSession, fallbackSession); - promptableSession.promptWithFallback = fallbackSession.promptWithFallback ?? promptableSession.promptWithFallback; // Retry with fallback model, also with auto-compaction support try { @@ -1416,10 +1451,11 @@ export async function createFnAgent(options: AgentOptions): Promise } }; - // Apply thinking level if specified - if (options.defaultThinkingLevel) { - promptableSession.setThinkingLevel(options.defaultThinkingLevel as any); - } + // Apply thinking level if specified (with compatibility fallback). + applyThinkingLevelIfSupported( + promptableSession, + selectedModel ? `${selectedModel.provider}/${selectedModel.id}` : describeModel(promptableSession), + ); // Wire up event listeners promptableSession.subscribe((event) => {