From 5866ea7988ecad9155d22c80948f72b7ec50f84c Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 29 Jun 2026 00:49:45 -0700 Subject: [PATCH] agents|fix: Deploy a subagent's injected skills with sync A subagent that lists skills for runtime injection now has those skills deployed automatically by `sync`. Previously an injected skill was deployed only when also declared as an explicit dependency, so a project deploying a single subagent on its own could reference a skill that was never installed. An injected skill absent from the library now fails `sync` with a not-found error, the same as any other unresolved reference. Authoring guidance no longer instructs pairing an injected skill with a duplicate dependency declaration. --- .../guidance/rulebooks/authoring-guidance.md | 6 +- .../__tests__/dependency-frontmatter.test.ts | 26 +++++++- .../lib/__tests__/dependency-resolver.test.ts | 63 +++++++++++++++++++ .../agents/src/lib/dependency-frontmatter.ts | 24 +++++++ .../agents/src/lib/dependency-resolver.ts | 28 +++++++-- 5 files changed, 138 insertions(+), 9 deletions(-) diff --git a/packages/agents/content/guidance/rulebooks/authoring-guidance.md b/packages/agents/content/guidance/rulebooks/authoring-guidance.md index 19ef7e1d..ac474eb8 100644 --- a/packages/agents/content/guidance/rulebooks/authoring-guidance.md +++ b/packages/agents/content/guidance/rulebooks/authoring-guidance.md @@ -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 @@ -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: @@ -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 diff --git a/packages/agents/src/lib/__tests__/dependency-frontmatter.test.ts b/packages/agents/src/lib/__tests__/dependency-frontmatter.test.ts index 392eb7a2..8a637102 100644 --- a/packages/agents/src/lib/__tests__/dependency-frontmatter.test.ts +++ b/packages/agents/src/lib/__tests__/dependency-frontmatter.test.ts @@ -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 { @@ -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, + ); + }); +}); diff --git a/packages/agents/src/lib/__tests__/dependency-resolver.test.ts b/packages/agents/src/lib/__tests__/dependency-resolver.test.ts index 9fcda2eb..41b7318d 100644 --- a/packages/agents/src/lib/__tests__/dependency-resolver.test.ts +++ b/packages/agents/src/lib/__tests__/dependency-resolver.test.ts @@ -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/, + ); + }); }); /** @@ -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, + edges?: DirectArtifacts, +): Promise { + 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') { diff --git a/packages/agents/src/lib/dependency-frontmatter.ts b/packages/agents/src/lib/dependency-frontmatter.ts index 718b603d..3262f387 100644 --- a/packages/agents/src/lib/dependency-frontmatter.ts +++ b/packages/agents/src/lib/dependency-frontmatter.ts @@ -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 { + 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 diff --git a/packages/agents/src/lib/dependency-resolver.ts b/packages/agents/src/lib/dependency-resolver.ts index 44f7feab..aec3005f 100644 --- a/packages/agents/src/lib/dependency-resolver.ts +++ b/packages/agents/src/lib/dependency-resolver.ts @@ -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'; @@ -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. @@ -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 { const filePath = path.join(contentDir, artifactFrontmatterPath(type, slug)); @@ -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