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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-24 22:32:49 -07:00
parent 8dc6598aa0
commit 190cc041bc
10 changed files with 352 additions and 15 deletions

View File

@@ -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.

View File

@@ -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<string, string> = {};
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([]);
});
});

View File

@@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto";
import type { TaskStore } from "@fusion/core"; import type { TaskStore } from "@fusion/core";
import { createSessionDiagnostics, nonfatal } from "./ai-session-diagnostics.js"; import { createSessionDiagnostics, nonfatal } from "./ai-session-diagnostics.js";
import { registerBeforeExitCleanup } from "./process-lifecycle.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 // Dynamic import for @fusion/core to get prompt override resolution
@@ -505,11 +506,20 @@ async function generateSpecWithAI(
* FNXC:McpConfig 2026-06-26-16:58: * 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. * 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({ const agent = await createFnAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt: effectiveSystemPrompt, systemPrompt: effectiveSystemPrompt,
tools: "none", tools: "none",
mcpServers, mcpServers,
...laneModelOptions(generationModel),
}); });
try { try {

View File

@@ -16,6 +16,7 @@ import { resolvePrompt } from "@fusion/core";
import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine";
import { registerBeforeExitCleanup } from "./process-lifecycle.js"; import { registerBeforeExitCleanup } from "./process-lifecycle.js";
import { laneModelOptions, resolveLaneSessionModel } from "./lane-session-model.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const createFnAgent: any = engineCreateFnAgent; const createFnAgent: any = engineCreateFnAgent;
@@ -338,11 +339,20 @@ export async function refineText(
* FNXC:McpConfig 2026-06-26-16:55: * 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. * 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({ const agentResult = await createFnAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt: effectivePrompt, systemPrompt: effectivePrompt,
tools: "readonly", tools: "readonly",
mcpServers, mcpServers,
...laneModelOptions(refineModel),
}); });
if (!agentResult?.session) { if (!agentResult?.session) {
@@ -411,11 +421,20 @@ export async function draftGoalDescription(
* FNXC:McpConfig 2026-06-26-16:55: * 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. * 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({ const agentResult = await createFnAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT, systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT,
tools: "readonly", tools: "readonly",
mcpServers, mcpServers,
...laneModelOptions(goalDraftModel),
}); });
if (!agentResult?.session) { if (!agentResult?.session) {

View File

@@ -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<LaneSessionModel> {
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 {};
}

View File

@@ -106,6 +106,7 @@ export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse };
import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine";
import { createPlanningBoardTools } from "./planning-board-tools.js"; import { createPlanningBoardTools } from "./planning-board-tools.js";
import { laneModelOptions, resolveLaneSessionModel } from "./lane-session-model.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type AgentResult = any; type AgentResult = any;
@@ -316,6 +317,14 @@ interface TargetInterviewSession {
/** Last terminal error for retry UX */ /** Last terminal error for retry UX */
error?: string; error?: string;
agent?: AgentResult; 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; thinkingOutput: string;
/** Thinking output generated while producing currentQuestion */ /** Thinking output generated while producing currentQuestion */
lastGeneratedThinking: string; lastGeneratedThinking: string;
@@ -816,11 +825,28 @@ export async function createTargetInterviewAgent(
*/ */
const mcpServers = (await resolveMcpServersForStore(store)).servers; 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({ return createFnAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt: getSystemPrompt(session.targetType), systemPrompt: getSystemPrompt(session.targetType),
tools: "readonly", tools: "readonly",
mcpServers, mcpServers,
...laneModelOptions(model),
allowMcpToolsInReadonly: true, allowMcpToolsInReadonly: true,
customTools: [...createPlanningBoardTools(store)], customTools: [...createPlanningBoardTools(store)],
/* /*

View File

@@ -1,6 +1,7 @@
import { access, mkdir, readFile, rm } from "node:fs/promises"; import { access, mkdir, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import type { Settings, TaskStore } from "@fusion/core"; import type { Settings, TaskStore } from "@fusion/core";
import { resolveProjectDefaultModel } from "@fusion/core";
import { createResolvedAgentSession, resolveMcpServersForStore, type PluginRunner } from "@fusion/engine"; import { createResolvedAgentSession, resolveMcpServersForStore, type PluginRunner } from "@fusion/engine";
import { runGitCommand } from "./routes/resolve-diff-base.js"; import { runGitCommand } from "./routes/resolve-diff-base.js";
@@ -56,17 +57,20 @@ function getHeadBranch(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`; 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 } { function getDefaultSessionModel(settings: Settings): { provider: string | undefined; modelId: string | undefined } {
if (settings.defaultProviderOverride && settings.defaultModelIdOverride) { const resolved = resolveProjectDefaultModel(settings);
return { return resolved.provider && resolved.modelId
provider: settings.defaultProviderOverride, ? { provider: resolved.provider, modelId: resolved.modelId }
modelId: settings.defaultModelIdOverride, : { provider: undefined, modelId: undefined };
};
}
return {
provider: settings.defaultProvider,
modelId: settings.defaultModelId,
};
} }
async function pathExists(path: string): Promise<boolean> { async function pathExists(path: string): Promise<boolean> {

View File

@@ -10,6 +10,7 @@ import {
resetDiagnosticsSink, resetDiagnosticsSink,
} from "./ai-session-diagnostics.js"; } from "./ai-session-diagnostics.js";
import { GenerationGuard, createAbortError, isAbortError } from "./ai-session-timeout.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"; import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine";
@@ -411,7 +412,15 @@ export async function decomposeForTriage(
* FNXC:McpConfig 2026-06-26-16:45: * 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. * 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 { try {
await agent.session.prompt(description); await agent.session.prompt(description);
const messages = agent.session.state.messages as Array<{ const messages = agent.session.state.messages as Array<{
@@ -538,11 +547,20 @@ async function generateSubtasks(
FNXC:McpConfig 2026-06-26-16:45: 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. 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({ const agentPromise = createFnAgent({
cwd, cwd,
systemPrompt, systemPrompt,
tools: "readonly", tools: "readonly",
mcpServers, mcpServers,
...laneModelOptions(streamingModel),
onThinking: (delta: string) => { onThinking: (delta: string) => {
const current = sessions.get(sessionId); const current = sessions.get(sessionId);
if (!current) return; if (!current) return;

View File

@@ -13,6 +13,7 @@ import type {
TaskStore, TaskStore,
} from "@fusion/core"; } from "@fusion/core";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import { resolveProjectDefaultModel } from "@fusion/core";
import { createFnAgent, promptWithFallback } from "./pi.js"; import { createFnAgent, promptWithFallback } from "./pi.js";
import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext, type RunAuditor } from "./run-audit.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 reflectionStore: ReflectionStore;
private readonly rootDir: string; private readonly rootDir: string;
private readonly modelProvider?: string; private readonly modelProvider?: string;
private readonly modelId?: string; private readonly modelId?: string;
constructor(options: AgentReflectionServiceOptions) { constructor(options: AgentReflectionServiceOptions) {
@@ -89,6 +91,23 @@ export class AgentReflectionService {
this.modelId = options.modelId; 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( async generateReflection(
agentId: string, agentId: string,
trigger: ReflectionTrigger, trigger: ReflectionTrigger,
@@ -117,12 +136,24 @@ export class AgentReflectionService {
let responseText = ""; 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: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({ const { session } = await createFnAgent({
cwd: this.rootDir, cwd: this.rootDir,
systemPrompt: REFLECTION_SYSTEM_PROMPT, systemPrompt: REFLECTION_SYSTEM_PROMPT,
tools: "readonly", tools: "readonly",
defaultProvider: this.modelProvider, ...(reflectionModel.provider && reflectionModel.modelId
defaultModelId: this.modelId, ? { defaultProvider: reflectionModel.provider, defaultModelId: reflectionModel.modelId }
: {}),
mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId })).servers, mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId })).servers,
onText: (delta: string) => { onText: (delta: string) => {
responseText += delta; responseText += delta;

View File

@@ -47,8 +47,23 @@ export class ResearchProviderRegistry {
const maxResults = Number(this.settings.researchGlobalMaxSearchResults ?? 10); const maxResults = Number(this.settings.researchGlobalMaxSearchResults ?? 10);
const fetchTimeoutMs = Number(this.settings.researchGlobalFetchTimeoutMs ?? 30_000); const fetchTimeoutMs = Number(this.settings.researchGlobalFetchTimeoutMs ?? 30_000);
const userAgent = this.settings.researchGlobalUserAgent ?? "FusionResearchBot/1.0"; 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<ResearchProviderType, ResearchProvider>([ this.providers = new Map<ResearchProviderType, ResearchProvider>([
[ [