diff --git a/CHANGELOG.md b/CHANGELOG.md index de652d4e3c..7aff104988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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) - **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 `/` 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) - **`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. - **`contributing/adding-a-community-provider.md` guide** — contributor-facing walkthrough of the Phase 2 registry pattern using Pi as the reference implementation. diff --git a/CLAUDE.md b/CLAUDE.md index 985475dda8..ed72a6f148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -697,7 +697,7 @@ async function createSession(conversationId: string, codebaseId: string) { 2. **Workflows** (YAML-based): - Stored in `.archon/workflows/` (searched recursively) - Multi-step AI execution chains, discovered at runtime - - **`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 `$.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) + - **`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 `$.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) - Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported - Model and options can be set per workflow or inherited from config defaults - `interactive: true` at the workflow level forces foreground execution on web (required for approval-gate workflows in the web UI) diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index 49e7756fce..5f375a76fa 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -308,6 +308,7 @@ nodes: | Tool restrictions | ✅ | `allowed_tools` / `denied_tools` (read, bash, edit, write, grep, find, ls) | | Thinking level | ✅ | `effort: low\|medium\|high\|max` (max → xhigh) | | Skills | ✅ | `skills: [name]` (searches `.agents/skills`, `.claude/skills`, user-global) | +| Inline sub-agents | ❌ | `agents:` is Claude-only; ignored with a warning on Pi | | System prompt override | ✅ | `systemPrompt:` | | Codebase env vars (`envInjection`) | ✅ | `.archon/config.yaml` `env:` section | | MCP servers | ❌ | Pi rejects MCP by design | diff --git a/packages/docs-web/src/content/docs/guides/authoring-workflows.md b/packages/docs-web/src/content/docs/guides/authoring-workflows.md index c4fdfc7830..d120d07c72 100644 --- a/packages/docs-web/src/content/docs/guides/authoring-workflows.md +++ b/packages/docs-web/src/content/docs/guides/authoring-workflows.md @@ -196,6 +196,7 @@ nodes: | `hooks` | object | — | Per-node SDK hook callbacks. Claude only. See [Hooks](/guides/hooks/) | | `mcp` | string | — | Path to MCP server config JSON file. Claude only. See [MCP Servers](/guides/mcp-servers/) | | `skills` | string[] | — | Skills to preload. Claude only. See [Skills](/guides/skills/) | +| `agents` | object | — | Inline sub-agent definitions keyed by kebab-case ID. Claude only. See [Inline sub-agents](#inline-sub-agents) | | `effort` | `'low'`\|`'medium'`\|`'high'`\|`'max'` | — | Reasoning depth. Claude only. Also settable at workflow level | | `thinking` | string \| object | — | Thinking mode: `'adaptive'`, `'disabled'`, or `{type:'enabled', budgetTokens:N}`. Claude only. Also settable at workflow level | | `maxBudgetUsd` | number | — | USD cost cap; node fails if exceeded. Claude only. Per-node only | @@ -404,6 +405,43 @@ nodes: - `undefined` (field absent) and `[]` have different semantics — absent means use default tool set, `[]` means no tools - Claude only — Codex nodes/steps emit a warning and continue (Codex doesn't support per-call tool restrictions) +### Inline sub-agents + +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. + +```yaml +nodes: + - id: triage + prompt: | + Fetch open issues via `gh issue list ...`. For each issue, spawn the + brief-gen sub-agent in parallel (one message, multiple Task tool calls) + to produce a 2-3 sentence brief. Then cluster briefs for duplicates. + model: sonnet + allowed_tools: [Bash, Read, Write, Task] + agents: + brief-gen: + description: Summarises a single GitHub issue in 2-3 sentences + prompt: | + You are concise. Read the issue provided in the caller's prompt. + Return JSON { summary, primarySymptom, affectedArea }. + model: haiku + tools: [Bash, Read] +``` + +Keys: + +- Agent IDs must be **kebab-case** (`^[a-z0-9]+(-[a-z0-9]+)*$`) +- Each definition requires `description` and `prompt`; `model`, `tools`, `disallowedTools`, `skills`, and `maxTurns` are optional +- 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) +- Claude only. Codex and community providers that don't support inline agents emit a warning and ignore the field + +**When to use `agents:` vs `.claude/agents/*.md` files:** + +- **`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. +- **`.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. + +Both sources coexist — inline agents and on-disk agents are both available to `Task(subagent_type=...)` at runtime. + --- ## Retry Configuration @@ -1126,10 +1164,11 @@ Before deploying a workflow: 10. **`hooks`** — attach SDK hook callbacks to Claude nodes for tool control and context injection 11. **`mcp:`** — attach per-node MCP servers via JSON config (Claude only) 12. **`skills:`** — preload skills into Claude nodes for domain expertise -13. **`effort` / `thinking`** — control reasoning depth and thinking mode per node or workflow (Claude only) -14. **`maxBudgetUsd`** — set a USD cost cap per node; fails with error if exceeded (Claude only) -15. **`systemPrompt`** — override the default system prompt per node (Claude only) -16. **`sandbox`** — OS-level filesystem/network restrictions per node or workflow (Claude only) -17. **Loop nodes** — use `loop:` within a DAG node for iterative execution until completion signal -18. **Defaults as templates** — browse `.archon/workflows/defaults/` for real examples to copy and modify -19. **Test thoroughly** — each command, the artifact flow, and edge cases +13. **`agents:`** — inline Claude sub-agent definitions invokable via the `Task` tool +14. **`effort` / `thinking`** — control reasoning depth and thinking mode per node or workflow (Claude only) +15. **`maxBudgetUsd`** — set a USD cost cap per node; fails with error if exceeded (Claude only) +16. **`systemPrompt`** — override the default system prompt per node (Claude only) +17. **`sandbox`** — OS-level filesystem/network restrictions per node or workflow (Claude only) +18. **Loop nodes** — use `loop:` within a DAG node for iterative execution until completion signal +19. **Defaults as templates** — browse `.archon/workflows/defaults/` for real examples to copy and modify +20. **Test thoroughly** — each command, the artifact flow, and edge cases diff --git a/packages/docs-web/src/content/docs/guides/skills.md b/packages/docs-web/src/content/docs/guides/skills.md index 8cfc5e5e81..d27262ffac 100644 --- a/packages/docs-web/src/content/docs/guides/skills.md +++ b/packages/docs-web/src/content/docs/guides/skills.md @@ -235,6 +235,7 @@ To use skills, ensure the node uses Claude (the default provider, or set ## Related +- [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) - [Per-Node MCP Servers](/guides/mcp-servers/) — `mcp:` field for external tool access - [Hooks](/guides/hooks/) — `hooks:` field for tool permission control - [skills.sh](https://skills.sh) — marketplace for discovering skills diff --git a/packages/providers/src/claude/capabilities.ts b/packages/providers/src/claude/capabilities.ts index 3874f796ce..dfb5e7ed08 100644 --- a/packages/providers/src/claude/capabilities.ts +++ b/packages/providers/src/claude/capabilities.ts @@ -5,6 +5,7 @@ export const CLAUDE_CAPABILITIES: ProviderCapabilities = { mcp: true, hooks: true, skills: true, + agents: true, toolRestrictions: true, structuredOutput: true, envInjection: true, diff --git a/packages/providers/src/claude/provider.test.ts b/packages/providers/src/claude/provider.test.ts index 16641b1555..77880128da 100644 --- a/packages/providers/src/claude/provider.test.ts +++ b/packages/providers/src/claude/provider.test.ts @@ -97,6 +97,7 @@ describe('ClaudeProvider', () => { mcp: true, hooks: true, skills: true, + agents: true, toolRestrictions: true, structuredOutput: true, envInjection: true, @@ -1165,4 +1166,128 @@ describe('sendQuery decomposition behaviors', () => { 'claude.result_is_error' ); }); + + describe('inline agents (nodeConfig.agents)', () => { + test('passes inline agents map through to SDK options.agents', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + const agents = { + 'brief-gen': { + description: 'Summarises issues', + prompt: 'Be concise.', + model: 'haiku', + tools: ['Bash', 'Read'], + }, + }; + + for await (const _ of client.sendQuery('test', '/workspace', undefined, { + nodeConfig: { agents }, + })) { + // consume + } + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0][0] as { options: Record }; + expect(callArgs.options.agents).toMatchObject(agents); + }); + + test('does not set options.agent when only inline agents are present', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + for await (const _ of client.sendQuery('test', '/workspace', undefined, { + nodeConfig: { + agents: { + 'sub-a': { description: 'd', prompt: 'p' }, + }, + }, + })) { + // consume + } + + const callArgs = mockQuery.mock.calls[0][0] as { options: Record }; + // agent (singular) is set by skills wrapper; inline-only must leave it unset + expect(callArgs.options.agent).toBeUndefined(); + }); + + test('merges inline agents with skills wrapper; user wins on ID collision', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + for await (const _ of client.sendQuery('test', '/workspace', undefined, { + nodeConfig: { + skills: ['my-skill'], + agents: { + // Intentionally collides with the internal 'dag-node-skills' wrapper ID + 'dag-node-skills': { + description: 'user override', + prompt: 'user-defined prompt', + }, + 'extra-sub': { description: 'd', prompt: 'p' }, + }, + }, + })) { + // consume + } + + const callArgs = mockQuery.mock.calls[0][0] as { options: Record }; + const outAgents = callArgs.options.agents as Record< + string, + { description: string; prompt: string } + >; + // Both entries present + expect(Object.keys(outAgents).sort()).toEqual(['dag-node-skills', 'extra-sub']); + // User's definition wins the collision + expect(outAgents['dag-node-skills'].description).toBe('user override'); + expect(outAgents['dag-node-skills'].prompt).toBe('user-defined prompt'); + }); + + test('logs a warning when user-defined dag-node-skills overrides the skills wrapper', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + for await (const _ of client.sendQuery('test', '/workspace', undefined, { + nodeConfig: { + skills: ['my-skill'], + agents: { + 'dag-node-skills': { description: 'user override', prompt: 'p' }, + }, + }, + })) { + // consume + } + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ nodeSkills: ['my-skill'] }), + 'claude.inline_agents_override_skills_wrapper' + ); + }); + + test('does NOT warn when inline agents do not collide with the skills wrapper', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + for await (const _ of client.sendQuery('test', '/workspace', undefined, { + nodeConfig: { + skills: ['my-skill'], + agents: { + 'brief-gen': { description: 'd', prompt: 'p' }, + }, + }, + })) { + // consume + } + + const warnCalls = mockLogger.warn.mock.calls.filter( + (args: unknown[]) => args[1] === 'claude.inline_agents_override_skills_wrapper' + ); + expect(warnCalls).toHaveLength(0); + }); + }); }); diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index 26935bf373..98bca6d832 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -450,6 +450,32 @@ async function applyNodeConfig( getLog().info({ skills, agentId }, 'claude.skills_agent_created'); } + // agents → inline AgentDefinition pass-through. + // Runs AFTER skills: so user-defined agents win on ID collision with + // the internal 'dag-node-skills' wrapper. + // options.agent is intentionally left alone — inline agents are sub-agents + // invokable via the Task tool, not the primary agent for the query. + if (nodeConfig.agents) { + // Warn loudly when a user-defined agent overrides the internal + // 'dag-node-skills' wrapper set by the skills: block above. The + // merge is by design (user wins) but silent capability removal + // is the exact failure mode we want to avoid. + if ( + Object.hasOwn(nodeConfig.agents, 'dag-node-skills') && + options.agents?.['dag-node-skills'] !== undefined + ) { + getLog().warn( + { nodeSkills: nodeConfig.skills ?? [] }, + 'claude.inline_agents_override_skills_wrapper' + ); + } + options.agents = { + ...(options.agents ?? {}), + ...(nodeConfig.agents as NonNullable), + }; + getLog().info({ agentIds: Object.keys(nodeConfig.agents) }, 'claude.inline_agents_registered'); + } + // effort if (nodeConfig.effort !== undefined) { options.effort = nodeConfig.effort as Options['effort']; diff --git a/packages/providers/src/codex/capabilities.ts b/packages/providers/src/codex/capabilities.ts index 03cc0773cf..9b179e2170 100644 --- a/packages/providers/src/codex/capabilities.ts +++ b/packages/providers/src/codex/capabilities.ts @@ -5,6 +5,7 @@ export const CODEX_CAPABILITIES: ProviderCapabilities = { mcp: false, hooks: false, skills: false, + agents: false, toolRestrictions: false, structuredOutput: true, envInjection: true, diff --git a/packages/providers/src/codex/provider.test.ts b/packages/providers/src/codex/provider.test.ts index 3e260722d1..669826ebc3 100644 --- a/packages/providers/src/codex/provider.test.ts +++ b/packages/providers/src/codex/provider.test.ts @@ -75,6 +75,7 @@ describe('CodexProvider', () => { mcp: false, hooks: false, skills: false, + agents: false, toolRestrictions: false, structuredOutput: true, envInjection: true, diff --git a/packages/providers/src/community/pi/capabilities.ts b/packages/providers/src/community/pi/capabilities.ts index 6a5ffbb97a..38e232736b 100644 --- a/packages/providers/src/community/pi/capabilities.ts +++ b/packages/providers/src/community/pi/capabilities.ts @@ -14,6 +14,7 @@ export const PI_CAPABILITIES: ProviderCapabilities = { mcp: false, hooks: false, skills: true, + agents: false, toolRestrictions: true, structuredOutput: false, envInjection: true, diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 4d5df7f60d..6fee3a654f 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -24,6 +24,7 @@ function makeMockProvider(id: string): IAgentProvider { mcp: false, hooks: false, skills: false, + agents: false, toolRestrictions: false, structuredOutput: false, envInjection: false, diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index 63444ac112..528d64dc51 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -123,6 +123,32 @@ export interface NodeConfig { mcp?: string; hooks?: unknown; skills?: string[]; + /** + * Inline sub-agent definitions (keyed by kebab-case agent ID). + * + * Intentional hand-written duplicate of `agentDefinitionSchema` (authoritative + * source: `@archon/workflows/schemas/dag-node`). Normally we follow the + * project rule "derive types from Zod via `z.infer`, never write parallel + * interfaces" — broken here on purpose: `@archon/providers/types` is the + * contract subpath consumed by `@archon/workflows`, so importing from + * `@archon/workflows` would create a circular dependency. + * + * Drift risk: when the schema gains a field, this shape must be updated + * by hand. Follow-up work: extract the agent-definition contract to a + * lower-tier package so `z.infer` can be used end-to-end (#1276). + */ + agents?: Record< + string, + { + description: string; + prompt: string; + model?: string; + tools?: string[]; + disallowedTools?: string[]; + skills?: string[]; + maxTurns?: number; + } + >; allowed_tools?: string[]; denied_tools?: string[]; effort?: string; @@ -158,6 +184,8 @@ export interface ProviderCapabilities { mcp: boolean; hooks: boolean; skills: boolean; + /** Whether the provider supports inline sub-agent definitions (Claude SDK's options.agents). */ + agents: boolean; toolRestrictions: boolean; structuredOutput: boolean; envInjection: boolean; diff --git a/packages/web/src/lib/api.generated.d.ts b/packages/web/src/lib/api.generated.d.ts index 56e705b646..a474ca310d 100644 --- a/packages/web/src/lib/api.generated.d.ts +++ b/packages/web/src/lib/api.generated.d.ts @@ -2199,6 +2199,17 @@ export interface components { }; mcp?: string; skills?: string[]; + agents?: { + [key: string]: { + description: string; + prompt: string; + model?: string; + tools?: string[]; + disallowedTools?: string[]; + skills?: string[]; + maxTurns?: number; + }; + }; /** @enum {string} */ effort?: 'low' | 'medium' | 'high' | 'max'; thinking?: diff --git a/packages/workflows/src/dag-executor.test.ts b/packages/workflows/src/dag-executor.test.ts index c5822197e5..5ad83b0ffb 100644 --- a/packages/workflows/src/dag-executor.test.ts +++ b/packages/workflows/src/dag-executor.test.ts @@ -105,6 +105,7 @@ const mockClaudeCapabilities = () => ({ mcp: true, hooks: true, skills: true, + agents: true, toolRestrictions: true, structuredOutput: true, envInjection: true, @@ -120,6 +121,7 @@ const mockCodexCapabilities = () => ({ mcp: false, hooks: false, skills: false, + agents: false, toolRestrictions: false, structuredOutput: true, envInjection: true, @@ -2427,6 +2429,90 @@ describe('executeDagWorkflow -- skills options', () => { const warning = messages.find(m => m.includes('skills') && m.includes('codex')); expect(warning).toBeDefined(); }); + + it('passes agents to sendQuery nodeConfig when node has inline agents', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun(); + + const agentsMap = { + 'brief-gen': { + description: 'Summarises an issue', + prompt: 'You are concise.', + model: 'haiku', + tools: ['Bash', 'Read'], + }, + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-dag', + testDir, + { + name: 'dag-agents', + nodes: [{ id: 'review', command: 'my-cmd', agents: agentsMap }], + }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + expect(mockSendQueryDag.mock.calls.length).toBeGreaterThan(0); + const optionsArg = mockSendQueryDag.mock.calls[0][3] as Record; + const nodeConfig = optionsArg?.nodeConfig as Record; + expect(nodeConfig?.agents).toEqual(agentsMap); + }); + + it('warns user when Codex DAG node has inline agents', async () => { + mockGetAgentProviderDag.mockReturnValue({ + sendQuery: mockSendQueryDag, + getType: () => 'codex', + getCapabilities: mockCodexCapabilities, + }); + + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun(); + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-dag', + testDir, + { + name: 'dag-codex-agents', + nodes: [ + { + id: 'review', + command: 'my-cmd', + provider: 'codex', + agents: { + 'brief-gen': { description: 'd', prompt: 'p' }, + }, + }, + ], + }, + workflowRun, + 'codex', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + { ...minimalConfig, assistant: 'codex' } + ); + + const sendMessage = platform.sendMessage as ReturnType; + const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); + const warning = messages.find(m => m.includes('agents') && m.includes('codex')); + expect(warning).toBeDefined(); + }); }); // --------------------------------------------------------------------------- @@ -2517,6 +2603,172 @@ nodes: }); }); +// --------------------------------------------------------------------------- +// Inline agents — field validation via parseWorkflow +// --------------------------------------------------------------------------- + +describe('agents field validation via parseWorkflow', () => { + it('parses a valid agents map on a DAG node', () => { + const yaml = ` +name: test-agents +description: test +nodes: + - id: triage + prompt: "Spawn a brief-gen sub-agent" + agents: + brief-gen: + description: Summarises an issue + prompt: "You are concise. Return JSON { summary }." + model: haiku + tools: [Bash, Read] +`; + const result = parseWorkflow(yaml, 'agents.yaml'); + expect(result.error).toBeNull(); + expect(result.workflow).not.toBeNull(); + const wf = result.workflow!; + const node = wf.nodes[0]; + expect(node.agents).toBeDefined(); + expect(node.agents!['brief-gen'].description).toBe('Summarises an issue'); + expect(node.agents!['brief-gen'].model).toBe('haiku'); + expect(node.agents!['brief-gen'].tools).toEqual(['Bash', 'Read']); + }); + + it('rejects an agent missing description', () => { + const yaml = ` +name: missing-desc +description: test +nodes: + - id: triage + prompt: "p" + agents: + brief-gen: + prompt: "You are concise." +`; + const result = parseWorkflow(yaml, 'missing-desc.yaml'); + expect(result.error).not.toBeNull(); + expect(result.error!.error).toContain('agents'); + }); + + it('rejects an agent missing prompt', () => { + const yaml = ` +name: missing-prompt +description: test +nodes: + - id: triage + prompt: "p" + agents: + brief-gen: + description: "A brief generator" +`; + const result = parseWorkflow(yaml, 'missing-prompt.yaml'); + expect(result.error).not.toBeNull(); + expect(result.error!.error).toContain('agents'); + }); + + it('rejects empty agents map', () => { + const yaml = ` +name: empty-agents +description: test +nodes: + - id: triage + prompt: "p" + agents: {} +`; + const result = parseWorkflow(yaml, 'empty-agents.yaml'); + expect(result.error).not.toBeNull(); + expect(result.error!.error).toContain('agents'); + }); + + it('rejects agent ID that is not kebab-case', () => { + const yaml = ` +name: bad-id +description: test +nodes: + - id: triage + prompt: "p" + agents: + BriefGen: + description: "d" + prompt: "p" +`; + const result = parseWorkflow(yaml, 'bad-id.yaml'); + expect(result.error).not.toBeNull(); + expect(result.error!.error).toContain('kebab-case'); + }); + + it('ignores agents on bash nodes (field stripped, no error)', () => { + const yaml = ` +name: bash-agents +description: test +nodes: + - id: lint + bash: "echo lint" + agents: + helper: + description: "d" + prompt: "p" +`; + const result = parseWorkflow(yaml, 'bash-agents.yaml'); + expect(result.error).toBeNull(); + const wf = result.workflow!; + expect(wf.nodes[0].agents).toBeUndefined(); + }); + + it('ignores agents on script nodes (field stripped, no error)', () => { + const yaml = ` +name: script-agents +description: test +nodes: + - id: run + script: 'console.log("hi")' + runtime: bun + agents: + helper: + description: "d" + prompt: "p" +`; + const result = parseWorkflow(yaml, 'script-agents.yaml'); + expect(result.error).toBeNull(); + const wf = result.workflow!; + expect(wf.nodes[0].agents).toBeUndefined(); + }); + + it('ignores agents on loop nodes (field stripped, no error)', () => { + const yaml = ` +name: loop-agents +description: test +nodes: + - id: iterate + loop: + prompt: "Do the work" + until: "DONE" + max_iterations: 2 + agents: + helper: + description: "d" + prompt: "p" +`; + const result = parseWorkflow(yaml, 'loop-agents.yaml'); + expect(result.error).toBeNull(); + const wf = result.workflow!; + expect(wf.nodes[0].agents).toBeUndefined(); + }); + + it('node with no agents field is undefined', () => { + const yaml = ` +name: no-agents +description: test +nodes: + - id: basic + prompt: "Do something" +`; + const result = parseWorkflow(yaml, 'no-agents.yaml'); + expect(result.error).toBeNull(); + const wf = result.workflow!; + expect(wf.nodes[0].agents).toBeUndefined(); + }); +}); + describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { let testDir: string; diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index 3680af28b5..ebaa7fe59a 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -285,6 +285,7 @@ async function resolveNodeProviderAndModel( ['hooks', 'hooks', node.hooks !== undefined], ['mcp', 'mcp', node.mcp !== undefined], ['skills', 'skills', node.skills !== undefined && node.skills.length > 0], + ['agents', 'agents', node.agents !== undefined], ['effort', 'effortControl', (node.effort ?? workflowLevelOptions.effort) !== undefined], ['thinking', 'thinkingControl', (node.thinking ?? workflowLevelOptions.thinking) !== undefined], ['maxBudgetUsd', 'costControl', node.maxBudgetUsd !== undefined], @@ -317,6 +318,23 @@ async function resolveNodeProviderAndModel( } } + // Surface agents + skills ID collision — user-defined 'dag-node-skills' + // silently overrides Archon's skills wrapper. User wins (by design) but + // the operator should know they've neutered the wrapper. + if ( + node.agents?.['dag-node-skills'] !== undefined && + node.skills !== undefined && + node.skills.length > 0 + ) { + getLog().warn({ nodeId: node.id }, 'dag.agents_skills_id_collision'); + await safeSendMessage( + platform, + conversationId, + `Warning: Node '${node.id}' defines an agent with reserved ID 'dag-node-skills' AND uses 'skills:'. Your inline agent overrides Archon's automatic skills wrapper — the 'skills:' field will NOT take effect. Rename the agent or remove 'skills:' to fix.`, + { workflowId: workflowRunId, nodeName: node.id } + ); + } + // Build universal base options const baseOptions: SendQueryOptions = {}; if (model) baseOptions.model = model; @@ -336,6 +354,7 @@ async function resolveNodeProviderAndModel( mcp: node.mcp, hooks: node.hooks, skills: node.skills, + agents: node.agents, allowed_tools: node.allowed_tools, denied_tools: node.denied_tools, effort: node.effort ?? workflowLevelOptions.effort, diff --git a/packages/workflows/src/schemas/dag-node.ts b/packages/workflows/src/schemas/dag-node.ts index fbf03a84f8..d41c6270c3 100644 --- a/packages/workflows/src/schemas/dag-node.ts +++ b/packages/workflows/src/schemas/dag-node.ts @@ -106,6 +106,26 @@ export const sandboxSettingsSchema = z export type SandboxSettings = z.infer; +/** + * Claude Agent SDK AgentDefinition — inline sub-agent available via the Task tool. + * Mirrors the SDK's AgentDefinition type (sdk.d.ts), minus mcpServers and the + * experimental critical-reminder field. + */ +export const agentDefinitionSchema = z.object({ + description: z.string().min(1, "'description' is required"), + prompt: z.string().min(1, "'prompt' is required"), + model: z.string().min(1).optional(), + tools: z.array(z.string().min(1)).optional(), + disallowedTools: z.array(z.string().min(1)).optional(), + skills: z.array(z.string().min(1)).optional(), + maxTurns: z.number().int().positive().optional(), +}); + +export type AgentDefinition = z.infer; + +// Kebab-case: no leading/trailing/double hyphens (e.g. `brief-gen`, not `-brief`, `brief-`, `brief--gen`). +const AGENT_ID_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; + // --------------------------------------------------------------------------- // DagNodeBase — common fields shared by all node types // --------------------------------------------------------------------------- @@ -129,6 +149,13 @@ export const dagNodeBaseSchema = z.object({ .array(z.string().min(1, 'each skill must be a non-empty string')) .nonempty("'skills' must be a non-empty array") .optional(), + agents: z + .record( + z.string().regex(AGENT_ID_REGEX, 'agent IDs must be kebab-case (a-z, 0-9, hyphen)'), + agentDefinitionSchema + ) + .refine(map => Object.keys(map).length > 0, "'agents' must have at least one entry") + .optional(), effort: effortLevelSchema.optional(), thinking: thinkingConfigSchema.optional(), maxBudgetUsd: z.number().positive().optional(), @@ -305,6 +332,7 @@ export const BASH_NODE_AI_FIELDS: readonly string[] = [ 'hooks', 'mcp', 'skills', + 'agents', 'effort', 'thinking', 'maxBudgetUsd', @@ -543,6 +571,7 @@ export const dagNodeSchema = dagNodeBaseSchema ...(data.hooks !== undefined ? { hooks: data.hooks } : {}), ...(data.mcp !== undefined ? { mcp: data.mcp.trim() } : {}), ...(data.skills !== undefined ? { skills: data.skills.map(s => s.trim()) } : {}), + ...(data.agents !== undefined ? { agents: data.agents } : {}), ...(data.effort !== undefined ? { effort: data.effort } : {}), ...(data.thinking !== undefined ? { thinking: data.thinking } : {}), ...(data.maxBudgetUsd !== undefined ? { maxBudgetUsd: data.maxBudgetUsd } : {}), diff --git a/packages/workflows/src/schemas/index.ts b/packages/workflows/src/schemas/index.ts index ae40416e82..ec44084ac9 100644 --- a/packages/workflows/src/schemas/index.ts +++ b/packages/workflows/src/schemas/index.ts @@ -51,6 +51,7 @@ export { effortLevelSchema, thinkingConfigSchema, sandboxSettingsSchema, + agentDefinitionSchema, } from './dag-node'; export type { TriggerRule, @@ -67,6 +68,7 @@ export type { EffortLevel, ThinkingConfig, SandboxSettings, + AgentDefinition, } from './dag-node'; // Workflow definition diff --git a/packages/workflows/src/validator.test.ts b/packages/workflows/src/validator.test.ts index 7d65ac69b1..6b391f54d8 100644 --- a/packages/workflows/src/validator.test.ts +++ b/packages/workflows/src/validator.test.ts @@ -344,3 +344,48 @@ describe('validateWorkflowResources — script nodes', () => { expect(scriptErrors).toHaveLength(0); }); }); + +// ============================================================================= +// validateWorkflowResources — inline agents capability warning +// ============================================================================= + +describe('validateWorkflowResources — agents capability', () => { + const agentsField = { + 'brief-gen': { description: 'd', prompt: 'p' }, + }; + + test('warns when provider does not support inline agents (codex)', async () => { + const workflow = makeWorkflow( + 'test', + [{ id: 'step1', prompt: 'p', agents: agentsField } as unknown as DagNode], + 'codex' + ); + const issues = await validateWorkflowResources(workflow, tmpDir); + const warning = issues.find(i => i.level === 'warning' && i.field === 'agents'); + expect(warning).toBeDefined(); + expect(warning!.message).toContain("not supported by provider 'codex'"); + expect(warning!.hint).toContain('claude'); + }); + + test('no agents-capability warning when provider is claude', async () => { + const workflow = makeWorkflow( + 'test', + [{ id: 'step1', prompt: 'p', agents: agentsField } as unknown as DagNode], + 'claude' + ); + const issues = await validateWorkflowResources(workflow, tmpDir); + const warning = issues.find(i => i.level === 'warning' && i.field === 'agents'); + expect(warning).toBeUndefined(); + }); + + test('no warning when node has no agents field', async () => { + const workflow = makeWorkflow( + 'test', + [{ id: 'step1', prompt: 'p' } as unknown as DagNode], + 'codex' + ); + const issues = await validateWorkflowResources(workflow, tmpDir); + const warning = issues.find(i => i.level === 'warning' && i.field === 'agents'); + expect(warning).toBeUndefined(); + }); +}); diff --git a/packages/workflows/src/validator.ts b/packages/workflows/src/validator.ts index 90e6b688ba..ab4c4beec4 100644 --- a/packages/workflows/src/validator.ts +++ b/packages/workflows/src/validator.ts @@ -406,6 +406,16 @@ export async function validateWorkflowResources( }); } + if ('agents' in node && node.agents && !caps.agents) { + issues.push({ + level: 'warning', + nodeId: node.id, + field: 'agents', + message: `Inline agents are not supported by provider '${provider}' — this will be ignored`, + hint: 'Remove the agents field or switch to a provider that supports inline agents (e.g. claude)', + }); + } + if (!caps.toolRestrictions) { if ( ('allowed_tools' in node && node.allowed_tools !== undefined) ||