From de893754e19f832c5826c0dfb8660820edd52857 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 23 Apr 2026 17:40:13 +0100 Subject: [PATCH 1/5] feat(agents): add skills system for dynamic knowledge loading Adds a skills registry that scans markdown files at startup and provides use_skill/remove_skill tools to Horton for on-demand knowledge injection. Includes an interactive tutorial skill for the perspectives analyzer pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agents/skills/tutorial.md | 234 ++++++++++++++++ .../tutorial/scaffold/entities/.gitkeep | 0 .../tutorial/scaffold/lib/electric-tools.ts | 80 ++++++ .../skills/tutorial/scaffold/package.json | 17 ++ .../agents/skills/tutorial/scaffold/server.ts | 51 ++++ .../skills/tutorial/scaffold/tsconfig.json | 15 + packages/agents/src/agents/horton.ts | 74 ++++- packages/agents/src/bootstrap.ts | 37 ++- packages/agents/src/server.ts | 6 +- packages/agents/src/skills/extract-meta.ts | 102 +++++++ packages/agents/src/skills/preamble.ts | 109 ++++++++ packages/agents/src/skills/registry.ts | 176 ++++++++++++ packages/agents/src/skills/tools.ts | 262 ++++++++++++++++++ packages/agents/src/skills/types.ts | 22 ++ .../agents/test/skills-integration.test.ts | 100 +++++++ packages/agents/test/skills-preamble.test.ts | 119 ++++++++ packages/agents/test/skills-registry.test.ts | 259 +++++++++++++++++ packages/agents/test/skills-tools.test.ts | 229 +++++++++++++++ packages/electric-ax/docker-compose.full.yml | 2 +- 19 files changed, 1881 insertions(+), 13 deletions(-) create mode 100644 packages/agents/skills/tutorial.md create mode 100644 packages/agents/skills/tutorial/scaffold/entities/.gitkeep create mode 100644 packages/agents/skills/tutorial/scaffold/lib/electric-tools.ts create mode 100644 packages/agents/skills/tutorial/scaffold/package.json create mode 100644 packages/agents/skills/tutorial/scaffold/server.ts create mode 100644 packages/agents/skills/tutorial/scaffold/tsconfig.json create mode 100644 packages/agents/src/skills/extract-meta.ts create mode 100644 packages/agents/src/skills/preamble.ts create mode 100644 packages/agents/src/skills/registry.ts create mode 100644 packages/agents/src/skills/tools.ts create mode 100644 packages/agents/src/skills/types.ts create mode 100644 packages/agents/test/skills-integration.test.ts create mode 100644 packages/agents/test/skills-preamble.test.ts create mode 100644 packages/agents/test/skills-registry.test.ts create mode 100644 packages/agents/test/skills-tools.test.ts diff --git a/packages/agents/skills/tutorial.md b/packages/agents/skills/tutorial.md new file mode 100644 index 0000000000..0e92b6d07c --- /dev/null +++ b/packages/agents/skills/tutorial.md @@ -0,0 +1,234 @@ +--- +description: Interactive tutorial — build a perspectives analyzer entity with the manager-worker pattern +whenToUse: User asks about building entities, wants a tutorial, is new to Electric Agents, or wants to learn multi-agent patterns +keywords: + - tutorial + - getting started + - learn + - multi-agent + - manager-worker + - perspectives + - entity +user-invocable: true +max: 25000 +--- + +# Tutorial: Build a Perspectives Analyzer + +Build a `perspectives` entity that analyzes questions from an optimist and a critic using the manager-worker pattern. Use the exact code below — do not invent different code. + +## Before starting + +Read `server.ts` in the working directory: + +- **Has `registerPerspectives`**: resume from where they left off (read `entities/perspectives.ts` to determine the step) +- **Has `server.ts` but no perspectives**: go to Step 1 +- **No `server.ts`**: scaffold the project — spawn a worker (`tools: ["bash"]`, systemPrompt: `"Set up an Electric Agents app project."`, initialMessage: `"mkdir -p TARGET/lib TARGET/entities && cp SKILL_DIR/scaffold/* TARGET/ && cp SKILL_DIR/scaffold/lib/* TARGET/lib/ && cp SKILL_DIR/scaffold/.env TARGET/ && cd TARGET && pnpm install && pnpm dev &"` — replace SKILL_DIR and TARGET). Then proceed to Step 1 while the worker runs. Wait for the worker to finish before writing files. + +## Steps + +**Step 1 — Welcome + first entity.** In one message: briefly introduce Electric Agents (durable streams backing agent sessions — use your docs knowledge), preview the perspectives analyzer, and show the Step 1 code. Ask to write. + +**Step 2 — After confirmation:** write `entities/perspectives.ts` with Step 1 code. Give CLI commands. Explain spawning briefly, show Step 2 code (adds one worker). Ask to write. + +**Step 3 — After confirmation:** write the updated file. Give CLI commands. Explain coordination, show Step 3 code (adds critic + state). Ask to write. + +**Step 4 — After confirmation:** write the updated file. Give CLI commands. + +**Step 5 — Wire up.** Read `server.ts`, show the import change, ask to write, update it. + +**Step 6 — Recap.** + +## Rules + +- Use the exact code below. Write files with your write tool. +- `server.ts` is at the working directory root. Entity files go in `entities/`. +- Worker spawn args MUST include `tools` array (e.g. `tools: ["bash", "read"]`). +- Prefer showing what changed between steps rather than repeating the entire file. +- Use `edit` tool for small changes (like updating server.ts). Use `write` for full entity file updates. + +--- + +# Code + +## Step 1: Minimal entity + +`entities/perspectives.ts`: + +```typescript +import type { EntityRegistry } from '@electric-ax/agents-runtime' + +export function registerPerspectives(registry: EntityRegistry) { + registry.define('perspectives', { + description: 'Analyzes questions from multiple perspectives', + async handler(ctx) { + ctx.useAgent({ + systemPrompt: + 'You are a balanced analyst. When given a question, provide a thoughtful analysis.', + model: 'claude-sonnet-4-6', + tools: [...ctx.electricTools], + }) + await ctx.agent.run() + }, + }) +} +``` + +`server.ts` additions: + +```typescript +import { registerPerspectives } from './entities/perspectives' +registerPerspectives(registry) +``` + +Test: `pnpm electric-agents spawn /perspectives/test-1 && pnpm electric-agents send /perspectives/test-1 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-1` + +## Step 2: One worker + +Full `entities/perspectives.ts`: + +```typescript +import type { + EntityRegistry, + HandlerContext, +} from '@electric-ax/agents-runtime' +import { Type } from '@sinclair/typebox' + +function createAnalyzeTool(ctx: HandlerContext) { + return { + name: 'analyze_question', + label: 'Analyze Question', + description: 'Spawns an optimist worker to analyze a question.', + parameters: Type.Object({ + question: Type.String({ description: 'The question to analyze' }), + }), + execute: async (_toolCallId: string, params: unknown) => { + const { question } = params as { question: string } + const parentId = ctx.entityUrl.split('/').pop() + await ctx.spawn( + 'worker', + `${parentId}-optimist`, + { + systemPrompt: + 'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.', + tools: ['bash', 'read'], + }, + { initialMessage: question, wake: 'runFinished' } + ) + return { + content: [ + { + type: 'text' as const, + text: "Spawned optimist worker. You'll be woken when it finishes.", + }, + ], + details: {}, + } + }, + } +} + +export function registerPerspectives(registry: EntityRegistry) { + registry.define('perspectives', { + description: 'Analyzes questions from multiple perspectives', + async handler(ctx) { + ctx.useAgent({ + systemPrompt: `You are a balanced analyst.\n\nWhen given a question:\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken when the worker finishes.\n3. When woken, finished_child.response contains the analysis.\n4. Present it to the user.`, + model: 'claude-sonnet-4-6', + tools: [...ctx.electricTools, createAnalyzeTool(ctx)], + }) + await ctx.agent.run() + }, + }) +} +``` + +Test: `pnpm electric-agents spawn /perspectives/test-2 && pnpm electric-agents send /perspectives/test-2 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-2` + +## Step 3: Two workers + state + +Full `entities/perspectives.ts`: + +```typescript +import type { + EntityRegistry, + HandlerContext, +} from '@electric-ax/agents-runtime' +import { Type } from '@sinclair/typebox' + +const PERSPECTIVES = [ + { + id: 'optimist', + systemPrompt: + 'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.', + }, + { + id: 'critic', + systemPrompt: + 'You are a critical analyst. Provide a sharp analysis focusing on risks, downsides, and challenges.', + }, +] + +function createAnalyzeTool(ctx: HandlerContext) { + return { + name: 'analyze_question', + label: 'Analyze Question', + description: 'Spawns optimist and critic workers to analyze a question.', + parameters: Type.Object({ + question: Type.String({ description: 'The question to analyze' }), + }), + execute: async (_toolCallId: string, params: unknown) => { + const { question } = params as { question: string } + const parentId = ctx.entityUrl.split('/').pop() + for (const p of PERSPECTIVES) { + const childId = `${parentId}-${p.id}` + await ctx.spawn( + 'worker', + childId, + { systemPrompt: p.systemPrompt, tools: ['bash', 'read'] }, + { initialMessage: question, wake: 'runFinished' } + ) + ctx.db.actions.children_insert({ + row: { key: p.id, url: `/worker/${childId}` }, + }) + } + return { + content: [ + { + type: 'text' as const, + text: 'Spawned optimist and critic workers.', + }, + ], + details: {}, + } + }, + } +} + +export function registerPerspectives(registry: EntityRegistry) { + registry.define('perspectives', { + description: + 'Analyzes questions from two perspectives: optimist and critic', + state: { children: { primaryKey: 'key' } }, + async handler(ctx) { + ctx.useAgent({ + systemPrompt: `You are a balanced analyst.\n\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken as each worker finishes.\n3. Each wake includes finished_child.response and other_children.\n4. Once both are done, synthesize a balanced response.`, + model: 'claude-sonnet-4-6', + tools: [...ctx.electricTools, createAnalyzeTool(ctx)], + }) + await ctx.agent.run() + }, + }) +} +``` + +Test: `pnpm electric-agents spawn /perspectives/test-3 && pnpm electric-agents send /perspectives/test-3 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-3` + +## What you learned + +- `registry.define()` — entity types with description, state, handler +- `ctx.useAgent()` + `ctx.agent.run()` — configure and run an LLM agent +- `ctx.spawn()` — spawn child entities with custom prompts +- Wake events — parents wake when children finish +- State collections — track data across wakes +- The worker pattern — one generic type, many roles diff --git a/packages/agents/skills/tutorial/scaffold/entities/.gitkeep b/packages/agents/skills/tutorial/scaffold/entities/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/agents/skills/tutorial/scaffold/lib/electric-tools.ts b/packages/agents/skills/tutorial/scaffold/lib/electric-tools.ts new file mode 100644 index 0000000000..f0dfbabe41 --- /dev/null +++ b/packages/agents/skills/tutorial/scaffold/lib/electric-tools.ts @@ -0,0 +1,80 @@ +import { Type } from '@sinclair/typebox' +import type { AgentTool } from '@electric-ax/agents-runtime' + +type CreateElectricToolsContext = { + entityUrl: string + entityType: string + args: Readonly> + upsertCronSchedule: (opts: { + id: string + expression: string + timezone?: string + payload?: unknown + debounceMs?: number + timeoutMs?: number + }) => Promise<{ txid: string }> + upsertFutureSendSchedule: (opts: { + id: string + payload: unknown + targetUrl?: string + fireAt: string + from?: string + messageType?: string + }) => Promise<{ txid: string }> + deleteSchedule: (opts: { id: string }) => Promise<{ txid: string }> +} + +export function createElectricTools( + ctx: CreateElectricToolsContext +): Array { + return [ + { + name: `upsert_cron_schedule`, + label: `Upsert Cron`, + description: `Create or update a recurring cron wake schedule.`, + parameters: Type.Object({ + id: Type.String({ description: `Stable schedule identifier` }), + expression: Type.String({ description: `Cron expression` }), + timezone: Type.Optional(Type.String({ description: `IANA timezone` })), + payload: Type.Any({ description: `Instruction for the agent` }), + }), + execute: async (_toolCallId, params) => { + const { id, expression, timezone, payload } = params as any + const tz = timezone ?? `UTC` + const { txid } = await ctx.upsertCronSchedule({ + id, + expression, + timezone: tz, + payload, + }) + return { + content: [ + { type: `text` as const, text: `Cron "${id}" set. txid=${txid}` }, + ], + details: { txid }, + } + }, + }, + { + name: `delete_schedule`, + label: `Delete Schedule`, + description: `Delete a schedule by id.`, + parameters: Type.Object({ + id: Type.String({ description: `Schedule identifier` }), + }), + execute: async (_toolCallId, params) => { + const { id } = params as { id: string } + const { txid } = await ctx.deleteSchedule({ id }) + return { + content: [ + { + type: `text` as const, + text: `Schedule "${id}" deleted. txid=${txid}`, + }, + ], + details: { txid }, + } + }, + }, + ] +} diff --git a/packages/agents/skills/tutorial/scaffold/package.json b/packages/agents/skills/tutorial/scaffold/package.json new file mode 100644 index 0000000000..b4f3ef2fe9 --- /dev/null +++ b/packages/agents/skills/tutorial/scaffold/package.json @@ -0,0 +1,17 @@ +{ + "name": "my-electric-agents-app", + "private": true, + "type": "module", + "scripts": { + "start": "tsx server.ts", + "dev": "tsx --watch server.ts" + }, + "dependencies": { + "@electric-ax/agents-runtime": "latest", + "@sinclair/typebox": "^0.34.49" + }, + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/agents/skills/tutorial/scaffold/server.ts b/packages/agents/skills/tutorial/scaffold/server.ts new file mode 100644 index 0000000000..6755b20453 --- /dev/null +++ b/packages/agents/skills/tutorial/scaffold/server.ts @@ -0,0 +1,51 @@ +import http from 'node:http' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + createEntityRegistry, + createRuntimeHandler, +} from '@electric-ax/agents-runtime' +import { createElectricTools } from './lib/electric-tools' + +try { + const here = path.dirname(fileURLToPath(import.meta.url)) + process.loadEnvFile(path.resolve(here, `.env`)) +} catch {} + +if (!process.env.ANTHROPIC_API_KEY) { + console.warn( + `[app] ANTHROPIC_API_KEY is not set — agent.run() will throw on the first wake.` + ) +} + +const ELECTRIC_AGENTS_URL = + process.env.ELECTRIC_AGENTS_URL ?? `http://localhost:4437` +const PORT = Number(process.env.PORT ?? 3000) +const SERVE_URL = process.env.SERVE_URL ?? `http://localhost:${PORT}` + +const registry = createEntityRegistry() + +// Register your entity types here: +// import { registerMyEntity } from "./entities/my-entity" +// registerMyEntity(registry) + +const runtime = createRuntimeHandler({ + baseUrl: ELECTRIC_AGENTS_URL, + serveEndpoint: `${SERVE_URL}/webhook`, + registry, + createElectricTools, +}) + +const server = http.createServer(async (req, res) => { + if (req.url === `/webhook` && req.method === `POST`) { + await runtime.onEnter(req, res) + return + } + res.writeHead(404) + res.end() +}) + +server.listen(PORT, async () => { + await runtime.registerTypes() + console.log(`App server ready on port ${PORT}`) +}) diff --git a/packages/agents/skills/tutorial/scaffold/tsconfig.json b/packages/agents/skills/tutorial/scaffold/tsconfig.json new file mode 100644 index 0000000000..9003690941 --- /dev/null +++ b/packages/agents/skills/tutorial/scaffold/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "noEmit": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 7f095cce14..ca1d347809 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -1,6 +1,7 @@ import Anthropic from '@anthropic-ai/sdk' import { serverLog } from '../log' import { createHortonDocsSupport } from '../docs/knowledge-base' +import { createSkillTools } from '../skills/tools' import { createBashTool } from '../tools/bash' import { createEditTool } from '../tools/edit' import { fetchUrlTool } from '../tools/fetch-url' @@ -15,6 +16,7 @@ import type { WakeEvent, } from '@electric-ax/agents-runtime' import type { ChangeEvent } from '@durable-streams/state' +import type { SkillsRegistry } from '../skills/types' const TITLE_MODEL = `claude-haiku-4-5-20251001` @@ -143,14 +145,33 @@ export async function generateTitle( export function buildHortonSystemPrompt( workingDirectory: string, - opts: { hasDocsSupport?: boolean } = {} + opts: { hasDocsSupport?: boolean; hasSkills?: boolean } = {} ): string { const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : `` + const skillsTools = opts.hasSkills + ? `\n- use_skill: load a skill (knowledge, instructions, or a tutorial) into your context to help with the user's request\n- remove_skill: unload a skill from context when you're done with it` + : `` const docsGuidance = opts.hasDocsSupport ? `\n- You have built-in Durable Agents docs context plus a docs search tool. Use that before broad web search when the question is about this repo, Electric Agents, or Durable Agents.\n- The docs TOC and docs search results include concrete file paths under the docs tree. Use the normal read tool with those returned paths.\n- Use repo read/bash tools for non-doc files or when you need to inspect exact implementation code in the workspace.` : `` + const skillsGuidance = opts.hasSkills + ? `\n# Skills\nYou have access to skills — specialized knowledge and guided workflows you can load on demand. Your context includes a skills catalog listing what's available. When the user's request matches a skill's description or keywords, load it with use_skill. + +Some skills are user-invocable — the user can trigger them with a slash command like \`/tutorial\`. When you see a message starting with \`/\` followed by a skill name, load that skill immediately with use_skill. Pass any text after the skill name as args. + +## IMPORTANT: How to use a loaded skill + +When you load a skill, it becomes your primary directive for that interaction. Follow the skill's instructions exactly: + +1. **Read all reference files first.** The use_skill tool response lists reference files with absolute paths. Read ALL of them with your read tool before responding to the user. These files contain the actual content the skill needs — without them you're guessing. +2. **Follow the skill's conversation flow.** If the skill defines steps, follow them in order. Do not improvise your own approach. +3. **Adopt the skill's persona and teaching style.** The skill defines how to interact — follow it. +4. **Unload when done.** Use remove_skill to free context space when the skill's workflow is complete. + +Do NOT load a skill and then ignore its instructions. The skill is there because it contains a tested, specific workflow. Your job is to execute it faithfully.` + : `` return `You are Horton, a friendly and capable assistant. You can chat, research the web, read and edit code, run shell commands, and dispatch subagents (workers) for isolated subtasks. Be warm and engaging in conversation; be precise and concrete when working with code. # Tools @@ -161,13 +182,13 @@ export function buildHortonSystemPrompt( - brave_search: search the web - fetch_url: fetch and convert a URL to markdown - spawn_worker: dispatch a subagent for an isolated task -${docsTools} +${docsTools}${skillsTools} # Working with files - Prefer edit over write when modifying existing files. - You must read a file before you can edit it. - Use absolute paths or paths relative to the current working directory. -${docsGuidance} +${docsGuidance}${skillsGuidance} # Risky actions Pause and confirm with the user before: @@ -236,8 +257,16 @@ function createAssistantHandler(options: { streamFn?: StreamFn docsSupport: HortonDocsSupport | null docsSearchTool?: AgentTool + skillsRegistry: SkillsRegistry | null }) { - const { workingDirectory, streamFn, docsSupport, docsSearchTool } = options + const { + workingDirectory, + streamFn, + docsSupport, + docsSearchTool, + skillsRegistry, + } = options + const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0) return async function assistantHandler( ctx: HandlerContext, @@ -247,6 +276,9 @@ function createAssistantHandler(options: { const tools = [ ...ctx.electricTools, ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool }), + ...(skillsRegistry && skillsRegistry.catalog.size > 0 + ? createSkillTools(skillsRegistry, ctx) + : []), ] if (docsSupport) { @@ -272,6 +304,30 @@ function createAssistantHandler(options: { content: () => ctx.timelineMessages(), cache: `volatile`, }, + ...(skillsRegistry && skillsRegistry.catalog.size > 0 + ? { + skills_catalog: { + content: () => skillsRegistry.renderCatalog(2_000), + max: 2_000, + cache: `stable` as const, + }, + } + : {}), + }, + }) + } else if (skillsRegistry && skillsRegistry.catalog.size > 0) { + ctx.useContext({ + sourceBudget: 100_000, + sources: { + skills_catalog: { + content: () => skillsRegistry.renderCatalog(2_000), + max: 2_000, + cache: `stable` as const, + }, + conversation: { + content: () => ctx.timelineMessages(), + cache: `volatile`, + }, }, }) } @@ -279,6 +335,7 @@ function createAssistantHandler(options: { ctx.useAgent({ systemPrompt: buildHortonSystemPrompt(workingDirectory, { hasDocsSupport: Boolean(docsSupport), + hasSkills, }), model: HORTON_MODEL, tools, @@ -314,9 +371,13 @@ function createAssistantHandler(options: { export function registerHorton( registry: EntityRegistry, - options: { workingDirectory: string; streamFn?: StreamFn } + options: { + workingDirectory: string + streamFn?: StreamFn + skillsRegistry?: SkillsRegistry | null + } ): Array { - const { workingDirectory, streamFn } = options + const { workingDirectory, streamFn, skillsRegistry = null } = options const docsSupport = createHortonDocsSupport(workingDirectory) const docsSearchTool = docsSupport?.createSearchTool() @@ -331,6 +392,7 @@ export function registerHorton( streamFn, docsSupport, docsSearchTool, + skillsRegistry, }) registry.define(`horton`, { diff --git a/packages/agents/src/bootstrap.ts b/packages/agents/src/bootstrap.ts index 6c6994e5db..348c6b5148 100644 --- a/packages/agents/src/bootstrap.ts +++ b/packages/agents/src/bootstrap.ts @@ -2,6 +2,8 @@ * Bootstrap built-in agent types on dev server startup. */ +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { createEntityRegistry, createRuntimeHandler, @@ -9,6 +11,7 @@ import { import { serverLog } from './log' import { registerHorton } from './agents/horton' import { registerWorker } from './agents/worker' +import { createSkillsRegistry } from './skills/registry' import type { AgentTool, EntityRegistry, @@ -18,6 +21,7 @@ import type { import type { ChangeEvent } from '@durable-streams/state' import type { StreamFn } from '@mariozechner/pi-agent-core' import type { IncomingMessage, ServerResponse } from 'node:http' +import type { SkillsRegistry } from './skills/types' export const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler` @@ -26,6 +30,7 @@ export interface AgentHandlerResult { runtime: RuntimeHandler registry: EntityRegistry typeNames: Array + skillsRegistry: SkillsRegistry | null } export interface BuiltinAgentHandlerOptions { @@ -59,9 +64,9 @@ export interface BuiltinAgentHandlerOptions { }) => Array | Promise> } -export function createBuiltinAgentHandler( +export async function createBuiltinAgentHandler( options: BuiltinAgentHandlerOptions -): AgentHandlerResult | null { +): Promise { const { agentServerUrl, serveEndpoint = `${agentServerUrl}${DEFAULT_BUILTIN_AGENT_HANDLER_PATH}`, @@ -78,10 +83,33 @@ export function createBuiltinAgentHandler( } const cwd = workingDirectory ?? process.cwd() + + const here = path.dirname(fileURLToPath(import.meta.url)) + const baseSkillsDir = path.resolve(here, `../skills`) + + let skillsRegistry: SkillsRegistry | null = null + try { + skillsRegistry = await createSkillsRegistry({ + baseSkillsDir, + appSkillsDir: path.resolve(cwd, `skills`), + cacheDir: path.resolve(cwd, `.electric-agents`), + }) + if (skillsRegistry.catalog.size > 0) { + serverLog.info( + `[electric-agents] ${skillsRegistry.catalog.size} skill(s) loaded: ${Array.from(skillsRegistry.catalog.keys()).join(`, `)}` + ) + } + } catch (err) { + serverLog.warn( + `[electric-agents] skills registry failed to initialize: ${err instanceof Error ? err.message : String(err)}` + ) + } + const registry = createEntityRegistry() const typeNames = registerHorton(registry, { workingDirectory: cwd, streamFn, + skillsRegistry, }) registerWorker(registry, { workingDirectory: cwd, streamFn }) @@ -101,16 +129,17 @@ export function createBuiltinAgentHandler( runtime, registry, typeNames, + skillsRegistry, } } -export function createAgentHandler( +export async function createAgentHandler( agentServerUrl: string, workingDirectory?: string, streamFn?: StreamFn, createElectricTools?: BuiltinAgentHandlerOptions[`createElectricTools`], serveEndpoint?: string -): AgentHandlerResult | null { +): Promise { return createBuiltinAgentHandler({ agentServerUrl, serveEndpoint, diff --git a/packages/agents/src/server.ts b/packages/agents/src/server.ts index 1cc34633e4..3a11c16338 100644 --- a/packages/agents/src/server.ts +++ b/packages/agents/src/server.ts @@ -49,7 +49,9 @@ export interface BuiltinAgentsServerOptions { export class BuiltinAgentsServer { private server: Server | null = null - private bootstrap: ReturnType | null = null + private bootstrap: Awaited< + ReturnType + > | null = null private _url: string | null = null private publicBaseUrl: string | null = null readonly options: BuiltinAgentsServerOptions @@ -113,7 +115,7 @@ export class BuiltinAgentsServer { : `${this.publicBaseUrl}/` ).toString() - this.bootstrap = createBuiltinAgentHandler({ + this.bootstrap = await createBuiltinAgentHandler({ agentServerUrl: this.options.agentServerUrl, serveEndpoint, workingDirectory: this.options.workingDirectory, diff --git a/packages/agents/src/skills/extract-meta.ts b/packages/agents/src/skills/extract-meta.ts new file mode 100644 index 0000000000..540df6abad --- /dev/null +++ b/packages/agents/src/skills/extract-meta.ts @@ -0,0 +1,102 @@ +import Anthropic from '@anthropic-ai/sdk' +import { serverLog } from '../log' +import { parsePreamble } from './preamble' + +const EXTRACT_MODEL = `claude-haiku-4-5-20251001` + +interface ExtractedMeta { + description: string + whenToUse: string + keywords: Array + arguments?: Array + argumentHint?: string + userInvocable?: boolean + max: number +} + +const DEFAULT_MAX = 10_000 + +export async function extractSkillMeta( + name: string, + content: string +): Promise { + const preamble = parsePreamble(content) + + if (preamble.description && preamble.whenToUse && preamble.keywords) { + return { + description: preamble.description, + whenToUse: preamble.whenToUse, + keywords: preamble.keywords, + ...(preamble.arguments && { arguments: preamble.arguments }), + ...(preamble.argumentHint && { argumentHint: preamble.argumentHint }), + ...(preamble.userInvocable && { userInvocable: true }), + max: preamble.max ?? DEFAULT_MAX, + } + } + + if (process.env.ANTHROPIC_API_KEY) { + try { + return await llmExtract(name, content, preamble) + } catch (err) { + serverLog.warn( + `[skills] LLM metadata extraction failed for "${name}": ${err instanceof Error ? err.message : String(err)}` + ) + } + } + + return { + description: preamble.description ?? humanize(name), + whenToUse: + preamble.whenToUse ?? `User asks about ${humanize(name).toLowerCase()}`, + keywords: preamble.keywords ?? [name], + max: preamble.max ?? DEFAULT_MAX, + } +} + +async function llmExtract( + name: string, + content: string, + partial: { + description?: string + whenToUse?: string + keywords?: Array + max?: number + } +): Promise { + const client = new Anthropic() + const truncated = content.slice(0, 8_000) + + const prompt = `Analyze this skill document and extract metadata. The skill is named "${name}". + + +${truncated} + + +Return ONLY a JSON object with these fields: +- "description": one-line summary of what this skill provides (max 100 chars) +- "whenToUse": when should an AI agent load this skill (max 200 chars) +- "keywords": array of 3-8 relevant keywords + +Return raw JSON, no markdown fences.` + + const res = await client.messages.create({ + model: EXTRACT_MODEL, + max_tokens: 256, + messages: [{ role: `user`, content: prompt }], + }) + + const text = res.content[0]?.type === `text` ? res.content[0].text : `` + const parsed = JSON.parse(text) + + return { + description: partial.description ?? parsed.description ?? humanize(name), + whenToUse: + partial.whenToUse ?? parsed.whenToUse ?? `User asks about ${name}`, + keywords: partial.keywords ?? parsed.keywords ?? [name], + max: partial.max ?? DEFAULT_MAX, + } +} + +function humanize(name: string): string { + return name.replace(/[-_]/g, ` `).replace(/\b\w/g, (c) => c.toUpperCase()) +} diff --git a/packages/agents/src/skills/preamble.ts b/packages/agents/src/skills/preamble.ts new file mode 100644 index 0000000000..34c6335227 --- /dev/null +++ b/packages/agents/src/skills/preamble.ts @@ -0,0 +1,109 @@ +export interface PreambleFields { + description?: string + whenToUse?: string + keywords?: Array + arguments?: Array + argumentHint?: string + userInvocable?: boolean + max?: number +} + +export function parsePreamble(content: string): PreambleFields { + const lines = content.split(`\n`) + if (lines[0]?.trim() !== `---`) return {} + + let closingIndex = -1 + for (let i = 1; i < Math.min(lines.length, 25); i++) { + if (lines[i]?.trim() === `---`) { + closingIndex = i + break + } + } + if (closingIndex === -1) return {} + + const result: PreambleFields = {} + for (let i = 1; i < closingIndex; i++) { + const line = lines[i]! + const colonIndex = line.indexOf(`:`) + if (colonIndex === -1) continue + + const key = line.slice(0, colonIndex).trim() + const rawValue = line.slice(colonIndex + 1).trim() + + switch (key) { + case `description`: + result.description = stripQuotes(rawValue) + break + case `whenToUse`: + result.whenToUse = stripQuotes(rawValue) + break + case `keywords`: { + if (rawValue.length === 0) { + // Multi-line YAML list: collect subsequent ` - value` lines + const items: Array = [] + for (let j = i + 1; j < closingIndex; j++) { + const next = lines[j]! + const match = next.match(/^\s+-\s+(.+)$/) + if (match) { + items.push(match[1]!.trim()) + i = j // advance outer loop past consumed lines + } else { + break + } + } + result.keywords = items + } else { + result.keywords = parseKeywords(rawValue) + } + break + } + case `arguments`: { + if (rawValue.length === 0) { + const items: Array = [] + for (let j = i + 1; j < closingIndex; j++) { + const next = lines[j]! + const match = next.match(/^\s+-\s+(.+)$/) + if (match) { + items.push(match[1]!.trim()) + i = j + } else { + break + } + } + result.arguments = items + } else { + result.arguments = parseKeywords(rawValue) + } + break + } + case `argument-hint`: + result.argumentHint = stripQuotes(rawValue) + break + case `user-invocable`: + result.userInvocable = rawValue === `true` + break + case `max`: { + const num = parseInt(rawValue, 10) + if (!Number.isNaN(num) && num > 0) result.max = num + break + } + } + } + + return result +} + +function parseKeywords(raw: string): Array { + const stripped = raw.replace(/^\[/, ``).replace(/\]$/, ``) + return stripped + .split(`,`) + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + +function stripQuotes(value: string): string { + if (value.length >= 2 && value.startsWith(`"`) && value.endsWith(`"`)) { + return value.slice(1, -1) + } + return value +} diff --git a/packages/agents/src/skills/registry.ts b/packages/agents/src/skills/registry.ts new file mode 100644 index 0000000000..a3802304b2 --- /dev/null +++ b/packages/agents/src/skills/registry.ts @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import fsSync from 'node:fs' +import path from 'node:path' +import { serverLog } from '../log' +import { extractSkillMeta } from './extract-meta' +import type { SkillMeta, SkillsRegistry } from './types' + +const CACHE_FILENAME = `skills-cache.json` + +interface SkillsRegistryOptions { + baseSkillsDir: string + appSkillsDir?: string + cacheDir: string +} + +type CacheFile = Record + +export async function createSkillsRegistry( + opts: SkillsRegistryOptions +): Promise { + const { baseSkillsDir, appSkillsDir, cacheDir } = opts + + const cachePath = path.join(cacheDir, CACHE_FILENAME) + const existingCache = await loadCache(cachePath) + + const files = new Map() + await scanDir(baseSkillsDir, files) + if (appSkillsDir) { + await scanDir(appSkillsDir, files) + } + + const catalog = new Map() + for (const [name, filePath] of files) { + const content = await fs.readFile(filePath, `utf-8`) + const hash = sha256(content) + + const cached = existingCache[name] + if (cached && cached.contentHash === hash && cached.source === filePath) { + catalog.set(name, cached) + continue + } + + serverLog.info(`[skills] extracting metadata for "${name}"`) + const meta = await extractSkillMeta(name, content) + const entry: SkillMeta = { + name, + ...meta, + charCount: content.length, + contentHash: hash, + source: filePath, + } + catalog.set(name, entry) + } + + await saveCache(cachePath, catalog, cacheDir) + + return { + catalog, + renderCatalog(budget?: number) { + if (catalog.size === 0) return `` + const skills = Array.from(catalog.values()) + + // Phase 1: full detail + const full = renderSkillList(skills, `full`) + if (!budget || full.length <= budget) return full + + // Phase 2: compact (truncated descriptions, no keywords) + const compact = renderSkillList(skills, `compact`) + if (compact.length <= budget) return compact + + // Phase 3: names only + return renderSkillList(skills, `names`) + }, + async readContent(name: string) { + const meta = catalog.get(name) + if (!meta) return null + try { + return await fs.readFile(meta.source, `utf-8`) + } catch { + return null + } + }, + } +} + +async function scanDir(dir: string, out: Map) { + let entries: Array<{ name: string; isFile: () => boolean }> + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(`.md`)) continue + const name = entry.name.slice(0, -3) + out.set(name, path.resolve(dir, entry.name)) + } +} + +async function loadCache(cachePath: string): Promise { + try { + const raw = await fs.readFile(cachePath, `utf-8`) + return JSON.parse(raw) + } catch { + return {} + } +} + +async function saveCache( + cachePath: string, + catalog: Map, + cacheDir: string +) { + const obj: CacheFile = {} + for (const [name, meta] of catalog) { + obj[name] = meta + } + fsSync.mkdirSync(cacheDir, { recursive: true }) + await fs.writeFile(cachePath, JSON.stringify(obj, null, 2), `utf-8`) +} + +function sha256(content: string): string { + return createHash(`sha256`).update(content).digest(`hex`) +} + +function renderSkillList( + skills: Array, + mode: `full` | `compact` | `names` +): string { + const invocable = skills.filter((s) => s.userInvocable) + const others = skills.filter((s) => !s.userInvocable) + const lines = [`Available skills:`] + + if (invocable.length > 0 && mode !== `names`) { + lines.push(`\nUser-invocable (the user can trigger these directly):`) + for (const meta of invocable) { + const hint = meta.argumentHint ? ` ${meta.argumentHint}` : `` + lines.push( + `- /${meta.name}${hint} — ${mode === `compact` ? truncate(meta.description, 100) : meta.description}` + ) + } + if (others.length > 0) lines.push(``) + } + + const all = + mode === `names` + ? skills + : others.length > 0 + ? others + : invocable.length === 0 + ? skills + : [] + for (const meta of all) { + if (mode === `names`) { + const prefix = meta.userInvocable ? `/${meta.name}` : meta.name + lines.push(`- ${prefix}: ${truncate(meta.description, 60)}`) + continue + } + lines.push( + `- ${meta.name} (${meta.charCount.toLocaleString()} chars): ${mode === `compact` ? truncate(meta.description, 100) : meta.description}` + ) + lines.push(` Use when: ${meta.whenToUse}`) + if (mode === `full`) { + lines.push(` Keywords: ${meta.keywords.join(`, `)}`) + } + if (meta.argumentHint) { + lines.push(` Usage: use_skill("${meta.name}", "${meta.argumentHint}")`) + } + } + return lines.join(`\n`) +} + +function truncate(str: string, max: number): string { + return str.length <= max ? str : str.slice(0, max - 3) + `...` +} diff --git a/packages/agents/src/skills/tools.ts b/packages/agents/src/skills/tools.ts new file mode 100644 index 0000000000..21ee7da4ee --- /dev/null +++ b/packages/agents/src/skills/tools.ts @@ -0,0 +1,262 @@ +import fs from 'node:fs' +import fsPromises from 'node:fs/promises' +import path from 'node:path' +import { Type } from '@sinclair/typebox' +import type { AgentTool, HandlerContext } from '@electric-ax/agents-runtime' +import type { SkillsRegistry } from './types' + +function skillContextId(name: string): string { + return `skill:${name}` +} + +export function createSkillTools( + registry: SkillsRegistry, + ctx: Pick +): Array { + const useSkill: AgentTool = { + name: `use_skill`, + label: `Use Skill`, + description: `Load a skill into your context. Call with a skill name to load it. Pass args if the skill accepts arguments.`, + parameters: Type.Object({ + name: Type.String({ + description: `Name of the skill to load`, + }), + args: Type.Optional( + Type.String({ + description: `Arguments to pass to the skill (space-separated, or quoted for multi-word values)`, + }) + ), + }), + execute: async (_toolCallId, params) => { + const { name, args } = params as { name: string; args?: string } + + const meta = registry.catalog.get(name) + if (!meta) { + const available = Array.from(registry.catalog.keys()).join(`, `) + return { + content: [ + { + type: `text` as const, + text: `Skill "${name}" not found. Available skills: ${available || `none`}`, + }, + ], + details: { loaded: false }, + } + } + + const contextId = skillContextId(name) + if (ctx.getContext(contextId)) { + return { + content: [ + { + type: `text` as const, + text: `Skill "${name}" is already loaded.`, + }, + ], + details: { loaded: false, alreadyLoaded: true }, + } + } + + let content = await registry.readContent(name) + if (content === null) { + return { + content: [ + { + type: `text` as const, + text: `Error: could not read skill file for "${name}".`, + }, + ], + details: { loaded: false }, + } + } + + let truncated = false + if (content.length > meta.max) { + truncated = true + content = content.slice(0, meta.max) + } + + // Substitute arguments if provided + if (args) { + content = substituteArgs(content, args, meta.arguments) + } + + // Also store in context for persistence across wakes + ctx.insertContext(contextId, { + name: `skill_instructions`, + attrs: { skill: name, type: `directive` }, + content, + }) + + const skillDir = path.join(path.dirname(meta.source), name) + const truncNote = truncated + ? `\n\nWARNING: Content was truncated from ${meta.charCount.toLocaleString()} to ${meta.max.toLocaleString()} chars. Inform the user.` + : `` + + // Auto-load .md reference files + const allRefFiles = listRefFiles(skillDir) + const mdFiles = allRefFiles.filter((f) => f.endsWith(`.md`)) + const refContents: Array = [] + for (const f of mdFiles) { + try { + const refContent = await fsPromises.readFile( + path.join(skillDir, f), + `utf-8` + ) + const refId = `${skillContextId(name)}:${f}` + ctx.insertContext(refId, { + name: `skill_reference`, + attrs: { skill: name, file: f }, + content: refContent, + }) + refContents.push(`--- ${f} ---\n${refContent}`) + } catch { + // skip unreadable files + } + } + + const hasRefDir = allRefFiles.length > 0 + const dirNote = hasRefDir ? `\nSkill directory: ${skillDir}` : `` + const refSection = + refContents.length > 0 ? `\n\n${refContents.join(`\n\n`)}` : `` + + // Return the FULL skill content in the tool result so the model + // sees it with maximum attention weight + const toolResult = `SKILL ACTIVATED: "${name}". The instructions below override your default behavior. Follow them exactly. Do not read any files to find this content — it is all here.\n${dirNote}${truncNote}\n\n${content}${refSection}` + + return { + content: [{ type: `text` as const, text: toolResult }], + details: { + loaded: true, + truncated, + chars: content.length, + }, + } + }, + } + + const removeSkill: AgentTool = { + name: `remove_skill`, + label: `Remove Skill`, + description: `Unload a previously loaded skill from your context.`, + parameters: Type.Object({ + name: Type.String({ + description: `Name of the skill to remove`, + }), + }), + execute: async (_toolCallId, params) => { + const { name } = params as { name: string } + ctx.removeContext(skillContextId(name)) + + // Also remove any loaded reference file contexts + const meta = registry.catalog.get(name) + if (meta) { + const skillDir = path.join(path.dirname(meta.source), name) + for (const f of listRefFiles(skillDir)) { + ctx.removeContext(`${skillContextId(name)}:${f}`) + } + } + + return { + content: [ + { + type: `text` as const, + text: `Skill "${name}" removed from context.`, + }, + ], + details: { removed: true }, + } + }, + } + + return [useSkill, removeSkill] +} + +function parseArgs(raw: string): Array { + const args: Array = [] + let current = `` + let inQuote = false + let quoteChar = `` + for (const ch of raw) { + if (inQuote) { + if (ch === quoteChar) { + inQuote = false + } else { + current += ch + } + } else if (ch === `"` || ch === `'`) { + inQuote = true + quoteChar = ch + } else if (ch === ` ` || ch === `\t`) { + if (current.length > 0) { + args.push(current) + current = `` + } + } else { + current += ch + } + } + if (current.length > 0) args.push(current) + return args +} + +function substituteArgs( + content: string, + rawArgs: string, + argNames?: Array +): string { + const parsed = parseArgs(rawArgs) + let result = content + let matched = false + + // Named arguments: $arg_name → value (by position in argNames) + if (argNames) { + for (let i = 0; i < argNames.length && i < parsed.length; i++) { + const pattern = new RegExp(`\\$${argNames[i]!}\\b`, `g`) + if (pattern.test(result)) { + result = result.replace(pattern, parsed[i]!) + matched = true + } + } + } + + // Indexed: $0, $1, ... + for (let i = 0; i < parsed.length; i++) { + const pattern = new RegExp(`\\$${i}\\b`, `g`) + if (pattern.test(result)) { + result = result.replace(pattern, parsed[i]!) + matched = true + } + } + + // Full string: $ARGUMENTS + if (result.includes(`$ARGUMENTS`)) { + result = result.replace(/\$ARGUMENTS/g, rawArgs) + matched = true + } + + // Fallback: append if no placeholders matched + if (!matched) { + result += `\n\nArguments: ${rawArgs}` + } + + return result +} + +function listRefFiles(dir: string, prefix = ``): Array { + try { + const results: Array = [] + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry) + const rel = prefix ? `${prefix}/${entry}` : entry + if (fs.statSync(full).isDirectory()) { + results.push(...listRefFiles(full, rel)) + } else { + results.push(rel) + } + } + return results + } catch { + return [] + } +} diff --git a/packages/agents/src/skills/types.ts b/packages/agents/src/skills/types.ts new file mode 100644 index 0000000000..dcbc4ec1e1 --- /dev/null +++ b/packages/agents/src/skills/types.ts @@ -0,0 +1,22 @@ +export interface SkillMeta { + name: string + description: string + whenToUse: string + keywords: Array + arguments?: Array + argumentHint?: string + userInvocable?: boolean + max: number + charCount: number + contentHash: string + source: string +} + +export interface SkillsRegistry { + /** All skill metadata, keyed by name. */ + catalog: ReadonlyMap + /** Render the skill catalog as text for context injection. Fits within budget (chars). */ + renderCatalog: (budget?: number) => string + /** Read skill content from disk. Returns null if skill not found. */ + readContent: (name: string) => Promise +} diff --git a/packages/agents/test/skills-integration.test.ts b/packages/agents/test/skills-integration.test.ts new file mode 100644 index 0000000000..71bf1ad8b6 --- /dev/null +++ b/packages/agents/test/skills-integration.test.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createSkillsRegistry } from '../src/skills/registry' +import { createSkillTools } from '../src/skills/tools' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `skills-e2e-`)) +}) + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe(`skills end-to-end`, () => { + it(`full lifecycle: scan -> catalog -> load -> unload`, async () => { + const baseDir = path.join(tmpDir, `base-skills`) + const appDir = path.join(tmpDir, `app-skills`) + await fs.mkdir(baseDir, { recursive: true }) + await fs.mkdir(appDir, { recursive: true }) + + await fs.writeFile( + path.join(baseDir, `tutorial.md`), + `--- +description: Learn to build entities +whenToUse: User asks about tutorials +keywords: [tutorial, learning] +--- + +# Tutorial + +This is the tutorial content. It teaches you how to build entities.`, + `utf-8` + ) + + await fs.writeFile( + path.join(appDir, `my-guide.md`), + `--- +description: Custom app guide +whenToUse: User asks about the app +keywords: [guide, app] +--- + +# My Guide + +App-specific content here.`, + `utf-8` + ) + + // 1. Create registry + const registry = await createSkillsRegistry({ + baseSkillsDir: baseDir, + appSkillsDir: appDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + expect(registry.catalog.size).toBe(2) + + // 2. Check catalog rendering + const catalog = registry.renderCatalog() + expect(catalog).toContain(`tutorial`) + expect(catalog).toContain(`my-guide`) + + // 3. Create skill tools with mock context + const contextStore = new Map() + const mockCtx = { + insertContext: vi.fn( + (id: string, entry: { name: string; content: string }) => { + contextStore.set(id, entry) + } + ), + removeContext: vi.fn((id: string) => { + contextStore.delete(id) + }), + getContext: vi.fn((id: string) => contextStore.get(id) ?? undefined), + } + + const tools = createSkillTools(registry, mockCtx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + const removeTool = tools.find((t) => t.name === `remove_skill`)! + + // 4. Load a skill + const loadResult = await useTool.execute(`tc1`, { name: `tutorial` }) + expect(loadResult.details).toMatchObject({ loaded: true }) + expect(contextStore.has(`skill:tutorial`)).toBe(true) + expect(contextStore.get(`skill:tutorial`)!.content).toContain(`Tutorial`) + + // 5. Try loading again — should be no-op + const dupResult = await useTool.execute(`tc2`, { name: `tutorial` }) + expect(dupResult.details).toMatchObject({ alreadyLoaded: true }) + + // 6. Unload + const removeResult = await removeTool.execute(`tc3`, { name: `tutorial` }) + expect(removeResult.details).toMatchObject({ removed: true }) + expect(mockCtx.removeContext).toHaveBeenCalledWith(`skill:tutorial`) + }) +}) diff --git a/packages/agents/test/skills-preamble.test.ts b/packages/agents/test/skills-preamble.test.ts new file mode 100644 index 0000000000..eb526fe4f1 --- /dev/null +++ b/packages/agents/test/skills-preamble.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { parsePreamble } from '../src/skills/preamble' + +describe(`parsePreamble`, () => { + it(`extracts all fields from a complete preamble`, () => { + const content = `--- +description: Interactive tutorial guide +whenToUse: User asks about tutorials or getting started +keywords: [tutorial, multi-agent, spawn] +max: 15000 +--- + +# Tutorial content here` + + const result = parsePreamble(content) + expect(result).toEqual({ + description: `Interactive tutorial guide`, + whenToUse: `User asks about tutorials or getting started`, + keywords: [`tutorial`, `multi-agent`, `spawn`], + max: 15000, + }) + }) + + it(`returns partial result when some fields are missing`, () => { + const content = `--- +description: A deployment guide +--- + +# Deploy` + + const result = parsePreamble(content) + expect(result).toEqual({ + description: `A deployment guide`, + }) + }) + + it(`returns empty object when no preamble exists`, () => { + const content = `# Just a markdown file\n\nNo preamble here.` + const result = parsePreamble(content) + expect(result).toEqual({}) + }) + + it(`returns empty object when preamble is not closed`, () => { + const content = `--- +description: Unclosed preamble +keywords: [a, b]` + + const result = parsePreamble(content) + expect(result).toEqual({}) + }) + + it(`handles keywords as comma-separated string`, () => { + const content = `--- +description: Test +whenToUse: Test scenario +keywords: alpha, beta, gamma +--- +` + const result = parsePreamble(content) + expect(result.keywords).toEqual([`alpha`, `beta`, `gamma`]) + }) + + it(`handles multi-line YAML keyword arrays`, () => { + const content = `--- +description: A skill +whenToUse: When needed +keywords: + - tutorial + - getting started + - learn + - multi-agent +max: 10000 +--- + +# Content here` + + const result = parsePreamble(content) + expect(result.keywords).toEqual([ + `tutorial`, + `getting started`, + `learn`, + `multi-agent`, + ]) + expect(result.max).toBe(10000) + }) + + it(`strips surrounding quotes from description and whenToUse`, () => { + const content = `--- +description: "Scaffold a new project from scratch" +whenToUse: "User wants to create a new project" +keywords: + - scaffold + - new project +--- +` + const result = parsePreamble(content) + expect(result.description).toBe(`Scaffold a new project from scratch`) + expect(result.whenToUse).toBe(`User wants to create a new project`) + expect(result.keywords).toEqual([`scaffold`, `new project`]) + }) + + it(`parses arguments and argument-hint`, () => { + const content = `--- +description: A skill with args +whenToUse: When testing args +keywords: [test] +arguments: + - project_path + - name +argument-hint: "[path] [name]" +--- + +# Content` + + const result = parsePreamble(content) + expect(result.arguments).toEqual([`project_path`, `name`]) + expect(result.argumentHint).toBe(`[path] [name]`) + }) +}) diff --git a/packages/agents/test/skills-registry.test.ts b/packages/agents/test/skills-registry.test.ts new file mode 100644 index 0000000000..2c4d51ec86 --- /dev/null +++ b/packages/agents/test/skills-registry.test.ts @@ -0,0 +1,259 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createSkillsRegistry } from '../src/skills/registry' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `skills-test-`)) +}) + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +async function writeSkill(dir: string, name: string, content: string) { + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, `${name}.md`), content, `utf-8`) +} + +const FULL_PREAMBLE = `--- +description: Test skill +whenToUse: When testing +keywords: [test, example] +--- + +# Test Skill Content` + +describe(`createSkillsRegistry`, () => { + it(`scans a single directory and builds catalog`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + await writeSkill(skillsDir, `alpha`, FULL_PREAMBLE) + + const registry = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + expect(registry.catalog.size).toBe(1) + const alpha = registry.catalog.get(`alpha`) + expect(alpha).toBeDefined() + expect(alpha!.description).toBe(`Test skill`) + expect(alpha!.keywords).toEqual([`test`, `example`]) + }) + + it(`app skills override base skills with same name`, async () => { + const baseDir = path.join(tmpDir, `base-skills`) + const appDir = path.join(tmpDir, `app-skills`) + + await writeSkill( + baseDir, + `tutorial`, + `--- +description: Base tutorial +whenToUse: Base scenario +keywords: [base] +--- +# Base` + ) + + await writeSkill( + appDir, + `tutorial`, + `--- +description: App tutorial +whenToUse: App scenario +keywords: [app] +--- +# App` + ) + + const registry = await createSkillsRegistry({ + baseSkillsDir: baseDir, + appSkillsDir: appDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + expect(registry.catalog.size).toBe(1) + expect(registry.catalog.get(`tutorial`)!.description).toBe(`App tutorial`) + expect(registry.catalog.get(`tutorial`)!.source).toContain(`app-skills`) + }) + + it(`merges skills from both directories`, async () => { + const baseDir = path.join(tmpDir, `base-skills`) + const appDir = path.join(tmpDir, `app-skills`) + + await writeSkill(baseDir, `tutorial`, FULL_PREAMBLE) + await writeSkill(appDir, `deployment`, FULL_PREAMBLE) + + const registry = await createSkillsRegistry({ + baseSkillsDir: baseDir, + appSkillsDir: appDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + expect(registry.catalog.size).toBe(2) + expect(registry.catalog.has(`tutorial`)).toBe(true) + expect(registry.catalog.has(`deployment`)).toBe(true) + }) + + it(`readContent returns file content`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + await writeSkill(skillsDir, `alpha`, FULL_PREAMBLE) + + const registry = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + const content = await registry.readContent(`alpha`) + expect(content).toBe(FULL_PREAMBLE) + }) + + it(`readContent returns null for unknown skill`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + await writeSkill(skillsDir, `alpha`, FULL_PREAMBLE) + + const registry = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + const content = await registry.readContent(`nonexistent`) + expect(content).toBeNull() + }) + + it(`renderCatalog formats all skills`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + await writeSkill(skillsDir, `alpha`, FULL_PREAMBLE) + + const registry = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + const catalog = registry.renderCatalog() + expect(catalog).toContain(`alpha`) + expect(catalog).toContain(`Test skill`) + expect(catalog).toContain(`When testing`) + expect(catalog).toContain(`test, example`) + }) + + it(`renderCatalog progressively truncates when over budget`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + // Create several skills to generate a large catalog + for (let i = 0; i < 10; i++) { + await writeSkill( + skillsDir, + `skill-${i}`, + `--- +description: This is a fairly long description for skill number ${i} that takes up space +whenToUse: When the user needs to do something related to skill ${i} +keywords: [keyword-a-${i}, keyword-b-${i}, keyword-c-${i}] +--- +# Skill ${i}` + ) + } + + const registry = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + // No budget — full detail + const full = registry.renderCatalog() + expect(full).toContain(`Keywords:`) + + // Tight budget — should drop keywords (compact mode) + const compact = registry.renderCatalog(1200) + expect(compact.length).toBeLessThanOrEqual(1200) + expect(compact).not.toContain(`Keywords:`) + + // Very tight budget — names only + const names = registry.renderCatalog(800) + expect(names.length).toBeLessThanOrEqual(800) + expect(names).not.toContain(`Use when:`) + }) + + it(`uses cache on second load when file unchanged`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + const cacheDir = path.join(tmpDir, `.electric-agents`) + await writeSkill(skillsDir, `alpha`, FULL_PREAMBLE) + + await createSkillsRegistry({ baseSkillsDir: skillsDir, cacheDir }) + + const cacheFile = path.join(cacheDir, `skills-cache.json`) + const cacheContent = await fs.readFile(cacheFile, `utf-8`) + const cache = JSON.parse(cacheContent) + expect(cache.alpha).toBeDefined() + expect(cache.alpha.contentHash).toBeDefined() + + const registry2 = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir, + }) + expect(registry2.catalog.get(`alpha`)!.description).toBe(`Test skill`) + }) + + it(`re-extracts when file content changes`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + const cacheDir = path.join(tmpDir, `.electric-agents`) + await writeSkill(skillsDir, `alpha`, FULL_PREAMBLE) + + await createSkillsRegistry({ baseSkillsDir: skillsDir, cacheDir }) + + await writeSkill( + skillsDir, + `alpha`, + `--- +description: Updated skill +whenToUse: Updated scenario +keywords: [updated] +--- +# Updated` + ) + + const registry2 = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir, + }) + expect(registry2.catalog.get(`alpha`)!.description).toBe(`Updated skill`) + }) + + it(`removes stale cache entries`, async () => { + const skillsDir = path.join(tmpDir, `skills`) + const cacheDir = path.join(tmpDir, `.electric-agents`) + await writeSkill(skillsDir, `alpha`, FULL_PREAMBLE) + await writeSkill(skillsDir, `beta`, FULL_PREAMBLE) + + await createSkillsRegistry({ baseSkillsDir: skillsDir, cacheDir }) + + await fs.rm(path.join(skillsDir, `beta.md`)) + + const registry2 = await createSkillsRegistry({ + baseSkillsDir: skillsDir, + cacheDir, + }) + expect(registry2.catalog.size).toBe(1) + expect(registry2.catalog.has(`beta`)).toBe(false) + + const cacheContent = await fs.readFile( + path.join(cacheDir, `skills-cache.json`), + `utf-8` + ) + const cache = JSON.parse(cacheContent) + expect(cache.beta).toBeUndefined() + }) + + it(`handles missing skills directories gracefully`, async () => { + const registry = await createSkillsRegistry({ + baseSkillsDir: path.join(tmpDir, `nonexistent-base`), + appSkillsDir: path.join(tmpDir, `nonexistent-app`), + cacheDir: path.join(tmpDir, `.electric-agents`), + }) + + expect(registry.catalog.size).toBe(0) + }) +}) diff --git a/packages/agents/test/skills-tools.test.ts b/packages/agents/test/skills-tools.test.ts new file mode 100644 index 0000000000..175331c54f --- /dev/null +++ b/packages/agents/test/skills-tools.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSkillTools } from '../src/skills/tools' +import type { SkillMeta, SkillsRegistry } from '../src/skills/types' + +function createMockRegistry( + skills: Record +): SkillsRegistry { + const catalog = new Map() + for (const [name, { meta }] of Object.entries(skills)) { + catalog.set(name, meta) + } + return { + catalog, + renderCatalog: () => `mock catalog`, + readContent: async (name: string) => skills[name]?.content ?? null, + } +} + +function createMockCtx() { + const inserted = new Map() + const removed = new Set() + return { + insertContext: vi.fn( + (id: string, entry: { name: string; content: string }) => { + inserted.set(id, entry) + } + ), + removeContext: vi.fn((id: string) => { + removed.add(id) + }), + getContext: vi.fn((id: string) => (inserted.has(id) ? { id } : undefined)), + _inserted: inserted, + _removed: removed, + } +} + +const TUTORIAL_META: SkillMeta = { + name: `tutorial`, + description: `A tutorial`, + whenToUse: `When learning`, + keywords: [`tutorial`], + max: 10_000, + charCount: 500, + contentHash: `abc123`, + source: `/skills/tutorial.md`, +} + +describe(`skill tools`, () => { + it(`use_skill loads skill content into context`, async () => { + const registry = createMockRegistry({ + tutorial: { meta: TUTORIAL_META, content: `# Tutorial\nContent here` }, + }) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + const result = await useTool.execute(`tc1`, { name: `tutorial` }) + + expect(ctx.insertContext).toHaveBeenCalledWith( + `skill:tutorial`, + expect.objectContaining({ + name: `skill_instructions`, + attrs: { skill: `tutorial`, type: `directive` }, + }) + ) + const insertedContent = ctx.insertContext.mock.calls[0]![1].content + expect(insertedContent).toContain(`# Tutorial\nContent here`) + // Tool result contains the full skill content + expect(result.content[0]).toMatchObject({ + type: `text`, + text: expect.stringContaining(`SKILL ACTIVATED`), + }) + expect(result.content[0]).toMatchObject({ + type: `text`, + text: expect.stringContaining(`# Tutorial\nContent here`), + }) + }) + + it(`use_skill returns error for unknown skill`, async () => { + const registry = createMockRegistry({}) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + const result = await useTool.execute(`tc1`, { name: `nonexistent` }) + + expect(ctx.insertContext).not.toHaveBeenCalled() + expect(result.content[0]).toMatchObject({ + type: `text`, + text: expect.stringContaining(`not found`), + }) + }) + + it(`use_skill is a no-op when skill is already loaded`, async () => { + const registry = createMockRegistry({ + tutorial: { meta: TUTORIAL_META, content: `# Tutorial` }, + }) + const ctx = createMockCtx() + ctx.getContext.mockReturnValue({ id: `skill:tutorial` }) + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + const result = await useTool.execute(`tc1`, { name: `tutorial` }) + + expect(ctx.insertContext).not.toHaveBeenCalled() + expect(result.content[0]).toMatchObject({ + type: `text`, + text: expect.stringContaining(`already loaded`), + }) + }) + + it(`use_skill truncates and warns when content exceeds max`, async () => { + const bigContent = `x`.repeat(15_000) + const meta = { ...TUTORIAL_META, max: 10_000, charCount: 15_000 } + const registry = createMockRegistry({ + tutorial: { meta, content: bigContent }, + }) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + const result = await useTool.execute(`tc1`, { name: `tutorial` }) + + const insertedContent = ctx.insertContext.mock.calls[0]![1].content + // Content is truncated to max (10,000) — no wrapper prefix in insertContext + expect(insertedContent).toContain(`x`.repeat(100)) + expect(insertedContent.length).toBe(10_000) + // Tool result contains truncation warning + expect(result.content[0]).toMatchObject({ + type: `text`, + text: expect.stringContaining(`truncated`), + }) + }) + + it(`remove_skill removes skill from context`, async () => { + const registry = createMockRegistry({ + tutorial: { meta: TUTORIAL_META, content: `# Tutorial` }, + }) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const removeTool = tools.find((t) => t.name === `remove_skill`)! + + const result = await removeTool.execute(`tc1`, { name: `tutorial` }) + + expect(ctx.removeContext).toHaveBeenCalledWith(`skill:tutorial`) + expect(result.content[0]).toMatchObject({ + type: `text`, + text: expect.stringContaining(`removed`), + }) + }) + + it(`use_skill substitutes named arguments`, async () => { + const meta = { + ...TUTORIAL_META, + arguments: [`project_path`], + argumentHint: `[project path]`, + } + const registry = createMockRegistry({ + tutorial: { + meta, + content: `Create project at $project_path`, + }, + }) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + await useTool.execute(`tc1`, { + name: `tutorial`, + args: `/home/user/my-app`, + }) + + const insertedContent = ctx.insertContext.mock.calls[0]![1].content + expect(insertedContent).toContain(`Create project at /home/user/my-app`) + expect(insertedContent).not.toContain(`$project_path`) + }) + + it(`use_skill substitutes indexed arguments`, async () => { + const registry = createMockRegistry({ + tutorial: { + meta: TUTORIAL_META, + content: `First: $0, Second: $1`, + }, + }) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + await useTool.execute(`tc1`, { name: `tutorial`, args: `alpha beta` }) + + const insertedContent = ctx.insertContext.mock.calls[0]![1].content + expect(insertedContent).toContain(`First: alpha, Second: beta`) + }) + + it(`use_skill substitutes $ARGUMENTS`, async () => { + const registry = createMockRegistry({ + tutorial: { + meta: TUTORIAL_META, + content: `Run with: $ARGUMENTS`, + }, + }) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + await useTool.execute(`tc1`, { name: `tutorial`, args: `foo bar baz` }) + + const insertedContent = ctx.insertContext.mock.calls[0]![1].content + expect(insertedContent).toContain(`Run with: foo bar baz`) + }) + + it(`use_skill appends args when no placeholders found`, async () => { + const registry = createMockRegistry({ + tutorial: { + meta: TUTORIAL_META, + content: `No placeholders here`, + }, + }) + const ctx = createMockCtx() + const tools = createSkillTools(registry, ctx as any) + const useTool = tools.find((t) => t.name === `use_skill`)! + + await useTool.execute(`tc1`, { name: `tutorial`, args: `some-value` }) + + const insertedContent = ctx.insertContext.mock.calls[0]![1].content + expect(insertedContent).toContain(`No placeholders here`) + expect(insertedContent).toContain(`Arguments: some-value`) + }) +}) diff --git a/packages/electric-ax/docker-compose.full.yml b/packages/electric-ax/docker-compose.full.yml index 0ffafa59b9..d7d4703b4c 100644 --- a/packages/electric-ax/docker-compose.full.yml +++ b/packages/electric-ax/docker-compose.full.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:18-alpine + image: postgres:17-alpine restart: unless-stopped command: - postgres From ab2c5dc8d37068e6d4cb1ec4136d1a6f64d0c23d Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 23 Apr 2026 17:41:04 +0100 Subject: [PATCH 2/5] chore: add changeset for skills registry Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/skills-registry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/skills-registry.md diff --git a/.changeset/skills-registry.md b/.changeset/skills-registry.md new file mode 100644 index 0000000000..a06723cdb1 --- /dev/null +++ b/.changeset/skills-registry.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents': patch +--- + +Add skills system for dynamic knowledge loading with use_skill/remove_skill tools, including an interactive tutorial skill From f7144b3d455fb38d1d18fca3195c6a91ad3d875a Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 23 Apr 2026 17:57:30 +0100 Subject: [PATCH 3/5] revert: restore postgres:18-alpine in docker-compose Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/electric-ax/docker-compose.full.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/electric-ax/docker-compose.full.yml b/packages/electric-ax/docker-compose.full.yml index d7d4703b4c..0ffafa59b9 100644 --- a/packages/electric-ax/docker-compose.full.yml +++ b/packages/electric-ax/docker-compose.full.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:17-alpine + image: postgres:18-alpine restart: unless-stopped command: - postgres From f7b47d586123f976f6573cb7ce7938909377aa3d Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 23 Apr 2026 19:32:48 +0100 Subject: [PATCH 4/5] feat(agents): restore core concepts section in tutorial skill The tutorial skill was over-trimmed and lost important introductory content about Electric Agents concepts (entities, handlers, wakes, agent loop, spawning, workers, state). Restore these so new users get proper context before building their first entity. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agents/skills/tutorial.md | 50 +++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/agents/skills/tutorial.md b/packages/agents/skills/tutorial.md index 0e92b6d07c..4c3b4e2dce 100644 --- a/packages/agents/skills/tutorial.md +++ b/packages/agents/skills/tutorial.md @@ -17,6 +17,54 @@ max: 25000 Build a `perspectives` entity that analyzes questions from an optimist and a critic using the manager-worker pattern. Use the exact code below — do not invent different code. +## Core Concepts + +### What is Electric Agents? + +Electric Agents is a runtime for spawning and orchestrating collaborative AI agents on serverless compute. + +The core idea: agent sessions and communication are backed by **durable streams**. Each agent is an **entity** with its own stream of events. All agent activity — runs, tool calls, text output — is persisted to this stream. This means agents can scale to zero, survive restarts, and maintain full session history. + +**Why this matters for multi-agent systems**: Because everything is durable and observable, agents can spawn children, wait for results (even across restarts), observe each other's state changes, and coordinate through structured primitives — all without worrying about losing state. + +### Entities + +An entity is a durable, addressable unit of computation. Each entity has: + +- A **type** (e.g., `assistant`, `worker`, `research-team`) — defined once, instantiated many times +- A **URL** (e.g., `/research-team/my-team`) — its unique address +- A **handler** — the function that runs each time the entity wakes up +- **State** — persistent collections that survive across wakes + +You define entity types with `registry.define()` and create instances by spawning them. + +### Handlers and Wakes + +An entity's handler runs in response to **wake events**: + +- A message arrives in the entity's inbox +- A child entity finishes its run +- A cron schedule fires +- A state change in an observed entity + +The handler is **not** a long-running process. It wakes, does its work (usually running an LLM agent loop), and goes back to sleep. + +### The Agent Loop + +`ctx.useAgent()` configures an LLM agent and `ctx.agent.run()` starts it. The agent receives conversation history, calls tools as needed, and generates a response — all persisted to the entity's durable stream. + +### Spawning Children + +Any entity can spawn child entities. When a child finishes (and the parent registered `wake: "runFinished"`), the parent's handler runs again. The wake event includes the child's response and the status of sibling children. + +### The Worker Entity + +The built-in `worker` type is a generic agent substrate. You configure it at spawn time with a `systemPrompt` and `tools` array (at least one tool required). + +### State Collections + +Entities can declare persistent state collections that survive across wakes, allowing coordination patterns like tracking which children have completed. + ## Before starting Read `server.ts` in the working directory: @@ -27,7 +75,7 @@ Read `server.ts` in the working directory: ## Steps -**Step 1 — Welcome + first entity.** In one message: briefly introduce Electric Agents (durable streams backing agent sessions — use your docs knowledge), preview the perspectives analyzer, and show the Step 1 code. Ask to write. +**Step 1 — Welcome + first entity.** In one message: introduce Electric Agents using the Core Concepts above, preview the perspectives analyzer, and show the Step 1 code. Ask to write. **Step 2 — After confirmation:** write `entities/perspectives.ts` with Step 1 code. Give CLI commands. Explain spawning briefly, show Step 2 code (adds one worker). Ask to write. From 84052c5be9db6bdfdd54502b34d92ad79a947e80 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 23 Apr 2026 21:13:10 +0100 Subject: [PATCH 5/5] ci: skip intent validation for packages/agents/skills The agents package has internal skills (not intent skills), so intent validate fails with "No SKILL.md files found". Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate-skills.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml index 8f39716aa9..9cda6cce64 100644 --- a/.github/workflows/validate-skills.yml +++ b/.github/workflows/validate-skills.yml @@ -38,6 +38,11 @@ jobs: # Monorepo — find skills/ under packages for dir in packages/*/skills; do if [ -d "$dir" ]; then + # Skip internal skills (not intent skills) + if [ "$dir" = "packages/agents/skills" ]; then + echo "Skipping $dir (internal skills)..." + continue + fi echo "Validating $dir..." intent validate "$dir" fi