feat(FN-1511): add skill selection resolver for deterministic session skills
- Add SkillSelectionResolver and SkillSelectionContext for deterministic skill sets - Implement skill-resolver.ts with request/filter/resolve workflow - Add session-skill-context.ts helper for unified skill selection across all session types - Wire skill override into createKbAgent via AgentOptions - Apply skill selection to triage, executor, reviewer, merger, and heartbeat sessions - Add comprehensive tests for skill-resolver and session-skill-context - Export skill resolver types and functions from engine index - Add skill selection resolver pattern to memory documentation
This commit is contained in:
@@ -905,6 +905,7 @@ export class HeartbeatMonitor {
|
||||
|
||||
// Lazy-load createKbAgent and promptWithFallback
|
||||
const { createKbAgent, promptWithFallback } = await import("./pi.js");
|
||||
const { buildSessionSkillContextSync } = await import("./session-skill-context.js");
|
||||
|
||||
// Build tools with task creation tracking and run context for mutation correlation
|
||||
const heartbeatTools = this.createHeartbeatTools(agentId, taskStore, taskId, runContext, audit);
|
||||
@@ -916,6 +917,9 @@ export class HeartbeatMonitor {
|
||||
agent: agent.role as AgentRole,
|
||||
});
|
||||
|
||||
// Build skill selection context for heartbeat session (uses waking agent's skills, no role fallback)
|
||||
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir);
|
||||
|
||||
// Create agent session
|
||||
const { session } = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
@@ -939,6 +943,8 @@ export class HeartbeatMonitor {
|
||||
toolCallCount++;
|
||||
agentLogger?.onToolEnd(name, isError, result);
|
||||
},
|
||||
// Skill selection: use waking agent's skills (heartbeat has no role fallback)
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
// Track for monitoring
|
||||
|
||||
@@ -12,6 +12,7 @@ import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createKbAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
|
||||
import { AuthStorage, ModelRegistry, SessionManager, getAgentDir, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
|
||||
@@ -1123,6 +1124,15 @@ export class TaskExecutor {
|
||||
// When runStepsInNewSessions is enabled, each step runs in its own
|
||||
// fresh agent session via StepSessionExecutor. Otherwise, the existing
|
||||
// single-session flow runs all steps in one monolithic session.
|
||||
|
||||
// Build skill selection context early so it's available in both paths
|
||||
const skillContext = await buildSessionSkillContext({
|
||||
agentStore: this.options.agentStore!,
|
||||
task: detail,
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir: this.rootDir,
|
||||
});
|
||||
|
||||
if (settings.runStepsInNewSessions) {
|
||||
// ── Step-Session Path ──────────────────────────────────────────
|
||||
executorLog.log(`${task.id}: using step-session mode (maxParallel=${settings.maxParallelSteps ?? 2})`);
|
||||
@@ -1136,6 +1146,8 @@ export class TaskExecutor {
|
||||
semaphore: this.options.semaphore,
|
||||
stuckTaskDetector: this.options.stuckTaskDetector,
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
// Pass skill selection context from the main executor session
|
||||
skillSelection: skillContext.skillSelectionContext,
|
||||
onStepStart: (stepIndex) => {
|
||||
this.options.stuckTaskDetector?.recordProgress(task.id);
|
||||
try {
|
||||
@@ -1436,6 +1448,8 @@ export class TaskExecutor {
|
||||
fallbackModelId: executorFallbackModelId,
|
||||
defaultThinkingLevel: executorThinkingLevel,
|
||||
sessionManager,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
if (isResuming) {
|
||||
@@ -1666,6 +1680,8 @@ export class TaskExecutor {
|
||||
fallbackModelId: executorFallbackModelId,
|
||||
defaultThinkingLevel: executorThinkingLevel,
|
||||
sessionManager: SessionManager.create(worktreePath),
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
// Update session file for the retry session (so pause/resume works)
|
||||
if (retrySessionFile) {
|
||||
@@ -2323,6 +2339,7 @@ export class TaskExecutor {
|
||||
validatorFallbackModelId: settings.validatorFallbackModelId,
|
||||
store,
|
||||
taskId,
|
||||
task: detail,
|
||||
agentPrompts: settings.agentPrompts,
|
||||
agentStore: this.options.agentStore,
|
||||
rootDir: this.rootDir,
|
||||
@@ -3091,6 +3108,14 @@ and show an appropriate message to the user.\`
|
||||
const stepInstructions = await this.resolveInstructionsForRole("executor");
|
||||
const stepSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, stepInstructions);
|
||||
|
||||
// Build skill selection context for workflow step session
|
||||
const skillContext = await buildSessionSkillContext({
|
||||
agentStore: this.options.agentStore!,
|
||||
task,
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir: this.rootDir,
|
||||
});
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd: worktreePath,
|
||||
systemPrompt: stepSystemPrompt,
|
||||
@@ -3100,6 +3125,8 @@ and show an appropriate message to the user.\`
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
|
||||
@@ -3911,6 +3938,15 @@ and show an appropriate message to the user.\`
|
||||
const childBasePrompt = `You are a child agent spawned by a parent task executor. Your job is to complete the following delegated task. Work autonomously and thoroughly. Report your findings and results.\n\nParent task: ${taskId}\nChild agent: ${agent.id} (${name})`;
|
||||
const childSystemPrompt = buildSystemPromptWithInstructions(childBasePrompt, childInstructions);
|
||||
|
||||
// Build skill selection context for child agent session
|
||||
const childTask = await this.store.getTask(taskId);
|
||||
const skillContext = await buildSessionSkillContext({
|
||||
agentStore: this.options.agentStore!,
|
||||
task: childTask,
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir: this.rootDir,
|
||||
});
|
||||
|
||||
// Create child agent session
|
||||
const { session: childSession } = await createKbAgent({
|
||||
cwd: childWorktreePath,
|
||||
@@ -3920,6 +3956,8 @@ and show an appropriate message to the user.\`
|
||||
defaultModelId: settings.defaultModelId,
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
// Store tracking state
|
||||
|
||||
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
||||
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
|
||||
import { resolveAgentPrompt } from "@fusion/core";
|
||||
import { createKbAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { mergerLog } from "./logger.js";
|
||||
@@ -2018,6 +2019,22 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
mergerInstructions,
|
||||
);
|
||||
|
||||
// Build skill selection context (assigned agent skills take precedence over role fallback)
|
||||
let skillContext = undefined;
|
||||
if (options.agentStore) {
|
||||
try {
|
||||
const task = await store.getTask(taskId);
|
||||
skillContext = await buildSessionSkillContext({
|
||||
agentStore: options.agentStore,
|
||||
task,
|
||||
sessionPurpose: "merger",
|
||||
projectRootDir: rootDir,
|
||||
});
|
||||
} catch {
|
||||
// Graceful fallback - no skill selection
|
||||
}
|
||||
}
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: mergerSystemPrompt,
|
||||
@@ -2030,6 +2047,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
options.onSession?.(session);
|
||||
@@ -2472,6 +2491,22 @@ If issues are found that need attention, describe them clearly.`;
|
||||
}
|
||||
const postMergeSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, postMergeInstructions);
|
||||
|
||||
// Build skill selection context for post-merge session
|
||||
let postMergeSkillContext = undefined;
|
||||
if (mergeOptions.agentStore) {
|
||||
try {
|
||||
const task = await store.getTask(taskId);
|
||||
postMergeSkillContext = await buildSessionSkillContext({
|
||||
agentStore: mergeOptions.agentStore,
|
||||
task,
|
||||
sessionPurpose: "merger",
|
||||
projectRootDir: rootDir,
|
||||
});
|
||||
} catch {
|
||||
// Graceful fallback - no skill selection
|
||||
}
|
||||
}
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: postMergeSystemPrompt,
|
||||
@@ -2481,6 +2516,8 @@ If issues are found that need attention, describe them clearly.`;
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(postMergeSkillContext?.skillSelectionContext ? { skillSelection: postMergeSkillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
mergerLog.log(`${taskId}: [post-merge] workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { TaskStore, TaskComment, AgentPromptsConfig } from "@fusion/core";
|
||||
import { resolveAgentPrompt } from "@fusion/core";
|
||||
import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { reviewerLog } from "./logger.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
@@ -208,6 +209,8 @@ export interface ReviewOptions {
|
||||
store?: TaskStore;
|
||||
/** Task ID for agent log persistence. Required alongside `store`. */
|
||||
taskId?: string;
|
||||
/** Task with optional assignedAgentId for skill selection. */
|
||||
task?: { assignedAgentId?: string | null };
|
||||
/** User comments on the task (author === "user"). For spec reviews, the reviewer explicitly checks that every comment is addressed. */
|
||||
userComments?: TaskComment[];
|
||||
/** Agent prompt configuration for resolving custom reviewer prompts. */
|
||||
@@ -283,6 +286,21 @@ export async function reviewStep(
|
||||
reviewerInstructions,
|
||||
);
|
||||
|
||||
// Build skill selection context (assigned agent skills take precedence over role fallback)
|
||||
let skillContext = undefined;
|
||||
if (options.agentStore && options.rootDir) {
|
||||
try {
|
||||
skillContext = await buildSessionSkillContext({
|
||||
agentStore: options.agentStore,
|
||||
task: options.task ?? {},
|
||||
sessionPurpose: "reviewer",
|
||||
projectRootDir: options.rootDir,
|
||||
});
|
||||
} catch {
|
||||
// Graceful fallback - no skill selection
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn a reviewer agent with read-only tools
|
||||
const { session } = await createKbAgent({
|
||||
cwd,
|
||||
@@ -297,6 +315,8 @@ export async function reviewStep(
|
||||
fallbackProvider: validatorFallbackProvider,
|
||||
fallbackModelId: validatorFallbackModelId,
|
||||
defaultThinkingLevel: options.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
reviewerLog.log(`${taskId}: reviewer using model ${describeModel(session)}`);
|
||||
|
||||
357
packages/engine/src/session-skill-context.test.ts
Normal file
357
packages/engine/src/session-skill-context.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
normalizeAgentSkills,
|
||||
buildSessionSkillContext,
|
||||
buildSessionSkillContextSync,
|
||||
SKILL_DIAGNOSTIC_MESSAGES,
|
||||
type SessionPurpose,
|
||||
} from "./session-skill-context.js";
|
||||
import type { Agent } from "@fusion/core";
|
||||
import type { AgentStore } from "@fusion/core";
|
||||
|
||||
describe("normalizeAgentSkills", () => {
|
||||
it("returns empty array for non-array input", () => {
|
||||
expect(normalizeAgentSkills(undefined)).toEqual([]);
|
||||
expect(normalizeAgentSkills(null)).toEqual([]);
|
||||
expect(normalizeAgentSkills("string")).toEqual([]);
|
||||
expect(normalizeAgentSkills({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles string entries", () => {
|
||||
const skills = ["triage", "executor", "reviewer"];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor", "reviewer"]);
|
||||
});
|
||||
|
||||
it("handles object entries with name property", () => {
|
||||
const skills = [
|
||||
{ name: "triage" },
|
||||
{ name: "executor" },
|
||||
{ name: "reviewer" },
|
||||
];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor", "reviewer"]);
|
||||
});
|
||||
|
||||
it("handles mixed string and object entries", () => {
|
||||
const skills = [
|
||||
"triage",
|
||||
{ name: "executor" },
|
||||
{ name: "reviewer" },
|
||||
"merger",
|
||||
];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor", "reviewer", "merger"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from entries", () => {
|
||||
const skills = [" triage ", { name: " executor " }];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor"]);
|
||||
});
|
||||
|
||||
it("drops empty entries", () => {
|
||||
const skills = ["", "triage", " ", "executor", { name: "" }, { name: "reviewer" }];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor", "reviewer"]);
|
||||
});
|
||||
|
||||
it("drops invalid entries", () => {
|
||||
const skills = [
|
||||
123,
|
||||
null,
|
||||
{ foo: "bar" },
|
||||
"triage",
|
||||
undefined,
|
||||
{ name: "executor" },
|
||||
];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor"]);
|
||||
});
|
||||
|
||||
it("deduplicates while preserving first occurrence order", () => {
|
||||
const skills = ["triage", "executor", "triage", "reviewer", "executor"];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor", "reviewer"]);
|
||||
});
|
||||
|
||||
it("handles duplicate object entries", () => {
|
||||
const skills = [
|
||||
{ name: "triage" },
|
||||
{ name: "executor" },
|
||||
{ name: "triage" },
|
||||
];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["triage", "executor"]);
|
||||
});
|
||||
|
||||
it("handles case-sensitive deduplication", () => {
|
||||
const skills = ["Triage", "triage", "EXECUTOR", "executor"];
|
||||
expect(normalizeAgentSkills(skills)).toEqual(["Triage", "triage", "EXECUTOR", "executor"]);
|
||||
});
|
||||
|
||||
it("returns empty array for array of only invalid entries", () => {
|
||||
expect(normalizeAgentSkills([null, undefined, "", 123, {}])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionSkillContextSync", () => {
|
||||
const projectRootDir = "/test/project";
|
||||
|
||||
describe("assigned agent skills", () => {
|
||||
it("uses assigned agent skills when available", () => {
|
||||
const agent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: { skills: ["triage", "executor"] },
|
||||
} as unknown as Agent;
|
||||
|
||||
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("assigned-agent");
|
||||
expect(result.resolvedSkillNames).toEqual(["triage", "executor"]);
|
||||
expect(result.skillSelectionContext).toEqual({
|
||||
projectRootDir,
|
||||
requestedSkillNames: ["triage", "executor"],
|
||||
sessionPurpose: "executor",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses object-style agent skills", () => {
|
||||
const agent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: { skills: [{ name: "triage" }, { name: "executor" }] },
|
||||
} as unknown as Agent;
|
||||
|
||||
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("assigned-agent");
|
||||
expect(result.resolvedSkillNames).toEqual(["triage", "executor"]);
|
||||
});
|
||||
|
||||
it("falls back to role when agent has empty skills", () => {
|
||||
const agent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: { skills: [] },
|
||||
} as unknown as Agent;
|
||||
|
||||
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["executor"]);
|
||||
});
|
||||
|
||||
it("falls back to role when agent has no metadata", () => {
|
||||
const agent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: {},
|
||||
} as unknown as Agent;
|
||||
|
||||
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["executor"]);
|
||||
});
|
||||
|
||||
it("falls back to role when agent has no metadata.skills", () => {
|
||||
const agent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
} as unknown as Agent;
|
||||
|
||||
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["executor"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role fallback skills", () => {
|
||||
it("returns triage role fallback for triage purpose", () => {
|
||||
const result = buildSessionSkillContextSync(null, "triage", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["triage"]);
|
||||
});
|
||||
|
||||
it("returns executor role fallback for executor purpose", () => {
|
||||
const result = buildSessionSkillContextSync(null, "executor", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["executor"]);
|
||||
});
|
||||
|
||||
it("returns reviewer role fallback for reviewer purpose", () => {
|
||||
const result = buildSessionSkillContextSync(null, "reviewer", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["reviewer"]);
|
||||
});
|
||||
|
||||
it("returns merger role fallback for merger purpose", () => {
|
||||
const result = buildSessionSkillContextSync(null, "merger", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["merger"]);
|
||||
});
|
||||
|
||||
it("returns no skills for heartbeat purpose (no role fallback)", () => {
|
||||
const result = buildSessionSkillContextSync(null, "heartbeat", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("none");
|
||||
expect(result.resolvedSkillNames).toEqual([]);
|
||||
expect(result.skillSelectionContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses agent skills over role fallback", () => {
|
||||
const agent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: { skills: ["custom-skill-1", "custom-skill-2"] },
|
||||
} as unknown as Agent;
|
||||
|
||||
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
|
||||
|
||||
expect(result.skillSource).toBe("assigned-agent");
|
||||
expect(result.resolvedSkillNames).toEqual(["custom-skill-1", "custom-skill-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no skills available", () => {
|
||||
it("returns undefined context when no skills and no fallback", () => {
|
||||
const result = buildSessionSkillContextSync(null, "heartbeat", projectRootDir);
|
||||
|
||||
expect(result.skillSelectionContext).toBeUndefined();
|
||||
expect(result.resolvedSkillNames).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionSkillContext", () => {
|
||||
const projectRootDir = "/test/project";
|
||||
|
||||
it("uses assigned agent skills when available", async () => {
|
||||
const mockAgent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: { skills: ["triage", "executor"] },
|
||||
} as unknown as Agent;
|
||||
|
||||
const mockAgentStore = {
|
||||
getAgent: vi.fn().mockResolvedValue(mockAgent),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: { assignedAgentId: "agent-001" },
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir,
|
||||
});
|
||||
|
||||
expect(result.skillSource).toBe("assigned-agent");
|
||||
expect(result.resolvedSkillNames).toEqual(["triage", "executor"]);
|
||||
expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001");
|
||||
});
|
||||
|
||||
it("falls back to role when no assignedAgentId", async () => {
|
||||
const mockAgentStore = {
|
||||
getAgent: vi.fn(),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: {},
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir,
|
||||
});
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["executor"]);
|
||||
expect(mockAgentStore.getAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to role when assigned agent not found", async () => {
|
||||
const mockAgentStore = {
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: { assignedAgentId: "nonexistent" },
|
||||
sessionPurpose: "triage",
|
||||
projectRootDir,
|
||||
});
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["triage"]);
|
||||
});
|
||||
|
||||
it("falls back to role when agent lookup throws", async () => {
|
||||
const mockAgentStore = {
|
||||
getAgent: vi.fn().mockRejectedValue(new Error("DB error")),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: { assignedAgentId: "agent-001" },
|
||||
sessionPurpose: "reviewer",
|
||||
projectRootDir,
|
||||
});
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.resolvedSkillNames).toEqual(["reviewer"]);
|
||||
});
|
||||
|
||||
it("uses heartbeat with no skills when no assigned agent", async () => {
|
||||
const mockAgentStore = {
|
||||
getAgent: vi.fn(),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: {},
|
||||
sessionPurpose: "heartbeat",
|
||||
projectRootDir,
|
||||
});
|
||||
|
||||
expect(result.skillSource).toBe("none");
|
||||
expect(result.resolvedSkillNames).toEqual([]);
|
||||
expect(result.skillSelectionContext).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SKILL_DIAGNOSTIC_MESSAGES", () => {
|
||||
it("provides missing skill message template", () => {
|
||||
const msg = SKILL_DIAGNOSTIC_MESSAGES.missing("custom-skill");
|
||||
expect(msg).toBe('skill selection: requested skill "custom-skill" not found in discovered skills');
|
||||
});
|
||||
|
||||
it("provides filtered skill message template", () => {
|
||||
const msg = SKILL_DIAGNOSTIC_MESSAGES.filtered("custom-skill");
|
||||
expect(msg).toBe('skill selection: requested skill "custom-skill" filtered out by execution-enabled settings');
|
||||
});
|
||||
|
||||
it("provides assigned agent message template", () => {
|
||||
const msg = SKILL_DIAGNOSTIC_MESSAGES.assignedAgentSkills(3, "agent-001");
|
||||
expect(msg).toBe("Using skills from assigned agent agent-001 (3 skills)");
|
||||
});
|
||||
|
||||
it("provides role fallback message template", () => {
|
||||
const msg = SKILL_DIAGNOSTIC_MESSAGES.roleFallbackSkills("triage", ["triage"]);
|
||||
expect(msg).toBe("Using role fallback skills for triage: [triage]");
|
||||
});
|
||||
|
||||
it("provides no skills available message template", () => {
|
||||
const msg = SKILL_DIAGNOSTIC_MESSAGES.noSkillsAvailable("heartbeat");
|
||||
expect(msg).toBe("No skills available for heartbeat session (no assigned agent, no role fallback)");
|
||||
});
|
||||
});
|
||||
290
packages/engine/src/session-skill-context.ts
Normal file
290
packages/engine/src/session-skill-context.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Shared skill selection context helper for session creation.
|
||||
*
|
||||
* Centralizes requested-skill extraction from agent metadata and callback wiring
|
||||
* for consistent skill selection across all session types (triage, executor,
|
||||
* step-session, reviewer, merger, heartbeat).
|
||||
*
|
||||
* ## Precedence Rules
|
||||
*
|
||||
* 1. **Assigned Agent Skills**: If `task.assignedAgentId` resolves to an agent
|
||||
* with valid normalized skills in `agent.metadata.skills`, those skills are used.
|
||||
*
|
||||
* 2. **Role Fallback Skills**: If assigned agent is missing or has no valid skills,
|
||||
* use subsystem role fallback mapping:
|
||||
* - `triage` → `triage`
|
||||
* - `executor` / `step-session` → `executor`
|
||||
* - `reviewer` → `reviewer`
|
||||
* - `merger` → `merger`
|
||||
* - `heartbeat` → no role fallback (use waking agent only)
|
||||
*
|
||||
* 3. **No Skills**: If neither source provides valid skills, pass no requested skills.
|
||||
*
|
||||
* ## Normalization
|
||||
*
|
||||
* `metadata.skills` entries are normalized deterministically:
|
||||
* - String entries are trimmed and filtered for non-empty
|
||||
* - Object entries with `name` property are extracted and trimmed
|
||||
* - Invalid/empty entries are dropped
|
||||
* - Results are deduplicated preserving stable insertion order
|
||||
*/
|
||||
|
||||
import type { Agent } from "@fusion/core";
|
||||
import type { AgentStore } from "@fusion/core";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Session purpose for skill selection context.
|
||||
* Maps to role fallback skills when no assigned agent is available.
|
||||
*/
|
||||
export type SessionPurpose = "triage" | "executor" | "reviewer" | "merger" | "heartbeat";
|
||||
|
||||
/**
|
||||
* Input parameters for building session skill context.
|
||||
*/
|
||||
export interface SessionSkillContextInput {
|
||||
/** Agent store for looking up assigned agent */
|
||||
agentStore: AgentStore;
|
||||
/** Task with optional assignedAgentId */
|
||||
task: { assignedAgentId?: string | null };
|
||||
/** Purpose of the session (determines role fallback) */
|
||||
sessionPurpose: SessionPurpose;
|
||||
/** Absolute path to project root */
|
||||
projectRootDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of building session skill context.
|
||||
* Contains the SkillSelectionContext for createKbAgent and any diagnostics.
|
||||
*/
|
||||
export interface SessionSkillContextResult {
|
||||
/** Context to pass to createKbAgent's skillSelection option */
|
||||
skillSelectionContext: SkillSelectionContext | undefined;
|
||||
/** Normalized skill names that were resolved (for logging/debugging) */
|
||||
resolvedSkillNames: string[];
|
||||
/** Source of the skills: 'assigned-agent', 'role-fallback', or 'none' */
|
||||
skillSource: "assigned-agent" | "role-fallback" | "none";
|
||||
}
|
||||
|
||||
// ── Skill Normalization ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize agent metadata skills deterministically.
|
||||
* - Accepts string entries and object entries with `name` property
|
||||
* - Trims whitespace, drops invalid/empty entries, deduplicates
|
||||
* - Preserves stable insertion order
|
||||
*/
|
||||
export function normalizeAgentSkills(
|
||||
metadataSkills: unknown,
|
||||
): string[] {
|
||||
if (!Array.isArray(metadataSkills)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const entry of metadataSkills) {
|
||||
let name: string | undefined;
|
||||
|
||||
if (typeof entry === "string") {
|
||||
name = entry.trim();
|
||||
} else if (entry && typeof entry === "object") {
|
||||
const namedEntry = (entry as Record<string, unknown>).name;
|
||||
if (typeof namedEntry === "string") {
|
||||
name = namedEntry.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Skip invalid/empty entries and deduplicate
|
||||
if (name && name.length > 0 && !seen.has(name)) {
|
||||
seen.add(name);
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Role Fallback Mapping ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map session purpose to role fallback skill names.
|
||||
* Heartbeat has no role fallback (uses waking agent only).
|
||||
*/
|
||||
const ROLE_FALLBACK_SKILLS: Record<Exclude<SessionPurpose, "heartbeat">, string[]> = {
|
||||
triage: ["triage"],
|
||||
executor: ["executor"],
|
||||
reviewer: ["reviewer"],
|
||||
merger: ["merger"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get role fallback skill names for a session purpose.
|
||||
* Returns undefined for heartbeat (no role fallback).
|
||||
*/
|
||||
function getRoleFallbackSkills(
|
||||
sessionPurpose: SessionPurpose,
|
||||
): string[] | undefined {
|
||||
if (sessionPurpose === "heartbeat") {
|
||||
// No role fallback for heartbeat - uses waking agent only
|
||||
return undefined;
|
||||
}
|
||||
return ROLE_FALLBACK_SKILLS[sessionPurpose];
|
||||
}
|
||||
|
||||
// ── Diagnostic Message Templates ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shared diagnostic message templates for consistent logging.
|
||||
*/
|
||||
export const SKILL_DIAGNOSTIC_MESSAGES = {
|
||||
missing: (skillName: string): string =>
|
||||
`skill selection: requested skill "${skillName}" not found in discovered skills`,
|
||||
|
||||
filtered: (skillName: string): string =>
|
||||
`skill selection: requested skill "${skillName}" filtered out by execution-enabled settings`,
|
||||
|
||||
assignedAgentSkills: (count: number, agentId: string): string =>
|
||||
`Using skills from assigned agent ${agentId} (${count} skills)`,
|
||||
|
||||
roleFallbackSkills: (purpose: SessionPurpose, skills: string[]): string =>
|
||||
`Using role fallback skills for ${purpose}: [${skills.join(", ")}]`,
|
||||
|
||||
noSkillsAvailable: (purpose: SessionPurpose): string =>
|
||||
`No skills available for ${purpose} session (no assigned agent, no role fallback)`,
|
||||
} as const;
|
||||
|
||||
// ── Main Builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build session skill context for createKbAgent.
|
||||
*
|
||||
* Applies precedence rules:
|
||||
* 1. Use assigned agent skills if available
|
||||
* 2. Fall back to role-based skills if no assigned agent or no valid skills
|
||||
* 3. Skip skill selection entirely if neither source provides valid skills
|
||||
*
|
||||
* @param input - Session skill context input parameters
|
||||
* @returns Skill selection context result with diagnostics
|
||||
*/
|
||||
export async function buildSessionSkillContext(
|
||||
input: SessionSkillContextInput,
|
||||
): Promise<SessionSkillContextResult> {
|
||||
const { agentStore, task, sessionPurpose, projectRootDir } = input;
|
||||
const { assignedAgentId } = task;
|
||||
|
||||
// Rule 1: Check assigned agent
|
||||
if (assignedAgentId) {
|
||||
try {
|
||||
const agent = await agentStore.getAgent(assignedAgentId);
|
||||
if (agent) {
|
||||
const agentSkills = normalizeAgentSkills(
|
||||
(agent.metadata as Record<string, unknown> | undefined)?.skills,
|
||||
);
|
||||
|
||||
if (agentSkills.length > 0) {
|
||||
// Found valid skills from assigned agent
|
||||
const skillSelectionContext: SkillSelectionContext = {
|
||||
projectRootDir,
|
||||
requestedSkillNames: agentSkills,
|
||||
sessionPurpose,
|
||||
};
|
||||
|
||||
return {
|
||||
skillSelectionContext,
|
||||
resolvedSkillNames: agentSkills,
|
||||
skillSource: "assigned-agent",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Agent lookup failed - fall through to role fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: Use role fallback skills
|
||||
const roleFallbackSkills = getRoleFallbackSkills(sessionPurpose);
|
||||
|
||||
if (roleFallbackSkills && roleFallbackSkills.length > 0) {
|
||||
const skillSelectionContext: SkillSelectionContext = {
|
||||
projectRootDir,
|
||||
requestedSkillNames: roleFallbackSkills,
|
||||
sessionPurpose,
|
||||
};
|
||||
|
||||
return {
|
||||
skillSelectionContext,
|
||||
resolvedSkillNames: roleFallbackSkills,
|
||||
skillSource: "role-fallback",
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 3: No skills available
|
||||
return {
|
||||
skillSelectionContext: undefined,
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Sync Builder (for hot paths) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build session skill context synchronously using cached agent data.
|
||||
*
|
||||
* Use this when you have the agent already loaded (e.g., from cache)
|
||||
* to avoid async agent lookup overhead.
|
||||
*/
|
||||
export function buildSessionSkillContextSync(
|
||||
agent: Agent | null | undefined,
|
||||
sessionPurpose: SessionPurpose,
|
||||
projectRootDir: string,
|
||||
): SessionSkillContextResult {
|
||||
// Rule 1: Check assigned agent skills
|
||||
if (agent) {
|
||||
const agentSkills = normalizeAgentSkills(
|
||||
(agent.metadata as Record<string, unknown> | undefined)?.skills,
|
||||
);
|
||||
|
||||
if (agentSkills.length > 0) {
|
||||
const skillSelectionContext: SkillSelectionContext = {
|
||||
projectRootDir,
|
||||
requestedSkillNames: agentSkills,
|
||||
sessionPurpose,
|
||||
};
|
||||
|
||||
return {
|
||||
skillSelectionContext,
|
||||
resolvedSkillNames: agentSkills,
|
||||
skillSource: "assigned-agent",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: Use role fallback skills
|
||||
const roleFallbackSkills = getRoleFallbackSkills(sessionPurpose);
|
||||
|
||||
if (roleFallbackSkills && roleFallbackSkills.length > 0) {
|
||||
const skillSelectionContext: SkillSelectionContext = {
|
||||
projectRootDir,
|
||||
requestedSkillNames: roleFallbackSkills,
|
||||
sessionPurpose,
|
||||
};
|
||||
|
||||
return {
|
||||
skillSelectionContext,
|
||||
resolvedSkillNames: roleFallbackSkills,
|
||||
skillSource: "role-fallback",
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 3: No skills available
|
||||
return {
|
||||
skillSelectionContext: undefined,
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import type { TaskDetail, Settings, TaskStore } from "@fusion/core";
|
||||
|
||||
import { createKbAgent, promptWithFallback, describeModel, compactSessionContext } from "./pi.js";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import { generateWorktreeName } from "./worktree-names.js";
|
||||
import { AgentSemaphore } from "./concurrency.js";
|
||||
import { StuckTaskDetector } from "./stuck-task-detector.js";
|
||||
@@ -74,6 +75,8 @@ export interface StepSessionExecutorOptions {
|
||||
onStepStart?: (stepIndex: number) => void;
|
||||
/** Callback invoked when a step completes (success or failure). */
|
||||
onStepComplete?: (stepIndex: number, result: StepResult) => void;
|
||||
/** Optional skill selection context for session creation. */
|
||||
skillSelection?: SkillSelectionContext;
|
||||
}
|
||||
|
||||
// ── File Scope Extraction ─────────────────────────────────────────────
|
||||
@@ -789,6 +792,8 @@ export class StepSessionExecutor {
|
||||
agentLogger.onToolEnd(name, isError, result);
|
||||
stuckTaskDetector?.recordActivity(trackingKey);
|
||||
},
|
||||
// Skill selection from step-session executor options
|
||||
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
|
||||
});
|
||||
session = createResult.session;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { createKbAgent, describeModel, promptWithFallback } from "./pi.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, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||||
@@ -632,6 +633,14 @@ export class TriageProcessor {
|
||||
triageInstructions,
|
||||
);
|
||||
|
||||
// Build skill selection context (assigned agent skills take precedence over role fallback)
|
||||
const skillContext = await buildSessionSkillContext({
|
||||
agentStore: this.options.agentStore!,
|
||||
task,
|
||||
sessionPurpose: "triage",
|
||||
projectRootDir: this.rootDir,
|
||||
});
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: triageSystemPrompt,
|
||||
@@ -659,6 +668,8 @@ export class TriageProcessor {
|
||||
? settings.planningFallbackModelId
|
||||
: settings.fallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
});
|
||||
|
||||
const modelDesc = describeModel(session);
|
||||
@@ -1137,6 +1148,7 @@ export class TriageProcessor {
|
||||
defaultThinkingLevel: currentSettings.defaultThinkingLevel,
|
||||
store,
|
||||
taskId,
|
||||
task: currentDetail,
|
||||
userComments: currentUserComments.length > 0 ? currentUserComments : undefined,
|
||||
agentStore: this.options.agentStore,
|
||||
rootDir,
|
||||
|
||||
Reference in New Issue
Block a user