Skip to content

Commit 60eeb00

Browse files
authored
feat(workflows): inline sub-agent definitions on DAG nodes (#1276)
* feat(workflows): inline sub-agent definitions on DAG nodes Add `agents:` node field letting workflow YAML define Claude Agent SDK sub-agents inline, keyed by kebab-case ID. The main agent can spawn them via the Task tool — useful for map-reduce patterns where a cheap model briefs items and a stronger model reduces. Authors no longer need standalone `.claude/agents/*.md` files for workflow-scoped helpers; the definitions live with the workflow. Claude only. Codex and community providers without the capability emit a capability warning and ignore the field. Merges with the internal `dag-node-skills` wrapper when `skills:` is also set — user-defined agents win on ID collision. * fix(workflows): address PR #1276 review feedback Critical: - Re-export agentDefinitionSchema + AgentDefinition from schemas/index.ts (matches the "schemas/index.ts re-exports all" convention). Important: - Surface user-override of internal 'dag-node-skills' wrapper: warn-level provider log + platform message to the user when agents: redefines the reserved ID alongside skills:. User-wins behavior preserved (by design) but silent capability removal is now observable. - Add validator test coverage for the agents-capability warning (codex node with agents: → warning; claude node → no warning; no-agents field → no warning). - Strengthen NodeConfig.agents duplicate-type comment explaining the intentional circular-dep avoidance and pointing at the Zod schema as authoritative source. Actual extraction is follow-up work. Simplifications: - Drop redundant typeof check in validator (schema already enforces). - Drop unreachable Object.keys(...).length > 0 check in dag-executor. - Drop rot-prone "(out of v1 scope)" parenthetical. - Drop WHAT-only comment on AGENT_ID_REGEX. - Tighten AGENT_ID_REGEX to reject trailing/double hyphens (/^[a-z0-9]+(-[a-z0-9]+)*$/). Tests: - parseWorkflow strips agents on script: and loop: nodes (parallel to the existing bash: coverage). - provider emits warn log on dag-node-skills collision; no warn on non-colliding inline agents. Docs: - Renumber authoring-workflows Summary section (12b → 13; bump 13-19). - Add Pi capability-table row for inline agents (❌, Claude-only). - Add when-to-use guidance (agents: vs .claude/agents/*.md) in the new "Inline sub-agents" section. - Cross-link skills.md Related → inline-sub-agents. - CHANGELOG [Unreleased] Added entry for #1276.
1 parent 4c6ddd9 commit 60eeb00

20 files changed

Lines changed: 602 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Inline sub-agent definitions on DAG nodes (`agents:`).** Define Claude Agent SDK `AgentDefinition`s directly in workflow YAML, keyed by kebab-case agent ID. The main agent can spawn them in parallel via the `Task` tool — useful for map-reduce patterns where a cheap model (e.g. Haiku) briefs items and a stronger model reduces. Removes the need to author `.claude/agents/*.md` files for workflow-scoped helpers. Claude only; Codex and community providers that don't support inline agents emit a capability warning and ignore the field. Merges with the internal `dag-node-skills` wrapper set by `skills:` on the same node — user-defined agents win on ID collision (a warning is logged). (#1276)
1213
- **Pi community provider (`@mariozechner/pi-coding-agent`).** First community provider under the Phase 2 registry (`builtIn: false`). One adapter exposes ~20 LLM backends (Anthropic, OpenAI, Google, Groq, Mistral, Cerebras, xAI, OpenRouter, Hugging Face, and more) via a `<pi-provider-id>/<model-id>` model format. Reads credentials from `~/.pi/agent/auth.json` (populated by running `pi /login` for OAuth subscriptions like Claude Pro/Max, ChatGPT Plus, GitHub Copilot) AND from env vars (env vars take priority per-request). Per-node workflow options supported: `effort`/`thinking` → Pi `thinkingLevel`; `allowed_tools`/`denied_tools` → filter Pi's 7 built-in coding tools; `skills` → resolved against `.agents/skills`, `.claude/skills` (project + user-global); `systemPrompt`; codebase env vars; session resume via `sessionId` round-trip. Unsupported fields (MCP, hooks, structured output, cost limits, fallback model, sandbox) trigger an explicit dag-executor warning rather than silently dropping. Use in workflow YAML: `provider: pi` + `model: anthropic/claude-haiku-4-5`. (#1270)
1314
- **`registerCommunityProviders()` aggregator** in `@archon/providers`. Process entrypoints (CLI, server, config-loader) now call one function to register every bundled community provider. Adding a new community provider is a single-line edit to this aggregator rather than touching each entrypoint — makes the Phase 2 "community providers are a localized addition" promise real.
1415
- **`contributing/adding-a-community-provider.md` guide** — contributor-facing walkthrough of the Phase 2 registry pattern using Pi as the reference implementation.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ async function createSession(conversationId: string, codebaseId: string) {
697697
2. **Workflows** (YAML-based):
698698
- Stored in `.archon/workflows/` (searched recursively)
699699
- Multi-step AI execution chains, discovered at runtime
700-
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
700+
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
701701
- Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported
702702
- Model and options can be set per workflow or inherited from config defaults
703703
- `interactive: true` at the workflow level forces foreground execution on web (required for approval-gate workflows in the web UI)

packages/docs-web/src/content/docs/getting-started/ai-assistants.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ nodes:
308308
| Tool restrictions | ✅ | `allowed_tools` / `denied_tools` (read, bash, edit, write, grep, find, ls) |
309309
| Thinking level | ✅ | `effort: low\|medium\|high\|max` (max → xhigh) |
310310
| Skills | ✅ | `skills: [name]` (searches `.agents/skills`, `.claude/skills`, user-global) |
311+
| Inline sub-agents | ❌ | `agents:` is Claude-only; ignored with a warning on Pi |
311312
| System prompt override | ✅ | `systemPrompt:` |
312313
| Codebase env vars (`envInjection`) | ✅ | `.archon/config.yaml` `env:` section |
313314
| MCP servers | ❌ | Pi rejects MCP by design |

packages/docs-web/src/content/docs/guides/authoring-workflows.md

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ nodes:
196196
| `hooks` | object | — | Per-node SDK hook callbacks. Claude only. See [Hooks](/guides/hooks/) |
197197
| `mcp` | string | — | Path to MCP server config JSON file. Claude only. See [MCP Servers](/guides/mcp-servers/) |
198198
| `skills` | string[] | — | Skills to preload. Claude only. See [Skills](/guides/skills/) |
199+
| `agents` | object | — | Inline sub-agent definitions keyed by kebab-case ID. Claude only. See [Inline sub-agents](#inline-sub-agents) |
199200
| `effort` | `'low'`\|`'medium'`\|`'high'`\|`'max'` | — | Reasoning depth. Claude only. Also settable at workflow level |
200201
| `thinking` | string \| object | — | Thinking mode: `'adaptive'`, `'disabled'`, or `{type:'enabled', budgetTokens:N}`. Claude only. Also settable at workflow level |
201202
| `maxBudgetUsd` | number | — | USD cost cap; node fails if exceeded. Claude only. Per-node only |
@@ -404,6 +405,43 @@ nodes:
404405
- `undefined` (field absent) and `[]` have different semantics — absent means use default tool set, `[]` means no tools
405406
- Claude only — Codex nodes/steps emit a warning and continue (Codex doesn't support per-call tool restrictions)
406407

408+
### Inline sub-agents
409+
410+
Define Claude sub-agents directly in the workflow YAML, without authoring `.claude/agents/*.md` files. The main agent can spawn them in parallel via the `Task` tool — useful for map-reduce patterns where a cheap model (e.g. Haiku) briefs items and a stronger model reduces.
411+
412+
```yaml
413+
nodes:
414+
- id: triage
415+
prompt: |
416+
Fetch open issues via `gh issue list ...`. For each issue, spawn the
417+
brief-gen sub-agent in parallel (one message, multiple Task tool calls)
418+
to produce a 2-3 sentence brief. Then cluster briefs for duplicates.
419+
model: sonnet
420+
allowed_tools: [Bash, Read, Write, Task]
421+
agents:
422+
brief-gen:
423+
description: Summarises a single GitHub issue in 2-3 sentences
424+
prompt: |
425+
You are concise. Read the issue provided in the caller's prompt.
426+
Return JSON { summary, primarySymptom, affectedArea }.
427+
model: haiku
428+
tools: [Bash, Read]
429+
```
430+
431+
Keys:
432+
433+
- Agent IDs must be **kebab-case** (`^[a-z0-9]+(-[a-z0-9]+)*$`)
434+
- Each definition requires `description` and `prompt`; `model`, `tools`, `disallowedTools`, `skills`, and `maxTurns` are optional
435+
- Map is merged with any SDK-level agents and with the internal `dag-node-skills` wrapper created by `skills:` — user-defined agents win on ID collision (a warning is logged when this happens)
436+
- Claude only. Codex and community providers that don't support inline agents emit a warning and ignore the field
437+
438+
**When to use `agents:` vs `.claude/agents/*.md` files:**
439+
440+
- **`agents:` (inline)** — use when the sub-agent is specific to ONE workflow's needs. Keeps the workflow self-contained in a single YAML file; travels cleanly in PRs and forks.
441+
- **`.claude/agents/*.md` (on-disk)** — use when the sub-agent is shared across multiple workflows OR the whole project (for example, a `triage-agent` used by several maintenance workflows). On-disk agents live outside workflow YAMLs and are picked up automatically by the Claude Agent SDK.
442+
443+
Both sources coexist — inline agents and on-disk agents are both available to `Task(subagent_type=...)` at runtime.
444+
407445
---
408446

409447
## Retry Configuration
@@ -1126,10 +1164,11 @@ Before deploying a workflow:
11261164
10. **`hooks`** — attach SDK hook callbacks to Claude nodes for tool control and context injection
11271165
11. **`mcp:`** — attach per-node MCP servers via JSON config (Claude only)
11281166
12. **`skills:`** — preload skills into Claude nodes for domain expertise
1129-
13. **`effort` / `thinking`** — control reasoning depth and thinking mode per node or workflow (Claude only)
1130-
14. **`maxBudgetUsd`** — set a USD cost cap per node; fails with error if exceeded (Claude only)
1131-
15. **`systemPrompt`** — override the default system prompt per node (Claude only)
1132-
16. **`sandbox`** — OS-level filesystem/network restrictions per node or workflow (Claude only)
1133-
17. **Loop nodes** — use `loop:` within a DAG node for iterative execution until completion signal
1134-
18. **Defaults as templates** — browse `.archon/workflows/defaults/` for real examples to copy and modify
1135-
19. **Test thoroughly** — each command, the artifact flow, and edge cases
1167+
13. **`agents:`** — inline Claude sub-agent definitions invokable via the `Task` tool
1168+
14. **`effort` / `thinking`** — control reasoning depth and thinking mode per node or workflow (Claude only)
1169+
15. **`maxBudgetUsd`** — set a USD cost cap per node; fails with error if exceeded (Claude only)
1170+
16. **`systemPrompt`** — override the default system prompt per node (Claude only)
1171+
17. **`sandbox`** — OS-level filesystem/network restrictions per node or workflow (Claude only)
1172+
18. **Loop nodes** — use `loop:` within a DAG node for iterative execution until completion signal
1173+
19. **Defaults as templates** — browse `.archon/workflows/defaults/` for real examples to copy and modify
1174+
20. **Test thoroughly** — each command, the artifact flow, and edge cases

packages/docs-web/src/content/docs/guides/skills.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ To use skills, ensure the node uses Claude (the default provider, or set
235235
236236
## Related
237237
238+
- [Inline sub-agents](/guides/authoring-workflows/#inline-sub-agents) — `agents:` field for workflow-scoped sub-agents (composes with `skills:` on the same node; user-defined agents win on ID collision with the internal `dag-node-skills` wrapper)
238239
- [Per-Node MCP Servers](/guides/mcp-servers/) — `mcp:` field for external tool access
239240
- [Hooks](/guides/hooks/) — `hooks:` field for tool permission control
240241
- [skills.sh](https://skills.sh) — marketplace for discovering skills

packages/providers/src/claude/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const CLAUDE_CAPABILITIES: ProviderCapabilities = {
55
mcp: true,
66
hooks: true,
77
skills: true,
8+
agents: true,
89
toolRestrictions: true,
910
structuredOutput: true,
1011
envInjection: true,

packages/providers/src/claude/provider.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ describe('ClaudeProvider', () => {
9797
mcp: true,
9898
hooks: true,
9999
skills: true,
100+
agents: true,
100101
toolRestrictions: true,
101102
structuredOutput: true,
102103
envInjection: true,
@@ -1165,4 +1166,128 @@ describe('sendQuery decomposition behaviors', () => {
11651166
'claude.result_is_error'
11661167
);
11671168
});
1169+
1170+
describe('inline agents (nodeConfig.agents)', () => {
1171+
test('passes inline agents map through to SDK options.agents', async () => {
1172+
mockQuery.mockImplementation(async function* () {
1173+
yield { type: 'result', session_id: 'sid' };
1174+
});
1175+
1176+
const agents = {
1177+
'brief-gen': {
1178+
description: 'Summarises issues',
1179+
prompt: 'Be concise.',
1180+
model: 'haiku',
1181+
tools: ['Bash', 'Read'],
1182+
},
1183+
};
1184+
1185+
for await (const _ of client.sendQuery('test', '/workspace', undefined, {
1186+
nodeConfig: { agents },
1187+
})) {
1188+
// consume
1189+
}
1190+
1191+
expect(mockQuery).toHaveBeenCalledTimes(1);
1192+
const callArgs = mockQuery.mock.calls[0][0] as { options: Record<string, unknown> };
1193+
expect(callArgs.options.agents).toMatchObject(agents);
1194+
});
1195+
1196+
test('does not set options.agent when only inline agents are present', async () => {
1197+
mockQuery.mockImplementation(async function* () {
1198+
yield { type: 'result', session_id: 'sid' };
1199+
});
1200+
1201+
for await (const _ of client.sendQuery('test', '/workspace', undefined, {
1202+
nodeConfig: {
1203+
agents: {
1204+
'sub-a': { description: 'd', prompt: 'p' },
1205+
},
1206+
},
1207+
})) {
1208+
// consume
1209+
}
1210+
1211+
const callArgs = mockQuery.mock.calls[0][0] as { options: Record<string, unknown> };
1212+
// agent (singular) is set by skills wrapper; inline-only must leave it unset
1213+
expect(callArgs.options.agent).toBeUndefined();
1214+
});
1215+
1216+
test('merges inline agents with skills wrapper; user wins on ID collision', async () => {
1217+
mockQuery.mockImplementation(async function* () {
1218+
yield { type: 'result', session_id: 'sid' };
1219+
});
1220+
1221+
for await (const _ of client.sendQuery('test', '/workspace', undefined, {
1222+
nodeConfig: {
1223+
skills: ['my-skill'],
1224+
agents: {
1225+
// Intentionally collides with the internal 'dag-node-skills' wrapper ID
1226+
'dag-node-skills': {
1227+
description: 'user override',
1228+
prompt: 'user-defined prompt',
1229+
},
1230+
'extra-sub': { description: 'd', prompt: 'p' },
1231+
},
1232+
},
1233+
})) {
1234+
// consume
1235+
}
1236+
1237+
const callArgs = mockQuery.mock.calls[0][0] as { options: Record<string, unknown> };
1238+
const outAgents = callArgs.options.agents as Record<
1239+
string,
1240+
{ description: string; prompt: string }
1241+
>;
1242+
// Both entries present
1243+
expect(Object.keys(outAgents).sort()).toEqual(['dag-node-skills', 'extra-sub']);
1244+
// User's definition wins the collision
1245+
expect(outAgents['dag-node-skills'].description).toBe('user override');
1246+
expect(outAgents['dag-node-skills'].prompt).toBe('user-defined prompt');
1247+
});
1248+
1249+
test('logs a warning when user-defined dag-node-skills overrides the skills wrapper', async () => {
1250+
mockQuery.mockImplementation(async function* () {
1251+
yield { type: 'result', session_id: 'sid' };
1252+
});
1253+
1254+
for await (const _ of client.sendQuery('test', '/workspace', undefined, {
1255+
nodeConfig: {
1256+
skills: ['my-skill'],
1257+
agents: {
1258+
'dag-node-skills': { description: 'user override', prompt: 'p' },
1259+
},
1260+
},
1261+
})) {
1262+
// consume
1263+
}
1264+
1265+
expect(mockLogger.warn).toHaveBeenCalledWith(
1266+
expect.objectContaining({ nodeSkills: ['my-skill'] }),
1267+
'claude.inline_agents_override_skills_wrapper'
1268+
);
1269+
});
1270+
1271+
test('does NOT warn when inline agents do not collide with the skills wrapper', async () => {
1272+
mockQuery.mockImplementation(async function* () {
1273+
yield { type: 'result', session_id: 'sid' };
1274+
});
1275+
1276+
for await (const _ of client.sendQuery('test', '/workspace', undefined, {
1277+
nodeConfig: {
1278+
skills: ['my-skill'],
1279+
agents: {
1280+
'brief-gen': { description: 'd', prompt: 'p' },
1281+
},
1282+
},
1283+
})) {
1284+
// consume
1285+
}
1286+
1287+
const warnCalls = mockLogger.warn.mock.calls.filter(
1288+
(args: unknown[]) => args[1] === 'claude.inline_agents_override_skills_wrapper'
1289+
);
1290+
expect(warnCalls).toHaveLength(0);
1291+
});
1292+
});
11681293
});

packages/providers/src/claude/provider.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,32 @@ async function applyNodeConfig(
450450
getLog().info({ skills, agentId }, 'claude.skills_agent_created');
451451
}
452452

453+
// agents → inline AgentDefinition pass-through.
454+
// Runs AFTER skills: so user-defined agents win on ID collision with
455+
// the internal 'dag-node-skills' wrapper.
456+
// options.agent is intentionally left alone — inline agents are sub-agents
457+
// invokable via the Task tool, not the primary agent for the query.
458+
if (nodeConfig.agents) {
459+
// Warn loudly when a user-defined agent overrides the internal
460+
// 'dag-node-skills' wrapper set by the skills: block above. The
461+
// merge is by design (user wins) but silent capability removal
462+
// is the exact failure mode we want to avoid.
463+
if (
464+
Object.hasOwn(nodeConfig.agents, 'dag-node-skills') &&
465+
options.agents?.['dag-node-skills'] !== undefined
466+
) {
467+
getLog().warn(
468+
{ nodeSkills: nodeConfig.skills ?? [] },
469+
'claude.inline_agents_override_skills_wrapper'
470+
);
471+
}
472+
options.agents = {
473+
...(options.agents ?? {}),
474+
...(nodeConfig.agents as NonNullable<Options['agents']>),
475+
};
476+
getLog().info({ agentIds: Object.keys(nodeConfig.agents) }, 'claude.inline_agents_registered');
477+
}
478+
453479
// effort
454480
if (nodeConfig.effort !== undefined) {
455481
options.effort = nodeConfig.effort as Options['effort'];

packages/providers/src/codex/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const CODEX_CAPABILITIES: ProviderCapabilities = {
55
mcp: false,
66
hooks: false,
77
skills: false,
8+
agents: false,
89
toolRestrictions: false,
910
structuredOutput: true,
1011
envInjection: true,

packages/providers/src/codex/provider.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe('CodexProvider', () => {
7575
mcp: false,
7676
hooks: false,
7777
skills: false,
78+
agents: false,
7879
toolRestrictions: false,
7980
structuredOutput: true,
8081
envInjection: true,

0 commit comments

Comments
 (0)