feat(FN-1511): add session-skill-context helper for unified skill selection

This commit is contained in:
gsxdsm
2026-04-14 08:12:47 -07:00
parent 7849ec2a51
commit 0944fff7ba
2 changed files with 647 additions and 0 deletions

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 { TaskStore } 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: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: ["triage", "executor"] },
} 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: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: [{ name: "triage" }, { name: "executor" }] },
} 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: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: [] },
} 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: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: {},
} 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: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
} 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: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: ["custom-skill-1", "custom-skill-2"] },
} 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: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: ["triage", "executor"] },
} as Agent;
const mockTaskStore = {
getAgent: vi.fn().mockResolvedValue(mockAgent),
} as unknown as TaskStore;
const result = await buildSessionSkillContext({
taskStore: mockTaskStore,
task: { assignedAgentId: "agent-001" },
sessionPurpose: "executor",
projectRootDir,
});
expect(result.skillSource).toBe("assigned-agent");
expect(result.resolvedSkillNames).toEqual(["triage", "executor"]);
expect(mockTaskStore.getAgent).toHaveBeenCalledWith("agent-001");
});
it("falls back to role when no assignedAgentId", async () => {
const mockTaskStore = {
getAgent: vi.fn(),
} as unknown as TaskStore;
const result = await buildSessionSkillContext({
taskStore: mockTaskStore,
task: {},
sessionPurpose: "executor",
projectRootDir,
});
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
expect(mockTaskStore.getAgent).not.toHaveBeenCalled();
});
it("falls back to role when assigned agent not found", async () => {
const mockTaskStore = {
getAgent: vi.fn().mockResolvedValue(null),
} as unknown as TaskStore;
const result = await buildSessionSkillContext({
taskStore: mockTaskStore,
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 mockTaskStore = {
getAgent: vi.fn().mockRejectedValue(new Error("DB error")),
} as unknown as TaskStore;
const result = await buildSessionSkillContext({
taskStore: mockTaskStore,
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 mockTaskStore = {
getAgent: vi.fn(),
} as unknown as TaskStore;
const result = await buildSessionSkillContext({
taskStore: mockTaskStore,
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 { TaskStore } from "@fusion/core";
import type { Agent } 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 {
/** Task store for looking up assigned agent */
taskStore: TaskStore;
/** 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 { taskStore, task, sessionPurpose, projectRootDir } = input;
const { assignedAgentId } = task;
// Rule 1: Check assigned agent
if (assignedAgentId) {
try {
const agent = await taskStore.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",
};
}