feat(FN-1511): add session-skill-context helper for unified skill selection
This commit is contained in:
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 { 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)");
|
||||
});
|
||||
});
|
||||
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 { 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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user