mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +02:00
refactor(x): capability record — skills become the model-activated subset
capabilities/types.ts introduces the assembly unit: id/title/summary +
lazy guidance + owned tools (what skills already were), plus an
activation axis ('model' | 'app' | 'always') and an eager
promptFragment(ctx) for app/always entries — pure over a persisted
composition context so composed prompts stay byte-identical.
SkillDefinition is now CapabilityDefinition & {content}: bundled skills
are unchanged model-activated entries, the loadSkill catalog filters to
that subset, and disk skills remain structurally limited to it (eager
fragments and app activation are bundled-only powers — a disk file must
never gain a standing system-prompt injection channel).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bd34531305
commit
484a9f0495
3 changed files with 91 additions and 13 deletions
|
|
@ -0,0 +1,56 @@
|
||||||
|
// The capability record: the unit of agent assembly. A capability owns what
|
||||||
|
// it contributes to an agent — lazy guidance the model can pull in, tools,
|
||||||
|
// and (for app/always activation) an eager system-prompt fragment.
|
||||||
|
//
|
||||||
|
// Activation is the axis that distinguishes the historical concepts:
|
||||||
|
// - 'model' — today's skills: a catalog line in the prompt; the model calls
|
||||||
|
// loadSkill when it recognizes the task; guidance arrives as a
|
||||||
|
// tool result and declared tools attach mid-turn.
|
||||||
|
// - 'app' — today's modes (voice, video, coach, code): the app toggles a
|
||||||
|
// fact about the world; the fragment is composed into the
|
||||||
|
// system prompt from token zero and tools attach at assembly.
|
||||||
|
// - 'always' — unconditional contributions (workspace context traits).
|
||||||
|
//
|
||||||
|
// Trust boundary: disk-loaded skills (~/.rowboat/skills, ~/.agents/skills)
|
||||||
|
// are structurally limited to the 'model' subset — eager prompt fragments
|
||||||
|
// and app activation are bundled-only powers. A model-activated skill's
|
||||||
|
// prose lands in conversation because the model chose to read it; a disk
|
||||||
|
// file injecting into every turn's system prompt would be a standing
|
||||||
|
// prompt-injection channel.
|
||||||
|
|
||||||
|
export type CapabilityActivation = "model" | "app" | "always";
|
||||||
|
|
||||||
|
// Inputs an eager fragment may read. All fields mirror persisted
|
||||||
|
// RequestedAgent.overrides.composition values, resolved before composition —
|
||||||
|
// a fragment must be a pure function of this context so composed prompts
|
||||||
|
// stay byte-identical for identical inputs (provider prefix caching and
|
||||||
|
// agent-snapshot inheritance both depend on it).
|
||||||
|
export interface CapabilityContext {
|
||||||
|
voiceInput: boolean;
|
||||||
|
voiceOutput: "summary" | "full" | null;
|
||||||
|
searchEnabled: boolean;
|
||||||
|
codeMode: "claude" | "codex" | null;
|
||||||
|
codeCwd: string | null;
|
||||||
|
videoMode: boolean;
|
||||||
|
coachMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityDefinition {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
// Defaults to 'model' (the historical skill behavior).
|
||||||
|
activation?: CapabilityActivation;
|
||||||
|
// Lazy guidance returned by loadSkill (model activation).
|
||||||
|
content?: string;
|
||||||
|
// BuiltinTools keys this capability owns.
|
||||||
|
tools?: string[];
|
||||||
|
// Eager system-prompt fragment (app/always activation). Returns null
|
||||||
|
// when the capability contributes nothing for this context. MUST be
|
||||||
|
// pure: same context, same bytes.
|
||||||
|
promptFragment?: (ctx: CapabilityContext) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelActivated(def: Pick<CapabilityDefinition, "activation">): boolean {
|
||||||
|
return def.activation === undefined || def.activation === "model";
|
||||||
|
}
|
||||||
|
|
@ -146,3 +146,25 @@ describe("skills index (disk skill merge layer)", () => {
|
||||||
expect(skills.resolveSkill(skillFile)).toBeNull();
|
expect(skills.resolveSkill(skillFile)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("capability record (types)", () => {
|
||||||
|
it("defaults to model activation — every bundled skill is model-activated", async () => {
|
||||||
|
const { isModelActivated } = await import("../capabilities/types.js");
|
||||||
|
// Bundled definitions carry no activation field today; they are the
|
||||||
|
// model-activated subset by default and must stay in the catalog.
|
||||||
|
expect(isModelActivated({})).toBe(true);
|
||||||
|
expect(isModelActivated({ activation: "model" })).toBe(true);
|
||||||
|
expect(isModelActivated({ activation: "app" })).toBe(false);
|
||||||
|
expect(isModelActivated({ activation: "always" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps app/always capabilities out of the loadSkill catalog", async () => {
|
||||||
|
const { buildSkillCatalog, availableSkills } = await import("./index.js");
|
||||||
|
const catalog = buildSkillCatalog();
|
||||||
|
// Every advertised id resolves via loadSkill (the catalog and the
|
||||||
|
// resolver operate on the same model-activated subset).
|
||||||
|
for (const id of availableSkills) {
|
||||||
|
expect(catalog).toContain(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { isModelActivated, type CapabilityDefinition } from "../capabilities/types.js";
|
||||||
import { loadDiskSkills } from "./disk-loader.js";
|
import { loadDiskSkills } from "./disk-loader.js";
|
||||||
import builtinToolsSkill from "./builtin-tools/skill.js";
|
import builtinToolsSkill from "./builtin-tools/skill.js";
|
||||||
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
|
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
|
||||||
|
|
@ -25,17 +26,12 @@ const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||||
|
|
||||||
// console.log(liveNoteSkill);
|
// console.log(liveNoteSkill);
|
||||||
|
|
||||||
type SkillDefinition = {
|
// A skill is the model-activated subset of the capability record
|
||||||
id: string; // Also used as folder name
|
// (capabilities/types.ts): lazy guidance the model pulls in via loadSkill,
|
||||||
title: string;
|
// plus the BuiltinTools it owns. `id` doubles as the folder name. Tool names
|
||||||
summary: string;
|
// are validated against the live catalog where descriptors are built
|
||||||
content: string;
|
// (loadSkill / the agent resolver); unknown names are dropped there.
|
||||||
// BuiltinTools keys this skill owns. Loading the skill attaches these as
|
type SkillDefinition = CapabilityDefinition & { content: string };
|
||||||
// native tool definitions (they are NOT in the copilot's base toolset).
|
|
||||||
// Names are validated against the live catalog where descriptors are
|
|
||||||
// built (loadSkill / the agent resolver); unknown names are dropped there.
|
|
||||||
tools?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResolvedSkill = {
|
type ResolvedSkill = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -242,9 +238,13 @@ function getSkillEntries(): SkillEntry[] {
|
||||||
* Reads the live skill set, so it reflects disk skills added/removed at runtime.
|
* Reads the live skill set, so it reflects disk skills added/removed at runtime.
|
||||||
*/
|
*/
|
||||||
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
||||||
|
// The catalog advertises the model-activated subset only: app/always
|
||||||
|
// capabilities are composed into the system prompt by the assembly layer,
|
||||||
|
// not offered for loadSkill.
|
||||||
|
const modelEntries = getSkillEntries().filter(isModelActivated);
|
||||||
const entries = options?.excludeIds
|
const entries = options?.excludeIds
|
||||||
? getSkillEntries().filter(e => !options.excludeIds!.includes(e.id))
|
? modelEntries.filter(e => !options.excludeIds!.includes(e.id))
|
||||||
: getSkillEntries();
|
: modelEntries;
|
||||||
const sections = entries.map((entry) => [
|
const sections = entries.map((entry) => [
|
||||||
`## ${entry.title}`,
|
`## ${entry.title}`,
|
||||||
`- **Skill file:** \`${entry.catalogPath}\``,
|
`- **Skill file:** \`${entry.catalogPath}\``,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue