feat(FN-3581): honor runtime model precedence for assigned agents

This merge delivers five major feature clusters: a fully rebuilt dependency graph plugin with draggable nodes, position persistence, modular architecture, highlighting and selection states, toolbar navigation, and keyboard controls; a new roadmap plugin with domain store, ordering logic, and compreh

Fusion-Task-Id: FN-3581
This commit is contained in:
Fusion
2026-05-07 06:39:55 -07:00
committed by gsxdsm
parent be54c30980
commit 5ffd1485c3
11 changed files with 417 additions and 114 deletions

View File

@@ -53,22 +53,100 @@ const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finali
* 1. Per-task modelProvider/modelId (both must be set)
* 2. Project/global execution lane fallback
*/
function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
for (const entry of entries) {
if (entry.agent !== "executor" || entry.type !== "text") continue;
const match = entry.text.match(/^Executor using model: (.+?)\/(.+)$/);
if (match) {
result = { provider: match[1], modelId: match[2] };
}
}
return result;
}
function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
for (const entry of entries) {
if (entry.agent !== "reviewer" || entry.type !== "text") continue;
const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/);
if (match) {
result = { provider: match[1], modelId: match[2] };
}
}
return result;
}
function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection {
const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
const model = typeof runtimeConfig?.model === "string" ? runtimeConfig.model.trim() : "";
if (model) {
const slashIdx = model.indexOf("/");
if (slashIdx > 0 && slashIdx < model.length - 1) {
return {
provider: model.slice(0, slashIdx),
modelId: model.slice(slashIdx + 1),
};
}
}
const provider = typeof runtimeConfig?.modelProvider === "string" ? runtimeConfig.modelProvider.trim() : "";
const modelId = typeof runtimeConfig?.modelId === "string" ? runtimeConfig.modelId.trim() : "";
return {
provider: provider || undefined,
modelId: modelId || undefined,
};
}
/**
* Resolve the effective executor model following the engine's resolution order:
* 1. Runtime executor model from agent log marker
* 2. Assigned agent runtime model (active runs only)
* 3. Per-task modelProvider/modelId override
* 4. Project/global execution lane fallback
*/
function resolveEffectiveExecutor(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
assignedAgent: Agent | null,
settings?: Settings,
): ModelSelection {
const fromLog = extractExecutorModelFromLog(logEntries);
if (fromLog) return fromLog;
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
if (assignedModel.provider && assignedModel.modelId) {
return assignedModel;
}
}
return resolveTaskExecutionModel(task, settings);
}
/**
* Resolve the effective validator model following the engine's resolution order:
* 1. Per-task validatorModelProvider/validatorModelId (both must be set)
* 2. Project/global validator lane fallback
* 1. Runtime reviewer model from agent log marker
* 2. Assigned agent runtime model (active runs only)
* 3. Per-task validatorModelProvider/validatorModelId override
* 4. Project/global validator lane fallback
*/
function resolveEffectiveValidator(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
assignedAgent: Agent | null,
settings?: Settings,
): ModelSelection {
const fromLog = extractReviewerModelFromLog(logEntries);
if (fromLog) return fromLog;
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
if (assignedModel.provider && assignedModel.modelId) {
return assignedModel;
}
}
return resolveTaskValidatorModel(task, settings);
}
@@ -1952,8 +2030,8 @@ export function TaskDetailContent({
<AgentLogViewer
entries={agentLogEntries}
loading={agentLogLoading}
executorModel={resolveEffectiveExecutor(task, settings)}
validatorModel={resolveEffectiveValidator(task, settings)}
executorModel={resolveEffectiveExecutor(task, agentLogEntries, assignedAgent, settings)}
validatorModel={resolveEffectiveValidator(task, agentLogEntries, assignedAgent, settings)}
planningModel={resolveEffectivePlanning(task, agentLogEntries, settings)}
hasMore={agentLogHasMore}
onLoadMore={loadMoreAgentLogs}

View File

@@ -478,6 +478,113 @@ describe("TaskDetailModal", () => {
});
});
it("shows executor/reviewer models from runtime agent-log markers", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Executor using model: openai/gpt-4o", type: "text" as const, agent: "executor" },
{ timestamp: "2026-01-01T00:00:02Z", taskId: "FN-099", text: "Reviewer using model: google/gemini-2.5-pro", type: "text" as const, agent: "reviewer" },
],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
});
const expandButton = screen.getByTestId("agent-log-model-expand") as HTMLButtonElement;
if (expandButton.getAttribute("aria-expanded") !== "true") {
fireEvent.click(expandButton);
}
const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
expect(header.textContent).toContain("openai/gpt-4o");
expect(header.textContent).toContain("google/gemini-2.5-pro");
});
it("falls back to assigned-agent runtime model when no runtime marker exists", async () => {
const { fetchSettings, fetchAgent } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
} as any);
vi.mocked(fetchAgent).mockResolvedValueOnce({
id: "agent-1",
name: "Agent One",
role: "executor",
state: "active",
runtimeConfig: { model: "openai/gpt-4.1" },
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const }],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent", assignedAgentId: "agent-1", status: "executing", column: "in-progress" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
});
const expandButton = screen.getByTestId("agent-log-model-expand") as HTMLButtonElement;
if (expandButton.getAttribute("aria-expanded") !== "true") {
fireEvent.click(expandButton);
}
const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
expect(header.textContent).toContain("openai/gpt-4.1");
});
describe("step progress", () => {
it("renders step progress section when steps exist", () => {
const { container } = render(

View File

@@ -74,6 +74,32 @@ vi.mock("../agent-session-helpers.js", async () => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
resolveExecutorSessionModel: (
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Record<string, unknown> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
return { provider: settings.executionProvider as string, modelId: settings.executionModelId as string };
}
if (typeof settings?.executionGlobalProvider === "string" && typeof settings?.executionGlobalModelId === "string") {
return { provider: settings.executionGlobalProvider as string, modelId: settings.executionGlobalModelId as string };
}
if (typeof settings?.defaultProviderOverride === "string" && typeof settings?.defaultModelIdOverride === "string") {
return { provider: settings.defaultProviderOverride as string, modelId: settings.defaultModelIdOverride as string };
}
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
}
return { provider: undefined, modelId: undefined };
},
};
});
vi.mock("../worktree-names.js", async () => {
@@ -3919,8 +3945,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -3964,8 +3993,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4004,8 +4036,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4044,8 +4079,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "openai",
lastModelId: "gpt-4o",
lastResolvedModelProvider: "openai",
lastResolvedModelId: "gpt-4o",
lastTaskModelProvider: "openai",
lastTaskModelId: "gpt-4o",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4073,8 +4111,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4099,8 +4140,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({

View File

@@ -46,6 +46,32 @@ vi.mock("../agent-session-helpers.js", async () => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
resolveExecutorSessionModel: (
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Record<string, unknown> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
return { provider: settings.executionProvider as string, modelId: settings.executionModelId as string };
}
if (typeof settings?.executionGlobalProvider === "string" && typeof settings?.executionGlobalModelId === "string") {
return { provider: settings.executionGlobalProvider as string, modelId: settings.executionGlobalModelId as string };
}
if (typeof settings?.defaultProviderOverride === "string" && typeof settings?.defaultModelIdOverride === "string") {
return { provider: settings.defaultProviderOverride as string, modelId: settings.defaultModelIdOverride as string };
}
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
}
return { provider: undefined, modelId: undefined };
},
};
});
vi.mock("node:child_process", () => {

View File

@@ -562,6 +562,27 @@ vi.mock("../agent-session-helpers.js", async () => {
pi.promptWithFallback(session, prompt, options as any),
),
describeAgentModel: vi.fn(async (session: any) => pi.describeModel(session)),
resolveExecutorSessionModel: vi.fn((taskModelProvider?: string, taskModelId?: string, settings?: any, assignedAgentRuntimeConfig?: Record<string, unknown>) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (settings?.executionProvider && settings?.executionModelId) {
return { provider: settings.executionProvider, modelId: settings.executionModelId };
}
if (settings?.executionGlobalProvider && settings?.executionGlobalModelId) {
return { provider: settings.executionGlobalProvider, modelId: settings.executionGlobalModelId };
}
if (settings?.defaultProviderOverride && settings?.defaultModelIdOverride) {
return { provider: settings.defaultProviderOverride, modelId: settings.defaultModelIdOverride };
}
if (settings?.defaultProvider && settings?.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return { provider: undefined, modelId: undefined };
}),
};
});

View File

@@ -11,6 +11,7 @@ import type { AgentRuntimeOptions } from "./agent-runtime.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { resolveTaskExecutionModel, type Settings } from "@fusion/core";
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
import { createLogger } from "./logger.js";
import { promptWithFallback, describeModel } from "./pi.js";
@@ -112,6 +113,31 @@ export function extractRuntimeModel(
};
}
export function resolveExecutorSessionModel(
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
): { provider: string | undefined; modelId: string | undefined } {
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
return assignedRuntimeModel;
}
const resolvedTaskModel = resolveTaskExecutionModel(
{
modelProvider: taskModelProvider,
modelId: taskModelId,
},
settings,
);
return {
provider: resolvedTaskModel.provider,
modelId: resolvedTaskModel.modelId,
};
}
/**
* Create an agent session using runtime resolution.
*

View File

@@ -25,7 +25,11 @@ import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import {
createResolvedAgentSession,
extractRuntimeHint,
resolveExecutorSessionModel,
} from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
@@ -498,40 +502,6 @@ function getExecutorSystemPrompt(settings: Settings): string {
return customPrompt || EXECUTOR_SYSTEM_PROMPT;
}
function resolveExecutorModelPair(
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Partial<Settings> | undefined,
): { provider: string | undefined; modelId: string | undefined } {
if (taskModelProvider && taskModelId) {
return { provider: taskModelProvider, modelId: taskModelId };
}
if (settings?.executionProvider && settings?.executionModelId) {
return {
provider: settings.executionProvider,
modelId: settings.executionModelId,
};
}
if (settings?.executionGlobalProvider && settings?.executionGlobalModelId) {
return {
provider: settings.executionGlobalProvider,
modelId: settings.executionGlobalModelId,
};
}
if (settings?.defaultProviderOverride && settings?.defaultModelIdOverride) {
return {
provider: settings.defaultProviderOverride,
modelId: settings.defaultModelIdOverride,
};
}
if (settings?.defaultProvider && settings?.defaultModelId) {
return {
provider: settings.defaultProvider,
modelId: settings.defaultModelId,
};
}
return { provider: undefined, modelId: undefined };
}
export interface TaskExecutorOptions {
semaphore?: AgentSemaphore;
@@ -573,8 +543,11 @@ export class TaskExecutor {
private activeSessions = new Map<string, {
session: AgentSession;
seenSteeringIds: Set<string>;
lastModelProvider?: string | null;
lastModelId?: string | null;
lastResolvedModelProvider?: string;
lastResolvedModelId?: string;
lastTaskModelProvider?: string | null;
lastTaskModelId?: string | null;
lastAssignedAgentId?: string | null;
}>();
/** Active step-session executors per task (mutually exclusive with activeSessions). */
private activeStepExecutors = new Map<string, StepSessionExecutor>();
@@ -1030,21 +1003,32 @@ export class TaskExecutor {
// Handle executor model hot-swap on active single-session executions
if (this.activeSessions.has(task.id) && !task.paused) {
const activeEntry = this.activeSessions.get(task.id)!;
const providerChanged = task.modelProvider !== activeEntry.lastModelProvider;
const modelIdChanged = task.modelId !== activeEntry.lastModelId;
const taskModelProviderChanged = task.modelProvider !== activeEntry.lastTaskModelProvider;
const taskModelIdChanged = task.modelId !== activeEntry.lastTaskModelId;
const assignedAgentChanged = (task.assignedAgentId ?? null) !== (activeEntry.lastAssignedAgentId ?? null);
if (providerChanged || modelIdChanged) {
activeEntry.lastModelProvider = task.modelProvider;
activeEntry.lastModelId = task.modelId;
if (taskModelProviderChanged || taskModelIdChanged || assignedAgentChanged) {
activeEntry.lastTaskModelProvider = task.modelProvider;
activeEntry.lastTaskModelId = task.modelId;
activeEntry.lastAssignedAgentId = task.assignedAgentId ?? null;
const settings = await this.store.getSettings();
// Resolve model using canonical lane hierarchy for hot-swap
const { provider: newProvider, modelId: newModelId } = resolveExecutorModelPair(
const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId);
const { provider: newProvider, modelId: newModelId } = resolveExecutorSessionModel(
task.modelProvider,
task.modelId,
settings,
assignedRuntimeConfig,
);
const providerChanged = newProvider !== activeEntry.lastResolvedModelProvider;
const modelIdChanged = newModelId !== activeEntry.lastResolvedModelId;
if (!providerChanged && !modelIdChanged) {
return;
}
activeEntry.lastResolvedModelProvider = newProvider;
activeEntry.lastResolvedModelId = newModelId;
if (newProvider && newModelId) {
try {
const model = this.modelRegistry.find(newProvider, newModelId);
@@ -1685,7 +1669,7 @@ export class TaskExecutor {
private async executeReviewHandoff(
task: Task,
_session: AgentSession,
_sessionEntry: { session: AgentSession; seenSteeringIds: Set<string>; lastModelProvider?: string | null; lastModelId?: string | null },
_sessionEntry: { session: AgentSession; seenSteeringIds: Set<string>; lastResolvedModelProvider?: string; lastResolvedModelId?: string; lastTaskModelProvider?: string | null; lastTaskModelId?: string | null; lastAssignedAgentId?: string | null },
): Promise<void> {
try {
executorLog.log(`Executing review handoff for ${task.id}`);
@@ -1875,6 +1859,15 @@ export class TaskExecutor {
return activeRun !== null;
}
private async getAssignedAgentRuntimeConfig(
assignedAgentId: string | null | undefined,
): Promise<Record<string, unknown> | undefined> {
const normalizedId = assignedAgentId?.trim();
if (!normalizedId || !this.options.agentStore) return undefined;
const agent = await this.options.agentStore.getAgent(normalizedId).catch(() => null);
return (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
}
/**
* Re-dispatch execute() for any unstarted in-progress task belonging to the
* given agent. Called after a heartbeat run completes to unblock tasks that
@@ -2398,6 +2391,7 @@ export class TaskExecutor {
stuckTaskDetector: this.options.stuckTaskDetector,
pluginRunner: this.options.pluginRunner,
runtimeHint: stepSessionRuntimeHint,
assignedAgentRuntimeConfig: (stepSessionAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
// Pass skill selection context from the main executor session
skillSelection: skillContext.skillSelectionContext,
// Pass agentStore and messageStore for delegation and messaging tools
@@ -2892,10 +2886,11 @@ export class TaskExecutor {
// 3. Global execution lane pair (executionGlobalProvider + executionGlobalModelId)
// 4. Project default override pair (defaultProviderOverride + defaultModelIdOverride)
// 5. Global default pair (defaultProvider + defaultModelId)
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
detail.modelProvider,
detail.modelId,
settings,
(assignedAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
);
const executorFallbackProvider = settings.fallbackProvider;
const executorFallbackModelId = settings.fallbackModelId;
@@ -2962,17 +2957,20 @@ export class TaskExecutor {
}),
});
const executorModelDesc = describeModel(session);
const executorModelMarker = `Executor using model: ${executorModelDesc}`;
if (isResuming) {
executorLog.log(`${task.id}: resumed session from ${task.sessionFile}`);
await this.store.logEntry(task.id, `Resumed agent session after unpause (model: ${describeModel(session)})`, undefined, this.currentRunContext);
await this.store.logEntry(task.id, `Resumed agent session after unpause (model: ${executorModelDesc})`, undefined, this.currentRunContext);
} else {
executorLog.log(`${task.id}: using model ${describeModel(session)}`);
await this.store.logEntry(task.id, `Executor using model: ${describeModel(session)}`, undefined, this.currentRunContext);
executorLog.log(`${task.id}: using model ${executorModelDesc}`);
await this.store.logEntry(task.id, executorModelMarker, undefined, this.currentRunContext);
// Persist session file path so pause/resume can reopen it
if (sessionFile) {
await this.store.updateTask(task.id, { sessionFile });
}
}
await this.store.appendAgentLog(task.id, executorModelMarker, "text", undefined, "executor");
// Make session available to custom tools (fn_task_update checkpoint capture, fn_review_step rewind)
sessionRef.current = session;
@@ -2988,8 +2986,11 @@ export class TaskExecutor {
this.activeSessions.set(task.id, {
session,
seenSteeringIds,
lastModelProvider: detail.modelProvider,
lastModelId: detail.modelId,
lastResolvedModelProvider: executorProvider,
lastResolvedModelId: executorModelId,
lastTaskModelProvider: detail.modelProvider,
lastTaskModelId: detail.modelId,
lastAssignedAgentId: detail.assignedAgentId ?? null,
});
// Register with stuck task detector for heartbeat monitoring
@@ -3274,8 +3275,11 @@ export class TaskExecutor {
this.activeSessions.set(task.id, {
session: retrySession,
seenSteeringIds,
lastModelProvider: detail.modelProvider,
lastModelId: detail.modelId,
lastResolvedModelProvider: executorProvider,
lastResolvedModelId: executorModelId,
lastTaskModelProvider: detail.modelProvider,
lastTaskModelId: detail.modelId,
lastAssignedAgentId: detail.assignedAgentId ?? null,
});
stuckDetector?.trackTask(task.id, retrySession);
@@ -4563,10 +4567,12 @@ ${feedback}
}
// Resolve model using the executor's model hierarchy
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId);
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
task.modelProvider,
task.modelId,
settings,
assignedRuntimeConfig,
);
// Create the fix agent session
@@ -6919,7 +6925,7 @@ Child agent: ${agent.id} (${name})`;
// honor project executionProvider/executionModelId overrides (parity
// with main executor at the top of agentWork()).
const { provider: childExecutorProvider, modelId: childExecutorModelId } =
resolveExecutorModelPair(undefined, undefined, settings);
resolveExecutorSessionModel(undefined, undefined, settings, agent.runtimeConfig as Record<string, unknown> | undefined);
// Create child agent session
const { session: childSession } = await createResolvedAgentSession({

View File

@@ -553,9 +553,12 @@ export async function reviewStep(
throw err;
}
reviewerLog.log(`${taskId}: reviewer using model ${describeModel(session)}`);
const reviewerModelDesc = describeModel(session);
const reviewerModelMarker = `Reviewer using model: ${reviewerModelDesc}`;
reviewerLog.log(`${taskId}: reviewer using model ${reviewerModelDesc}`);
if (options.store && options.taskId) {
await options.store.logEntry(options.taskId, `Reviewer using model: ${describeModel(session)}`);
await options.store.logEntry(options.taskId, reviewerModelMarker);
await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined);
}
// Notify the caller so it can track this session in a per-task subagent map.

View File

@@ -23,6 +23,7 @@ import {
createResolvedAgentSession,
describeAgentModel,
promptWithAutoRetry,
resolveExecutorSessionModel,
} from "./agent-session-helpers.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import { generateWorktreeName } from "./worktree-names.js";
@@ -96,6 +97,8 @@ export interface StepSessionExecutorOptions {
pluginRunner?: import("./plugin-runner.js").PluginRunner;
/** Optional runtime hint resolved from assigned agent runtimeConfig. */
runtimeHint?: string;
/** Optional assigned-agent runtime config for model override precedence. */
assignedAgentRuntimeConfig?: Record<string, unknown>;
/** Callback invoked when a step starts executing. */
onStepStart?: (stepIndex: number) => void;
/** Callback invoked when a step completes (success or failure). */
@@ -564,40 +567,6 @@ interface SessionHandle {
abortBash: () => void;
}
function resolveExecutorModelPair(
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Partial<Settings> | undefined,
): { provider: string | undefined; modelId: string | undefined } {
if (taskModelProvider && taskModelId) {
return { provider: taskModelProvider, modelId: taskModelId };
}
if (settings?.executionProvider && settings?.executionModelId) {
return {
provider: settings.executionProvider,
modelId: settings.executionModelId,
};
}
if (settings?.executionGlobalProvider && settings?.executionGlobalModelId) {
return {
provider: settings.executionGlobalProvider,
modelId: settings.executionGlobalModelId,
};
}
if (settings?.defaultProviderOverride && settings?.defaultModelIdOverride) {
return {
provider: settings.defaultProviderOverride,
modelId: settings.defaultModelIdOverride,
};
}
if (settings?.defaultProvider && settings?.defaultModelId) {
return {
provider: settings.defaultProvider,
modelId: settings.defaultModelId,
};
}
return { provider: undefined, modelId: undefined };
}
/** Fallback store used when step logging persistence is not configured. */
const NOOP_TASK_STORE: Pick<TaskStore, "appendAgentLog"> = {
@@ -961,10 +930,11 @@ export class StepSessionExecutor {
// 3. Global execution lane pair (settings.executionGlobalProvider + settings.executionGlobalModelId)
// 4. Project default override pair (settings.defaultProviderOverride + settings.defaultModelIdOverride)
// 5. Global default pair (settings.defaultProvider + settings.defaultModelId)
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
taskDetail.modelProvider,
taskDetail.modelId,
settings,
this.options.assignedAgentRuntimeConfig,
);
const createResult = await createResolvedAgentSession({