diff --git a/.changeset/fn-8869-role-agent-project-model-override.md b/.changeset/fn-8869-role-agent-project-model-override.md new file mode 100644 index 0000000000..7c0aeef1a3 --- /dev/null +++ b/.changeset/fn-8869-role-agent-project-model-override.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Role-based permanent agents inherit project model override and thinking in Agents and Chat. +category: fix +dev: Shared resolvePermanentAgentEffectiveModel/Thinking in @fusion/core wires role inheritance into Chat, Agents, and model-less heartbeats. diff --git a/docs/agents.md b/docs/agents.md index cdf0aa98c5..1924676488 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -366,14 +366,13 @@ Executor precedence for task runs: If the assigned agent runtime model is missing or incomplete, Fusion continues to automatic provider/model resolution without mixing partial runtime fields into the selected pair. -### Durable-agent heartbeat model precedence and unavailable-provider behavior +### Permanent role-agent identity, chat, and heartbeat model inheritance -Heartbeat sessions for durable agents resolve models with the same fresh-settings-first rule: +For permanent role agents, the Agents page, Agent Detail, Chat session creation, direct chat, room responders, and model-less heartbeats share one identity resolution chain. Explicit session or room thinking wins, then an agent's explicit thinking, then its role lane, project default override, and global default. A complete per-agent runtime model (`runtimeConfig.model` or `modelProvider` + `modelId`) wins for agent-bound interactive sessions and heartbeats; incomplete pairs are ignored. -1. Execution-lane settings fallback (`executionProvider`/`executionModelId` → `executionGlobalProvider`/`executionGlobalModelId` → project/global defaults) -2. Agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) only when both provider and model ID are present and no execution/default pair is configured +Built-in workflow roles select their own lanes: Planner uses planning, Reviewer uses validator, Merger uses merger, and Executor/other permanent agents use execution. Thus an unset role lane inherits `defaultProviderOverride`/`defaultModelIdOverride` before global defaults. The Agent Detail picker labels an empty stored model as **Inherit project/role default**; saving inherit leaves the agent row empty rather than materialising the selected project model. -Heartbeat no longer passes a stale runtime model ahead of a saved execution lane or project default override. +Model-less durable-agent heartbeats use the same role-lane chain. Calling the low-level heartbeat resolver without role context deliberately retains its historical execution-lane behavior for compatibility. Task execution remains settings-first as described above and is not changed by identity inheritance. Task-scoped heartbeat runs for durable agents execute inside the task's git worktree (same as ephemeral task execution), while no-task heartbeat runs continue to execute from the project root. Heartbeat and executor system prompts share the same active-goal context injector (`buildGoalContextSection`), so both lanes receive identical goal preambles when active goals exist. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 3aeb4d95cb..773abe7e12 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -354,7 +354,9 @@ project workflow-lane baseline stored on the active default workflow → global → selected-workflow lane → project default override → global default. The task-detail Workflow, Chat, and Agent Log displays use this same effective model resolution, so their Plan/Triage, Executor, Reviewer, and fallback lanes match the -sessions that actually run. +sessions that actually run. Permanent role-agent identity surfaces (Agents and Chat) +also inherit their matching role lane; when it is unset, the project default override +feeds that inheritance before the global default. **Built-in prompt overrides.** Built-in workflow prompt/gate node text has a similar project-scoped persistence model, but it is separate from workflow settings: prompt overrides are stored per `(workflowId, nodeId, projectId)` and resolve as `stored prompt ?? shipped prompt`. Resetting a prompt deletes the stored node override and restores the built-in IR text; graph structure and setting declarations remain read-only for built-ins. See [Workflow Steps → Overriding built-in workflow prompts](./workflow-steps.md#overriding-built-in-workflow-prompts). diff --git a/packages/core/src/__tests__/agent-effective-model.test.ts b/packages/core/src/__tests__/agent-effective-model.test.ts new file mode 100644 index 0000000000..96977d4bd2 --- /dev/null +++ b/packages/core/src/__tests__/agent-effective-model.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + getPrimaryWorkflowRole, + resolvePermanentAgentEffectiveModel, + resolvePermanentAgentEffectiveThinkingLevel, +} from "../ai/agent-effective-model.js"; + +const builtIn = (role: "triage" | "executor" | "reviewer" | "merger", runtimeConfig?: Record) => ({ + role, + roles: [role], + metadata: { builtInWorkflowRole: true, workflowRole: role }, + runtimeConfig, +}); + +describe("permanent agent effective model inheritance", () => { + it("uses the project override for every built-in workflow role when lanes are empty", () => { + const settings = { + defaultProviderOverride: "anthropic", + defaultModelIdOverride: "claude-project", + defaultProvider: "openai", + defaultModelId: "gpt-global", + }; + + for (const role of ["triage", "executor", "reviewer", "merger"] as const) { + expect(resolvePermanentAgentEffectiveModel(builtIn(role, { enabled: false }), settings)).toEqual({ + provider: "anthropic", + modelId: "claude-project", + }); + } + }); + + it("selects role lanes before project defaults and retains legacy role discovery", () => { + const settings = { + planningProvider: "planning-provider", + planningModelId: "planning-model", + executionProvider: "execution-provider", + executionModelId: "execution-model", + validatorProvider: "validator-provider", + validatorModelId: "validator-model", + mergerProvider: "merger-provider", + mergerModelId: "merger-model", + defaultProviderOverride: "project-provider", + defaultModelIdOverride: "project-model", + }; + const expected = { + triage: ["planning-provider", "planning-model"], + executor: ["execution-provider", "execution-model"], + reviewer: ["validator-provider", "validator-model"], + merger: ["merger-provider", "merger-model"], + } as const; + + for (const role of ["triage", "executor", "reviewer", "merger"] as const) { + expect(resolvePermanentAgentEffectiveModel({ role, runtimeConfig: {} }, settings)).toEqual({ + provider: expected[role][0], + modelId: expected[role][1], + }); + } + expect(getPrimaryWorkflowRole({ roles: ["executor", "merger"] })).toBe("executor"); + }); + + it("keeps complete agent model overrides but ignores incomplete pairs", () => { + const settings = { defaultProviderOverride: "project-provider", defaultModelIdOverride: "project-model" }; + expect(resolvePermanentAgentEffectiveModel(builtIn("merger", { + modelProvider: "agent-provider", + modelId: "agent-model", + }), settings)).toEqual({ provider: "agent-provider", modelId: "agent-model" }); + expect(resolvePermanentAgentEffectiveModel(builtIn("merger", { modelProvider: "partial-provider" }), settings)).toEqual({ + provider: "project-provider", + modelId: "project-model", + }); + }); + + it("preserves the mock test-mode resolver result", () => { + expect(resolvePermanentAgentEffectiveModel(builtIn("merger", { enabled: false }), { + testMode: true, + mergerProvider: "real-provider", + mergerModelId: "real-model", + })).toEqual({ provider: "mock", modelId: "scripted" }); + }); + + it("resolves thinking from explicit session, agent, role lane, project, then global defaults", () => { + const settings = { + planningThinkingLevel: "low", + executionThinkingLevel: "medium", + validatorThinkingLevel: "high", + mergerThinkingLevel: "xhigh", + defaultThinkingLevelOverride: "minimal", + defaultThinkingLevel: "off", + }; + expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("triage", {}), settings)).toBe("low"); + expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("executor", {}), settings)).toBe("medium"); + expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("reviewer", {}), settings)).toBe("high"); + expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("merger", {}), settings)).toBe("xhigh"); + expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("merger", { thinkingLevel: "minimal" }), settings)).toBe("minimal"); + expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("merger", { thinkingLevel: "minimal" }), settings, "off")).toBe("off"); + }); +}); diff --git a/packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts b/packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts index ff2da10f22..d1c190c63a 100644 --- a/packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts +++ b/packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts @@ -45,6 +45,8 @@ const GOVERNED_IDS = ["triage", "todo", "in-progress", "in-review"] as const; */ const NON_COLUMN_RECEIVERS: ReadonlySet = new Set([ "role", + // FNXC:AgentModelInheritance 2026-08-10-09:21: `metadataRole` is a provisioned workflow-agent role, not a task lifecycle column. + "metadataRole", // FNXC:PrincipalHeldPlanning 2026-08-10-08:20: a workflow work item's `workflowRole` is the stage principal // role ("triage"/"executor"/"reviewer"/"merger") — the same non-column meaning `role` is already exempt for. "workflowRole", diff --git a/packages/core/src/ai/agent-effective-model.ts b/packages/core/src/ai/agent-effective-model.ts new file mode 100644 index 0000000000..9690ff405b --- /dev/null +++ b/packages/core/src/ai/agent-effective-model.ts @@ -0,0 +1,82 @@ +import type { Settings } from "../types.js"; +import { + resolveExecutionSettingsModel, + resolveMergerPhaseThinkingLevel, + resolveMergerSettingsModel, + resolvePhaseThinkingLevel, + resolvePlanningSettingsModel, + resolveValidatorSettingsModel, + type ResolvedModelSelection, +} from "./model-resolution.js"; + +export type PermanentAgentModelLike = { + roles?: string[]; + role?: string | null; + runtimeConfig?: Record | null; + metadata?: Record | null; +}; + +export type PrimaryWorkflowRole = "triage" | "executor" | "reviewer" | "merger"; + +function completeRuntimeModel(runtimeConfig?: Record | null): ResolvedModelSelection | undefined { + if (!runtimeConfig) return undefined; + const provider = typeof runtimeConfig.modelProvider === "string" ? runtimeConfig.modelProvider.trim() : ""; + const modelId = typeof runtimeConfig.modelId === "string" ? runtimeConfig.modelId.trim() : ""; + if (provider && modelId) return { provider, modelId }; + const legacy = typeof runtimeConfig.model === "string" ? runtimeConfig.model.trim() : ""; + const slash = legacy.indexOf("/"); + if (slash > 0 && slash < legacy.length - 1) { + return { provider: legacy.slice(0, slash), modelId: legacy.slice(slash + 1) }; + } + return undefined; +} + +/** Return the built-in workflow role, favoring provisioned role metadata when present. */ +export function getPrimaryWorkflowRole(agent: PermanentAgentModelLike): PrimaryWorkflowRole | undefined { + const metadataRole = agent.metadata?.builtInWorkflowRole === true && typeof agent.metadata.workflowRole === "string" + ? agent.metadata.workflowRole + : undefined; + if (metadataRole === "triage" || metadataRole === "executor" || metadataRole === "reviewer" || metadataRole === "merger") return metadataRole; + const roles = Array.isArray(agent.roles) ? agent.roles : []; + for (const role of ["triage", "executor", "reviewer", "merger"] as const) if (roles.includes(role)) return role; + return agent.role === "triage" || agent.role === "executor" || agent.role === "reviewer" || agent.role === "merger" ? agent.role : undefined; +} + +/* +FNXC:AgentModelInheritance 2026-08-09-22:38: +Permanent agent identity sessions preserve a complete per-agent runtime model, but an empty or +partial runtime model inherits the matching workflow role lane. Each lane reaches the project +default override before globals, keeping Agents, Chat, and heartbeats consistent without writing +an inherited pair onto the agent row. +*/ +export function resolvePermanentAgentEffectiveModel( + agent: PermanentAgentModelLike, + settings?: Partial, +): ResolvedModelSelection { + const runtimeModel = completeRuntimeModel(agent.runtimeConfig); + if (runtimeModel) return runtimeModel; + switch (getPrimaryWorkflowRole(agent)) { + case "triage": return resolvePlanningSettingsModel(settings); + case "reviewer": return resolveValidatorSettingsModel(settings); + case "merger": return resolveMergerSettingsModel(settings); + default: return resolveExecutionSettingsModel(settings); + } +} + +export function resolvePermanentAgentEffectiveThinkingLevel( + agent: PermanentAgentModelLike, + settings?: Partial, + explicitThinkingLevel?: string, +): string | undefined { + const explicit = typeof explicitThinkingLevel === "string" && explicitThinkingLevel.trim() ? explicitThinkingLevel.trim() : undefined; + const runtime = typeof agent.runtimeConfig?.thinkingLevel === "string" && agent.runtimeConfig.thinkingLevel.trim() + ? agent.runtimeConfig.thinkingLevel.trim() + : undefined; + if (explicit ?? runtime) return explicit ?? runtime; + switch (getPrimaryWorkflowRole(agent)) { + case "triage": return resolvePhaseThinkingLevel("planning", settings); + case "reviewer": return resolvePhaseThinkingLevel("validation", settings); + case "merger": return resolveMergerPhaseThinkingLevel(settings); + default: return resolvePhaseThinkingLevel("execution", settings); + } +} diff --git a/packages/core/src/ai/model-resolution.ts b/packages/core/src/ai/model-resolution.ts index b7774e4ce7..6b7824deb8 100644 --- a/packages/core/src/ai/model-resolution.ts +++ b/packages/core/src/ai/model-resolution.ts @@ -162,6 +162,20 @@ export function resolvePhaseThinkingLevel( ); } +/* +FNXC:AgentModelInheritance 2026-08-09-22:38: +Permanent role-agent identity sessions need the merger thinking chain in core because dashboard +and engine share it; keep it aligned with the established merger lane precedence. +*/ +export function resolveMergerPhaseThinkingLevel(settings?: Partial): string | undefined { + return firstThinkingLevel( + settings?.mergerThinkingLevel, + settings?.mergerGlobalThinkingLevel, + settings?.defaultThinkingLevelOverride, + settings?.defaultThinkingLevel, + ); +} + export function resolveProjectDefaultModel(settings?: Partial): ResolvedModelSelection { return applyTestModeOverrides( pickFirstModelPair( diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index cbfff6baea..66dd930227 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1490,6 +1490,7 @@ export { resolveSelectedWorkflowModelLane, resolveMergerFallbackModel, resolveMergerSettingsModel, + resolveMergerPhaseThinkingLevel, resolvePlanningSettingsModel, resolveProjectDefaultModel, resolveTaskExecutionModel, @@ -1505,6 +1506,12 @@ export { routeTaskValidatorModel, } from "./ai/model-resolution.js"; export type { ResolvedModelSelection, RouterLaneOptions } from "./ai/model-resolution.js"; +export { + getPrimaryWorkflowRole, + resolvePermanentAgentEffectiveModel, + resolvePermanentAgentEffectiveThinkingLevel, +} from "./ai/agent-effective-model.js"; +export type { PermanentAgentModelLike, PrimaryWorkflowRole } from "./ai/agent-effective-model.js"; export { routeModel, routeModelAndEmit, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 385524548a..8c0617709e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1664,6 +1664,7 @@ export { resolveSelectedWorkflowModelLane, resolveMergerFallbackModel, resolveMergerSettingsModel, + resolveMergerPhaseThinkingLevel, resolvePhaseThinkingLevel, resolvePlanningSettingsModel, resolveProjectDefaultModel, @@ -1681,6 +1682,12 @@ export { routeTaskValidatorModel, } from "./ai/model-resolution.js"; export type { ModelThinkingPhase, ResolvedModelSelection, RouterLaneOptions } from "./ai/model-resolution.js"; +export { + getPrimaryWorkflowRole, + resolvePermanentAgentEffectiveModel, + resolvePermanentAgentEffectiveThinkingLevel, +} from "./ai/agent-effective-model.js"; +export type { PermanentAgentModelLike, PrimaryWorkflowRole } from "./ai/agent-effective-model.js"; export { routeModel, routeModelAndEmit, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3ce1e17a27..1048818431 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1586,4 +1586,11 @@ export type { DoneColumnSortMode, DisplayColumnSortOptions } from "./tasks/task- export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types/agents/agents.js"; export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js"; + +/* FNXC:AgentModelInheritance 2026-08-09-22:38: The dashboard aliases @fusion/core to this browser-safe leaf, so expose pure agent identity model resolution here rather than importing the Node-heavy barrel. */ +export { + getPrimaryWorkflowRole, + resolvePermanentAgentEffectiveModel, + resolvePermanentAgentEffectiveThinkingLevel, +} from "./ai/agent-effective-model.js"; export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js"; diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 004c8d2567..7e026204aa 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -15,8 +15,14 @@ import remarkGfm from "remark-gfm"; import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api"; import type { Agent } from "../api"; -import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel } from "@fusion/core"; -import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core"; +import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core"; +import { + AGENT_PERMISSIONS, + getErrorMessage, + isEphemeralAgent, + resolvePermanentAgentEffectiveModel, + resolvePermanentAgentEffectiveThinkingLevel, +} from "@fusion/core"; import { AgentLogViewer } from "./AgentLogViewer"; import { LoadingSpinner } from "./LoadingSpinner"; import { AgentReflectionsTab } from "./AgentReflectionsTab"; @@ -261,6 +267,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild const { t } = useTranslation("app"); const [agent, setAgent] = useState(null); const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1); + const [agentModelSettings, setAgentModelSettings] = useState>({}); const { confirm } = useConfirm(); const [logs, setLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -310,7 +317,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild let cancelled = false; void fetchSettings(projectId) .then((settings) => { - if (!cancelled) setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1); + if (!cancelled) { + setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1); + setAgentModelSettings(settings); + } }) .catch(() => { if (!cancelled) setHeartbeatMultiplier(1); @@ -1187,6 +1197,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild health={health} onChildClick={onChildClick} projectId={projectId} + agentModelSettings={agentModelSettings} /> )} @@ -1288,6 +1299,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild key={agent.id} agent={agent} projectId={projectId} + agentModelSettings={agentModelSettings} addToast={addToast} onSaved={handleSavedMutation} onHasChangesChange={handleConfigChangesState} @@ -1364,11 +1376,13 @@ function DashboardTab({ health, onChildClick, projectId, + agentModelSettings, }: { agent: AgentDetail; health: AgentHealthStatus; onChildClick?: (childId: string) => void; projectId?: string; + agentModelSettings: Partial; }) { const { t } = useTranslation("app"); const stateStyle = STATE_COLORS[agent.state]; @@ -1398,7 +1412,8 @@ function DashboardTab({ const slashIdx = rc.model.indexOf("/"); return rc.model.slice(slashIdx + 1); } - return null; + const effective = resolvePermanentAgentEffectiveModel(agent, agentModelSettings); + return effective.provider && effective.modelId ? `${effective.provider}/${effective.modelId}` : null; })(); // Fetch budget status on mount @@ -4027,6 +4042,7 @@ function HeartbeatProcedureSection({ function ConfigTab({ agent, projectId, + agentModelSettings, addToast, onSaved, onHasChangesChange, @@ -4035,6 +4051,7 @@ function ConfigTab({ }: { agent: AgentDetail; projectId?: string; + agentModelSettings: Partial; addToast: (message: string, type?: "success" | "error") => void; onSaved: () => Promise; onHasChangesChange?: (hasChanges: boolean) => void; @@ -4064,7 +4081,7 @@ function ConfigTab({ initial[field.key] = String(raw); } } - initial.thinkingLevel = typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "off"; + initial.thinkingLevel = typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : ""; return initial; }); @@ -4440,7 +4457,7 @@ function ConfigTab({ if (runtimeMode !== (initialRuntimeHint ? "runtime" : "model")) return true; if (modelValue !== initialModelValue) return true; if (selectedRuntimeId !== initialRuntimeHint) return true; - if ((formValues.thinkingLevel || "off") !== (typeof rc.thinkingLevel === "string" ? rc.thinkingLevel : "off")) return true; + if ((formValues.thinkingLevel ?? "") !== (typeof rc.thinkingLevel === "string" ? rc.thinkingLevel : "")) return true; return false; })(); @@ -4489,7 +4506,7 @@ function ConfigTab({ setBudgetValues(deriveBudgetValues(agent.runtimeConfig)); setFormValues((prev) => ({ ...prev, - thinkingLevel: typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "off", + thinkingLevel: typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "", })); setModelValue(initialModelValue); setSelectedRuntimeId(initialRuntimeHint); @@ -4682,8 +4699,11 @@ function ConfigTab({ newRuntimeConfig.heartbeatPromptTemplate = heartbeatPromptTemplate; } - const selectedThinkingLevel = (formValues.thinkingLevel || "off") as ThinkingLevel; - newRuntimeConfig.thinkingLevel = selectedThinkingLevel; + if (formValues.thinkingLevel) { + newRuntimeConfig.thinkingLevel = formValues.thinkingLevel as ThinkingLevel; + } else { + delete newRuntimeConfig.thinkingLevel; + } if (runtimeMode === "runtime") { if (selectedRuntimeId.trim()) { @@ -5115,8 +5135,10 @@ function ConfigTab({ {runtimeMode === "model" ? (
{/* - FNXC:Settings-ThinkingLevel 2026-07-12-00:00: - Agent Detail now lets operators change a built-in agent's persisted runtimeConfig.thinkingLevel after creation through the shared inline model-dropdown control, matching NewAgentDialog's concrete-only agent semantics. + FNXC:AgentModelInheritance 2026-08-09-23:10: + An empty agent thinking selection remains an inherit marker rather than persisting "off". + Display the resolved role/project thinking as the dropdown default so operators can inspect + the active value without materializing it onto the permanent agent runtime configuration. */} { setFormValues((prev) => ({ ...prev, thinkingLevel: level as ThinkingLevel })); void scheduleAutoSave(); diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 0a05cee748..9fb73157fd 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -26,7 +26,7 @@ import { MIN_HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_PRESETS, } from "../utils/heartbeatIntervals"; -import { isEphemeralAgent, getErrorMessage } from "@fusion/core"; +import { isEphemeralAgent, getErrorMessage, resolvePermanentAgentEffectiveModel, type Settings } from "@fusion/core"; import { formatAgentSkillBadgeLabel } from "../utils/agentSkills"; import { ORG_CHART_LAYOUT_STORAGE_KEY, @@ -139,7 +139,7 @@ FNXC:AgentsView 2026-06-23-04:00: Agent list cards must expose the configured model or plugin runtime without requiring a detail-view open. Use the same runtimeHint/modelProvider+modelId/legacy model fallback order as the detail view and leave no-override agents as Auto at render time. */ -function getAgentModelLabel(agent: Agent): AgentModelLabel { +function getAgentModelLabel(agent: Agent, settings?: Partial): AgentModelLabel { const runtimeConfig = agent.runtimeConfig ?? {}; const runtimeHint = typeof runtimeConfig.runtimeHint === "string" ? runtimeConfig.runtimeHint : ""; if (runtimeHint) { @@ -158,7 +158,8 @@ function getAgentModelLabel(agent: Agent): AgentModelLabel { return { label: legacyModel.slice(slashIdx + 1), isRuntime: false }; } - return { label: null, isRuntime: false }; + const effective = resolvePermanentAgentEffectiveModel(agent, settings); + return { label: effective.provider && effective.modelId ? `${effective.provider}/${effective.modelId}` : null, isRuntime: false }; } function getOrgChartLeafCount(node: OrgTreeNode): number { @@ -640,6 +641,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState>({}); /** Global heartbeat multiplier loaded from project settings */ const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1); + const [agentModelSettings, setAgentModelSettings] = useState>({}); /** Whether the heartbeat multiplier is currently being saved */ const [isSavingMultiplier, setIsSavingMultiplier] = useState(false); /** Agent IDs with an in-flight state transition (for optimistic update guard) */ @@ -661,6 +663,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin .then((settings) => { if (!isMountedRef.current) return; setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1); + setAgentModelSettings(settings); }) .catch(() => { // Use default on error @@ -2049,7 +2052,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const isHeartbeatDisabled = !isAgentHeartbeatEnabled(agent); const heartbeatSelectValue = isHeartbeatDisabled ? HEARTBEAT_DISABLED_OPTION_VALUE : String(configuredIntervalMs); const isUpdatingHeartbeat = isBulkHeartbeatMutationRunning || updatingHeartbeatAgentId === agent.id || heartbeatMutationAgentIds.has(agent.id); - const modelLabel = getAgentModelLabel(agent); + const modelLabel = getAgentModelLabel(agent, agentModelSettings); return (
{ + beforeEach(() => { + setupAgentDetailMocks(); + }); + + it("shows a built-in merger's inherited project model", async () => { + mockFetchAgent.mockResolvedValue(createMockAgent({ + role: "merger" as any, + roles: ["merger"] as any, + metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, + runtimeConfig: { enabled: false }, + })); + mockFetchSettings.mockResolvedValue({ + defaultProviderOverride: "anthropic", + defaultModelIdOverride: "claude-project", + } as any); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("button", { name: "Dashboard" })); + + expect(await screen.findByText("anthropic/claude-project")).toBeInTheDocument(); + }); + + it("keeps a complete agent model ahead of inherited settings", async () => { + mockFetchAgent.mockResolvedValue(createMockAgent({ + role: "merger" as any, + roles: ["merger"] as any, + runtimeConfig: { modelProvider: "openai", modelId: "agent-model" }, + })); + mockFetchSettings.mockResolvedValue({ + defaultProviderOverride: "anthropic", + defaultModelIdOverride: "claude-project", + } as any); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("button", { name: "Dashboard" })); + + expect(await screen.findByText("openai/agent-model")).toBeInTheDocument(); + expect(screen.queryByText("anthropic/claude-project")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx index 88ea6174f0..16e2b93b3f 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx @@ -29,6 +29,7 @@ import { mockFetchModels, mockFetchPluginRuntimes, mockFetchSkillContent, + mockFetchSettings, mockFetchWorkspaceFileContent, mockMarkMessageRead, mockResetAgentBudget, @@ -191,6 +192,27 @@ describe("Budget Settings", () => { }); }); + it("shows inherited role thinking without materializing an agent override", async () => { + mockFetchAgent.mockResolvedValue(createMockAgent({ + role: "merger" as AgentCapability, + roles: ["merger"] as AgentCapability[], + metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, + runtimeConfig: { enabled: false }, + })); + mockFetchSettings.mockResolvedValue({ + defaultProviderOverride: "anthropic", + defaultModelIdOverride: "claude-project", + defaultThinkingLevelOverride: "high", + } as any); + + const user = userEvent.setup(); + render(); + await navigateToSettings(user); + + await screen.findByLabelText("Agent Model thinking level"); + expect(screen.getByTestId("custom-model-dropdown")).toHaveAttribute("data-default-thinking-level", "high"); + }); + it("calls updateAgent with correct budgetConfig in runtimeConfig on save", async () => { mockUpdateAgent.mockResolvedValue(createMockAgent() as any); diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx index 7d70ec5653..fbc24907b3 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx @@ -327,6 +327,31 @@ describe("AgentsView", () => { expect(getCardModelRow("agent-auto").textContent).toMatch(/Model:\s*Auto/); }); + it("renders the inherited project model for a model-less built-in role agent", async () => { + mockFetchAgents.mockResolvedValueOnce([{ + ...mockAgents[0], + id: "agent-built-in-merger", + name: "Workflow Merger", + role: "merger", + roles: ["merger"], + metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, + runtimeConfig: { enabled: false }, + }]); + mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} }); + mockFetchSettings.mockResolvedValueOnce({ + defaultProviderOverride: "anthropic", + defaultModelIdOverride: "claude-project", + }); + + const { container } = renderView(); + await waitFor(() => { + expect(screen.getByText("Workflow Merger")).toBeInTheDocument(); + }); + await waitFor(() => { + expect(container.querySelector(".agent-model-runtime")?.textContent).toMatch(/anthropic\/claude-project/); + }); + }); + it("renders cross-pane overview above split layout", async () => { const { container } = renderView(); diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 2ed176390d..0a667938fc 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -4122,4 +4122,49 @@ describe("ChatManager generation isolation", () => { }); }); + it("forwards inherited project model and thinking for direct role-agent chat", async () => { + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", name: "Workflow Merger", role: "merger", roles: ["merger"], + metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, runtimeConfig: { enabled: false }, + }); + const createResolvedSession = vi.fn(async () => ({ + session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [{ role: "assistant", content: "Done" }] } }, + })); + __setCreateResolvedAgentSession(createResolvedSession as any); + const chatManager = createChatManagerWithSettings({ + defaultProviderOverride: "anthropic", defaultModelIdOverride: "claude-project", defaultThinkingLevelOverride: "high", + } as any); + + await chatManager.sendMessage("chat-001", "Hello"); + + expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({ + defaultProvider: "anthropic", defaultModelId: "claude-project", defaultThinkingLevel: "high", + })); + }); + + it("forwards inherited role settings for room responders while room thinking wins", async () => { + (mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team", thinkingLevel: "off" }); + (mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([{ roomId: "room-1", agentId: "agent-001", role: "member" }]); + (mockChatStore as any).addRoomMessage = vi.fn().mockImplementation((_roomId: string, input: any) => ({ id: "room-message", ...input })); + const merger = { + id: "agent-001", name: "Workflow Merger", role: "merger", roles: ["merger"], state: "active", + metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, runtimeConfig: { enabled: false }, + }; + mockAgentStore.listAgents.mockResolvedValue([merger]); + mockAgentStore.getAgent.mockResolvedValue(merger); + const createResolvedSession = vi.fn(async () => ({ + session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [{ role: "assistant", content: "Done" }] } }, + })); + __setCreateResolvedAgentSession(createResolvedSession as any); + const chatManager = createChatManagerWithSettings({ + defaultProviderOverride: "anthropic", defaultModelIdOverride: "claude-project", mergerThinkingLevel: "high", + } as any); + + await chatManager.sendRoomMessage("room-1", "Hello @Workflow_Merger"); + + expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({ + defaultProvider: "anthropic", defaultModelId: "claude-project", defaultThinkingLevel: "off", + })); + }); + }); diff --git a/packages/dashboard/src/__tests__/routes-chat-sessions-project-model.test.ts b/packages/dashboard/src/__tests__/routes-chat-sessions-project-model.test.ts new file mode 100644 index 0000000000..5c3cd6de0b --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-chat-sessions-project-model.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node + +import express from "express"; +import multer from "multer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentStore } from "@fusion/core"; +import { request } from "../test-request.js"; +import { registerChatRoutes } from "../routes/register-chat-routes.js"; + +function postJson(app: express.Express, body: unknown) { + return request(app, "POST", "/api/chat/sessions", JSON.stringify(body), { + "content-type": "application/json", + }); +} + +/** + * FNXC:AgentModelInheritance 2026-08-10-08:51: + * Chat-session creation must persist the permanent role agent's inherited model and thinking + * when callers leave both fields empty. This route-level fixture prevents a future route refactor + * from bypassing the shared resolver after project settings have been loaded. + */ +describe("POST /api/chat/sessions permanent role model inheritance", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses project inheritance while preserving client and complete agent overrides", async () => { + const createSession = vi.fn(async (input: Record) => ({ id: `session-${createSession.mock.calls.length}`, ...input })); + const chatStore = { createSession }; + const mergerAgent = { + id: "workflow-merger", + roles: ["merger"], + metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, + runtimeConfig: { enabled: false }, + }; + const explicitAgent = { + ...mergerAgent, + id: "explicit-agent", + runtimeConfig: { modelProvider: "agent-provider", modelId: "agent-model" }, + }; + + vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined); + vi.spyOn(AgentStore.prototype, "getAgent").mockImplementation(async (id) => ( + id === explicitAgent.id ? explicitAgent as never : mergerAgent as never + )); + + const scopedStore = { + getFusionDir: () => "/route-project/.fusion", + getAsyncLayer: () => undefined, + getSettings: async () => ({ + defaultProvider: "global-provider", + defaultModelId: "global-model", + defaultProviderOverride: "project-provider", + defaultModelIdOverride: "project-model", + defaultThinkingLevelOverride: "high", + }), + }; + const app = express(); + app.use(express.json()); + const router = express.Router(); + registerChatRoutes({ + router, + store: scopedStore, + options: { chatStore }, + getProjectContext: async () => ({ store: scopedStore, projectId: "project-1", engine: undefined }), + rethrowAsApiError: (error: unknown) => { throw error; }, + } as never, { + parseLastEventId: () => undefined, + replayBufferedSSE: () => false, + validateOptionalModelField: () => undefined, + upload: multer(), + }); + app.use("/api", router); + + const inherited = await postJson(app, { agentId: mergerAgent.id }); + expect(inherited.status).toBe(201); + expect(inherited.body.session).toMatchObject({ + modelProvider: "project-provider", + modelId: "project-model", + thinkingLevel: "high", + }); + + const clientOverride = await postJson(app, { + agentId: mergerAgent.id, + modelProvider: "client-provider", + modelId: "client-model", + }); + expect(clientOverride.status).toBe(201); + expect(clientOverride.body.session).toMatchObject({ + modelProvider: "client-provider", + modelId: "client-model", + thinkingLevel: "high", + }); + + const agentOverride = await postJson(app, { agentId: explicitAgent.id }); + expect(agentOverride.status).toBe(201); + expect(agentOverride.body.session).toMatchObject({ + modelProvider: "agent-provider", + modelId: "agent-model", + thinkingLevel: "high", + }); + }); +}); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 2871a8dc6d..cecfd3b3f9 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -38,6 +38,8 @@ import { summarizeTitle, FUSION_RUNTIME_SELF_AWARENESS, createLogger, + resolvePermanentAgentEffectiveModel, + resolvePermanentAgentEffectiveThinkingLevel, } from "@fusion/core"; import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; @@ -1425,33 +1427,7 @@ export class ChatManager { */ getPluginSkills?(): Array<{ pluginId: string; pluginRoot?: string; skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] } }>; }, - private getSettings?: () => Promise | undefined> | Pick | undefined, + private getSettings?: () => Promise | undefined> | Partial | undefined, private messageStore?: MessageStore, // Scoped task store for the chat's project — enables workflow-authoring // tools (fn_workflow_*) and explicit-task document tools. Optional so @@ -1566,23 +1542,14 @@ export class ChatManager { defaultThinkingLevelOverride?: Settings["defaultThinkingLevelOverride"]; executionThinkingLevel?: Settings["executionThinkingLevel"]; executionGlobalThinkingLevel?: Settings["executionGlobalThinkingLevel"]; - }> { + } & Partial> { if (!this.getSettings) { return {}; } try { const settings = await this.getSettings(); - return { - fallbackProvider: settings?.fallbackProvider ?? undefined, - fallbackModelId: settings?.fallbackModelId ?? undefined, - defaultProvider: settings?.defaultProvider ?? undefined, - defaultModelId: settings?.defaultModelId ?? undefined, - defaultThinkingLevel: settings?.defaultThinkingLevel ?? undefined, - defaultThinkingLevelOverride: settings?.defaultThinkingLevelOverride ?? undefined, - executionThinkingLevel: settings?.executionThinkingLevel ?? undefined, - executionGlobalThinkingLevel: settings?.executionGlobalThinkingLevel ?? undefined, - }; + return settings ?? {}; } catch (err) { const message = err instanceof Error ? err.message : String(err); diagnostics.warn(`Failed to load chat fallback settings: ${message}`); @@ -2138,13 +2105,19 @@ export class ChatManager { * FNXC:GrokCliRouting 2026-07-09-22:10: * Room responders with no explicit send-time or responder runtime model still need the configured chat/project default to reach createResolvedAgentSession. Without forwarding a defaultProvider of grok-cli, the no-visible-key auto-derive seam cannot route to the Grok CLI runtime and pi can surface the direct xAI missing-key error. */ - const effectiveModelProvider = input.modelProvider ?? responderRuntimeModel.provider ?? chatModelSettings.defaultProvider; - const effectiveModelId = input.modelId ?? responderRuntimeModel.modelId ?? chatModelSettings.defaultModelId; + const inheritedResponderModel = resolvePermanentAgentEffectiveModel(input.responder, chatModelSettings); + const hasCompleteResponderRuntimeModel = !!responderRuntimeModel.provider && !!responderRuntimeModel.modelId; + const effectiveModelProvider = input.modelProvider ?? (hasCompleteResponderRuntimeModel ? responderRuntimeModel.provider : inheritedResponderModel.provider); + const effectiveModelId = input.modelId ?? (hasCompleteResponderRuntimeModel ? responderRuntimeModel.modelId : inheritedResponderModel.modelId); /* * FNXC:Chat-ThinkingLevel 2026-07-12-00:00: * Room responders apply the room-level reasoning-effort default through the engine `defaultThinkingLevel` session option. An unset room value inherits the resolved project/global chat default and every direct or ambient responder in the room receives the same effective level. */ - const effectiveThinkingLevel = resolveExecutorThinkingLevel(input.roomThinkingLevel ?? undefined, chatModelSettings); + const effectiveThinkingLevel = resolvePermanentAgentEffectiveThinkingLevel( + input.responder, + chatModelSettings, + input.roomThinkingLevel ?? undefined, + ); /* * FNXC:ChatModels 2026-07-01-16:42: * Room responders should pass configured fallback models even when the room send chose an explicit model. The engine still swaps only for retryable provider/model-selection failures, so an unavailable Sonnet 5 can recover without making ordinary prompt errors ambiguous. @@ -2567,8 +2540,10 @@ export class ChatManager { if (runtimeModel.provider && runtimeModel.modelId) { hasExplicitAgentRuntimeModel = true; } - effectiveModelProvider ??= runtimeModel.provider; - effectiveModelId ??= runtimeModel.modelId; + const inheritedAgentModel = resolvePermanentAgentEffectiveModel(agent, await this.getChatModelSettings()); + const hasCompleteRuntimeModel = !!runtimeModel.provider && !!runtimeModel.modelId; + effectiveModelProvider ??= hasCompleteRuntimeModel ? runtimeModel.provider : inheritedAgentModel.provider; + effectiveModelId ??= hasCompleteRuntimeModel ? runtimeModel.modelId : inheritedAgentModel.modelId; failureContextProvider = effectiveModelProvider; failureContextModelId = effectiveModelId; } @@ -2688,7 +2663,9 @@ export class ChatManager { * FNXC:Chat-ThinkingLevel 2026-07-10-00:00: * Model-loop chat sessions apply the per-session thinking level through the engine `defaultThinkingLevel` session option; an empty session value inherits the project/global execution default resolved by resolveExecutorThinkingLevel. */ - const effectiveThinkingLevel = resolveExecutorThinkingLevel(session.thinkingLevel ?? undefined, chatModelSettings); + const effectiveThinkingLevel = agent + ? resolvePermanentAgentEffectiveThinkingLevel(agent, chatModelSettings, session.thinkingLevel ?? undefined) + : resolveExecutorThinkingLevel(session.thinkingLevel ?? undefined, chatModelSettings); if (agent?.id && !this.messageStore) { const warning = { diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 0b3c0cb0eb..0c4f5fbb24 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -3,7 +3,13 @@ import { archivedColumnsForTask } from "../task-lifecycle-lanes.js"; import { createReadStream } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; -import { THINKING_LEVELS, type EnrichedChatSession, type ChatAttachment } from "@fusion/core"; +import { + THINKING_LEVELS, + resolvePermanentAgentEffectiveModel, + resolvePermanentAgentEffectiveThinkingLevel, + type EnrichedChatSession, + type ChatAttachment, +} from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; /* FNXC:GrokAcp 2026-07-11-18:30: @@ -445,8 +451,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): * POST /api/chat/sessions * Create a new chat session. * Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string, thinkingLevel?: string } - * If modelProvider and modelId are provided, those are used. Otherwise the model is - * resolved from the agent's runtimeConfig.model setting. + * If modelProvider and modelId are provided, those are used. Otherwise the model and + * thinking level resolve through the agent's permanent-role inheritance chain. * The session is scoped to the project identified by projectId query param or header. */ router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => { @@ -479,9 +485,16 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided"); } + const agent = await agentStore.getAgent(agentId); + if (!agent) { + throw notFound(`Agent ${agentId} not found`); + } + const settings = await scopedStore.getSettings(); + // Fetch the agent to resolve model configuration (only if client didn't provide model) let resolvedProvider: string | null = null; let resolvedModelId: string | null = null; + let inheritedThinkingLevel: string | undefined; if (hasClientModelProvider && hasClientModelId) { // Use client-provided model @@ -489,18 +502,12 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): resolvedModelId = modelId!.trim(); } else { // Resolve from agent's runtimeConfig.model - const agent = await agentStore.getAgent(agentId); - if (!agent) { - throw notFound(`Agent ${agentId} not found`); - } - - // Parse the agent's model config from runtimeConfig.model - // Format: "provider/modelId" (e.g., "anthropic/claude-sonnet-4-5") - const runtimeModel = typeof agent.runtimeConfig?.model === "string" ? agent.runtimeConfig.model : ""; - const slashIdx = runtimeModel.indexOf("/"); - resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : null; - resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : null; + const resolved = resolvePermanentAgentEffectiveModel(agent, settings); + resolvedProvider = resolved.provider ?? null; + resolvedModelId = resolved.modelId ?? null; + inheritedThinkingLevel = resolvePermanentAgentEffectiveThinkingLevel(agent, settings); } + inheritedThinkingLevel ??= resolvePermanentAgentEffectiveThinkingLevel(agent, settings); // Create the chat session with projectId for multi-project scoping const session = await chatStore.createSession({ @@ -509,7 +516,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): projectId: projectId ?? null, modelProvider: resolvedProvider, modelId: resolvedModelId, - ...(thinkingLevel ? { thinkingLevel } : {}), + ...((thinkingLevel ?? inheritedThinkingLevel) ? { thinkingLevel: thinkingLevel ?? inheritedThinkingLevel } : {}), }); res.status(201).json({ session }); diff --git a/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts b/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts index ca9b9927fe..e4d6fe7196 100644 --- a/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts @@ -126,3 +126,16 @@ describe("agent-session-helpers test mode overrides", () => { }); }); }); + +describe("role-aware heartbeat test mode", () => { + it("still forces mock for a supplied permanent workflow role", () => { + expect(resolveHeartbeatSessionModels({ + testMode: true, + mergerProvider: "real-provider", + mergerModelId: "real-model", + }, { enabled: false }, { roles: ["merger"] })).toMatchObject({ + defaultProvider: "mock", + defaultModelId: "scripted", + }); + }); +}); diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 004545bcd7..31aea39439 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -1260,3 +1260,57 @@ describe("resolveImplicitPlanningFallbackModel (FN-7719)", () => { }); }); }); + +describe("role-aware heartbeat model inheritance", () => { + const projectOverride = { + defaultProviderOverride: "project-provider", + defaultModelIdOverride: "project-model", + defaultProvider: "global-provider", + defaultModelId: "global-model", + }; + + it("uses each supplied workflow role lane and leaves no-role calls on execution", () => { + const settings = { + ...projectOverride, + planningProvider: "planning-provider", + planningModelId: "planning-model", + executionProvider: "execution-provider", + executionModelId: "execution-model", + validatorProvider: "validator-provider", + validatorModelId: "validator-model", + mergerProvider: "merger-provider", + mergerModelId: "merger-model", + }; + const expected = { + triage: ["planning-provider", "planning-model"], + executor: ["execution-provider", "execution-model"], + reviewer: ["validator-provider", "validator-model"], + merger: ["merger-provider", "merger-model"], + } as const; + + for (const role of ["triage", "executor", "reviewer", "merger"] as const) { + expect(resolveHeartbeatSessionModels(settings, { enabled: false }, { roles: [role] })).toMatchObject({ + defaultProvider: expected[role][0], + defaultModelId: expected[role][1], + }); + } + expect(resolveHeartbeatSessionModels(settings, { enabled: false })).toMatchObject({ + defaultProvider: "execution-provider", + defaultModelId: "execution-model", + }); + }); + + it("uses project override for model-less roles and preserves complete runtime assignments", () => { + expect(resolveHeartbeatSessionModels(projectOverride, { enabled: false }, { roles: ["merger"] })).toMatchObject({ + defaultProvider: "project-provider", + defaultModelId: "project-model", + }); + expect(resolveHeartbeatSessionModels(projectOverride, { + modelProvider: "agent-provider", + modelId: "agent-model", + }, { roles: ["merger"] })).toMatchObject({ + defaultProvider: "agent-provider", + defaultModelId: "agent-model", + }); + }); +}); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index cfa0db63d6..f796bba121 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -29,6 +29,7 @@ import { evaluateImplementationTaskBind, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode, + resolvePermanentAgentEffectiveThinkingLevel, AWAITING_APPROVAL_PAUSE_REASON, rankAssignedTasksForWakeDelta, formatAssignedTasksWakeDeltaSection, @@ -3066,7 +3067,13 @@ export class HeartbeatMonitor { heartbeatModelSettings = taskDetail ? await mergeEffectiveSettings(taskStore, taskDetail, heartbeatBaseSettings) : await mergeProjectWorkflowModelLaneBaseline(taskStore, heartbeatBaseSettings); - const heartbeatSessionModels = resolveHeartbeatSessionModels(heartbeatModelSettings, agent.runtimeConfig); + /* + FNXC:AgentModelInheritance 2026-08-09-22:38: + A model-less durable workflow role agent inherits its own role lane rather than always + taking the execution lane; complete runtime models remain authoritative in the helper. + */ + const heartbeatSessionModels = resolveHeartbeatSessionModels(heartbeatModelSettings, agent.runtimeConfig, agent); + const effectiveHeartbeatThinkingLevel = resolvePermanentAgentEffectiveThinkingLevel(agent, heartbeatModelSettings); // FNXC:CommandCenterActivity 2026-08-09-11:12: Heartbeat model selection happens after // logger construction, so refresh telemetry before the session boundary and tool callbacks. attachAgentUsageTelemetry(agentLogger, { @@ -3106,6 +3113,7 @@ export class HeartbeatMonitor { fallbackProvider: heartbeatSessionModels.fallbackProvider, fallbackModelId: heartbeatSessionModels.fallbackModelId, fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, heartbeatModelSettings), + ...(effectiveHeartbeatThinkingLevel ? { defaultThinkingLevel: effectiveHeartbeatThinkingLevel } : {}), runAuditor: audit, settings: heartbeatModelSettings, mcpServers: heartbeatMcp.servers, diff --git a/packages/engine/src/agents/agent-session-helpers.ts b/packages/engine/src/agents/agent-session-helpers.ts index b04777106d..ee66689f03 100644 --- a/packages/engine/src/agents/agent-session-helpers.ts +++ b/packages/engine/src/agents/agent-session-helpers.ts @@ -17,6 +17,7 @@ import { isTestModeActive, resolveAgentToolOutputMaxChars, resolveExecutionSettingsModel, + resolvePermanentAgentEffectiveModel, resolveExecutorFallbackModel, resolveMergerSettingsModel, resolvePhaseThinkingLevel, @@ -799,6 +800,7 @@ export function resolveValidatorSessionModel( export function resolveHeartbeatSessionModels( settings: Partial | undefined, assignedAgentRuntimeConfig?: Record, + agent?: { roles?: string[]; role?: string; metadata?: Record | null }, ): { defaultProvider: string | undefined; defaultModelId: string | undefined; @@ -815,7 +817,9 @@ export function resolveHeartbeatSessionModels( }; } - const executionSettingsModel = resolveExecutionSettingsModel(settings); + const executionSettingsModel = agent + ? resolvePermanentAgentEffectiveModel(agent, settings) + : resolveExecutionSettingsModel(settings); const executorFallbackModel = resolveExecutorFallbackModel(settings); const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); /*