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:
gsxdsm
2026-04-14 08:40:21 -07:00
parent 78190897be
commit 203948cca9
8 changed files with 765 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)" : ""}`);

View File

@@ -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)}`);

View 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)");
});
});

View 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",
};
}

View File

@@ -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;

View File

@@ -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,