Files
fusion/packages/engine/src/cli-runtime/session-skill-context.ts
gsxdsm 9f5f981e33 FN-9114: expose enabled skills and enforce agent skill reads
Make project-enabled skills available across agent sessions while preserving explicit per-agent skills as observable read-first requirements.

- resolve all enabled discovered skills without using agent metadata as an availability filter
- carry forced skill intent through PI, plugin, chat, workflow, merger, and heartbeat session paths
- report resolved and unavailable forced skills in session summaries and diagnostics
- document the updated skill model and add release notes and regression coverage

Files changed:
 .changeset/fn-9114-forced-skills.md                |   7 +
 docs/PLUGIN_AUTHORING.md                           |   2 +-
 docs/agents.md                                     |  10 +-
 docs/diagnostics.md                                |   2 +-
 docs/settings-reference.md                         |   2 +-
 packages/core/src/__tests__/skill-settings.test.ts |   9 +
 .../dashboard/src/__tests__/chat-manager.test.ts   |  26 ++-
 packages/dashboard/src/chat.ts                     |  10 +
 .../engine/src/__tests__/agent-skills-flow.test.ts |  14 +-
 .../compound-engineering-skill-resolution.test.ts  |   2 +-
 .../engine/src/__tests__/heartbeat-skills.test.ts  |  41 ++++
 .../__tests__/hermes-runtime-integration.test.ts   |  63 +++++-
 .../engine/src/__tests__/merger-skills.test.ts     |  33 ++-
 packages/engine/src/__tests__/pi.test.ts           |  35 ++-
 .../__tests__/plugin-skill-body-delivery.test.ts   |   8 +-
 .../src/__tests__/plugin-skill-integration.test.ts |   3 +-
 .../src/__tests__/session-skill-context.test.ts    |  40 ++--
 .../engine/src/__tests__/skill-resolver.test.ts    |  38 +++-
 .../__tests__/step-execute-skill-loading.test.ts   |   6 +
 packages/engine/src/agents/agent-runtime.ts        |   2 +
 .../engine/src/agents/agent-session-helpers.ts     |  88 ++++++-
 .../src/cli-runtime/session-skill-context.ts       | 117 ++++------
 packages/engine/src/cli-runtime/skill-resolver.ts  | 252 +++++++++------------
 .../engine/src/executor/execute-workflow-step.ts   |  14 ++
 packages/engine/src/executor/run-implementation.ts |   5 +
 packages/engine/src/pi.ts                          |  28 ++-
 26 files changed, 576 insertions(+), 281 deletions(-)

Fusion-Task-Id: FN-9114

Fusion-Task-Lineage: fe63ebd1-f947-40d7-be7b-c13d7f1c35c9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-15 21:57:22 -07:00

409 lines
15 KiB
TypeScript

/**
* 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 the built-in Fusion skill fallback mapping:
* - `triage` → `fusion`
* - `executor` / `step-session` → `fusion`
* - `reviewer` → `fusion`
* - `merger` → `fusion`
* - `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 { dirname } from "node:path";
import type { Agent, AgentStore } from "@fusion/core";
import { resolvePluginSkillBodyPath, resolvePluginSkillEnabled } from "@fusion/core";
import { piLog } from "../logger.js";
import type { PluginRunner } from "../plugins/plugin-runner.js";
import { readProjectSettings, resolveProjectRoot, type SkillSelectionContext } from "./skill-resolver.js";
// ── Types ───────────────────────────────────────────────────────────────────
/**
* Session purpose for skill selection context.
* Maps to built-in 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;
/** Optional plugin runner for plugin-contributed skills */
pluginRunner?: PluginRunner;
}
/**
* Result of building session skill context.
* Contains the SkillSelectionContext for createFnAgent and any diagnostics.
*/
export interface SessionSkillContextResult {
/** Context to pass to createFnAgent's skillSelection option */
skillSelectionContext: SkillSelectionContext | undefined;
/** Forced names are intent only; resolver confirms availability downstream. */
forcedSkillNames: string[];
/** Ensure-present names (role fallback and plugin contributions). */
resolvedSkillNames: string[];
/** Source of the skills: 'assigned-agent', 'role-fallback', or 'none' */
skillSource: "assigned-agent" | "role-fallback" | "none";
/** Extra skill body directories to pass to createFnAgent's additionalSkillPaths */
additionalSkillPaths: string[];
}
// ── 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 the entry is a full skill ID ("source::path"), extract just the
// skill name (last two path segments) so it matches the discovered
// skill.name produced by extractSkillName().
if (name && name.length > 0) {
if (name.includes("::")) {
const idPath = name.split("::").pop()!;
const parts = idPath.replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length >= 2) {
name = parts.slice(-2).join("/");
}
}
if (!seen.has(name)) {
seen.add(name);
result.push(name);
}
}
}
return result;
}
/**
* FNXC:PluginSkills 2026-07-12-00:00:
* Session assembly must honor the same per-project plugin-skill override as the Skills view. Resolve worktree project roots before reading settings, then delegate effective enablement to @fusion/core so static plugin defaults cannot drift from project toggles again.
*
* FNXC:PluginSkills 2026-07-12-00:00:
* GitHub #2017 showed that plugin skill names alone never delivered bodies because the pi loader does not scan plugin packages. Resolve each enabled plugin skill body through @fusion/core's traversal-guarded primitive and thread its body directory plus parent discovery root as additionalSkillPaths, mirroring the compound-engineering FUSION_CE_SKILLS_DIR mechanism while preserving the explicit body-dir contract.
*/
export function collectPluginSkillNames(
pluginRunner: PluginRunner | undefined,
projectRootDir?: string,
): { names: string[]; pluginIds: string[]; additionalSkillPaths: string[] } {
if (!pluginRunner) {
return { names: [], pluginIds: [], additionalSkillPaths: [] };
}
let settings = {};
if (projectRootDir) {
try {
settings = readProjectSettings(resolveProjectRoot(projectRootDir));
} catch {
settings = {};
}
}
const pluginSkills = pluginRunner.getPluginSkills();
const seenNames = new Set<string>();
const pluginIds = new Set<string>();
const additionalSkillPathSet = new Set<string>();
const names: string[] = [];
for (const contribution of pluginSkills) {
const { pluginId, skill } = contribution;
const name = skill.name.trim();
let bodyPath: ReturnType<typeof resolvePluginSkillBodyPath> | undefined;
if (contribution.pluginRoot) {
try {
bodyPath = resolvePluginSkillBodyPath(skill, contribution.pluginRoot);
} catch (error) {
piLog.warn(
`[skills] Plugin ${pluginId} skill ${name} body path could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (!resolvePluginSkillEnabled(settings, pluginId, name, skill.enabled, bodyPath?.relativePath)) {
continue;
}
if (name.length === 0 || seenNames.has(name)) {
continue;
}
seenNames.add(name);
pluginIds.add(pluginId);
names.push(name);
if (bodyPath) {
const bodyDir = dirname(bodyPath.absolutePath);
additionalSkillPathSet.add(bodyDir);
additionalSkillPathSet.add(dirname(bodyDir));
}
// FNXC:EngineDiagnostics 2026-07-26-08:17: one line per plugin skill per session is steady-state discovery chatter.
piLog.debug(`[skills] Plugin ${pluginId} contributes skill: ${name}`);
}
return {
names,
pluginIds: Array.from(pluginIds),
additionalSkillPaths: Array.from(additionalSkillPathSet),
};
}
// ── 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: ["fusion"],
executor: ["fusion"],
reviewer: ["fusion"],
merger: ["fusion"],
};
/**
* 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 createFnAgent.
*
* 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> {
let forcedSkillNames: string[] = [];
if (input.task.assignedAgentId) {
try {
const agent = await input.agentStore.getAgent(input.task.assignedAgentId);
forcedSkillNames = normalizeAgentSkills((agent?.metadata as Record<string, unknown> | undefined)?.skills);
} catch { /* role fallback still applies */ }
}
const base = resolveRoleFallback(input.sessionPurpose, input.projectRootDir);
const skillSelectionContext: SkillSelectionContext = {
projectRootDir: input.projectRootDir,
requestedSkillNames: base.resolvedSkillNames,
...(forcedSkillNames.length ? { forcedSkillNames } : {}),
sessionPurpose: input.sessionPurpose,
};
return mergePluginSkills({ ...base, skillSelectionContext, forcedSkillNames, skillSource: forcedSkillNames.length ? "assigned-agent" : base.skillSource }, input.sessionPurpose, input.projectRootDir, input.pluginRunner);
}
function resolveRoleFallback(
sessionPurpose: SessionPurpose,
projectRootDir: string,
): SessionSkillContextResult {
const roleFallbackSkills = getRoleFallbackSkills(sessionPurpose);
if (roleFallbackSkills && roleFallbackSkills.length > 0) {
return {
skillSelectionContext: {
projectRootDir,
requestedSkillNames: roleFallbackSkills,
sessionPurpose,
},
forcedSkillNames: [],
resolvedSkillNames: roleFallbackSkills,
skillSource: "role-fallback",
additionalSkillPaths: [],
};
}
/*
FNXC:SkillResolution 2026-08-16-03:52:
GitHub #1422 requires project settings to govern every session, including a
heartbeat with neither agent nor role skills. Keep an empty context so `-`
exclusions reach the resolver instead of letting the loader expose disabled skills.
*/
return {
skillSelectionContext: {
projectRootDir,
requestedSkillNames: [],
sessionPurpose,
},
forcedSkillNames: [],
resolvedSkillNames: [],
skillSource: "none",
additionalSkillPaths: [],
};
}
function mergeAdditionalSkillPaths(...pathGroups: string[][]): string[] {
return Array.from(new Set(pathGroups.flat()));
}
function mergePluginSkills(
baseResult: SessionSkillContextResult,
sessionPurpose: SessionPurpose,
projectRootDir: string,
pluginRunner: PluginRunner | undefined,
): SessionSkillContextResult {
const { names: pluginSkillNames, additionalSkillPaths } = collectPluginSkillNames(pluginRunner, projectRootDir);
if (pluginSkillNames.length === 0) {
return { ...baseResult, additionalSkillPaths: mergeAdditionalSkillPaths(baseResult.additionalSkillPaths, additionalSkillPaths) };
}
const mergedNames = [...baseResult.resolvedSkillNames];
const existingNames = new Set(mergedNames.map((name) => name.toLowerCase()));
const appendedPluginNames: string[] = [];
for (const pluginSkillName of pluginSkillNames) {
const key = pluginSkillName.toLowerCase();
if (existingNames.has(key)) {
continue;
}
existingNames.add(key);
mergedNames.push(pluginSkillName);
appendedPluginNames.push(pluginSkillName);
}
if (appendedPluginNames.length > 0) {
// FNXC:EngineDiagnostics 2026-07-26-08:17: merge summary is expected every session that has plugin skills — debug-only.
piLog.debug(
`[skills] Merged ${appendedPluginNames.length} plugin skill(s) into ${sessionPurpose} session: [${appendedPluginNames.join(", ")}]`,
);
}
if (mergedNames.length === 0) {
return { ...baseResult, additionalSkillPaths: mergeAdditionalSkillPaths(baseResult.additionalSkillPaths, additionalSkillPaths) };
}
return {
skillSelectionContext: {
projectRootDir,
requestedSkillNames: mergedNames,
...(baseResult.forcedSkillNames.length ? { forcedSkillNames: baseResult.forcedSkillNames } : {}),
sessionPurpose,
},
forcedSkillNames: baseResult.forcedSkillNames,
resolvedSkillNames: mergedNames,
skillSource: baseResult.skillSource === "none" ? "role-fallback" : baseResult.skillSource,
additionalSkillPaths: mergeAdditionalSkillPaths(baseResult.additionalSkillPaths, additionalSkillPaths),
};
}
// ── 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,
pluginRunner?: PluginRunner,
): SessionSkillContextResult {
const forcedSkillNames = normalizeAgentSkills((agent?.metadata as Record<string, unknown> | undefined)?.skills);
const base = resolveRoleFallback(sessionPurpose, projectRootDir);
return mergePluginSkills({
...base,
forcedSkillNames,
skillSelectionContext: {
projectRootDir,
requestedSkillNames: base.resolvedSkillNames,
...(forcedSkillNames.length ? { forcedSkillNames } : {}),
sessionPurpose,
},
skillSource: forcedSkillNames.length ? "assigned-agent" : base.skillSource,
}, sessionPurpose, projectRootDir, pluginRunner);
}