Skip to content

Commit cd29461

Browse files
committed
feat(emergent): add SKILL.md and CAPABILITY.yaml export for forged tools
SkillExporter converts EmergentTool instances into the standard SKILL.md format consumed by agentos-skills-registry and the CapabilityManifestScanner. Three export levels: in-memory string, single file write, and full skill pack (SKILL.md + CAPABILITY.yaml directory). Sandbox tools receive a code redaction notice. 7 tests covering composable/sandbox export, YAML schema, disk I/O, parameter tables, and provenance capture.
1 parent c841a3c commit cd29461

3 files changed

Lines changed: 714 additions & 0 deletions

File tree

src/emergent/SkillExporter.ts

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
/**
2+
* @fileoverview Skill exporter for emergent tools.
3+
* @module @framers/agentos/emergent/SkillExporter
4+
*
5+
* Converts runtime-forged {@link EmergentTool} instances into the standard
6+
* SKILL.md + CAPABILITY.yaml format consumed by the agentos-skills-registry
7+
* and the {@link CapabilityManifestScanner}.
8+
*
9+
* This enables emergent tools that have proven their worth (agent-tier or
10+
* shared-tier) to be persisted as first-class skills, discoverable by the
11+
* capability discovery engine alongside curated human-authored skills.
12+
*
13+
* Three export levels:
14+
* - `exportToolAsSkill()` — pure in-memory string conversion to SKILL.md markdown
15+
* - `writeSkillFile()` — writes a single SKILL.md to disk
16+
* - `exportToolAsSkillPack()` — writes both SKILL.md and CAPABILITY.yaml (a full
17+
* capability directory ready for scanner pickup)
18+
*/
19+
20+
import * as fs from 'node:fs/promises';
21+
import * as path from 'node:path';
22+
import YAML from 'yaml';
23+
24+
import type { EmergentTool, ComposableToolSpec, SandboxedToolSpec } from './types.js';
25+
import type { JSONSchemaObject } from '../core/tools/ITool.js';
26+
27+
// ============================================================================
28+
// FRONTMATTER HELPERS
29+
// ============================================================================
30+
31+
/**
32+
* Derives a human-readable title from a tool's machine-readable name.
33+
* Replaces underscores and hyphens with spaces, then title-cases each word.
34+
*
35+
* @param name - Machine-readable tool name (e.g. "fetch_github_pr_summary").
36+
* @returns Title-cased display name (e.g. "Fetch Github Pr Summary").
37+
*
38+
* @example
39+
* ```ts
40+
* titleCase('fetch_github_pr_summary'); // "Fetch Github Pr Summary"
41+
* ```
42+
*/
43+
function titleCase(name: string): string {
44+
return name
45+
.replace(/[-_]/g, ' ')
46+
.replace(/\b\w/g, (c) => c.toUpperCase());
47+
}
48+
49+
/**
50+
* Extracts the list of sub-tool names required by a composable implementation.
51+
* Sandboxed tools declare their API allowlist instead.
52+
*
53+
* @param impl - The tool's implementation specification.
54+
* @returns Deduplicated array of required tool names.
55+
*/
56+
function extractRequiredTools(impl: EmergentTool['implementation']): string[] {
57+
if (impl.mode === 'compose') {
58+
// Collect all unique tool names referenced across pipeline steps
59+
const tools = new Set((impl as ComposableToolSpec).steps.map((s) => s.tool));
60+
return [...tools];
61+
}
62+
// Sandbox tools don't depend on named tools — they have an API allowlist
63+
return [];
64+
}
65+
66+
/**
67+
* Extracts tags from the tool's metadata and implementation mode.
68+
* Always includes 'generated' and 'emergent' to distinguish forged skills
69+
* from hand-authored ones.
70+
*
71+
* @param tool - The emergent tool to derive tags from.
72+
* @returns Array of tag strings.
73+
*/
74+
function deriveTags(tool: EmergentTool): string[] {
75+
const tags = new Set<string>(['generated', 'emergent']);
76+
tags.add(tool.implementation.mode);
77+
tags.add(tool.tier);
78+
return [...tags];
79+
}
80+
81+
// ============================================================================
82+
// PARAMETER TABLE
83+
// ============================================================================
84+
85+
/**
86+
* Builds a markdown table describing the tool's input parameters from its
87+
* JSON Schema `inputSchema`.
88+
*
89+
* Only processes top-level `properties` from an object schema. Nested schemas
90+
* are represented as their `type` string with a note to see the full schema.
91+
*
92+
* @param schema - The tool's inputSchema (JSON Schema object).
93+
* @returns Markdown table string, or empty string if no properties exist.
94+
*/
95+
function buildParameterTable(schema: JSONSchemaObject): string {
96+
const properties = schema.properties;
97+
if (!properties || typeof properties !== 'object') {
98+
return '';
99+
}
100+
101+
const required = new Set<string>(
102+
Array.isArray(schema.required) ? schema.required : [],
103+
);
104+
105+
const rows: string[] = [];
106+
rows.push('| Parameter | Type | Required | Description |');
107+
rows.push('|-----------|------|----------|-------------|');
108+
109+
for (const [name, prop] of Object.entries(properties)) {
110+
const p = prop as Record<string, unknown>;
111+
const type = String(p.type ?? 'unknown');
112+
const isRequired = required.has(name) ? 'Yes' : 'No';
113+
const description = String(p.description ?? '');
114+
rows.push(`| ${name} | ${type} | ${isRequired} | ${description} |`);
115+
}
116+
117+
return rows.join('\n');
118+
}
119+
120+
// ============================================================================
121+
// SKILL.md EXPORT
122+
// ============================================================================
123+
124+
/**
125+
* Converts an {@link EmergentTool} into a SKILL.md markdown string.
126+
*
127+
* The generated SKILL.md follows the standard format used by the curated
128+
* skills in `packages/agentos-skills-registry/registry/curated/`. It includes
129+
* YAML frontmatter, a purpose section, usage guidance, a parameter table,
130+
* and implementation notes.
131+
*
132+
* Sandbox tools receive a redaction notice instead of exposed source code —
133+
* the SKILL.md documents the tool's interface without leaking runtime code.
134+
*
135+
* @param tool - The emergent tool to export.
136+
* @returns Complete SKILL.md content as a string.
137+
*
138+
* @example
139+
* ```ts
140+
* import { exportToolAsSkill } from '@framers/agentos/emergent/SkillExporter';
141+
*
142+
* const markdown = exportToolAsSkill(myEmergentTool);
143+
* console.log(markdown);
144+
* // ---
145+
* // name: fetch-data
146+
* // version: '1.0.0'
147+
* // ...
148+
* // ---
149+
* // # Fetch Data
150+
* // ...
151+
* ```
152+
*/
153+
export function exportToolAsSkill(tool: EmergentTool): string {
154+
const tags = deriveTags(tool);
155+
const requiredTools = extractRequiredTools(tool.implementation);
156+
const displayName = titleCase(tool.name);
157+
158+
// -- YAML frontmatter --
159+
const frontmatter: Record<string, unknown> = {
160+
name: tool.name,
161+
version: '1.0.0',
162+
description: tool.description,
163+
author: 'emergent-engine',
164+
namespace: 'emergent',
165+
category: 'emergent',
166+
tags,
167+
requires_secrets: [],
168+
requires_tools: requiredTools,
169+
metadata: {
170+
agentos: {
171+
tier: tool.tier,
172+
createdBy: tool.createdBy,
173+
createdAt: tool.createdAt,
174+
mode: tool.implementation.mode,
175+
},
176+
},
177+
};
178+
179+
// Serialize frontmatter with the yaml library for consistent formatting
180+
const frontmatterYaml = YAML.stringify(frontmatter).trim();
181+
182+
// -- Body sections --
183+
const sections: string[] = [];
184+
sections.push(`# ${displayName}`);
185+
sections.push('');
186+
187+
// Purpose
188+
sections.push('## Purpose');
189+
sections.push('');
190+
sections.push(tool.description);
191+
sections.push('');
192+
193+
// Usage
194+
sections.push('## Usage');
195+
sections.push('');
196+
if (tool.implementation.mode === 'compose') {
197+
const steps = (tool.implementation as ComposableToolSpec).steps;
198+
sections.push(
199+
`This tool is a composable pipeline of ${steps.length} step(s) that chains ` +
200+
`the following tools in sequence: ${steps.map((s) => `\`${s.tool}\``).join(' -> ')}.`,
201+
);
202+
} else {
203+
// Sandbox tools — explain without exposing code
204+
const sandbox = tool.implementation as SandboxedToolSpec;
205+
sections.push(
206+
'This tool executes sandboxed code with the following API allowlist: ' +
207+
`${sandbox.allowlist.map((a) => `\`${a}\``).join(', ')}.`,
208+
);
209+
sections.push('');
210+
sections.push(
211+
'> **Note:** Sandbox source code is redacted in the skill export for security. ' +
212+
'The tool must be re-forged or the original sandbox source must be provided separately.',
213+
);
214+
}
215+
sections.push('');
216+
217+
// Parameters
218+
const paramTable = buildParameterTable(tool.inputSchema);
219+
if (paramTable) {
220+
sections.push('## Parameters');
221+
sections.push('');
222+
sections.push(paramTable);
223+
sections.push('');
224+
}
225+
226+
// Output schema
227+
if (tool.outputSchema && Object.keys(tool.outputSchema).length > 0) {
228+
sections.push('## Output');
229+
sections.push('');
230+
sections.push('```json');
231+
sections.push(JSON.stringify(tool.outputSchema, null, 2));
232+
sections.push('```');
233+
sections.push('');
234+
}
235+
236+
// Implementation details (for composable tools only — sandbox code is redacted)
237+
if (tool.implementation.mode === 'compose') {
238+
const steps = (tool.implementation as ComposableToolSpec).steps;
239+
sections.push('## Pipeline Steps');
240+
sections.push('');
241+
for (let i = 0; i < steps.length; i++) {
242+
const step = steps[i];
243+
sections.push(`${i + 1}. **${step.name}** — calls \`${step.tool}\``);
244+
if (step.condition) {
245+
sections.push(` - Condition: \`${step.condition}\``);
246+
}
247+
const mappingEntries = Object.entries(step.inputMapping);
248+
if (mappingEntries.length > 0) {
249+
sections.push(` - Input mapping: ${mappingEntries.map(([k, v]) => `\`${k}\` = \`${String(v)}\``).join(', ')}`);
250+
}
251+
}
252+
sections.push('');
253+
}
254+
255+
// Provenance
256+
sections.push('## Provenance');
257+
sections.push('');
258+
sections.push(`- **Source:** ${tool.source}`);
259+
sections.push(`- **Tier:** ${tool.tier}`);
260+
sections.push(`- **Total uses:** ${tool.usageStats.totalUses}`);
261+
sections.push(
262+
`- **Success rate:** ${tool.usageStats.totalUses > 0
263+
? ((tool.usageStats.successCount / tool.usageStats.totalUses) * 100).toFixed(1)
264+
: '0.0'}%`,
265+
);
266+
sections.push(`- **Confidence:** ${tool.usageStats.confidenceScore.toFixed(2)}`);
267+
sections.push('');
268+
269+
return `---\n${frontmatterYaml}\n---\n\n${sections.join('\n')}`;
270+
}
271+
272+
// ============================================================================
273+
// CAPABILITY.yaml EXPORT
274+
// ============================================================================
275+
276+
/**
277+
* Builds a CAPABILITY.yaml content string for an emergent tool.
278+
*
279+
* The format matches the schema expected by {@link CapabilityManifestScanner}:
280+
* ```yaml
281+
* id: tool:<name>
282+
* kind: tool
283+
* name: <name>
284+
* displayName: <Title Case Name>
285+
* description: <description>
286+
* category: emergent
287+
* tags: [generated, ...]
288+
* inputSchema: { ... }
289+
* outputSchema: { ... }
290+
* skillContent: ./SKILL.md
291+
* ```
292+
*
293+
* @param tool - The emergent tool to export.
294+
* @returns CAPABILITY.yaml content string.
295+
*/
296+
export function buildCapabilityYaml(tool: EmergentTool): string {
297+
const tags = deriveTags(tool);
298+
299+
const manifest: Record<string, unknown> = {
300+
id: `tool:${tool.name}`,
301+
kind: 'tool',
302+
name: tool.name,
303+
displayName: titleCase(tool.name),
304+
description: tool.description,
305+
category: 'emergent',
306+
tags,
307+
requiredSecrets: [],
308+
requiredTools: extractRequiredTools(tool.implementation),
309+
inputSchema: tool.inputSchema,
310+
outputSchema: tool.outputSchema,
311+
// Relative path to the SKILL.md companion file in the same directory
312+
skillContent: './SKILL.md',
313+
};
314+
315+
return YAML.stringify(manifest);
316+
}
317+
318+
// ============================================================================
319+
// FILE I/O
320+
// ============================================================================
321+
322+
/**
323+
* Writes a SKILL.md file to disk for an emergent tool.
324+
*
325+
* Creates the output directory if it does not exist. The file is written to
326+
* `<outputDir>/<tool.name>/SKILL.md`.
327+
*
328+
* @param tool - The emergent tool to export.
329+
* @param outputDir - Base directory where the skill subdirectory will be created.
330+
* @returns Absolute path to the written SKILL.md file.
331+
*
332+
* @throws {Error} If the filesystem write fails (permissions, disk full, etc.).
333+
*
334+
* @example
335+
* ```ts
336+
* const skillPath = await writeSkillFile(myTool, '/home/user/.wunderland/capabilities');
337+
* // => "/home/user/.wunderland/capabilities/my-tool/SKILL.md"
338+
* ```
339+
*/
340+
export async function writeSkillFile(
341+
tool: EmergentTool,
342+
outputDir: string,
343+
): Promise<string> {
344+
const skillDir = path.join(outputDir, tool.name);
345+
await fs.mkdir(skillDir, { recursive: true });
346+
347+
const skillPath = path.join(skillDir, 'SKILL.md');
348+
const content = exportToolAsSkill(tool);
349+
await fs.writeFile(skillPath, content, 'utf-8');
350+
351+
return skillPath;
352+
}
353+
354+
/**
355+
* Exports an emergent tool as a full skill pack (SKILL.md + CAPABILITY.yaml).
356+
*
357+
* Creates a directory named after the tool under `outputDir`, containing both
358+
* files. This directory structure is compatible with the
359+
* {@link CapabilityManifestScanner} and can be placed in any scan directory
360+
* (`~/.wunderland/capabilities/`, `./.wunderland/capabilities/`, etc.) for
361+
* automatic discovery.
362+
*
363+
* @param tool - The emergent tool to export.
364+
* @param outputDir - Base directory where the skill subdirectory will be created.
365+
* @returns Paths to the written SKILL.md and CAPABILITY.yaml files.
366+
*
367+
* @throws {Error} If the filesystem writes fail (permissions, disk full, etc.).
368+
*
369+
* @example
370+
* ```ts
371+
* const { skillPath, capabilityPath } = await exportToolAsSkillPack(
372+
* myTool,
373+
* '/home/user/.wunderland/capabilities',
374+
* );
375+
* // skillPath => "/home/user/.wunderland/capabilities/my-tool/SKILL.md"
376+
* // capabilityPath => "/home/user/.wunderland/capabilities/my-tool/CAPABILITY.yaml"
377+
* ```
378+
*/
379+
export async function exportToolAsSkillPack(
380+
tool: EmergentTool,
381+
outputDir: string,
382+
): Promise<{ skillPath: string; capabilityPath: string }> {
383+
const skillDir = path.join(outputDir, tool.name);
384+
await fs.mkdir(skillDir, { recursive: true });
385+
386+
// Write both files in parallel — they are independent
387+
const skillContent = exportToolAsSkill(tool);
388+
const capabilityContent = buildCapabilityYaml(tool);
389+
390+
const skillPath = path.join(skillDir, 'SKILL.md');
391+
const capabilityPath = path.join(skillDir, 'CAPABILITY.yaml');
392+
393+
await Promise.all([
394+
fs.writeFile(skillPath, skillContent, 'utf-8'),
395+
fs.writeFile(capabilityPath, capabilityContent, 'utf-8'),
396+
]);
397+
398+
return { skillPath, capabilityPath };
399+
}

0 commit comments

Comments
 (0)