From 190cc041bce6bb8480444d15e0fa659f14789514 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 22:32:49 -0700 Subject: [PATCH] fix(lanes): resolve a model pair everywhere a session is constructed Audit of the class behind the planning bug: createFnAgent forwards no model unless both defaultProvider and defaultModelId are set, after which pi-coding-agent picks its own built-in default (anthropic/claude-opus-4-8). Seven lanes resolved no pair at all, so they hit that path on every call -- a permanent 401 invalid x-api-key for custom-provider and subscription operators, and a hole in test-mode forcing: - milestone/slice interviews (no model plumbing at all) - subtask breakdown, triage and streaming paths - agent generation - text refine and goal drafting - agent reflection (optional ctor pair no production caller supplies) Two more resolved the halves independently, which the runtime treats as unset: research synthesis defaults and pr-conflict-resolver's hand-rolled copy of resolveProjectDefaultModel (which also skipped test-mode overrides). Add lane-session-model.ts as the shared resolver and a source ratchet that fails when a dashboard session is constructed from an inline literal with no model decision. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lane-model-pair-resolution.md | 7 + .../__tests__/lane-model-pair-ratchet.test.ts | 132 ++++++++++++++++++ packages/dashboard/src/agent-generation.ts | 10 ++ packages/dashboard/src/ai-refine.ts | 19 +++ packages/dashboard/src/lane-session-model.ts | 75 ++++++++++ .../src/milestone-slice-interview.ts | 26 ++++ .../dashboard/src/pr-conflict-resolver.ts | 24 ++-- packages/dashboard/src/subtask-breakdown.ts | 20 ++- packages/engine/src/agent-reflection.ts | 35 ++++- .../engine/src/research/provider-registry.ts | 19 ++- 10 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 .changeset/lane-model-pair-resolution.md create mode 100644 packages/dashboard/src/__tests__/lane-model-pair-ratchet.test.ts create mode 100644 packages/dashboard/src/lane-session-model.ts diff --git a/.changeset/lane-model-pair-resolution.md b/.changeset/lane-model-pair-resolution.md new file mode 100644 index 0000000000..f4e222f24f --- /dev/null +++ b/.changeset/lane-model-pair-resolution.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: AI helper lanes now run on your configured model instead of silently falling back to a default Anthropic model. +category: fix +dev: `createFnAgent`/`createResolvedAgentSession` forward no model unless BOTH `defaultProvider` and `defaultModelId` are set, after which pi-coding-agent picks its own built-in default (`anthropic/claude-opus-4-8`). Milestone/slice interviews, subtask breakdown (triage + streaming), agent generation, text refine, goal drafting, and agent reflection all resolved no pair and hit that path on every call — a permanent `401 invalid x-api-key` for custom-provider/subscription operators and a hole in test-mode forcing. All now resolve through the shared `resolveLaneSessionModel` (dashboard) or `resolveProjectDefaultModel` (engine). Also pairs the research synthesis provider/model halves and replaces `pr-conflict-resolver`'s hand-rolled default resolution. A source ratchet (`lane-model-pair-ratchet.test.ts`) keeps new dashboard lanes from reintroducing the pattern. diff --git a/packages/dashboard/src/__tests__/lane-model-pair-ratchet.test.ts b/packages/dashboard/src/__tests__/lane-model-pair-ratchet.test.ts new file mode 100644 index 0000000000..9ec90a68ab --- /dev/null +++ b/packages/dashboard/src/__tests__/lane-model-pair-ratchet.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node + +/* +FNXC:LaneModelResolution 2026-07-24-17:40: +Ratchet for the "silent runtime default model" bug class. + +`createFnAgent` / `createResolvedAgentSession` forward NO model to the runtime unless BOTH +`defaultProvider` and `defaultModelId` are present (`resolveConfiguredModel` in pi.ts returns +undefined for a half-set pair, and `createSessionWithModel` spreads `model` only when truthy). +pi-coding-agent then selects its OWN built-in default — `anthropic/claude-opus-4-8` — so a +call site that passes no pair silently leaves the operator's configured provider and issues a +direct Anthropic call. Symptoms: `401 invalid x-api-key` for custom-provider, subscription, and +CLI-runtime operators on a model they never selected, plus a hole in `testMode` forcing. + +This scans dashboard source for session-construction call sites and requires each to make a +model decision visible — either passing a pair, spreading one in, or appearing on the +allowlist below with a reason. It scans the dashboard package, where every instance of this +class was found; the engine task lanes (executor, reviewer, merger, triage, heartbeat) all +resolve through `resolve*SettingsModel` helpers and were audited clean. It is a source ratchet, not a behavior test: it exists so a new +lane cannot quietly re-introduce the pattern, which is how this bug reached three separate +lanes before anyone noticed. +*/ + +import { describe, expect, it } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SRC_DIR = join(fileURLToPath(new URL("../", import.meta.url))); +const CONSTRUCTORS = ["createFnAgent(", "createResolvedAgentSession("]; + +/** + * Call sites that deliberately pass no model pair. Each entry needs a reason; an entry whose + * file no longer contains a bare call site is reported as stale so the list cannot rot. + */ +const ALLOWLIST: Record = {}; + +function listSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === "__tests__" || entry === "node_modules" || entry === "dist") continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...listSourceFiles(full)); + } else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts")) { + out.push(full); + } + } + return out; +} + +/** Slice from a call's opening paren to its matching close, so nested objects stay inside. */ +function callArgumentText(source: string, openParenIndex: number): string { + let depth = 0; + for (let i = openParenIndex; i < source.length; i++) { + const ch = source[i]; + if (ch === "(" || ch === "{" || ch === "[") depth++; + else if (ch === ")" || ch === "}" || ch === "]") { + depth--; + if (depth === 0) return source.slice(openParenIndex, i + 1); + } + } + return source.slice(openParenIndex); +} + +interface BareCallSite { + file: string; + line: number; +} + +function findBareCallSites(): BareCallSite[] { + const bare: BareCallSite[] = []; + for (const file of listSourceFiles(SRC_DIR)) { + const source = readFileSync(file, "utf-8"); + for (const constructor of CONSTRUCTORS) { + let index = source.indexOf(constructor); + while (index !== -1) { + const openParen = index + constructor.length - 1; + const args = callArgumentText(source, openParen); + /* + A site is satisfied when the model decision is visible at the call: + - an explicit `defaultProvider` key (including the conditional-assignment shape where + it is set on a prepared options object in the same file); + - `...laneModelOptions(model)`, the shared helper; + - any spread, which hands a prepared options object through — the pair is then the + responsibility of whoever built it (chat.ts's `sessionOptions`, for example); + - a non-literal argument (`createFnAgent(agentOptions)`), which cannot be checked + statically from the call site alone. + This is deliberately permissive about HOW the pair arrives and strict only about the + shape this bug class actually took: an inline object literal with no model at all. + */ + const isInlineLiteral = args.replace(/\s/g, "").startsWith("({"); + const declaresModel = !isInlineLiteral + || args.includes("defaultProvider") + || args.includes("laneModelOptions(") + || args.includes("..."); + if (!declaresModel) { + bare.push({ + file: file.slice(SRC_DIR.length), + line: source.slice(0, index).split("\n").length, + }); + } + index = source.indexOf(constructor, index + constructor.length); + } + } + } + return bare; +} + +describe("AI session construction always makes a model decision", () => { + it("has no unallowlisted call site that omits the provider/model pair", () => { + const offenders = findBareCallSites().filter( + (site) => !Object.keys(ALLOWLIST).some((allowed) => site.file.endsWith(allowed)), + ); + + expect( + offenders.map((site) => `${site.file}:${site.line}`), + "These sites reach the runtime with no provider/model pair, so pi substitutes its own " + + "built-in anthropic default and the operator's configured provider is bypassed. " + + "Resolve a pair (see lane-session-model.ts) or add an allowlist entry with a reason.", + ).toEqual([]); + }); + + it("keeps the allowlist free of stale entries", () => { + const bareFiles = new Set(findBareCallSites().map((site) => site.file)); + const stale = Object.keys(ALLOWLIST).filter( + (allowed) => ![...bareFiles].some((file) => file.endsWith(allowed)), + ); + + expect(stale, "Allowlisted files no longer have a bare call site — drop them.").toEqual([]); + }); +}); diff --git a/packages/dashboard/src/agent-generation.ts b/packages/dashboard/src/agent-generation.ts index 68ee31a4c9..f7d00277a3 100644 --- a/packages/dashboard/src/agent-generation.ts +++ b/packages/dashboard/src/agent-generation.ts @@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto"; import type { TaskStore } from "@fusion/core"; import { createSessionDiagnostics, nonfatal } from "./ai-session-diagnostics.js"; import { registerBeforeExitCleanup } from "./process-lifecycle.js"; +import { laneModelOptions, resolveLaneSessionModel } from "./lane-session-model.js"; // Dynamic import for @fusion/core to get prompt override resolution @@ -505,11 +506,20 @@ async function generateSpecWithAI( * FNXC:McpConfig 2026-06-26-16:58: * Agent onboarding generation is a tools:none readonly helper, but routes can provide a dashboard-scoped TaskStore. Forward the resolved in-memory MCP server set consistently without changing tool semantics; no-store callers remain empty and secrets are never logged. */ + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + Resolve an explicit provider/model pair. Without one the runtime silently substitutes its + own built-in default model (anthropic/claude-opus-4-8), leaving the operator's configured + provider and failing with `401 invalid x-api-key` for anyone with no raw Anthropic key — + and bypassing test-mode forcing. See lane-session-model.ts. + */ + const generationModel = await resolveLaneSessionModel(store); const agent = await createFnAgent({ cwd: rootDir, systemPrompt: effectiveSystemPrompt, tools: "none", mcpServers, + ...laneModelOptions(generationModel), }); try { diff --git a/packages/dashboard/src/ai-refine.ts b/packages/dashboard/src/ai-refine.ts index 071bd9c211..e0909a866a 100644 --- a/packages/dashboard/src/ai-refine.ts +++ b/packages/dashboard/src/ai-refine.ts @@ -16,6 +16,7 @@ import { resolvePrompt } from "@fusion/core"; import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { registerBeforeExitCleanup } from "./process-lifecycle.js"; +import { laneModelOptions, resolveLaneSessionModel } from "./lane-session-model.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createFnAgent: any = engineCreateFnAgent; @@ -338,11 +339,20 @@ export async function refineText( * FNXC:McpConfig 2026-06-26-16:55: * Text refinement is a readonly dashboard helper that receives the request-scoped TaskStore from routes. Resolve configured MCP servers at session creation and forward only the in-memory server set; keep no-store callers on an empty set and never log materialized secrets. */ + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + Resolve an explicit provider/model pair. Without one the runtime silently substitutes its + own built-in default model (anthropic/claude-opus-4-8), leaving the operator's configured + provider and failing with `401 invalid x-api-key` for anyone with no raw Anthropic key — + and bypassing test-mode forcing. See lane-session-model.ts. + */ + const refineModel = await resolveLaneSessionModel(store); const agentResult = await createFnAgent({ cwd: rootDir, systemPrompt: effectivePrompt, tools: "readonly", mcpServers, + ...laneModelOptions(refineModel), }); if (!agentResult?.session) { @@ -411,11 +421,20 @@ export async function draftGoalDescription( * FNXC:McpConfig 2026-06-26-16:55: * Goal description drafting shares the text-refine readonly helper seam and now resolves MCP from the dashboard-scoped TaskStore when routes can provide it. No-store callers intentionally receive an empty server set; do not log env/header secret values. */ + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + Resolve an explicit provider/model pair. Without one the runtime silently substitutes its + own built-in default model (anthropic/claude-opus-4-8), leaving the operator's configured + provider and failing with `401 invalid x-api-key` for anyone with no raw Anthropic key — + and bypassing test-mode forcing. See lane-session-model.ts. + */ + const goalDraftModel = await resolveLaneSessionModel(store); const agentResult = await createFnAgent({ cwd: rootDir, systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT, tools: "readonly", mcpServers, + ...laneModelOptions(goalDraftModel), }); if (!agentResult?.session) { diff --git a/packages/dashboard/src/lane-session-model.ts b/packages/dashboard/src/lane-session-model.ts new file mode 100644 index 0000000000..663609f535 --- /dev/null +++ b/packages/dashboard/src/lane-session-model.ts @@ -0,0 +1,75 @@ +/** + * Shared provider/model resolution for dashboard AI helper lanes. + * + * FNXC:LaneModelResolution 2026-07-24-17:40: + * `createFnAgent`/`createResolvedAgentSession` forward NO model to the runtime unless BOTH + * `defaultProvider` and `defaultModelId` are set (`resolveConfiguredModel` in pi.ts returns + * undefined for a half-set pair, and `createSessionWithModel` spreads the override only when + * it is truthy). The runtime then silently picks its OWN built-in default — + * `anthropic/claude-opus-4-8` — so a lane that resolves no pair leaves the operator's + * configured provider entirely and issues a direct Anthropic call. For anyone without a raw + * Anthropic API key (custom-provider, subscription, and CLI-runtime operators) that surfaces + * as `401 invalid x-api-key` from a model they never selected. It also means `testMode` + * cannot force such a lane onto the mock provider. + * + * Dashboard helper lanes (interviews, refine, translate, subtask breakdown, agent generation) + * are planning-adjacent, so they resolve the planning lane pair — the same helper the planning + * routes use — with test-mode overrides applied by `resolvePlanningSettingsModel` itself. + * + * Both halves are required: a half-set pair is treated as unset because that is exactly how + * the runtime treats it, and pretending otherwise just moves the silent fallthrough. + */ +import { resolvePlanningSettingsModel, type TaskStore } from "@fusion/core"; + +export interface LaneSessionModel { + provider?: string; + modelId?: string; +} + +/** Runtime-ready options fragment: present only when a COMPLETE pair resolved. */ +export function laneModelOptions(model: LaneSessionModel): { + defaultProvider?: string; + defaultModelId?: string; +} { + return model.provider && model.modelId + ? { defaultProvider: model.provider, defaultModelId: model.modelId } + : {}; +} + +/** + * Resolve the planning-lane provider/model pair for a helper session. + * + * @param store - Task store to read effective settings from. Optional so callers with no + * store degrade to the previous behavior rather than throwing. + * @param cached - A pair already pinned to this session (keeps a multi-turn session on one + * model even if settings change mid-flight, and survives agent rebuilds). + * @param onUnresolved - Invoked when no complete pair could be resolved, so the lane can warn + * through its own diagnostics sink instead of failing silently onto the runtime default. + */ +export async function resolveLaneSessionModel( + store: TaskStore | undefined, + cached?: LaneSessionModel, + onUnresolved?: (reason: "no-store" | "unset" | "error", error?: unknown) => void, +): Promise { + if (cached?.provider && cached?.modelId) { + return { provider: cached.provider, modelId: cached.modelId }; + } + + if (!store) { + onUnresolved?.("no-store"); + return {}; + } + + try { + const settings = await store.getSettings(); + const resolved = resolvePlanningSettingsModel(settings); + if (resolved.provider && resolved.modelId) { + return { provider: resolved.provider, modelId: resolved.modelId }; + } + onUnresolved?.("unset"); + } catch (error) { + onUnresolved?.("error", error); + } + + return {}; +} diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index a5701849c8..40238635db 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -106,6 +106,7 @@ export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse }; import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { createPlanningBoardTools } from "./planning-board-tools.js"; +import { laneModelOptions, resolveLaneSessionModel } from "./lane-session-model.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AgentResult = any; @@ -316,6 +317,14 @@ interface TargetInterviewSession { /** Last terminal error for retry UX */ error?: string; agent?: AgentResult; + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + The provider/model pair this interview runs on, pinned on first resolution so every rebuild + (retry, resumed session) stays on the same model instead of falling through to the runtime's + built-in Anthropic default. See lane-session-model.ts. + */ + modelProvider?: string; + modelId?: string; thinkingOutput: string; /** Thinking output generated while producing currentQuestion */ lastGeneratedThinking: string; @@ -816,11 +825,28 @@ export async function createTargetInterviewAgent( */ const mcpServers = (await resolveMcpServersForStore(store)).servers; + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + This lane previously passed NO provider/model pair at all, so every milestone/slice + interview turn ran on the runtime's built-in default model regardless of the operator's + configured provider — a permanent `401 invalid x-api-key` for anyone without a raw + Anthropic key, and a hole in test-mode forcing. Resolve the planning pair here (covering + both the first turn and the rebuild call sites) and pin it to the session. + */ + const model = await resolveLaneSessionModel( + store, + { provider: session.modelProvider, modelId: session.modelId }, + (reason) => diagnostics.warn("Milestone/slice interview has no resolved provider/model pair; the runtime will use its built-in default model", { sessionId: session.id, operation: "resolve-interview-model", reason }), + ); + session.modelProvider = model.provider; + session.modelId = model.modelId; + return createFnAgent({ cwd: rootDir, systemPrompt: getSystemPrompt(session.targetType), tools: "readonly", mcpServers, + ...laneModelOptions(model), allowMcpToolsInReadonly: true, customTools: [...createPlanningBoardTools(store)], /* diff --git a/packages/dashboard/src/pr-conflict-resolver.ts b/packages/dashboard/src/pr-conflict-resolver.ts index 2088c2933c..6ec6b97a97 100644 --- a/packages/dashboard/src/pr-conflict-resolver.ts +++ b/packages/dashboard/src/pr-conflict-resolver.ts @@ -1,6 +1,7 @@ import { access, mkdir, readFile, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { Settings, TaskStore } from "@fusion/core"; +import { resolveProjectDefaultModel } from "@fusion/core"; import { createResolvedAgentSession, resolveMcpServersForStore, type PluginRunner } from "@fusion/engine"; import { runGitCommand } from "./routes/resolve-diff-base.js"; @@ -56,17 +57,20 @@ function getHeadBranch(taskId: string): string { return `fusion/${taskId.toLowerCase()}`; } +/* +FNXC:LaneModelResolution 2026-07-24-17:40: +Delegate to the shared core resolver instead of hand-rolling the override→default chain. +The local copy drifted in two ways: it never applied `applyTestModeOverrides`, so a project +with `testMode: true` could still issue a real provider call from PR conflict resolution, and +it returned the two halves independently, so a settings row with only one half set propagated +a half-set pair — which the runtime treats as unset and silently replaces with its own +built-in Anthropic default. `resolveProjectDefaultModel` handles both. +*/ function getDefaultSessionModel(settings: Settings): { provider: string | undefined; modelId: string | undefined } { - if (settings.defaultProviderOverride && settings.defaultModelIdOverride) { - return { - provider: settings.defaultProviderOverride, - modelId: settings.defaultModelIdOverride, - }; - } - return { - provider: settings.defaultProvider, - modelId: settings.defaultModelId, - }; + const resolved = resolveProjectDefaultModel(settings); + return resolved.provider && resolved.modelId + ? { provider: resolved.provider, modelId: resolved.modelId } + : { provider: undefined, modelId: undefined }; } async function pathExists(path: string): Promise { diff --git a/packages/dashboard/src/subtask-breakdown.ts b/packages/dashboard/src/subtask-breakdown.ts index 14eedcf99a..856751dd81 100644 --- a/packages/dashboard/src/subtask-breakdown.ts +++ b/packages/dashboard/src/subtask-breakdown.ts @@ -10,6 +10,7 @@ import { resetDiagnosticsSink, } from "./ai-session-diagnostics.js"; import { GenerationGuard, createAbortError, isAbortError } from "./ai-session-timeout.js"; +import { laneModelOptions, resolveLaneSessionModel } from "./lane-session-model.js"; import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; @@ -411,7 +412,15 @@ export async function decomposeForTriage( * FNXC:McpConfig 2026-06-26-16:45: * Triage subtask decomposition is a readonly planning helper; when the dashboard triage hook provides a scoped store, resolve MCP at session creation and forward only the in-memory server set. Keep no-store callers on an empty MCP set and never log materialized secrets. */ - const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly", mcpServers }); + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + Resolve an explicit provider/model pair. Without one the runtime silently substitutes its + own built-in default model (anthropic/claude-opus-4-8), leaving the operator's configured + provider and failing with `401 invalid x-api-key` for anyone with no raw Anthropic key — + and bypassing test-mode forcing. See lane-session-model.ts. + */ + const subtaskModel = await resolveLaneSessionModel(store); + const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly", mcpServers, ...laneModelOptions(subtaskModel) }); try { await agent.session.prompt(description); const messages = agent.session.state.messages as Array<{ @@ -538,11 +547,20 @@ async function generateSubtasks( FNXC:McpConfig 2026-06-26-16:45: Streaming subtask generation is a readonly planning helper that now carries the dashboard-scoped TaskStore into the timeout-bounded worker. Resolve MCP inside the GenerationGuard window and forward only counts/errors if diagnostics are added; never expose plaintext env/header secrets. */ + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + Resolve an explicit provider/model pair. Without one the runtime silently substitutes its + own built-in default model (anthropic/claude-opus-4-8), leaving the operator's configured + provider and failing with `401 invalid x-api-key` for anyone with no raw Anthropic key — + and bypassing test-mode forcing. See lane-session-model.ts. + */ + const streamingModel = await resolveLaneSessionModel(store); const agentPromise = createFnAgent({ cwd, systemPrompt, tools: "readonly", mcpServers, + ...laneModelOptions(streamingModel), onThinking: (delta: string) => { const current = sessions.get(sessionId); if (!current) return; diff --git a/packages/engine/src/agent-reflection.ts b/packages/engine/src/agent-reflection.ts index 23930f0d54..7ad3b90496 100644 --- a/packages/engine/src/agent-reflection.ts +++ b/packages/engine/src/agent-reflection.ts @@ -13,6 +13,7 @@ import type { TaskStore, } from "@fusion/core"; import { createLogger } from "./logger.js"; +import { resolveProjectDefaultModel } from "@fusion/core"; import { createFnAgent, promptWithFallback } from "./pi.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { createRunAuditor, generateSyntheticRunId, type EngineRunContext, type RunAuditor } from "./run-audit.js"; @@ -78,6 +79,7 @@ export class AgentReflectionService { private readonly reflectionStore: ReflectionStore; private readonly rootDir: string; private readonly modelProvider?: string; + private readonly modelId?: string; constructor(options: AgentReflectionServiceOptions) { @@ -89,6 +91,23 @@ export class AgentReflectionService { this.modelId = options.modelId; } + private async resolveReflectionModel(): Promise<{ provider?: string; modelId?: string }> { + if (this.modelProvider && this.modelId) { + return { provider: this.modelProvider, modelId: this.modelId }; + } + try { + const settings = await this.taskStore.getSettings(); + const resolved = resolveProjectDefaultModel(settings); + if (resolved.provider && resolved.modelId) { + return { provider: resolved.provider, modelId: resolved.modelId }; + } + } catch (error) { + reflectionLog.warn(`Failed to resolve reflection model from settings: ${String(error)}`); + } + reflectionLog.warn("No provider/model pair resolved for reflection; the runtime will use its built-in default model"); + return {}; + } + async generateReflection( agentId: string, trigger: ReflectionTrigger, @@ -117,12 +136,24 @@ export class AgentReflectionService { let responseText = ""; // FNXC:McpConfig 2026-06-25-23:05: Agent-reflection sessions receive the resolved MCP set for the reflected agent identity while preserving the no-secret-logging contract at the runtime forwarding seam. + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + `modelProvider`/`modelId` are optional constructor options that NO production caller + supplies (in-process runtime and both dashboard reflection routes construct this service + with only the stores + rootDir), so every reflection ran on the runtime's own built-in + default model — leaving the operator's configured provider, failing with + `401 invalid x-api-key` where no raw Anthropic key exists, and bypassing test-mode + forcing. Fall back to the project default pair when no explicit pair was injected. + Both halves are required because the runtime treats a half-set pair as unset. + */ + const reflectionModel = await this.resolveReflectionModel(); const { session } = await createFnAgent({ cwd: this.rootDir, systemPrompt: REFLECTION_SYSTEM_PROMPT, tools: "readonly", - defaultProvider: this.modelProvider, - defaultModelId: this.modelId, + ...(reflectionModel.provider && reflectionModel.modelId + ? { defaultProvider: reflectionModel.provider, defaultModelId: reflectionModel.modelId } + : {}), mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId })).servers, onText: (delta: string) => { responseText += delta; diff --git a/packages/engine/src/research/provider-registry.ts b/packages/engine/src/research/provider-registry.ts index 9ac5dd909e..11c4055f72 100644 --- a/packages/engine/src/research/provider-registry.ts +++ b/packages/engine/src/research/provider-registry.ts @@ -47,8 +47,23 @@ export class ResearchProviderRegistry { const maxResults = Number(this.settings.researchGlobalMaxSearchResults ?? 10); const fetchTimeoutMs = Number(this.settings.researchGlobalFetchTimeoutMs ?? 30_000); const userAgent = this.settings.researchGlobalUserAgent ?? "FusionResearchBot/1.0"; - const synthesisProvider = this.settings.researchGlobalDefaults?.synthesisProvider ?? this.settings.defaultProvider; - const synthesisModelId = this.settings.researchGlobalDefaults?.synthesisModelId ?? this.settings.defaultModelId; + /* + FNXC:LaneModelResolution 2026-07-24-17:40: + Resolve the synthesis provider and model as a PAIR. Resolving each half independently let a + `researchGlobalDefaults.synthesisProvider` with no `synthesisModelId` (or the reverse) produce + a half-set pair, which the runtime treats as fully unset (`resolveConfiguredModel` returns + undefined unless BOTH are present) and silently replaces with its own built-in Anthropic + default. Fall back to the project default pair only when the research override is incomplete. + */ + const researchSynthesisPair = this.settings.researchGlobalDefaults?.synthesisProvider + && this.settings.researchGlobalDefaults?.synthesisModelId + ? { + provider: this.settings.researchGlobalDefaults.synthesisProvider, + modelId: this.settings.researchGlobalDefaults.synthesisModelId, + } + : undefined; + const synthesisProvider = researchSynthesisPair?.provider ?? this.settings.defaultProvider; + const synthesisModelId = researchSynthesisPair?.modelId ?? this.settings.defaultModelId; this.providers = new Map([ [