mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +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();
|
||||
});
|
||||
});
|
||||
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { isModelActivated, type CapabilityDefinition } from "../capabilities/types.js";
|
||||
import { loadDiskSkills } from "./disk-loader.js";
|
||||
import builtinToolsSkill from "./builtin-tools/skill.js";
|
||||
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
|
||||
|
|
@ -25,17 +26,12 @@ const CATALOG_PREFIX = "src/application/assistant/skills";
|
|||
|
||||
// console.log(liveNoteSkill);
|
||||
|
||||
type SkillDefinition = {
|
||||
id: string; // Also used as folder name
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
// BuiltinTools keys this skill owns. Loading the skill attaches these as
|
||||
// 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[];
|
||||
};
|
||||
// A skill is the model-activated subset of the capability record
|
||||
// (capabilities/types.ts): lazy guidance the model pulls in via loadSkill,
|
||||
// plus the BuiltinTools it owns. `id` doubles as the folder name. Tool names
|
||||
// are validated against the live catalog where descriptors are built
|
||||
// (loadSkill / the agent resolver); unknown names are dropped there.
|
||||
type SkillDefinition = CapabilityDefinition & { content: string };
|
||||
|
||||
type ResolvedSkill = {
|
||||
id: string;
|
||||
|
|
@ -242,9 +238,13 @@ function getSkillEntries(): SkillEntry[] {
|
|||
* Reads the live skill set, so it reflects disk skills added/removed at runtime.
|
||||
*/
|
||||
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
|
||||
? getSkillEntries().filter(e => !options.excludeIds!.includes(e.id))
|
||||
: getSkillEntries();
|
||||
? modelEntries.filter(e => !options.excludeIds!.includes(e.id))
|
||||
: modelEntries;
|
||||
const sections = entries.map((entry) => [
|
||||
`## ${entry.title}`,
|
||||
`- **Skill file:** \`${entry.catalogPath}\``,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue