feat(FN-3703): implement assigned-agent triage inheritance in engine and tr

Implements assigned-agent triage inheritance (FN-3703), allowing triage logic to be delegated to the assigned agent rather than always routing through the original owner, with test coverage and documentation. Also adds immediate wake controls for agent inbox and message API (FN-3087), wires shared s

Fusion-Task-Id: FN-3703
This commit is contained in:
Fusion
2026-05-07 11:18:56 -07:00
committed by gsxdsm
parent 13808fc5e7
commit edbc8b2535
8 changed files with 311 additions and 56 deletions

View File

@@ -72,6 +72,43 @@ vi.mock("../agent-session-helpers.js", async () => {
}
return { provider: undefined, modelId: undefined };
},
resolvePlanningSessionModel: (
taskPlanningModelProvider: string | undefined,
taskPlanningModelId: 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 (
typeof assignedAgentRuntimeConfig?.modelProvider === "string"
&& typeof assignedAgentRuntimeConfig?.modelId === "string"
) {
return {
provider: assignedAgentRuntimeConfig.modelProvider,
modelId: assignedAgentRuntimeConfig.modelId,
};
}
if (taskPlanningModelProvider && taskPlanningModelId) {
return { provider: taskPlanningModelProvider, modelId: taskPlanningModelId };
}
if (typeof settings?.planningProvider === "string" && typeof settings?.planningModelId === "string") {
return { provider: settings.planningProvider as string, modelId: settings.planningModelId as string };
}
if (typeof settings?.planningGlobalProvider === "string" && typeof settings?.planningGlobalModelId === "string") {
return { provider: settings.planningGlobalProvider as string, modelId: settings.planningGlobalModelId 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

@@ -2419,8 +2419,8 @@ describe("taskCreate tool model inheritance", () => {
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
provider: "anthropic",
modelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as Settings),
@@ -2451,8 +2451,8 @@ describe("taskCreate tool model inheritance", () => {
// Per-task override should take precedence over settings
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "google",
defaultModelId: "gemini-2.5-pro",
provider: "google",
modelId: "gemini-2.5-pro",
}),
);
});
@@ -2516,8 +2516,8 @@ describe("taskCreate tool model inheritance", () => {
// Should use settings planning model when no per-task override
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
provider: "openai",
modelId: "gpt-4o",
}),
);
});
@@ -2581,8 +2581,8 @@ describe("taskCreate tool model inheritance", () => {
// Should use project default override when planning lanes are absent
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
provider: "openai",
modelId: "gpt-4o",
}),
);
});
@@ -2646,8 +2646,8 @@ describe("taskCreate tool model inheritance", () => {
// Incomplete override should fall through to global defaults
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
provider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
);
});
@@ -2709,12 +2709,186 @@ describe("taskCreate tool model inheritance", () => {
// Should fall back to global defaults
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
provider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
);
});
});
describe("assigned-agent triage inheritance", () => {
it("injects assigned-agent identity into triage system prompt", async () => {
const task = createTriageTask({ id: "FN-AGENT-001", assignedAgentId: "agent-007" });
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
});
const mockAgentStore = {
getAgent: vi.fn().mockResolvedValue({
id: "agent-007",
name: "Atlas",
title: "Senior Planner",
role: "executor",
soul: "Think in milestones.",
instructionsText: "Always preserve rollout safety.",
memory: "Atlas memory context",
}),
};
let capturedArgs: any;
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedArgs = opts;
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
agentStore: mockAgentStore as any,
});
await processor.specifyTask(task);
expect(capturedArgs.systemPrompt).toContain("## Identity");
expect(capturedArgs.systemPrompt).toContain("You are Atlas, Senior Planner");
expect(capturedArgs.systemPrompt).toContain("agent ID: agent-007");
});
it("prefers assigned-agent runtime model and falls back when incomplete", async () => {
const completeRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-1", assignedAgentId: "agent-model-complete" });
const incompleteRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-2", assignedAgentId: "agent-model-incomplete" });
const store = createMockStore({
getTask: vi.fn()
.mockResolvedValueOnce({ ...completeRuntimeTask, attachments: [] })
.mockResolvedValueOnce({ ...incompleteRuntimeTask, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
planningProvider: "openai",
planningModelId: "gpt-4o",
} as Settings),
});
const mockAgentStore = {
getAgent: vi.fn().mockImplementation(async (id: string) => {
if (id === "agent-model-complete") {
return {
id,
name: "Model Agent",
role: "executor",
runtimeConfig: {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
},
};
}
return {
id,
name: "Incomplete Model Agent",
role: "executor",
runtimeConfig: {
modelProvider: "anthropic",
},
};
}),
};
const capturedArgs: any[] = [];
mockCreateFnAgent.mockImplementation(async (opts: any) => {
capturedArgs.push(opts);
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
agentStore: mockAgentStore as any,
});
await processor.specifyTask(completeRuntimeTask);
await processor.specifyTask(incompleteRuntimeTask);
const completeCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-1");
const fallbackCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-2");
expect(completeCall).toMatchObject({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(fallbackCall).toMatchObject({ provider: "openai", modelId: "gpt-4o" });
});
it("passes assigned agent memory context into triage memory tools", async () => {
const rootDir = await createTriageFixtureRoot("fusion-triage-agent-memory-");
try {
const task = createTriageTask({ id: "FN-AGENT-MEM-001", assignedAgentId: "agent-memory-1" });
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryBackendType: "file",
} as Settings),
});
const mockAgentStore = {
getAgent: vi.fn().mockResolvedValue({
id: "agent-memory-1",
name: "Memory Agent",
role: "executor",
memory: "The launch runway is blocked by migration sequencing.",
}),
};
let capturedArgs: any;
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedArgs = opts;
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, rootDir, {
pollIntervalMs: 100_000,
agentStore: mockAgentStore as any,
});
await processor.specifyTask(task);
const memorySearchTool = capturedArgs.customTools.find((tool: any) => tool.name === "fn_memory_search");
expect(memorySearchTool).toBeDefined();
const result = await memorySearchTool.execute("tool-run", { query: "runway", limit: 5 });
expect(result.details.results.some((hit: any) => String(hit.path).includes(".fusion/agent-memory/agent-memory-1/MEMORY.md"))).toBe(true);
} finally {
await cleanupTriageFixtureRoot(rootDir);
}
});
});
});
describe("computeUserCommentFingerprint", () => {

View File

@@ -11,7 +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 { resolveTaskExecutionModel, resolveTaskPlanningModel, 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";
@@ -138,6 +138,31 @@ export function resolveExecutorSessionModel(
};
}
export function resolvePlanningSessionModel(
taskPlanningModelProvider: string | undefined,
taskPlanningModelId: 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 resolvedTaskPlanningModel = resolveTaskPlanningModel(
{
planningModelProvider: taskPlanningModelProvider,
planningModelId: taskPlanningModelId,
},
settings,
);
return {
provider: resolvedTaskPlanningModel.provider,
modelId: resolvedTaskPlanningModel.modelId,
};
}
/**
* Create an agent session using runtime resolution.
*

View File

@@ -475,9 +475,10 @@ export class PeerExchangeService {
const result = await core.applyProjectSettingsSnapshot(sharedState.projectSettings);
if (sharedState.authMaterial && core.applyAuthMaterialSnapshot) {
const authResult = core.applyAuthMaterialSnapshot(sharedState.authMaterial);
const authResultWithCount = authResult as { authCount?: number };
const authCount =
typeof (authResult as { authCount?: number }).authCount === "number"
? (authResult as { authCount: number }).authCount
typeof authResultWithCount.authCount === "number"
? authResultWithCount.authCount
: Object.keys(sharedState.authMaterial.payload.providerAuth ?? {}).length;
return { ...result, authCount: Math.max(result.authCount, authCount) };
}

View File

@@ -19,13 +19,18 @@ import type {
AgentSession,
} from "@mariozechner/pi-coding-agent";
import { describeModel, promptWithFallback } from "./pi.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import {
createResolvedAgentSession,
extractRuntimeHint,
resolvePlanningSessionModel,
} from "./agent-session-helpers.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
import { AgentLogger } from "./agent-logger.js";
import {
resolveAgentInstructions,
resolveAgentInstructionsWithRatings,
buildSystemPromptWithInstructions,
buildPluginPromptSection,
} from "./agent-instructions.js";
@@ -934,6 +939,10 @@ export class TriageProcessor {
// Track subtasks created during triage when breakIntoSubtasks was requested.
const createdSubtasksRef: { current: string[] } = { current: [] };
const assignedAgent = task.assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
: null;
const customTools = [
...this.createTriageTools({
parentTaskId: task.id,
@@ -949,7 +958,15 @@ export class TriageProcessor {
getSettings: async () => this.store.getSettings(),
})
: []),
...createMemoryTools(this.rootDir, settings),
...createMemoryTools(this.rootDir, settings, assignedAgent
? {
agentMemory: {
agentId: assignedAgent.id,
agentName: assignedAgent.name,
memory: assignedAgent.memory,
},
}
: undefined),
// Agent delegation tools — discover and delegate work to other agents.
...(this.options.agentStore ? [
createListAgentsTool(this.options.agentStore),
@@ -967,19 +984,22 @@ export class TriageProcessor {
),
];
const assignedAgent = task.assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
: null;
let triageRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
// Resolve per-agent custom instructions for the triage role
// Resolve per-agent custom instructions for the triage role or assigned agent.
let triageInstructions = "";
if (this.options.agentStore) {
if (assignedAgent) {
triageInstructions = await resolveAgentInstructionsWithRatings(
assignedAgent,
this.rootDir,
this.options.agentStore,
);
} else if (this.options.agentStore) {
try {
const agents = await this.options.agentStore.listAgents({ role: "triage" });
for (const agent of agents) {
triageRuntimeHint ??= extractRuntimeHint(agent.runtimeConfig);
if (agent.instructionsText || agent.instructionsPath) {
if (agent.instructionsText || agent.instructionsPath || agent.soul || agent.memory) {
triageInstructions = await resolveAgentInstructions(agent, this.rootDir);
break;
}
@@ -990,10 +1010,13 @@ export class TriageProcessor {
}
}
planLog.log(`${task.id}: planning in ${isFast ? "fast" : "standard"} mode`);
const triageIdentitySection = assignedAgent
? `## Identity\n\nYou are ${assignedAgent.name}${assignedAgent.title?.trim() ? `, ${assignedAgent.title.trim()}` : ""} (agent ID: ${assignedAgent.id}, role: ${assignedAgent.role}).`
: "";
const triageSystemPrompt = buildSystemPromptWithInstructions(
resolveAgentPrompt("triage", settings.agentPrompts)
|| (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : TRIAGE_SYSTEM_PROMPT),
triageInstructions,
[triageIdentitySection, triageInstructions].filter((section) => section.trim()).join("\n\n"),
);
const triageContributions = this.options.pluginRunner
?.getPromptContributionsForSurface("triage")
@@ -1030,30 +1053,16 @@ export class TriageProcessor {
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
// Resolve planning model using canonical lane hierarchy:
// 1. Task planning override pair (planningModelProvider + planningModelId)
// 2. Project planning lane pair (planningProvider + planningModelId)
// 3. Global planning lane pair (planningGlobalProvider + planningGlobalModelId)
// 4. Project default override pair (defaultProviderOverride + defaultModelIdOverride)
// 5. Global default pair (defaultProvider + defaultModelId)
defaultProvider: task.planningModelProvider && task.planningModelId
? task.planningModelProvider
: (settings.planningProvider && settings.planningModelId
? settings.planningProvider
: (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalProvider
: (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultProviderOverride
: settings.defaultProvider))),
defaultModelId: task.planningModelProvider && task.planningModelId
? task.planningModelId
: (settings.planningProvider && settings.planningModelId
? settings.planningModelId
: (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalModelId
: (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId))),
// Resolve planning model using executor-style precedence:
// 1. Assigned durable agent runtime model pair when complete
// 2. Task planning override pair
// 3. Planning/project/global fallbacks
...resolvePlanningSessionModel(
task.planningModelProvider,
task.planningModelId,
settings,
assignedAgent?.runtimeConfig,
),
fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId
? settings.planningFallbackProvider
: settings.fallbackProvider,