Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
slug: authoring-guidance
description: Conventions for authoring CodeAssembly skills, subagents, rulebooks, and collections.
delivery: skill
version: 1
version: 2
---

# Authoring guidance
Expand All @@ -11,7 +11,7 @@ Conventions for authoring CodeAssembly artifacts β€” skills, subagents, rulebook

## Declaring dependencies

When a rulebook, skill, or subagent relies on another β€” a skill that invokes another skill, a subagent that injects a skill β€” declare the edge in its frontmatter `dependencies:` block, grouped by type:
When a rulebook, skill, or subagent relies on another β€” a skill that invokes another skill, a subagent that calls a skill it does not inject β€” declare the edge in its frontmatter `dependencies:` block, grouped by type:

```yaml
dependencies:
Expand Down Expand Up @@ -41,7 +41,7 @@ members:

- **Rulebooks:** `slug`, `description`, `delivery` (`ambient`, `skill`, or both), optional `skill-name`, optional `version`.
- **Skills:** `name`, `description`, optional `user-invocable` (defaults to `true`).
- **Subagents:** `name`, `description`, `tools`, optional `maxTurns`, optional `skills` (skills injected into the subagent's context β€” pair with a matching `dependencies:` edge so `sync` deploys them).
- **Subagents:** `name`, `description`, `tools`, optional `maxTurns`, optional `skills` (skills injected into the subagent's context; `sync` pulls them into the deploy closure automatically).
- **Collections:** `name`, `description`, and a `members:` block β€” the collection's only payload.

## Naming
Expand Down
26 changes: 25 additions & 1 deletion packages/agents/src/lib/__tests__/dependency-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { readDependencies, readMembers } from '../dependency-frontmatter.ts';
import { readDependencies, readInjectedSkills, readMembers } from '../dependency-frontmatter.ts';

/** Wraps a frontmatter body in `---` delimiters with a throwaway markdown body. */
function withFrontmatter(frontmatter: string): string {
Expand Down Expand Up @@ -101,3 +101,27 @@ describe(readMembers, () => {
expect(() => readMembers(content, 'collections/all.md')).toThrow(/all\.md.*dependencies/s);
});
});

describe(readInjectedSkills, () => {
it('reads the top-level skills list, normalizing bare and structured entries alike', () => {
const content = withFrontmatter(
'name: orchestrated-coder\nskills:\n - anti-patterns\n - name: commit\n source: npm',
);

expect(readInjectedSkills(content)).toEqual(['anti-patterns', 'commit']);
});

it('returns no skills for an absent key, absent frontmatter, or a null value', () => {
expect(readInjectedSkills(withFrontmatter('name: canary'))).toEqual([]);
expect(readInjectedSkills('# No frontmatter\n')).toEqual([]);
expect(readInjectedSkills(withFrontmatter('skills:'))).toEqual([]);
});

it('throws when skills is not a list, naming the source label', () => {
const content = withFrontmatter('skills: anti-patterns');

expect(() => readInjectedSkills(content, 'subagents/orchestrated-coder.md')).toThrow(
/orchestrated-coder\.md.*list/s,
);
});
});
63 changes: 63 additions & 0 deletions packages/agents/src/lib/__tests__/dependency-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,52 @@ describe(resolveClosure, () => {

await expect(resolveClosure({ collection: ['bad'] }, contentDir)).rejects.toThrow(/collection bad.*@everything/s);
});

it("pulls a subagent's injected skills into the closure without a dependencies edge", async () => {
await writeArtifact(contentDir, 'skill', 'anti-patterns');
await writeSubagent(contentDir, 'orchestrated-coder', ['anti-patterns']);

const closure = await resolveClosure({ subagent: ['orchestrated-coder'] }, contentDir);

expect(closure).toEqual({ rulebooks: [], skills: ['anti-patterns'], subagents: ['orchestrated-coder'] });
});

it('pulls injected skills into the closure for a subagent reached transitively', async () => {
await writeArtifact(contentDir, 'skill', 'anti-patterns');
await writeSubagent(contentDir, 'orchestrated-coder', ['anti-patterns']);
await writeArtifact(contentDir, 'collection', 'recommended', { subagent: ['orchestrated-coder'] });

const closure = await resolveClosure({ collection: ['recommended'] }, contentDir);

expect(closure.skills).toEqual(['anti-patterns']);
expect(closure.subagents).toEqual(['orchestrated-coder']);
});

it('deduplicates a skill named in both the injection list and the dependencies edge', async () => {
await writeArtifact(contentDir, 'skill', 'anti-patterns');
await writeSubagent(contentDir, 'orchestrated-coder', ['anti-patterns'], { skill: ['anti-patterns'] });

const closure = await resolveClosure({ subagent: ['orchestrated-coder'] }, contentDir);

expect(closure.skills).toEqual(['anti-patterns']);
});

it('throws naming the cycle when an injected skill loops back to the subagent', async () => {
await writeArtifact(contentDir, 'skill', 'loops', { subagent: ['coder'] });
await writeSubagent(contentDir, 'coder', ['loops']);

await expect(resolveClosure({ subagent: ['coder'] }, contentDir)).rejects.toThrow(
/cycle.*subagent:coder β†’ skill:loops β†’ subagent:coder/s,
);
});

it('throws naming the skill when an injected skill is missing from the library', async () => {
await writeSubagent(contentDir, 'orchestrated-coder', ['ghost']);

await expect(resolveClosure({ subagent: ['orchestrated-coder'] }, contentDir)).rejects.toThrow(
/skill "ghost" was not found/,
);
});
});

/**
Expand All @@ -147,6 +193,23 @@ async function writeArtifact(
await writeFile(filePath, `---\nname: ${slug}\n${renderEdges(type, edges)}---\n\n# ${slug}\n`, 'utf8');
}

/**
* Writes a subagent frontmatter file carrying a top-level `skills:` injection list, plus optional `dependencies:`
* edges. Distinct from `writeArtifact`, which never emits the top-level `skills:` field.
*/
async function writeSubagent(
contentDir: string,
slug: string,
injects: ReadonlyArray<string>,
edges?: DirectArtifacts,
): Promise<void> {
const filePath = path.join(contentDir, artifactFrontmatterPath('subagent', slug));
await mkdir(path.dirname(filePath), { recursive: true });
const injected = injects.map((skill) => ` - ${skill}`).join('\n');
const frontmatter = `name: ${slug}\nskills:\n${injected}\n${renderEdges('subagent', edges)}`;
await writeFile(filePath, `---\n${frontmatter}---\n\n# ${slug}\n`, 'utf8');
}

/** Renders an artifact's edge block: `members:` for a collection, `dependencies:` otherwise; empty when there are none. */
function renderEdges(type: ArtifactType, edges: DirectArtifacts | '@library' | undefined): string {
if (edges === '@library') {
Expand Down
24 changes: 24 additions & 0 deletions packages/agents/src/lib/dependency-frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ export function readDependencies(content: string, sourceLabel?: string): Artifac
return parseTypeBlock(block, `Invalid dependencies${where}`);
}

/**
* Reads a subagent's top-level `skills:` frontmatter β€” the runtime injection list the harness loads into the
* subagent's context. Each entry is a bare slug or a `{ name }` object (extra keys tolerated). Absent frontmatter,
* an absent `skills:` key, or a null value all resolve to no injected skills. A non-list value throws, naming
* `sourceLabel` when provided.
*/
export function readInjectedSkills(content: string, sourceLabel?: string): ReadonlyArray<string> {
const { lines } = parseFrontmatter(content);
const parsed: unknown = parseYaml(lines.join('\n'));
if (!isRecord(parsed)) {
return [];
}
if (parsed.skills === undefined || parsed.skills === null) {
return [];
}

const where = sourceLabel === undefined ? '' : ` in ${sourceLabel}`;
const entries = z.array(EntrySchema).safeParse(parsed.skills);
if (!entries.success) {
throw new Error(`Invalid skills${where}: "skills" must be a list of slugs.`);
}
return entries.data.map((entry) => entry.name);
}

/**
* Reads a collection's `members:` frontmatter β€” its constituents, which resolution follows transitively. The value
* is either the computed token `'@library'` (every deployable artifact, expanded by the resolver) or an explicit
Expand Down
28 changes: 23 additions & 5 deletions packages/agents/src/lib/dependency-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';

import { ARTIFACT_TYPE_VALUES, artifactFrontmatterPath, type ArtifactType } from './artifact-types.ts';
import { type ArtifactDependencies, readDependencies, readMembers } from './dependency-frontmatter.ts';
import {
type ArtifactDependencies,
readDependencies,
readInjectedSkills,
readMembers,
} from './dependency-frontmatter.ts';
import { enumerateLibrarySlugs } from './library-catalog.ts';
import { isMissingFile } from './type-guards.ts';

Expand All @@ -18,7 +23,8 @@ export interface ResolvedClosure {

/**
* Expands the directly-declared artifacts into their transitive closure, reading each visited artifact's edges β€” a
* collection's `members:`, every other type's `dependencies:` β€” and following them across every type. The result is
* collection's `members:`, every other type's `dependencies:`, plus a subagent's top-level `skills:` injection list β€”
* and following them across every type. The result is
* deduped (a diamond dependency appears once) and acyclic β€” a cycle throws an error naming the offending path. A
* collection is a traversal-only node: its members are followed but the collection itself is dropped from the
* deployable result. A referenced artifact whose library file is absent throws an error naming its type and slug.
Expand Down Expand Up @@ -72,8 +78,9 @@ export async function resolveClosure(direct: DirectArtifacts, contentDir: string

/**
* Reads one artifact's outgoing edges, throwing a clear error when its library file is absent. A collection's edges
* come from `members:` β€” the full catalog when it carries `'@library'`, otherwise its explicit members β€” while every
* other type's come from `dependencies:`.
* come from `members:` β€” the full catalog when it carries `'@library'`, otherwise its explicit members. Every other
* type's edges come from `dependencies:`; a subagent additionally unions its top-level `skills:` injection list into
* those skill edges, so an injected skill enters the closure without a duplicate `dependencies:` declaration.
*/
async function readArtifactEdges(type: ArtifactType, slug: string, contentDir: string): Promise<ArtifactDependencies> {
const filePath = path.join(contentDir, artifactFrontmatterPath(type, slug));
Expand All @@ -92,7 +99,18 @@ async function readArtifactEdges(type: ArtifactType, slug: string, contentDir: s
const members = readMembers(content, label);
return members.kind === 'library' ? await enumerateLibrarySlugs(contentDir) : members.edges;
}
return readDependencies(content, label);

const dependencies = readDependencies(content, label);
// A subagent's top-level `skills:` is its runtime injection list; union it into the skill edges so injected skills
// enter the closure without a duplicate `dependencies:` declaration. `visit` carries dedup and cycle-safety, so the
// union is emitted unfiltered β€” a skill named in both lists collapses to one visit.
if (type === 'subagent') {
const injected = readInjectedSkills(content, label);
if (injected.length > 0) {
return { ...dependencies, skill: [...(dependencies.skill ?? []), ...injected] };
}
}
return dependencies;
}

// endregion | Helpers
Loading