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:
7
.changeset/lane-model-pair-resolution.md
Normal file
7
.changeset/lane-model-pair-resolution.md
Normal 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.
|
||||||
132
packages/dashboard/src/__tests__/lane-model-pair-ratchet.test.ts
Normal file
132
packages/dashboard/src/__tests__/lane-model-pair-ratchet.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
75
packages/dashboard/src/lane-session-model.ts
Normal file
75
packages/dashboard/src/lane-session-model.ts
Normal 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 {};
|
||||||
|
}
|
||||||
@@ -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)],
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>([
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user