feat(agent): add named subagent profiles
- commit
- e6dacd21bde48123c0f1be3749fb9aac6e1dc555
- author
- brice · · 2026-08-04 04:18 UTC
- parent
- e455c59
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
24 files · +1232 -121
M .gitignore +1 -0
| @@ -72,4 +72,5 @@ logs/ | |||
| 72 | 72 | *.rej | |
| 73 | 73 | .tmp/ | |
| 74 | 74 | tmp/ | |
| 75 | + | .agent-reviews/ | |
| 75 | 76 | npm/ | |
| @@ -72,4 +72,5 @@ logs/ | |||||
| 72 | *.rej | 72 | *.rej | ||
| 73 | .tmp/ | 73 | .tmp/ | ||
| 74 | tmp/ | 74 | tmp/ | ||
| 75 | + | .agent-reviews/ | |||
| 75 | npm/ | 76 | npm/ | ||
M README.md +11 -3
| @@ -26,6 +26,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 26 | 26 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | |
| 27 | 27 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | |
| 28 | 28 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | |
| 29 | + | - named subagent profiles from `~/.agents/agents/*.md`; see [docs/subagents.md](docs/subagents.md). | |
| 29 | 30 | - portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md). | |
| 30 | 31 | ||
| 31 | 32 | ## Install | |
| @@ -280,7 +281,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 280 | 281 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | |
| 281 | 282 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 282 | 283 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| 283 | - | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | |
| 284 | + | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | | |
| 284 | 285 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | |
| 285 | 286 | | `/compact` | Summarize the transcript into one message to free up context. | | |
| 286 | 287 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zot/main.go`. | | |
| @@ -341,7 +342,9 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 341 | 342 | ``` | |
| 342 | 343 | /swarm # open the dashboard | |
| 343 | 344 | /swarm new <task> # spawn an agent | |
| 345 | + | /swarm new --agent reviewer <task> # use a named markdown profile | |
| 344 | 346 | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | |
| 347 | + | /swarm new --reasoning high <task> # set the child reasoning level | |
| 345 | 348 | /swarm logs <id> # jump straight into one agent's transcript | |
| 346 | 349 | /swarm send <id> <text> # send a follow-up without opening the dashboard | |
| 347 | 350 | /swarm resume # pick a stopped agent to bring back | |
| @@ -376,14 +379,18 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 376 | 379 | ||
| 377 | 380 | **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. | |
| 378 | 381 | ||
| 379 | - | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and complete final response. If an agent produced no assistant response, zot includes a truncated transcript tail for diagnostics instead. The main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`. | |
| 382 | + | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). If named markdown profiles are available, zot adds a compact `[subagents_list]` to the main prompt; the model can choose a profile with the tool's `agent` field and optionally override its `model`, `provider`, or `reasoning`. Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and complete final response. If an agent produced no assistant response, zot includes a truncated transcript tail for diagnostics instead. The main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`. | |
| 383 | + | ||
| 384 | + | ### Named subagent profiles | |
| 385 | + | ||
| 386 | + | zot discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-swarm enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `swarm_spawn`. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples. | |
| 380 | 387 | ||
| 381 | 388 | ### `/settings` | |
| 382 | 389 | ||
| 383 | 390 | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | |
| 384 | 391 | ||
| 385 | 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | |
| 386 | - | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |
| 393 | + | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details. | |
| 387 | 394 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| 388 | 395 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 389 | 396 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | |
| @@ -1026,6 +1033,7 @@ packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter i | |||
| 1026 | 1033 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1027 | 1034 | packages/agent/tools/ read, write, edit, bash, sandbox | |
| 1028 | 1035 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1036 | + | packages/agent/subagents/ named markdown profile discovery and dispatch | |
| 1029 | 1037 | packages/agent/swarm/ background subagent runtime | |
| 1030 | 1038 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | |
| 1031 | 1039 | packages/agent/ext/ public Go SDK for writing extensions (package ext) | |
| @@ -26,6 +26,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 26 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | 26 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | ||
| 27 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | 27 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | ||
| 28 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | 28 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | ||
| 29 | + | - named subagent profiles from `~/.agents/agents/*.md`; see [docs/subagents.md](docs/subagents.md). | |||
| 29 | - portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md). | 30 | - portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md). | ||
| 30 | 31 | ||||
| 31 | ## Install | 32 | ## Install | ||
| @@ -280,7 +281,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 280 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | 281 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | ||
| 281 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 282 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| 282 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | 283 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | ||
| 283 | - | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | 284 | + | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | |
| 284 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | 285 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | ||
| 285 | | `/compact` | Summarize the transcript into one message to free up context. | | 286 | | `/compact` | Summarize the transcript into one message to free up context. | | ||
| 286 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zot/main.go`. | | 287 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zot/main.go`. | | ||
| @@ -341,7 +342,9 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 341 | ``` | 342 | ``` | ||
| 342 | /swarm # open the dashboard | 343 | /swarm # open the dashboard | ||
| 343 | /swarm new <task> # spawn an agent | 344 | /swarm new <task> # spawn an agent | ||
| 345 | + | /swarm new --agent reviewer <task> # use a named markdown profile | |||
| 344 | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | 346 | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | ||
| 347 | + | /swarm new --reasoning high <task> # set the child reasoning level | |||
| 345 | /swarm logs <id> # jump straight into one agent's transcript | 348 | /swarm logs <id> # jump straight into one agent's transcript | ||
| 346 | /swarm send <id> <text> # send a follow-up without opening the dashboard | 349 | /swarm send <id> <text> # send a follow-up without opening the dashboard | ||
| 347 | /swarm resume # pick a stopped agent to bring back | 350 | /swarm resume # pick a stopped agent to bring back | ||
| @@ -376,14 +379,18 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 376 | 379 | ||||
| 377 | **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. | 380 | **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. | ||
| 378 | 381 | ||||
| 379 | - | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and complete final response. If an agent produced no assistant response, zot includes a truncated transcript tail for diagnostics instead. The main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`. | 382 | + | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). If named markdown profiles are available, zot adds a compact `[subagents_list]` to the main prompt; the model can choose a profile with the tool's `agent` field and optionally override its `model`, `provider`, or `reasoning`. Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and complete final response. If an agent produced no assistant response, zot includes a truncated transcript tail for diagnostics instead. The main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`. |
| 383 | + | ||||
| 384 | + | ### Named subagent profiles | |||
| 385 | + | ||||
| 386 | + | zot discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-swarm enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `swarm_spawn`. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples. | |||
| 380 | 387 | ||||
| 381 | ### `/settings` | 388 | ### `/settings` | ||
| 382 | 389 | ||||
| 383 | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | 390 | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | ||
| 384 | 391 | ||||
| 385 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | ||
| 386 | - | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | 393 | + | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details. |
| 387 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | 394 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | ||
| 388 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 395 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 389 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | 396 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | ||
| @@ -1026,6 +1033,7 @@ packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter i | |||||
| 1026 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | 1033 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | ||
| 1027 | packages/agent/tools/ read, write, edit, bash, sandbox | 1034 | packages/agent/tools/ read, write, edit, bash, sandbox | ||
| 1028 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1035 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1036 | + | packages/agent/subagents/ named markdown profile discovery and dispatch | |||
| 1029 | packages/agent/swarm/ background subagent runtime | 1037 | packages/agent/swarm/ background subagent runtime | ||
| 1030 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | 1038 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | ||
| 1031 | packages/agent/ext/ public Go SDK for writing extensions (package ext) | 1039 | packages/agent/ext/ public Go SDK for writing extensions (package ext) | ||
A docs/subagents.md +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | # Named subagent profiles | |
| 2 | + | ||
| 3 | + | zot can discover reusable named subagent definitions from markdown files with a frontmatter block. This is a common agent-profile layout and is not tied to a particular host application. | |
| 4 | + | ||
| 5 | + | ## Discovery | |
| 6 | + | ||
| 7 | + | The default directory is: | |
| 8 | + | ||
| 9 | + | ```text | |
| 10 | + | ~/.agents/agents/*.md | |
| 11 | + | ``` | |
| 12 | + | ||
| 13 | + | zot also reads these optional locations: | |
| 14 | + | ||
| 15 | + | - directories listed in `ZOT_AGENT_PROFILES` (use the platform path-list separator) | |
| 16 | + | - `~/.pi/agent/agents/*.md` as a compatibility fallback | |
| 17 | + | ||
| 18 | + | Profiles are read-only inputs. zot does not execute files from these directories. Project-local profile directories are not scanned automatically; use `ZOT_AGENT_PROFILES` when a project intentionally opts into an additional profile directory. | |
| 19 | + | ||
| 20 | + | ## File format | |
| 21 | + | ||
| 22 | + | ```markdown | |
| 23 | + | --- | |
| 24 | + | name: reviewer | |
| 25 | + | description: Read-only code reviewer | |
| 26 | + | tools: read, grep, find, ls, diagnostics, code_search | |
| 27 | + | model: openai-codex/gpt-5.6-luna | |
| 28 | + | thinking: max | |
| 29 | + | systemPromptMode: replace | |
| 30 | + | inheritProjectContext: false | |
| 31 | + | inheritSkills: false | |
| 32 | + | --- | |
| 33 | + | ||
| 34 | + | You are a review worker. Inspect the requested scope, report evidence-backed findings, and do not edit files. | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | Supported metadata: | |
| 38 | + | ||
| 39 | + | | Field | Meaning | | |
| 40 | + | |---|---| | |
| 41 | + | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | |
| 42 | + | | `description` | Short description shown to the main agent. | | |
| 43 | + | | `tools` | Comma-separated or list-form tool names. zot currently enforces its own `read`, `write`, `edit`, and `bash` registry; unknown names do not grant capabilities. | | |
| 44 | + | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | |
| 45 | + | | `provider` | Optional separate provider ID for a model without a provider prefix. | | |
| 46 | + | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | |
| 47 | + | | `systemPromptMode` | `append` (default) or `replace`. | | |
| 48 | + | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | |
| 49 | + | | `inheritSkills` | Set to `false` to omit skill discovery from the child. | | |
| 50 | + | ||
| 51 | + | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | |
| 52 | + | ||
| 53 | + | ## Selecting a profile | |
| 54 | + | ||
| 55 | + | When **auto-swarm** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile: | |
| 56 | + | ||
| 57 | + | ```json | |
| 58 | + | { | |
| 59 | + | "task": "Review the authentication package and report correctness or security issues.", | |
| 60 | + | "agent": "reviewer" | |
| 61 | + | } | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | The selected profile's body, model, thinking level, system-prompt mode, context inheritance, and tool selection are applied to the child. The parent prompt contains only profile metadata; the full body is loaded by the child after explicit selection. | |
| 65 | + | ||
| 66 | + | A per-spawn reasoning override is also accepted: | |
| 67 | + | ||
| 68 | + | ```json | |
| 69 | + | { | |
| 70 | + | "task": "Implement the parser change and add regression tests.", | |
| 71 | + | "agent": "implementer", | |
| 72 | + | "reasoning": "high" | |
| 73 | + | } | |
| 74 | + | ``` | |
| 75 | + | ||
| 76 | + | `thinking` is accepted as an alias for `reasoning`. If neither is supplied, the child inherits the host reasoning level for an unnamed spawn, or the profile's `thinking` value for a named spawn. | |
| 77 | + | ||
| 78 | + | The interactive command also supports the same selection explicitly: | |
| 79 | + | ||
| 80 | + | ```text | |
| 81 | + | /swarm new --agent reviewer Review the authentication package | |
| 82 | + | /swarm new --agent implementer --reasoning high Implement the parser change | |
| 83 | + | ``` | |
| 84 | + | ||
| 85 | + | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | # Named subagent profiles | |||
| 2 | + | ||||
| 3 | + | zot can discover reusable named subagent definitions from markdown files with a frontmatter block. This is a common agent-profile layout and is not tied to a particular host application. | |||
| 4 | + | ||||
| 5 | + | ## Discovery | |||
| 6 | + | ||||
| 7 | + | The default directory is: | |||
| 8 | + | ||||
| 9 | + | ```text | |||
| 10 | + | ~/.agents/agents/*.md | |||
| 11 | + | ``` | |||
| 12 | + | ||||
| 13 | + | zot also reads these optional locations: | |||
| 14 | + | ||||
| 15 | + | - directories listed in `ZOT_AGENT_PROFILES` (use the platform path-list separator) | |||
| 16 | + | - `~/.pi/agent/agents/*.md` as a compatibility fallback | |||
| 17 | + | ||||
| 18 | + | Profiles are read-only inputs. zot does not execute files from these directories. Project-local profile directories are not scanned automatically; use `ZOT_AGENT_PROFILES` when a project intentionally opts into an additional profile directory. | |||
| 19 | + | ||||
| 20 | + | ## File format | |||
| 21 | + | ||||
| 22 | + | ```markdown | |||
| 23 | + | --- | |||
| 24 | + | name: reviewer | |||
| 25 | + | description: Read-only code reviewer | |||
| 26 | + | tools: read, grep, find, ls, diagnostics, code_search | |||
| 27 | + | model: openai-codex/gpt-5.6-luna | |||
| 28 | + | thinking: max | |||
| 29 | + | systemPromptMode: replace | |||
| 30 | + | inheritProjectContext: false | |||
| 31 | + | inheritSkills: false | |||
| 32 | + | --- | |||
| 33 | + | ||||
| 34 | + | You are a review worker. Inspect the requested scope, report evidence-backed findings, and do not edit files. | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | Supported metadata: | |||
| 38 | + | ||||
| 39 | + | | Field | Meaning | | |||
| 40 | + | |---|---| | |||
| 41 | + | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | |||
| 42 | + | | `description` | Short description shown to the main agent. | | |||
| 43 | + | | `tools` | Comma-separated or list-form tool names. zot currently enforces its own `read`, `write`, `edit`, and `bash` registry; unknown names do not grant capabilities. | | |||
| 44 | + | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | |||
| 45 | + | | `provider` | Optional separate provider ID for a model without a provider prefix. | | |||
| 46 | + | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | |||
| 47 | + | | `systemPromptMode` | `append` (default) or `replace`. | | |||
| 48 | + | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | |||
| 49 | + | | `inheritSkills` | Set to `false` to omit skill discovery from the child. | | |||
| 50 | + | ||||
| 51 | + | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | |||
| 52 | + | ||||
| 53 | + | ## Selecting a profile | |||
| 54 | + | ||||
| 55 | + | When **auto-swarm** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile: | |||
| 56 | + | ||||
| 57 | + | ```json | |||
| 58 | + | { | |||
| 59 | + | "task": "Review the authentication package and report correctness or security issues.", | |||
| 60 | + | "agent": "reviewer" | |||
| 61 | + | } | |||
| 62 | + | ``` | |||
| 63 | + | ||||
| 64 | + | The selected profile's body, model, thinking level, system-prompt mode, context inheritance, and tool selection are applied to the child. The parent prompt contains only profile metadata; the full body is loaded by the child after explicit selection. | |||
| 65 | + | ||||
| 66 | + | A per-spawn reasoning override is also accepted: | |||
| 67 | + | ||||
| 68 | + | ```json | |||
| 69 | + | { | |||
| 70 | + | "task": "Implement the parser change and add regression tests.", | |||
| 71 | + | "agent": "implementer", | |||
| 72 | + | "reasoning": "high" | |||
| 73 | + | } | |||
| 74 | + | ``` | |||
| 75 | + | ||||
| 76 | + | `thinking` is accepted as an alias for `reasoning`. If neither is supplied, the child inherits the host reasoning level for an unnamed spawn, or the profile's `thinking` value for a named spawn. | |||
| 77 | + | ||||
| 78 | + | The interactive command also supports the same selection explicitly: | |||
| 79 | + | ||||
| 80 | + | ```text | |||
| 81 | + | /swarm new --agent reviewer Review the authentication package | |||
| 82 | + | /swarm new --agent implementer --reasoning high Implement the parser change | |||
| 83 | + | ``` | |||
| 84 | + | ||||
| 85 | + | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. | |||
M packages/agent/args.go +12 -0
| @@ -119,6 +119,12 @@ type Args struct { | |||
| 119 | 119 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | |
| 120 | 120 | SwarmAgent string | |
| 121 | 121 | ||
| 122 | + | // Subagent selects a named markdown profile for a swarm child. | |
| 123 | + | // It is intentionally an internal child flag; the parent swarm tool | |
| 124 | + | // passes only the profile name and the child discovers the definition | |
| 125 | + | // locally. | |
| 126 | + | Subagent string | |
| 127 | + | ||
| 122 | 128 | // AgentName/AgentDataDir/PermissionSet are populated by `zot run` | |
| 123 | 129 | // for local zotfile agents. They scope sessions and enforce the | |
| 124 | 130 | // manifest's declared file/bash permissions. | |
| @@ -273,6 +279,12 @@ func ParseArgs(in []string) (Args, error) { | |||
| 273 | 279 | } | |
| 274 | 280 | a.SwarmAgent = v | |
| 275 | 281 | a.Mode = ModeSwarmAgent | |
| 282 | + | case "--subagent": | |
| 283 | + | v, err := want(&i, arg) | |
| 284 | + | if err != nil { | |
| 285 | + | return a, err | |
| 286 | + | } | |
| 287 | + | a.Subagent = v | |
| 276 | 288 | case "--tools": | |
| 277 | 289 | v, err := want(&i, arg) | |
| 278 | 290 | if err != nil { | |
| @@ -119,6 +119,12 @@ type Args struct { | |||||
| 119 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | 119 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | ||
| 120 | SwarmAgent string | 120 | SwarmAgent string | ||
| 121 | 121 | ||||
| 122 | + | // Subagent selects a named markdown profile for a swarm child. | |||
| 123 | + | // It is intentionally an internal child flag; the parent swarm tool | |||
| 124 | + | // passes only the profile name and the child discovers the definition | |||
| 125 | + | // locally. | |||
| 126 | + | Subagent string | |||
| 127 | + | ||||
| 122 | // AgentName/AgentDataDir/PermissionSet are populated by `zot run` | 128 | // AgentName/AgentDataDir/PermissionSet are populated by `zot run` | ||
| 123 | // for local zotfile agents. They scope sessions and enforce the | 129 | // for local zotfile agents. They scope sessions and enforce the | ||
| 124 | // manifest's declared file/bash permissions. | 130 | // manifest's declared file/bash permissions. | ||
| @@ -273,6 +279,12 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 273 | } | 279 | } | ||
| 274 | a.SwarmAgent = v | 280 | a.SwarmAgent = v | ||
| 275 | a.Mode = ModeSwarmAgent | 281 | a.Mode = ModeSwarmAgent | ||
| 282 | + | case "--subagent": | |||
| 283 | + | v, err := want(&i, arg) | |||
| 284 | + | if err != nil { | |||
| 285 | + | return a, err | |||
| 286 | + | } | |||
| 287 | + | a.Subagent = v | |||
| 276 | case "--tools": | 288 | case "--tools": | ||
| 277 | v, err := want(&i, arg) | 289 | v, err := want(&i, arg) | ||
| 278 | if err != nil { | 290 | if err != nil { | ||
M packages/agent/args_test.go +10 -0
| @@ -2,6 +2,16 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | + | func TestParseArgsSubagentAndReasoning(t *testing.T) { | |
| 6 | + | args, err := ParseArgs([]string{"--swarm-agent", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"}) | |
| 7 | + | if err != nil { | |
| 8 | + | t.Fatal(err) | |
| 9 | + | } | |
| 10 | + | if args.Mode != ModeSwarmAgent || args.Subagent != "reviewer" || args.Reasoning != "high" || args.Prompt != "task" { | |
| 11 | + | t.Fatalf("parsed args = mode=%q subagent=%q reasoning=%q prompt=%q", args.Mode, args.Subagent, args.Reasoning, args.Prompt) | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | ||
| 5 | 15 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | |
| 6 | 16 | args, err := ParseArgs([]string{"--temperature", "0"}) | |
| 7 | 17 | if err != nil { | |
| @@ -2,6 +2,16 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | + | func TestParseArgsSubagentAndReasoning(t *testing.T) { | |||
| 6 | + | args, err := ParseArgs([]string{"--swarm-agent", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"}) | |||
| 7 | + | if err != nil { | |||
| 8 | + | t.Fatal(err) | |||
| 9 | + | } | |||
| 10 | + | if args.Mode != ModeSwarmAgent || args.Subagent != "reviewer" || args.Reasoning != "high" || args.Prompt != "task" { | |||
| 11 | + | t.Fatalf("parsed args = mode=%q subagent=%q reasoning=%q prompt=%q", args.Mode, args.Subagent, args.Reasoning, args.Prompt) | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | ||||
| 5 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | 15 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | ||
| 6 | args, err := ParseArgs([]string{"--temperature", "0"}) | 16 | args, err := ParseArgs([]string{"--temperature", "0"}) | ||
| 7 | if err != nil { | 17 | if err != nil { | ||
M packages/agent/build.go +66 -4
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | zotdocs "github.com/patriceckhart/zot" | |
| 12 | 12 | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 13 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 13 | 14 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 14 | 15 | "github.com/patriceckhart/zot/packages/core" | |
| 15 | 16 | "github.com/patriceckhart/zot/packages/provider" | |
| @@ -302,6 +303,44 @@ func canonicalProvider(name string) string { | |||
| 302 | 303 | return n | |
| 303 | 304 | } | |
| 304 | 305 | ||
| 306 | + | func findSubagentProfile(cwd, name string) (*subagents.Profile, error) { | |
| 307 | + | name = strings.TrimSpace(name) | |
| 308 | + | if name == "" { | |
| 309 | + | return nil, nil | |
| 310 | + | } | |
| 311 | + | home, _ := os.UserHomeDir() | |
| 312 | + | profiles, _ := subagents.Discover(cwd, home) | |
| 313 | + | profile := subagents.Find(profiles, name) | |
| 314 | + | if profile == nil { | |
| 315 | + | return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name) | |
| 316 | + | } | |
| 317 | + | return profile, nil | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func applySubagentProfile(args *Args, profile *subagents.Profile) { | |
| 321 | + | if args == nil || profile == nil { | |
| 322 | + | return | |
| 323 | + | } | |
| 324 | + | profileProvider, profileModel := profile.ModelSelection() | |
| 325 | + | if args.Provider == "" && profileProvider != "" { | |
| 326 | + | args.Provider = profileProvider | |
| 327 | + | } | |
| 328 | + | if args.Model == "" && profileModel != "" { | |
| 329 | + | args.Model = profileModel | |
| 330 | + | } | |
| 331 | + | if args.Reasoning == "" && profile.Thinking != "" { | |
| 332 | + | // Keep an explicit "off" value non-empty so it overrides a | |
| 333 | + | // persisted global reasoning setting when the child resolves. | |
| 334 | + | args.Reasoning = strings.TrimSpace(profile.Thinking) | |
| 335 | + | } | |
| 336 | + | if !args.NoTools && len(args.Tools) == 0 && len(profile.Tools) > 0 { | |
| 337 | + | args.Tools = append([]string(nil), profile.Tools...) | |
| 338 | + | } | |
| 339 | + | if profile.InheritSkills != nil && !*profile.InheritSkills { | |
| 340 | + | args.NoSkill = true | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 305 | 344 | // Resolve merges args, config, and env into a Resolved set. | |
| 306 | 345 | // | |
| 307 | 346 | // Unlike the earlier version, Resolve NEVER returns an error for | |
| @@ -310,6 +349,15 @@ func canonicalProvider(name string) string { | |||
| 310 | 349 | // hard error (used by print/json modes). | |
| 311 | 350 | func Resolve(args Args, requireCred bool) (Resolved, error) { | |
| 312 | 351 | cfg, _ := LoadConfig() | |
| 352 | + | var selectedProfile *subagents.Profile | |
| 353 | + | if strings.TrimSpace(args.Subagent) != "" { | |
| 354 | + | var err error | |
| 355 | + | selectedProfile, err = findSubagentProfile(args.CWD, args.Subagent) | |
| 356 | + | if err != nil { | |
| 357 | + | return Resolved{}, err | |
| 358 | + | } | |
| 359 | + | applySubagentProfile(&args, selectedProfile) | |
| 360 | + | } | |
| 313 | 361 | ||
| 314 | 362 | // User-requested provider (explicit > config > default). | |
| 315 | 363 | // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock") | |
| @@ -586,7 +634,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 586 | 634 | skillTool *skills.Tool | |
| 587 | 635 | skillAddendum string | |
| 588 | 636 | ) | |
| 589 | - | if !args.NoSkill { | |
| 637 | + | if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) { | |
| 590 | 638 | homeDir, _ := os.UserHomeDir() | |
| 591 | 639 | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills) | |
| 592 | 640 | if len(discovered) > 0 { | |
| @@ -599,7 +647,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 599 | 647 | ||
| 600 | 648 | summaries := toolSummaries(reg, args) | |
| 601 | 649 | ||
| 602 | - | contextFiles := loadAgentsContext(args.CWD, ZotHome()) | |
| 650 | + | contextFiles := []ContextFile(nil) | |
| 651 | + | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | |
| 652 | + | contextFiles = loadAgentsContext(args.CWD, ZotHome()) | |
| 653 | + | } | |
| 603 | 654 | append_ := append([]string(nil), args.AppendSystemPrompt...) | |
| 604 | 655 | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { | |
| 605 | 656 | append_ = append(append_, agentsAddendum) | |
| @@ -607,16 +658,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 607 | 658 | if skillAddendum != "" { | |
| 608 | 659 | append_ = append(append_, skillAddendum) | |
| 609 | 660 | } | |
| 610 | - | if AutoSwarmEnabled() { | |
| 661 | + | interactiveMode := args.Mode == "" || args.Mode == ModeInteractive | |
| 662 | + | if selectedProfile == nil && interactiveMode && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |
| 663 | + | homeDir, _ := os.UserHomeDir() | |
| 664 | + | profiles, _ := subagents.Discover(args.CWD, homeDir) | |
| 665 | + | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | |
| 666 | + | append_ = append(append_, subagentsAddendum) | |
| 667 | + | } | |
| 611 | 668 | append_ = append(append_, AutoSwarmSystemAddendum) | |
| 612 | 669 | } | |
| 670 | + | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | |
| 671 | + | append_ = append(append_, selectedProfile.SystemPrompt) | |
| 672 | + | } | |
| 613 | 673 | ||
| 614 | 674 | // Custom system prompt resolution order: | |
| 615 | 675 | // 1. --system-prompt flag (highest priority; ad-hoc per run) | |
| 616 | 676 | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | |
| 617 | 677 | // 3. built-in default (defaultIdentity + defaultGuidelines) | |
| 618 | 678 | custom := args.SystemPrompt | |
| 619 | - | if custom == "" { | |
| 679 | + | if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" { | |
| 680 | + | custom = selectedProfile.SystemPrompt | |
| 681 | + | } else if custom == "" { | |
| 620 | 682 | custom = readUserSystemPrompt(ZotHome()) | |
| 621 | 683 | } | |
| 622 | 684 | ||
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | zotdocs "github.com/patriceckhart/zot" | 11 | zotdocs "github.com/patriceckhart/zot" | ||
| 12 | "github.com/patriceckhart/zot/packages/agent/skills" | 12 | "github.com/patriceckhart/zot/packages/agent/skills" | ||
| 13 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 13 | "github.com/patriceckhart/zot/packages/agent/tools" | 14 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 14 | "github.com/patriceckhart/zot/packages/core" | 15 | "github.com/patriceckhart/zot/packages/core" | ||
| 15 | "github.com/patriceckhart/zot/packages/provider" | 16 | "github.com/patriceckhart/zot/packages/provider" | ||
| @@ -302,6 +303,44 @@ func canonicalProvider(name string) string { | |||||
| 302 | return n | 303 | return n | ||
| 303 | } | 304 | } | ||
| 304 | 305 | ||||
| 306 | + | func findSubagentProfile(cwd, name string) (*subagents.Profile, error) { | |||
| 307 | + | name = strings.TrimSpace(name) | |||
| 308 | + | if name == "" { | |||
| 309 | + | return nil, nil | |||
| 310 | + | } | |||
| 311 | + | home, _ := os.UserHomeDir() | |||
| 312 | + | profiles, _ := subagents.Discover(cwd, home) | |||
| 313 | + | profile := subagents.Find(profiles, name) | |||
| 314 | + | if profile == nil { | |||
| 315 | + | return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name) | |||
| 316 | + | } | |||
| 317 | + | return profile, nil | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func applySubagentProfile(args *Args, profile *subagents.Profile) { | |||
| 321 | + | if args == nil || profile == nil { | |||
| 322 | + | return | |||
| 323 | + | } | |||
| 324 | + | profileProvider, profileModel := profile.ModelSelection() | |||
| 325 | + | if args.Provider == "" && profileProvider != "" { | |||
| 326 | + | args.Provider = profileProvider | |||
| 327 | + | } | |||
| 328 | + | if args.Model == "" && profileModel != "" { | |||
| 329 | + | args.Model = profileModel | |||
| 330 | + | } | |||
| 331 | + | if args.Reasoning == "" && profile.Thinking != "" { | |||
| 332 | + | // Keep an explicit "off" value non-empty so it overrides a | |||
| 333 | + | // persisted global reasoning setting when the child resolves. | |||
| 334 | + | args.Reasoning = strings.TrimSpace(profile.Thinking) | |||
| 335 | + | } | |||
| 336 | + | if !args.NoTools && len(args.Tools) == 0 && len(profile.Tools) > 0 { | |||
| 337 | + | args.Tools = append([]string(nil), profile.Tools...) | |||
| 338 | + | } | |||
| 339 | + | if profile.InheritSkills != nil && !*profile.InheritSkills { | |||
| 340 | + | args.NoSkill = true | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | ||||
| 305 | // Resolve merges args, config, and env into a Resolved set. | 344 | // Resolve merges args, config, and env into a Resolved set. | ||
| 306 | // | 345 | // | ||
| 307 | // Unlike the earlier version, Resolve NEVER returns an error for | 346 | // Unlike the earlier version, Resolve NEVER returns an error for | ||
| @@ -310,6 +349,15 @@ func canonicalProvider(name string) string { | |||||
| 310 | // hard error (used by print/json modes). | 349 | // hard error (used by print/json modes). | ||
| 311 | func Resolve(args Args, requireCred bool) (Resolved, error) { | 350 | func Resolve(args Args, requireCred bool) (Resolved, error) { | ||
| 312 | cfg, _ := LoadConfig() | 351 | cfg, _ := LoadConfig() | ||
| 352 | + | var selectedProfile *subagents.Profile | |||
| 353 | + | if strings.TrimSpace(args.Subagent) != "" { | |||
| 354 | + | var err error | |||
| 355 | + | selectedProfile, err = findSubagentProfile(args.CWD, args.Subagent) | |||
| 356 | + | if err != nil { | |||
| 357 | + | return Resolved{}, err | |||
| 358 | + | } | |||
| 359 | + | applySubagentProfile(&args, selectedProfile) | |||
| 360 | + | } | |||
| 313 | 361 | ||||
| 314 | // User-requested provider (explicit > config > default). | 362 | // User-requested provider (explicit > config > default). | ||
| 315 | // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock") | 363 | // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock") | ||
| @@ -586,7 +634,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 586 | skillTool *skills.Tool | 634 | skillTool *skills.Tool | ||
| 587 | skillAddendum string | 635 | skillAddendum string | ||
| 588 | ) | 636 | ) | ||
| 589 | - | if !args.NoSkill { | 637 | + | if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) { |
| 590 | homeDir, _ := os.UserHomeDir() | 638 | homeDir, _ := os.UserHomeDir() | ||
| 591 | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills) | 639 | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills) | ||
| 592 | if len(discovered) > 0 { | 640 | if len(discovered) > 0 { | ||
| @@ -599,7 +647,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 599 | 647 | ||||
| 600 | summaries := toolSummaries(reg, args) | 648 | summaries := toolSummaries(reg, args) | ||
| 601 | 649 | ||||
| 602 | - | contextFiles := loadAgentsContext(args.CWD, ZotHome()) | 650 | + | contextFiles := []ContextFile(nil) |
| 651 | + | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | |||
| 652 | + | contextFiles = loadAgentsContext(args.CWD, ZotHome()) | |||
| 653 | + | } | |||
| 603 | append_ := append([]string(nil), args.AppendSystemPrompt...) | 654 | append_ := append([]string(nil), args.AppendSystemPrompt...) | ||
| 604 | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { | 655 | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { | ||
| 605 | append_ = append(append_, agentsAddendum) | 656 | append_ = append(append_, agentsAddendum) | ||
| @@ -607,16 +658,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 607 | if skillAddendum != "" { | 658 | if skillAddendum != "" { | ||
| 608 | append_ = append(append_, skillAddendum) | 659 | append_ = append(append_, skillAddendum) | ||
| 609 | } | 660 | } | ||
| 610 | - | if AutoSwarmEnabled() { | 661 | + | interactiveMode := args.Mode == "" || args.Mode == ModeInteractive |
| 662 | + | if selectedProfile == nil && interactiveMode && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |||
| 663 | + | homeDir, _ := os.UserHomeDir() | |||
| 664 | + | profiles, _ := subagents.Discover(args.CWD, homeDir) | |||
| 665 | + | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | |||
| 666 | + | append_ = append(append_, subagentsAddendum) | |||
| 667 | + | } | |||
| 611 | append_ = append(append_, AutoSwarmSystemAddendum) | 668 | append_ = append(append_, AutoSwarmSystemAddendum) | ||
| 612 | } | 669 | } | ||
| 670 | + | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | |||
| 671 | + | append_ = append(append_, selectedProfile.SystemPrompt) | |||
| 672 | + | } | |||
| 613 | 673 | ||||
| 614 | // Custom system prompt resolution order: | 674 | // Custom system prompt resolution order: | ||
| 615 | // 1. --system-prompt flag (highest priority; ad-hoc per run) | 675 | // 1. --system-prompt flag (highest priority; ad-hoc per run) | ||
| 616 | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | 676 | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | ||
| 617 | // 3. built-in default (defaultIdentity + defaultGuidelines) | 677 | // 3. built-in default (defaultIdentity + defaultGuidelines) | ||
| 618 | custom := args.SystemPrompt | 678 | custom := args.SystemPrompt | ||
| 619 | - | if custom == "" { | 679 | + | if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" { |
| 680 | + | custom = selectedProfile.SystemPrompt | |||
| 681 | + | } else if custom == "" { | |||
| 620 | custom = readUserSystemPrompt(ZotHome()) | 682 | custom = readUserSystemPrompt(ZotHome()) | ||
| 621 | } | 683 | } | ||
| 622 | 684 | ||||
M packages/agent/build_test.go +91 -0
| @@ -59,6 +59,97 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||
| 59 | 59 | } | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | + | func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | |
| 63 | + | root := t.TempDir() | |
| 64 | + | home := filepath.Join(root, "home") | |
| 65 | + | zotHome := filepath.Join(root, "zot-home") | |
| 66 | + | project := filepath.Join(root, "repo") | |
| 67 | + | if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if err := os.MkdirAll(project, 0o755); err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | t.Setenv("HOME", home) | |
| 74 | + | t.Setenv("ZOT_HOME", zotHome) | |
| 75 | + | enabled := true | |
| 76 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | profile := `--- | |
| 80 | + | name: reviewer | |
| 81 | + | description: Read-only code reviewer | |
| 82 | + | thinking: high | |
| 83 | + | --- | |
| 84 | + | Review without editing. | |
| 85 | + | ` | |
| 86 | + | if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | r, err := Resolve(Args{CWD: project}, false) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} { | |
| 95 | + | if !strings.Contains(r.SystemPrompt, want) { | |
| 96 | + | t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestResolveAppliesSelectedSubagentProfile(t *testing.T) { | |
| 102 | + | root := t.TempDir() | |
| 103 | + | home := filepath.Join(root, "home") | |
| 104 | + | zotHome := filepath.Join(root, "zot-home") | |
| 105 | + | project := filepath.Join(root, "repo") | |
| 106 | + | if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | if err := os.MkdirAll(project, 0o755); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | t.Setenv("HOME", home) | |
| 113 | + | t.Setenv("ZOT_HOME", zotHome) | |
| 114 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | profile := `--- | |
| 121 | + | name: reviewer | |
| 122 | + | description: Read-only code reviewer | |
| 123 | + | tools: read | |
| 124 | + | thinking: off | |
| 125 | + | systemPromptMode: replace | |
| 126 | + | inheritProjectContext: false | |
| 127 | + | inheritSkills: false | |
| 128 | + | --- | |
| 129 | + | You are a read-only reviewer. | |
| 130 | + | ` | |
| 131 | + | if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false) | |
| 136 | + | if err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | if r.Reasoning != "" { | |
| 140 | + | t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning) | |
| 141 | + | } | |
| 142 | + | if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 { | |
| 143 | + | t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry) | |
| 144 | + | } | |
| 145 | + | if len(r.ContextFiles) != 0 { | |
| 146 | + | t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles) | |
| 147 | + | } | |
| 148 | + | if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") { | |
| 149 | + | t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 62 | 153 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |
| 63 | 154 | files := loadAgentsContext(t.TempDir(), t.TempDir()) | |
| 64 | 155 | if len(files) != 0 { | |
| @@ -59,6 +59,97 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||||
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | + | func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | |||
| 63 | + | root := t.TempDir() | |||
| 64 | + | home := filepath.Join(root, "home") | |||
| 65 | + | zotHome := filepath.Join(root, "zot-home") | |||
| 66 | + | project := filepath.Join(root, "repo") | |||
| 67 | + | if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | if err := os.MkdirAll(project, 0o755); err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | t.Setenv("HOME", home) | |||
| 74 | + | t.Setenv("ZOT_HOME", zotHome) | |||
| 75 | + | enabled := true | |||
| 76 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | profile := `--- | |||
| 80 | + | name: reviewer | |||
| 81 | + | description: Read-only code reviewer | |||
| 82 | + | thinking: high | |||
| 83 | + | --- | |||
| 84 | + | Review without editing. | |||
| 85 | + | ` | |||
| 86 | + | if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | r, err := Resolve(Args{CWD: project}, false) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} { | |||
| 95 | + | if !strings.Contains(r.SystemPrompt, want) { | |||
| 96 | + | t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestResolveAppliesSelectedSubagentProfile(t *testing.T) { | |||
| 102 | + | root := t.TempDir() | |||
| 103 | + | home := filepath.Join(root, "home") | |||
| 104 | + | zotHome := filepath.Join(root, "zot-home") | |||
| 105 | + | project := filepath.Join(root, "repo") | |||
| 106 | + | if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | if err := os.MkdirAll(project, 0o755); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | t.Setenv("HOME", home) | |||
| 113 | + | t.Setenv("ZOT_HOME", zotHome) | |||
| 114 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | profile := `--- | |||
| 121 | + | name: reviewer | |||
| 122 | + | description: Read-only code reviewer | |||
| 123 | + | tools: read | |||
| 124 | + | thinking: off | |||
| 125 | + | systemPromptMode: replace | |||
| 126 | + | inheritProjectContext: false | |||
| 127 | + | inheritSkills: false | |||
| 128 | + | --- | |||
| 129 | + | You are a read-only reviewer. | |||
| 130 | + | ` | |||
| 131 | + | if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil { | |||
| 132 | + | t.Fatal(err) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false) | |||
| 136 | + | if err != nil { | |||
| 137 | + | t.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | if r.Reasoning != "" { | |||
| 140 | + | t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning) | |||
| 141 | + | } | |||
| 142 | + | if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 { | |||
| 143 | + | t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry) | |||
| 144 | + | } | |||
| 145 | + | if len(r.ContextFiles) != 0 { | |||
| 146 | + | t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles) | |||
| 147 | + | } | |||
| 148 | + | if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") { | |||
| 149 | + | t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 62 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | 153 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | ||
| 63 | files := loadAgentsContext(t.TempDir(), t.TempDir()) | 154 | files := loadAgentsContext(t.TempDir(), t.TempDir()) | ||
| 64 | if len(files) != 0 { | 155 | if len(files) != 0 { | ||
M packages/agent/cli.go +19 -8
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | 19 | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 20 | 20 | "github.com/patriceckhart/zot/packages/agent/modes" | |
| 21 | 21 | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 22 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 22 | 23 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 23 | 24 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 24 | 25 | "github.com/patriceckhart/zot/packages/core" | |
| @@ -729,6 +730,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 729 | 730 | iv.TrackSwarmAgent(a, task) | |
| 730 | 731 | } | |
| 731 | 732 | } | |
| 733 | + | resolveSubagent := func(name string) (*subagents.Profile, error) { | |
| 734 | + | return findSubagentProfile(r.CWD, name) | |
| 735 | + | } | |
| 732 | 736 | ||
| 733 | 737 | // Inject the swarm_spawn auto-swarm tool only when /settings -> | |
| 734 | 738 | // auto-swarm is currently enabled. Registering it unconditionally | |
| @@ -744,11 +748,13 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 744 | 748 | return reg | |
| 745 | 749 | } | |
| 746 | 750 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 747 | - | Swarm: swarmMgr, | |
| 748 | - | Enabled: AutoSwarmEnabled, | |
| 749 | - | DefaultModel: func() string { return r.Model }, | |
| 750 | - | DefaultProvider: func() string { return r.Provider }, | |
| 751 | - | OnSpawned: onSpawnedSwarm, | |
| 751 | + | Swarm: swarmMgr, | |
| 752 | + | Enabled: AutoSwarmEnabled, | |
| 753 | + | DefaultModel: func() string { return r.Model }, | |
| 754 | + | DefaultProvider: func() string { return r.Provider }, | |
| 755 | + | DefaultReasoning: func() string { return r.Reasoning }, | |
| 756 | + | ResolveSubagent: resolveSubagent, | |
| 757 | + | OnSpawned: onSpawnedSwarm, | |
| 752 | 758 | } | |
| 753 | 759 | return reg | |
| 754 | 760 | } | |
| @@ -1247,6 +1253,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1247 | 1253 | if r.SkillTool != nil { | |
| 1248 | 1254 | startupSkills = r.SkillTool.Skills() | |
| 1249 | 1255 | } | |
| 1256 | + | homeDir, _ := os.UserHomeDir() | |
| 1257 | + | discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir) | |
| 1258 | + | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | |
| 1250 | 1259 | ||
| 1251 | 1260 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 1252 | 1261 | Terminal: term, | |
| @@ -1267,6 +1276,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1267 | 1276 | CompactUser: initialCfg.CompactUserInput(), | |
| 1268 | 1277 | ExtensionThemes: extMgr.ThemeOptions, | |
| 1269 | 1278 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| 1279 | + | SubagentsSystemAddendum: subagentsAddendum, | |
| 1270 | 1280 | SettingsStore: configSettingsStore{}, | |
| 1271 | 1281 | Model: r.Model, | |
| 1272 | 1282 | Provider: r.Provider, | |
| @@ -1360,9 +1370,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1360 | 1370 | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | |
| 1361 | 1371 | sessBaselineMsgs = len(currentAg.Messages()) | |
| 1362 | 1372 | }, | |
| 1363 | - | Extensions: extMgr, | |
| 1364 | - | Swarm: swarmMgr, | |
| 1365 | - | ChangelogChan: changelogCh, | |
| 1373 | + | Extensions: extMgr, | |
| 1374 | + | Swarm: swarmMgr, | |
| 1375 | + | ResolveSubagent: resolveSubagent, | |
| 1376 | + | ChangelogChan: changelogCh, | |
| 1366 | 1377 | OnChangelogDismiss: func() { | |
| 1367 | 1378 | // For dev builds (0.0.0) store the actual release version | |
| 1368 | 1379 | // so the same changelog doesn't show again next launch. | |
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "github.com/patriceckhart/zot/packages/agent/extproto" | 19 | "github.com/patriceckhart/zot/packages/agent/extproto" | ||
| 20 | "github.com/patriceckhart/zot/packages/agent/modes" | 20 | "github.com/patriceckhart/zot/packages/agent/modes" | ||
| 21 | "github.com/patriceckhart/zot/packages/agent/skills" | 21 | "github.com/patriceckhart/zot/packages/agent/skills" | ||
| 22 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 22 | "github.com/patriceckhart/zot/packages/agent/swarm" | 23 | "github.com/patriceckhart/zot/packages/agent/swarm" | ||
| 23 | "github.com/patriceckhart/zot/packages/agent/tools" | 24 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 24 | "github.com/patriceckhart/zot/packages/core" | 25 | "github.com/patriceckhart/zot/packages/core" | ||
| @@ -729,6 +730,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 729 | iv.TrackSwarmAgent(a, task) | 730 | iv.TrackSwarmAgent(a, task) | ||
| 730 | } | 731 | } | ||
| 731 | } | 732 | } | ||
| 733 | + | resolveSubagent := func(name string) (*subagents.Profile, error) { | |||
| 734 | + | return findSubagentProfile(r.CWD, name) | |||
| 735 | + | } | |||
| 732 | 736 | ||||
| 733 | // Inject the swarm_spawn auto-swarm tool only when /settings -> | 737 | // Inject the swarm_spawn auto-swarm tool only when /settings -> | ||
| 734 | // auto-swarm is currently enabled. Registering it unconditionally | 738 | // auto-swarm is currently enabled. Registering it unconditionally | ||
| @@ -744,11 +748,13 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 744 | return reg | 748 | return reg | ||
| 745 | } | 749 | } | ||
| 746 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | 750 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | ||
| 747 | - | Swarm: swarmMgr, | 751 | + | Swarm: swarmMgr, |
| 748 | - | Enabled: AutoSwarmEnabled, | 752 | + | Enabled: AutoSwarmEnabled, |
| 749 | - | DefaultModel: func() string { return r.Model }, | 753 | + | DefaultModel: func() string { return r.Model }, |
| 750 | - | DefaultProvider: func() string { return r.Provider }, | 754 | + | DefaultProvider: func() string { return r.Provider }, |
| 751 | - | OnSpawned: onSpawnedSwarm, | 755 | + | DefaultReasoning: func() string { return r.Reasoning }, |
| 756 | + | ResolveSubagent: resolveSubagent, | |||
| 757 | + | OnSpawned: onSpawnedSwarm, | |||
| 752 | } | 758 | } | ||
| 753 | return reg | 759 | return reg | ||
| 754 | } | 760 | } | ||
| @@ -1247,6 +1253,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1247 | if r.SkillTool != nil { | 1253 | if r.SkillTool != nil { | ||
| 1248 | startupSkills = r.SkillTool.Skills() | 1254 | startupSkills = r.SkillTool.Skills() | ||
| 1249 | } | 1255 | } | ||
| 1256 | + | homeDir, _ := os.UserHomeDir() | |||
| 1257 | + | discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir) | |||
| 1258 | + | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | |||
| 1250 | 1259 | ||||
| 1251 | iv = modes.NewInteractive(modes.InteractiveConfig{ | 1260 | iv = modes.NewInteractive(modes.InteractiveConfig{ | ||
| 1252 | Terminal: term, | 1261 | Terminal: term, | ||
| @@ -1267,6 +1276,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1267 | CompactUser: initialCfg.CompactUserInput(), | 1276 | CompactUser: initialCfg.CompactUserInput(), | ||
| 1268 | ExtensionThemes: extMgr.ThemeOptions, | 1277 | ExtensionThemes: extMgr.ThemeOptions, | ||
| 1269 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | 1278 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | ||
| 1279 | + | SubagentsSystemAddendum: subagentsAddendum, | |||
| 1270 | SettingsStore: configSettingsStore{}, | 1280 | SettingsStore: configSettingsStore{}, | ||
| 1271 | Model: r.Model, | 1281 | Model: r.Model, | ||
| 1272 | Provider: r.Provider, | 1282 | Provider: r.Provider, | ||
| @@ -1360,9 +1370,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1360 | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | 1370 | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | ||
| 1361 | sessBaselineMsgs = len(currentAg.Messages()) | 1371 | sessBaselineMsgs = len(currentAg.Messages()) | ||
| 1362 | }, | 1372 | }, | ||
| 1363 | - | Extensions: extMgr, | 1373 | + | Extensions: extMgr, |
| 1364 | - | Swarm: swarmMgr, | 1374 | + | Swarm: swarmMgr, |
| 1365 | - | ChangelogChan: changelogCh, | 1375 | + | ResolveSubagent: resolveSubagent, |
| 1376 | + | ChangelogChan: changelogCh, | |||
| 1366 | OnChangelogDismiss: func() { | 1377 | OnChangelogDismiss: func() { | ||
| 1367 | // For dev builds (0.0.0) store the actual release version | 1378 | // For dev builds (0.0.0) store the actual release version | ||
| 1368 | // so the same changelog doesn't show again next launch. | 1379 | // so the same changelog doesn't show again next launch. | ||
M packages/agent/modes/auto_swarm_test.go +25 -0
| @@ -11,6 +11,31 @@ import ( | |||
| 11 | 11 | "github.com/patriceckhart/zot/packages/core" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) { | |
| 15 | + | iv := &Interactive{ | |
| 16 | + | agent: &core.Agent{System: "base system"}, | |
| 17 | + | cfg: InteractiveConfig{ | |
| 18 | + | AutoSwarmSystemAddendum: "auto-swarm guidance", | |
| 19 | + | SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]", | |
| 20 | + | }, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | iv.applyAutoSwarmSystemPrompt(true) | |
| 24 | + | if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-swarm guidance") { | |
| 25 | + | t.Fatalf("enabled system prompt missing swarm blocks: %q", iv.agent.System) | |
| 26 | + | } | |
| 27 | + | before := iv.agent.System | |
| 28 | + | iv.applyAutoSwarmSystemPrompt(true) | |
| 29 | + | if iv.agent.System != before { | |
| 30 | + | t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | iv.applyAutoSwarmSystemPrompt(false) | |
| 34 | + | if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-swarm guidance") { | |
| 35 | + | t.Fatalf("disabled system prompt retained swarm blocks: %q", iv.agent.System) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 14 | 39 | func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { | |
| 15 | 40 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | |
| 16 | 41 | mgr := swarm.New(swarm.Config{ | |
| @@ -11,6 +11,31 @@ import ( | |||||
| 11 | "github.com/patriceckhart/zot/packages/core" | 11 | "github.com/patriceckhart/zot/packages/core" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) { | |||
| 15 | + | iv := &Interactive{ | |||
| 16 | + | agent: &core.Agent{System: "base system"}, | |||
| 17 | + | cfg: InteractiveConfig{ | |||
| 18 | + | AutoSwarmSystemAddendum: "auto-swarm guidance", | |||
| 19 | + | SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]", | |||
| 20 | + | }, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | iv.applyAutoSwarmSystemPrompt(true) | |||
| 24 | + | if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-swarm guidance") { | |||
| 25 | + | t.Fatalf("enabled system prompt missing swarm blocks: %q", iv.agent.System) | |||
| 26 | + | } | |||
| 27 | + | before := iv.agent.System | |||
| 28 | + | iv.applyAutoSwarmSystemPrompt(true) | |||
| 29 | + | if iv.agent.System != before { | |||
| 30 | + | t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | iv.applyAutoSwarmSystemPrompt(false) | |||
| 34 | + | if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-swarm guidance") { | |||
| 35 | + | t.Fatalf("disabled system prompt retained swarm blocks: %q", iv.agent.System) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 14 | func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { | 39 | func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { | ||
| 15 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | 40 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | ||
| 16 | mgr := swarm.New(swarm.Config{ | 41 | mgr := swarm.New(swarm.Config{ | ||
M packages/agent/modes/interactive.go +56 -19
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 17 | 17 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | |
| 18 | 18 | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 19 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 19 | 20 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 20 | 21 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 21 | 22 | "github.com/patriceckhart/zot/packages/core" | |
| @@ -123,6 +124,10 @@ type InteractiveConfig struct { | |||
| 123 | 124 | // Plumbed in from the cli so this package doesn't have to import | |
| 124 | 125 | // agent (cycle). | |
| 125 | 126 | AutoSwarmSystemAddendum string | |
| 127 | + | ||
| 128 | + | // SubagentsSystemAddendum is the metadata-only [subagents_list] | |
| 129 | + | // block that is added or removed together with auto-swarm. | |
| 130 | + | SubagentsSystemAddendum string | |
| 126 | 131 | SettingsStore SettingsStore | |
| 127 | 132 | ||
| 128 | 133 | // Agent is optional. If nil, zot opens without credentials; the | |
| @@ -262,6 +267,11 @@ type InteractiveConfig struct { | |||
| 262 | 267 | // subprocesses). | |
| 263 | 268 | Swarm *swarm.Swarm | |
| 264 | 269 | ||
| 270 | + | // ResolveSubagent validates a named markdown profile for direct | |
| 271 | + | // /swarm commands. Auto-swarm uses the equivalent callback on its | |
| 272 | + | // tool; keeping this optional preserves lightweight embedders. | |
| 273 | + | ResolveSubagent func(name string) (*subagents.Profile, error) | |
| 274 | + | ||
| 265 | 275 | // SkillSnapshot, if non-nil, returns the current list of | |
| 266 | 276 | // discovered SKILL.md files. Re-invoked each time /skills opens | |
| 267 | 277 | // so the picker reflects edits made during the session. | |
| @@ -3052,9 +3062,14 @@ func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider | |||
| 3052 | 3062 | } | |
| 3053 | 3063 | ||
| 3054 | 3064 | func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |
| 3065 | + | home, _ := os.UserHomeDir() | |
| 3066 | + | profiles, _ := subagents.Discover(cwd, home) | |
| 3067 | + | subagentsAddendum := subagents.SystemPromptAddendum(profiles) | |
| 3068 | + | ||
| 3055 | 3069 | i.mu.Lock() | |
| 3056 | 3070 | i.agent = ag | |
| 3057 | 3071 | i.cfg.CWD = cwd | |
| 3072 | + | i.cfg.SubagentsSystemAddendum = subagentsAddendum | |
| 3058 | 3073 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 3059 | 3074 | i.view.StartupContextPaths = nil | |
| 3060 | 3075 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| @@ -6487,28 +6502,48 @@ func truncateForSummary(s string, n int) string { | |||
| 6487 | 6502 | } | |
| 6488 | 6503 | ||
| 6489 | 6504 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | |
| 6490 | - | // (active=false) the auto-swarm system-prompt block on the running | |
| 6491 | - | // agent so the model proactively considers swarm_spawn when the user | |
| 6492 | - | // flips the toggle. The block lives at the tail of agent.System so | |
| 6493 | - | // stripping is a plain suffix-trim; idempotent in both directions. | |
| 6505 | + | // (active=false) the auto-swarm prompt blocks on the running agent. | |
| 6506 | + | // The profile manifest and delegation guidance are managed together so | |
| 6507 | + | // toggling auto-swarm never leaves the model with names but no tool. | |
| 6494 | 6508 | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |
| 6495 | 6509 | if i.agent == nil { | |
| 6496 | 6510 | return | |
| 6497 | 6511 | } | |
| 6498 | - | addendum := i.cfg.AutoSwarmSystemAddendum | |
| 6499 | - | if addendum == "" { | |
| 6512 | + | addenda := make([]string, 0, 2) | |
| 6513 | + | if addendum := strings.TrimSpace(i.cfg.SubagentsSystemAddendum); addendum != "" { | |
| 6514 | + | addenda = append(addenda, addendum) | |
| 6515 | + | } | |
| 6516 | + | if addendum := strings.TrimSpace(i.cfg.AutoSwarmSystemAddendum); addendum != "" { | |
| 6517 | + | addenda = append(addenda, addendum) | |
| 6518 | + | } | |
| 6519 | + | if len(addenda) == 0 { | |
| 6500 | 6520 | return | |
| 6501 | 6521 | } | |
| 6522 | + | ||
| 6502 | 6523 | sys := i.agent.System | |
| 6503 | - | has := strings.Contains(sys, addendum) | |
| 6504 | - | switch { | |
| 6505 | - | case active && !has: | |
| 6506 | - | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | |
| 6507 | - | sys += "\n\n" | |
| 6524 | + | if active { | |
| 6525 | + | for _, addendum := range addenda { | |
| 6526 | + | if strings.Contains(sys, addendum) { | |
| 6527 | + | continue | |
| 6528 | + | } | |
| 6529 | + | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | |
| 6530 | + | sys += "\n\n" | |
| 6531 | + | } | |
| 6532 | + | sys += addendum | |
| 6508 | 6533 | } | |
| 6509 | - | i.agent.System = sys + addendum | |
| 6510 | - | case !active && has: | |
| 6511 | - | i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n" | |
| 6534 | + | i.agent.System = sys | |
| 6535 | + | return | |
| 6536 | + | } | |
| 6537 | + | ||
| 6538 | + | changed := false | |
| 6539 | + | for _, addendum := range addenda { | |
| 6540 | + | if strings.Contains(sys, addendum) { | |
| 6541 | + | changed = true | |
| 6542 | + | sys = strings.ReplaceAll(sys, addendum, "") | |
| 6543 | + | } | |
| 6544 | + | } | |
| 6545 | + | if changed { | |
| 6546 | + | i.agent.System = strings.TrimRight(sys, "\n") + "\n" | |
| 6512 | 6547 | } | |
| 6513 | 6548 | } | |
| 6514 | 6549 | ||
| @@ -6535,11 +6570,13 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||
| 6535 | 6570 | } | |
| 6536 | 6571 | if active && i.cfg.Swarm != nil { | |
| 6537 | 6572 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 6538 | - | Swarm: i.cfg.Swarm, | |
| 6539 | - | Enabled: func() bool { return true }, | |
| 6540 | - | DefaultModel: func() string { return i.cfg.Model }, | |
| 6541 | - | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 6542 | - | OnSpawned: i.trackSwarmAgent, | |
| 6573 | + | Swarm: i.cfg.Swarm, | |
| 6574 | + | Enabled: func() bool { return true }, | |
| 6575 | + | DefaultModel: func() string { return i.cfg.Model }, | |
| 6576 | + | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 6577 | + | DefaultReasoning: func() string { return i.cfg.Reasoning }, | |
| 6578 | + | ResolveSubagent: i.cfg.ResolveSubagent, | |
| 6579 | + | OnSpawned: i.trackSwarmAgent, | |
| 6543 | 6580 | } | |
| 6544 | 6581 | } | |
| 6545 | 6582 | i.agent.SetTools(next) | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/patriceckhart/zot/packages/agent/extproto" | 16 | "github.com/patriceckhart/zot/packages/agent/extproto" | ||
| 17 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | 17 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | ||
| 18 | "github.com/patriceckhart/zot/packages/agent/skills" | 18 | "github.com/patriceckhart/zot/packages/agent/skills" | ||
| 19 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 19 | "github.com/patriceckhart/zot/packages/agent/swarm" | 20 | "github.com/patriceckhart/zot/packages/agent/swarm" | ||
| 20 | "github.com/patriceckhart/zot/packages/agent/tools" | 21 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 21 | "github.com/patriceckhart/zot/packages/core" | 22 | "github.com/patriceckhart/zot/packages/core" | ||
| @@ -123,6 +124,10 @@ type InteractiveConfig struct { | |||||
| 123 | // Plumbed in from the cli so this package doesn't have to import | 124 | // Plumbed in from the cli so this package doesn't have to import | ||
| 124 | // agent (cycle). | 125 | // agent (cycle). | ||
| 125 | AutoSwarmSystemAddendum string | 126 | AutoSwarmSystemAddendum string | ||
| 127 | + | ||||
| 128 | + | // SubagentsSystemAddendum is the metadata-only [subagents_list] | |||
| 129 | + | // block that is added or removed together with auto-swarm. | |||
| 130 | + | SubagentsSystemAddendum string | |||
| 126 | SettingsStore SettingsStore | 131 | SettingsStore SettingsStore | ||
| 127 | 132 | ||||
| 128 | // Agent is optional. If nil, zot opens without credentials; the | 133 | // Agent is optional. If nil, zot opens without credentials; the | ||
| @@ -262,6 +267,11 @@ type InteractiveConfig struct { | |||||
| 262 | // subprocesses). | 267 | // subprocesses). | ||
| 263 | Swarm *swarm.Swarm | 268 | Swarm *swarm.Swarm | ||
| 264 | 269 | ||||
| 270 | + | // ResolveSubagent validates a named markdown profile for direct | |||
| 271 | + | // /swarm commands. Auto-swarm uses the equivalent callback on its | |||
| 272 | + | // tool; keeping this optional preserves lightweight embedders. | |||
| 273 | + | ResolveSubagent func(name string) (*subagents.Profile, error) | |||
| 274 | + | ||||
| 265 | // SkillSnapshot, if non-nil, returns the current list of | 275 | // SkillSnapshot, if non-nil, returns the current list of | ||
| 266 | // discovered SKILL.md files. Re-invoked each time /skills opens | 276 | // discovered SKILL.md files. Re-invoked each time /skills opens | ||
| 267 | // so the picker reflects edits made during the session. | 277 | // so the picker reflects edits made during the session. | ||
| @@ -3052,9 +3062,14 @@ func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider | |||||
| 3052 | } | 3062 | } | ||
| 3053 | 3063 | ||||
| 3054 | func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | 3064 | func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | ||
| 3065 | + | home, _ := os.UserHomeDir() | |||
| 3066 | + | profiles, _ := subagents.Discover(cwd, home) | |||
| 3067 | + | subagentsAddendum := subagents.SystemPromptAddendum(profiles) | |||
| 3068 | + | ||||
| 3055 | i.mu.Lock() | 3069 | i.mu.Lock() | ||
| 3056 | i.agent = ag | 3070 | i.agent = ag | ||
| 3057 | i.cfg.CWD = cwd | 3071 | i.cfg.CWD = cwd | ||
| 3072 | + | i.cfg.SubagentsSystemAddendum = subagentsAddendum | |||
| 3058 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | 3073 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | ||
| 3059 | i.view.StartupContextPaths = nil | 3074 | i.view.StartupContextPaths = nil | ||
| 3060 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | 3075 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | ||
| @@ -6487,28 +6502,48 @@ func truncateForSummary(s string, n int) string { | |||||
| 6487 | } | 6502 | } | ||
| 6488 | 6503 | ||||
| 6489 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | 6504 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | ||
| 6490 | - | // (active=false) the auto-swarm system-prompt block on the running | 6505 | + | // (active=false) the auto-swarm prompt blocks on the running agent. |
| 6491 | - | // agent so the model proactively considers swarm_spawn when the user | 6506 | + | // The profile manifest and delegation guidance are managed together so |
| 6492 | - | // flips the toggle. The block lives at the tail of agent.System so | 6507 | + | // toggling auto-swarm never leaves the model with names but no tool. |
| 6493 | - | // stripping is a plain suffix-trim; idempotent in both directions. | |||
| 6494 | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | 6508 | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | ||
| 6495 | if i.agent == nil { | 6509 | if i.agent == nil { | ||
| 6496 | return | 6510 | return | ||
| 6497 | } | 6511 | } | ||
| 6498 | - | addendum := i.cfg.AutoSwarmSystemAddendum | 6512 | + | addenda := make([]string, 0, 2) |
| 6499 | - | if addendum == "" { | 6513 | + | if addendum := strings.TrimSpace(i.cfg.SubagentsSystemAddendum); addendum != "" { |
| 6514 | + | addenda = append(addenda, addendum) | |||
| 6515 | + | } | |||
| 6516 | + | if addendum := strings.TrimSpace(i.cfg.AutoSwarmSystemAddendum); addendum != "" { | |||
| 6517 | + | addenda = append(addenda, addendum) | |||
| 6518 | + | } | |||
| 6519 | + | if len(addenda) == 0 { | |||
| 6500 | return | 6520 | return | ||
| 6501 | } | 6521 | } | ||
| 6522 | + | ||||
| 6502 | sys := i.agent.System | 6523 | sys := i.agent.System | ||
| 6503 | - | has := strings.Contains(sys, addendum) | 6524 | + | if active { |
| 6504 | - | switch { | 6525 | + | for _, addendum := range addenda { |
| 6505 | - | case active && !has: | 6526 | + | if strings.Contains(sys, addendum) { |
| 6506 | - | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | 6527 | + | continue |
| 6507 | - | sys += "\n\n" | 6528 | + | } |
| 6529 | + | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | |||
| 6530 | + | sys += "\n\n" | |||
| 6531 | + | } | |||
| 6532 | + | sys += addendum | |||
| 6508 | } | 6533 | } | ||
| 6509 | - | i.agent.System = sys + addendum | 6534 | + | i.agent.System = sys |
| 6510 | - | case !active && has: | 6535 | + | return |
| 6511 | - | i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n" | 6536 | + | } |
| 6537 | + | ||||
| 6538 | + | changed := false | |||
| 6539 | + | for _, addendum := range addenda { | |||
| 6540 | + | if strings.Contains(sys, addendum) { | |||
| 6541 | + | changed = true | |||
| 6542 | + | sys = strings.ReplaceAll(sys, addendum, "") | |||
| 6543 | + | } | |||
| 6544 | + | } | |||
| 6545 | + | if changed { | |||
| 6546 | + | i.agent.System = strings.TrimRight(sys, "\n") + "\n" | |||
| 6512 | } | 6547 | } | ||
| 6513 | } | 6548 | } | ||
| 6514 | 6549 | ||||
| @@ -6535,11 +6570,13 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||||
| 6535 | } | 6570 | } | ||
| 6536 | if active && i.cfg.Swarm != nil { | 6571 | if active && i.cfg.Swarm != nil { | ||
| 6537 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | 6572 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | ||
| 6538 | - | Swarm: i.cfg.Swarm, | 6573 | + | Swarm: i.cfg.Swarm, |
| 6539 | - | Enabled: func() bool { return true }, | 6574 | + | Enabled: func() bool { return true }, |
| 6540 | - | DefaultModel: func() string { return i.cfg.Model }, | 6575 | + | DefaultModel: func() string { return i.cfg.Model }, |
| 6541 | - | DefaultProvider: func() string { return i.cfg.Provider }, | 6576 | + | DefaultProvider: func() string { return i.cfg.Provider }, |
| 6542 | - | OnSpawned: i.trackSwarmAgent, | 6577 | + | DefaultReasoning: func() string { return i.cfg.Reasoning }, |
| 6578 | + | ResolveSubagent: i.cfg.ResolveSubagent, | |||
| 6579 | + | OnSpawned: i.trackSwarmAgent, | |||
| 6543 | } | 6580 | } | ||
| 6544 | } | 6581 | } | ||
| 6545 | i.agent.SetTools(next) | 6582 | i.agent.SetTools(next) | ||
M packages/agent/modes/swarm_dialog.go +12 -0
| @@ -275,6 +275,12 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||
| 275 | 275 | if a.Model != "" { | |
| 276 | 276 | row++ | |
| 277 | 277 | } | |
| 278 | + | if a.Subagent != "" { | |
| 279 | + | row++ | |
| 280 | + | } | |
| 281 | + | if a.Reasoning != "" { | |
| 282 | + | row++ | |
| 283 | + | } | |
| 278 | 284 | if a.Err != "" { | |
| 279 | 285 | row++ | |
| 280 | 286 | } | |
| @@ -1095,6 +1101,12 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |||
| 1095 | 1101 | } | |
| 1096 | 1102 | header = append(header, " "+th.FG256(th.Muted, modelLine)) | |
| 1097 | 1103 | } | |
| 1104 | + | if a.Subagent != "" { | |
| 1105 | + | header = append(header, " "+th.FG256(th.Muted, "agent: "+a.Subagent)) | |
| 1106 | + | } | |
| 1107 | + | if a.Reasoning != "" { | |
| 1108 | + | header = append(header, " "+th.FG256(th.Muted, "reasoning: "+a.Reasoning)) | |
| 1109 | + | } | |
| 1098 | 1110 | if a.Err != "" { | |
| 1099 | 1111 | header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err)) | |
| 1100 | 1112 | } | |
| @@ -275,6 +275,12 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||||
| 275 | if a.Model != "" { | 275 | if a.Model != "" { | ||
| 276 | row++ | 276 | row++ | ||
| 277 | } | 277 | } | ||
| 278 | + | if a.Subagent != "" { | |||
| 279 | + | row++ | |||
| 280 | + | } | |||
| 281 | + | if a.Reasoning != "" { | |||
| 282 | + | row++ | |||
| 283 | + | } | |||
| 278 | if a.Err != "" { | 284 | if a.Err != "" { | ||
| 279 | row++ | 285 | row++ | ||
| 280 | } | 286 | } | ||
| @@ -1095,6 +1101,12 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |||||
| 1095 | } | 1101 | } | ||
| 1096 | header = append(header, " "+th.FG256(th.Muted, modelLine)) | 1102 | header = append(header, " "+th.FG256(th.Muted, modelLine)) | ||
| 1097 | } | 1103 | } | ||
| 1104 | + | if a.Subagent != "" { | |||
| 1105 | + | header = append(header, " "+th.FG256(th.Muted, "agent: "+a.Subagent)) | |||
| 1106 | + | } | |||
| 1107 | + | if a.Reasoning != "" { | |||
| 1108 | + | header = append(header, " "+th.FG256(th.Muted, "reasoning: "+a.Reasoning)) | |||
| 1109 | + | } | |||
| 1098 | if a.Err != "" { | 1110 | if a.Err != "" { | ||
| 1099 | header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err)) | 1111 | header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err)) | ||
| 1100 | } | 1112 | } | ||
M packages/agent/modes/swarm_slash.go +75 -20
| @@ -13,8 +13,8 @@ import ( | |||
| 13 | 13 | // | |
| 14 | 14 | // /swarm -> open the dashboard | |
| 15 | 15 | // /swarm list -> open the dashboard | |
| 16 | - | // /swarm new [--model M] [--provider P] <task...> | |
| 17 | - | // -> spawn an agent (optionally pinned to a model) | |
| 16 | + | // /swarm new [--agent A] [--model M] [--provider P] [--reasoning L] <task...> | |
| 17 | + | // -> spawn an agent with optional profile/model overrides | |
| 18 | 18 | // /swarm kill <id> -> stop a running agent | |
| 19 | 19 | // /swarm remove <id> -> tear down a terminated agent | |
| 20 | 20 | // /swarm logs <id> -> open the scrollable transcript view | |
| @@ -53,7 +53,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 53 | 53 | // feed the dialog identical callbacks. | |
| 54 | 54 | spawnAdapter := func(task, model, provider string) error { | |
| 55 | 55 | _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 56 | - | Task: task, Model: model, Provider: provider, | |
| 56 | + | Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning, | |
| 57 | 57 | }) | |
| 58 | 58 | return err | |
| 59 | 59 | } | |
| @@ -93,30 +93,53 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 93 | 93 | i.swarmStatus("", "/swarm new <task>: missing task") | |
| 94 | 94 | return | |
| 95 | 95 | } | |
| 96 | - | // Permit `--model X --provider Y` flags before the task so | |
| 97 | - | // scripts can pin a model without going through the dialog. | |
| 98 | - | // Anything that isn't a recognised flag terminates parsing | |
| 99 | - | // and the rest becomes the task; this keeps `/swarm new | |
| 100 | - | // --model foo do a thing` and `/swarm new do --model thing` | |
| 101 | - | // (where --model is part of the task) unambiguous — only | |
| 102 | - | // leading flags are consumed. | |
| 103 | - | model, provider, task := parseSpawnFlags(rest) | |
| 96 | + | // Permit profile/model/provider/reasoning flags before the task | |
| 97 | + | // so scripts can pin a child without going through the dialog. | |
| 98 | + | // Anything that isn't a recognised flag terminates parsing and | |
| 99 | + | // the rest becomes the task; only leading flags are consumed. | |
| 100 | + | model, provider, reasoning, subagent, task := parseSpawnFlags(rest) | |
| 104 | 101 | if task == "" { | |
| 105 | - | i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)") | |
| 102 | + | i.swarmStatus("", "/swarm new: missing task (after any spawn flags)") | |
| 106 | 103 | return | |
| 107 | 104 | } | |
| 105 | + | if subagent != "" && i.cfg.ResolveSubagent != nil { | |
| 106 | + | profile, err := i.cfg.ResolveSubagent(subagent) | |
| 107 | + | if err != nil { | |
| 108 | + | i.swarmStatus("", "spawn: "+err.Error()) | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | if profile == nil { | |
| 112 | + | i.swarmStatus("", "spawn: unknown subagent profile "+subagent) | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | subagent = profile.Name | |
| 116 | + | if model == "" && provider == "" { | |
| 117 | + | profileProvider, profileModel := profile.ModelSelection() | |
| 118 | + | if profileProvider != "" && profileModel != "" { | |
| 119 | + | provider, model = profileProvider, profileModel | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | if reasoning == "" { | |
| 123 | + | reasoning = profile.Thinking | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if reasoning == "" { | |
| 127 | + | reasoning = i.cfg.Reasoning | |
| 128 | + | } | |
| 108 | 129 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 109 | - | Task: task, Model: model, Provider: provider, | |
| 130 | + | Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent, | |
| 110 | 131 | }) | |
| 111 | 132 | if err != nil { | |
| 112 | 133 | i.swarmStatus("", "spawn: "+err.Error()) | |
| 113 | 134 | return | |
| 114 | 135 | } | |
| 115 | - | if model != "" { | |
| 116 | - | i.swarmStatus("spawned "+a.ID+" (model "+model+")", "") | |
| 117 | - | } else { | |
| 118 | - | i.swarmStatus("spawned "+a.ID, "") | |
| 136 | + | status := "spawned " + a.ID | |
| 137 | + | if subagent != "" { | |
| 138 | + | status += " (agent " + subagent + ")" | |
| 139 | + | } else if model != "" { | |
| 140 | + | status += " (model " + model + ")" | |
| 119 | 141 | } | |
| 142 | + | i.swarmStatus(status, "") | |
| 120 | 143 | case "kill", "stop": | |
| 121 | 144 | if rest == "" { | |
| 122 | 145 | i.swarmStatus("", "/swarm kill <id>: missing id") | |
| @@ -220,24 +243,40 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 220 | 243 | } | |
| 221 | 244 | } | |
| 222 | 245 | ||
| 223 | - | // parseSpawnFlags consumes any leading `--model X` / `--provider Y` | |
| 224 | - | // flags from s and returns them along with the remaining task body. | |
| 246 | + | // parseSpawnFlags consumes leading profile/model/provider/reasoning flags | |
| 247 | + | // from s and returns them along with the remaining task body. | |
| 225 | 248 | // We deliberately only honour LEADING flags so a task like "check | |
| 226 | 249 | // --model lookup" doesn't accidentally swallow part of its prose as | |
| 227 | 250 | // the model name. | |
| 228 | 251 | // | |
| 229 | 252 | // Recognised forms: | |
| 230 | 253 | // | |
| 254 | + | // --agent X two-token form | |
| 255 | + | // --agent=X single-token form | |
| 231 | 256 | // --model X two-token form | |
| 232 | 257 | // --model=X single-token form | |
| 233 | 258 | // --provider X two-token form | |
| 234 | 259 | // --provider=X single-token form | |
| 235 | - | func parseSpawnFlags(s string) (model, provider, task string) { | |
| 260 | + | // --reasoning X two-token form (also --thinking) | |
| 261 | + | // --reasoning=X single-token form | |
| 262 | + | func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) { | |
| 236 | 263 | fields := strings.Fields(s) | |
| 237 | 264 | i := 0 | |
| 238 | 265 | for i < len(fields) { | |
| 239 | 266 | f := fields[i] | |
| 240 | 267 | switch { | |
| 268 | + | case f == "--agent": | |
| 269 | + | if i+1 < len(fields) { | |
| 270 | + | subagent = fields[i+1] | |
| 271 | + | i += 2 | |
| 272 | + | } else { | |
| 273 | + | i++ | |
| 274 | + | } | |
| 275 | + | continue | |
| 276 | + | case strings.HasPrefix(f, "--agent="): | |
| 277 | + | subagent = strings.TrimPrefix(f, "--agent=") | |
| 278 | + | i++ | |
| 279 | + | continue | |
| 241 | 280 | case f == "--model": | |
| 242 | 281 | // Consume the flag even when no value follows so a | |
| 243 | 282 | // dangling "--model" doesn't leak into the task. The | |
| @@ -265,6 +304,22 @@ func parseSpawnFlags(s string) (model, provider, task string) { | |||
| 265 | 304 | provider = strings.TrimPrefix(f, "--provider=") | |
| 266 | 305 | i++ | |
| 267 | 306 | continue | |
| 307 | + | case f == "--reasoning" || f == "--thinking": | |
| 308 | + | if i+1 < len(fields) { | |
| 309 | + | reasoning = fields[i+1] | |
| 310 | + | i += 2 | |
| 311 | + | } else { | |
| 312 | + | i++ | |
| 313 | + | } | |
| 314 | + | continue | |
| 315 | + | case strings.HasPrefix(f, "--reasoning="): | |
| 316 | + | reasoning = strings.TrimPrefix(f, "--reasoning=") | |
| 317 | + | i++ | |
| 318 | + | continue | |
| 319 | + | case strings.HasPrefix(f, "--thinking="): | |
| 320 | + | reasoning = strings.TrimPrefix(f, "--thinking=") | |
| 321 | + | i++ | |
| 322 | + | continue | |
| 268 | 323 | } | |
| 269 | 324 | break | |
| 270 | 325 | } | |
| @@ -13,8 +13,8 @@ import ( | |||||
| 13 | // | 13 | // | ||
| 14 | // /swarm -> open the dashboard | 14 | // /swarm -> open the dashboard | ||
| 15 | // /swarm list -> open the dashboard | 15 | // /swarm list -> open the dashboard | ||
| 16 | - | // /swarm new [--model M] [--provider P] <task...> | 16 | + | // /swarm new [--agent A] [--model M] [--provider P] [--reasoning L] <task...> |
| 17 | - | // -> spawn an agent (optionally pinned to a model) | 17 | + | // -> spawn an agent with optional profile/model overrides |
| 18 | // /swarm kill <id> -> stop a running agent | 18 | // /swarm kill <id> -> stop a running agent | ||
| 19 | // /swarm remove <id> -> tear down a terminated agent | 19 | // /swarm remove <id> -> tear down a terminated agent | ||
| 20 | // /swarm logs <id> -> open the scrollable transcript view | 20 | // /swarm logs <id> -> open the scrollable transcript view | ||
| @@ -53,7 +53,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 53 | // feed the dialog identical callbacks. | 53 | // feed the dialog identical callbacks. | ||
| 54 | spawnAdapter := func(task, model, provider string) error { | 54 | spawnAdapter := func(task, model, provider string) error { | ||
| 55 | _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | 55 | _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | ||
| 56 | - | Task: task, Model: model, Provider: provider, | 56 | + | Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning, |
| 57 | }) | 57 | }) | ||
| 58 | return err | 58 | return err | ||
| 59 | } | 59 | } | ||
| @@ -93,30 +93,53 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 93 | i.swarmStatus("", "/swarm new <task>: missing task") | 93 | i.swarmStatus("", "/swarm new <task>: missing task") | ||
| 94 | return | 94 | return | ||
| 95 | } | 95 | } | ||
| 96 | - | // Permit `--model X --provider Y` flags before the task so | 96 | + | // Permit profile/model/provider/reasoning flags before the task |
| 97 | - | // scripts can pin a model without going through the dialog. | 97 | + | // so scripts can pin a child without going through the dialog. |
| 98 | - | // Anything that isn't a recognised flag terminates parsing | 98 | + | // Anything that isn't a recognised flag terminates parsing and |
| 99 | - | // and the rest becomes the task; this keeps `/swarm new | 99 | + | // the rest becomes the task; only leading flags are consumed. |
| 100 | - | // --model foo do a thing` and `/swarm new do --model thing` | 100 | + | model, provider, reasoning, subagent, task := parseSpawnFlags(rest) |
| 101 | - | // (where --model is part of the task) unambiguous — only | |||
| 102 | - | // leading flags are consumed. | |||
| 103 | - | model, provider, task := parseSpawnFlags(rest) | |||
| 104 | if task == "" { | 101 | if task == "" { | ||
| 105 | - | i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)") | 102 | + | i.swarmStatus("", "/swarm new: missing task (after any spawn flags)") |
| 106 | return | 103 | return | ||
| 107 | } | 104 | } | ||
| 105 | + | if subagent != "" && i.cfg.ResolveSubagent != nil { | |||
| 106 | + | profile, err := i.cfg.ResolveSubagent(subagent) | |||
| 107 | + | if err != nil { | |||
| 108 | + | i.swarmStatus("", "spawn: "+err.Error()) | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | if profile == nil { | |||
| 112 | + | i.swarmStatus("", "spawn: unknown subagent profile "+subagent) | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | subagent = profile.Name | |||
| 116 | + | if model == "" && provider == "" { | |||
| 117 | + | profileProvider, profileModel := profile.ModelSelection() | |||
| 118 | + | if profileProvider != "" && profileModel != "" { | |||
| 119 | + | provider, model = profileProvider, profileModel | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | if reasoning == "" { | |||
| 123 | + | reasoning = profile.Thinking | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | if reasoning == "" { | |||
| 127 | + | reasoning = i.cfg.Reasoning | |||
| 128 | + | } | |||
| 108 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | 129 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | ||
| 109 | - | Task: task, Model: model, Provider: provider, | 130 | + | Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent, |
| 110 | }) | 131 | }) | ||
| 111 | if err != nil { | 132 | if err != nil { | ||
| 112 | i.swarmStatus("", "spawn: "+err.Error()) | 133 | i.swarmStatus("", "spawn: "+err.Error()) | ||
| 113 | return | 134 | return | ||
| 114 | } | 135 | } | ||
| 115 | - | if model != "" { | 136 | + | status := "spawned " + a.ID |
| 116 | - | i.swarmStatus("spawned "+a.ID+" (model "+model+")", "") | 137 | + | if subagent != "" { |
| 117 | - | } else { | 138 | + | status += " (agent " + subagent + ")" |
| 118 | - | i.swarmStatus("spawned "+a.ID, "") | 139 | + | } else if model != "" { |
| 140 | + | status += " (model " + model + ")" | |||
| 119 | } | 141 | } | ||
| 142 | + | i.swarmStatus(status, "") | |||
| 120 | case "kill", "stop": | 143 | case "kill", "stop": | ||
| 121 | if rest == "" { | 144 | if rest == "" { | ||
| 122 | i.swarmStatus("", "/swarm kill <id>: missing id") | 145 | i.swarmStatus("", "/swarm kill <id>: missing id") | ||
| @@ -220,24 +243,40 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 220 | } | 243 | } | ||
| 221 | } | 244 | } | ||
| 222 | 245 | ||||
| 223 | - | // parseSpawnFlags consumes any leading `--model X` / `--provider Y` | 246 | + | // parseSpawnFlags consumes leading profile/model/provider/reasoning flags |
| 224 | - | // flags from s and returns them along with the remaining task body. | 247 | + | // from s and returns them along with the remaining task body. |
| 225 | // We deliberately only honour LEADING flags so a task like "check | 248 | // We deliberately only honour LEADING flags so a task like "check | ||
| 226 | // --model lookup" doesn't accidentally swallow part of its prose as | 249 | // --model lookup" doesn't accidentally swallow part of its prose as | ||
| 227 | // the model name. | 250 | // the model name. | ||
| 228 | // | 251 | // | ||
| 229 | // Recognised forms: | 252 | // Recognised forms: | ||
| 230 | // | 253 | // | ||
| 254 | + | // --agent X two-token form | |||
| 255 | + | // --agent=X single-token form | |||
| 231 | // --model X two-token form | 256 | // --model X two-token form | ||
| 232 | // --model=X single-token form | 257 | // --model=X single-token form | ||
| 233 | // --provider X two-token form | 258 | // --provider X two-token form | ||
| 234 | // --provider=X single-token form | 259 | // --provider=X single-token form | ||
| 235 | - | func parseSpawnFlags(s string) (model, provider, task string) { | 260 | + | // --reasoning X two-token form (also --thinking) |
| 261 | + | // --reasoning=X single-token form | |||
| 262 | + | func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) { | |||
| 236 | fields := strings.Fields(s) | 263 | fields := strings.Fields(s) | ||
| 237 | i := 0 | 264 | i := 0 | ||
| 238 | for i < len(fields) { | 265 | for i < len(fields) { | ||
| 239 | f := fields[i] | 266 | f := fields[i] | ||
| 240 | switch { | 267 | switch { | ||
| 268 | + | case f == "--agent": | |||
| 269 | + | if i+1 < len(fields) { | |||
| 270 | + | subagent = fields[i+1] | |||
| 271 | + | i += 2 | |||
| 272 | + | } else { | |||
| 273 | + | i++ | |||
| 274 | + | } | |||
| 275 | + | continue | |||
| 276 | + | case strings.HasPrefix(f, "--agent="): | |||
| 277 | + | subagent = strings.TrimPrefix(f, "--agent=") | |||
| 278 | + | i++ | |||
| 279 | + | continue | |||
| 241 | case f == "--model": | 280 | case f == "--model": | ||
| 242 | // Consume the flag even when no value follows so a | 281 | // Consume the flag even when no value follows so a | ||
| 243 | // dangling "--model" doesn't leak into the task. The | 282 | // dangling "--model" doesn't leak into the task. The | ||
| @@ -265,6 +304,22 @@ func parseSpawnFlags(s string) (model, provider, task string) { | |||||
| 265 | provider = strings.TrimPrefix(f, "--provider=") | 304 | provider = strings.TrimPrefix(f, "--provider=") | ||
| 266 | i++ | 305 | i++ | ||
| 267 | continue | 306 | continue | ||
| 307 | + | case f == "--reasoning" || f == "--thinking": | |||
| 308 | + | if i+1 < len(fields) { | |||
| 309 | + | reasoning = fields[i+1] | |||
| 310 | + | i += 2 | |||
| 311 | + | } else { | |||
| 312 | + | i++ | |||
| 313 | + | } | |||
| 314 | + | continue | |||
| 315 | + | case strings.HasPrefix(f, "--reasoning="): | |||
| 316 | + | reasoning = strings.TrimPrefix(f, "--reasoning=") | |||
| 317 | + | i++ | |||
| 318 | + | continue | |||
| 319 | + | case strings.HasPrefix(f, "--thinking="): | |||
| 320 | + | reasoning = strings.TrimPrefix(f, "--thinking=") | |||
| 321 | + | i++ | |||
| 322 | + | continue | |||
| 268 | } | 323 | } | ||
| 269 | break | 324 | break | ||
| 270 | } | 325 | } | ||
M packages/agent/modes/swarm_slash_test.go +17 -14
| @@ -177,26 +177,29 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||
| 177 | 177 | ||
| 178 | 178 | func TestParseSpawnFlags(t *testing.T) { | |
| 179 | 179 | cases := []struct { | |
| 180 | - | in string | |
| 181 | - | wantModel, wantProv string | |
| 182 | - | wantTask string | |
| 180 | + | in string | |
| 181 | + | wantModel, wantProv string | |
| 182 | + | wantReasoning, wantSubagent string | |
| 183 | + | wantTask string | |
| 183 | 184 | }{ | |
| 184 | - | {"do x", "", "", "do x"}, | |
| 185 | - | {"--model claude do x", "claude", "", "do x"}, | |
| 186 | - | {"--model=claude do x", "claude", "", "do x"}, | |
| 187 | - | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"}, | |
| 188 | - | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"}, | |
| 185 | + | {"do x", "", "", "", "", "do x"}, | |
| 186 | + | {"--model claude do x", "claude", "", "", "", "do x"}, | |
| 187 | + | {"--model=claude do x", "claude", "", "", "", "do x"}, | |
| 188 | + | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |
| 189 | + | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |
| 190 | + | {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"}, | |
| 191 | + | {"--thinking=max do x", "", "", "max", "", "do x"}, | |
| 189 | 192 | // Only LEADING flags are consumed. | |
| 190 | - | {"do --model x", "", "", "do --model x"}, | |
| 193 | + | {"do --model x", "", "", "", "", "do --model x"}, | |
| 191 | 194 | // Missing value: --model with no follow-up token leaves model empty | |
| 192 | 195 | // and the next field starts the task. | |
| 193 | - | {"--model", "", "", ""}, | |
| 196 | + | {"--model", "", "", "", "", ""}, | |
| 194 | 197 | } | |
| 195 | 198 | for _, c := range cases { | |
| 196 | - | m, p, task := parseSpawnFlags(c.in) | |
| 197 | - | if m != c.wantModel || p != c.wantProv || task != c.wantTask { | |
| 198 | - | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)", | |
| 199 | - | c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask) | |
| 199 | + | m, p, r, a, task := parseSpawnFlags(c.in) | |
| 200 | + | if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask { | |
| 201 | + | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)", | |
| 202 | + | c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask) | |
| 200 | 203 | } | |
| 201 | 204 | } | |
| 202 | 205 | } | |
| @@ -177,26 +177,29 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||||
| 177 | 177 | ||||
| 178 | func TestParseSpawnFlags(t *testing.T) { | 178 | func TestParseSpawnFlags(t *testing.T) { | ||
| 179 | cases := []struct { | 179 | cases := []struct { | ||
| 180 | - | in string | 180 | + | in string |
| 181 | - | wantModel, wantProv string | 181 | + | wantModel, wantProv string |
| 182 | - | wantTask string | 182 | + | wantReasoning, wantSubagent string |
| 183 | + | wantTask string | |||
| 183 | }{ | 184 | }{ | ||
| 184 | - | {"do x", "", "", "do x"}, | 185 | + | {"do x", "", "", "", "", "do x"}, |
| 185 | - | {"--model claude do x", "claude", "", "do x"}, | 186 | + | {"--model claude do x", "claude", "", "", "", "do x"}, |
| 186 | - | {"--model=claude do x", "claude", "", "do x"}, | 187 | + | {"--model=claude do x", "claude", "", "", "", "do x"}, |
| 187 | - | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"}, | 188 | + | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, |
| 188 | - | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"}, | 189 | + | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, |
| 190 | + | {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"}, | |||
| 191 | + | {"--thinking=max do x", "", "", "max", "", "do x"}, | |||
| 189 | // Only LEADING flags are consumed. | 192 | // Only LEADING flags are consumed. | ||
| 190 | - | {"do --model x", "", "", "do --model x"}, | 193 | + | {"do --model x", "", "", "", "", "do --model x"}, |
| 191 | // Missing value: --model with no follow-up token leaves model empty | 194 | // Missing value: --model with no follow-up token leaves model empty | ||
| 192 | // and the next field starts the task. | 195 | // and the next field starts the task. | ||
| 193 | - | {"--model", "", "", ""}, | 196 | + | {"--model", "", "", "", "", ""}, |
| 194 | } | 197 | } | ||
| 195 | for _, c := range cases { | 198 | for _, c := range cases { | ||
| 196 | - | m, p, task := parseSpawnFlags(c.in) | 199 | + | m, p, r, a, task := parseSpawnFlags(c.in) |
| 197 | - | if m != c.wantModel || p != c.wantProv || task != c.wantTask { | 200 | + | if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask { |
| 198 | - | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)", | 201 | + | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)", |
| 199 | - | c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask) | 202 | + | c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask) |
| 200 | } | 203 | } | ||
| 201 | } | 204 | } | ||
| 202 | } | 205 | } | ||
M packages/agent/settings_store.go +1 -1
| @@ -183,7 +183,7 @@ func AutoSwarmEnabled() bool { | |||
| 183 | 183 | // by name. Kept short so it doesn't bloat the cached prompt prefix. | |
| 184 | 184 | const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory. | |
| 185 | 185 | ||
| 186 | - | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | |
| 186 | + | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | |
| 187 | 187 | ||
| 188 | 188 | Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. | |
| 189 | 189 | ||
| @@ -183,7 +183,7 @@ func AutoSwarmEnabled() bool { | |||||
| 183 | // by name. Kept short so it doesn't bloat the cached prompt prefix. | 183 | // by name. Kept short so it doesn't bloat the cached prompt prefix. | ||
| 184 | const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory. | 184 | const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory. | ||
| 185 | 185 | ||||
| 186 | - | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | 186 | + | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. |
| 187 | 187 | ||||
| 188 | Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. | 188 | Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. | ||
| 189 | 189 | ||||
A packages/agent/subagents/profiles.go +354 -0
| @@ -0,0 +1,354 @@ | |||
| 1 | + | // Package subagents discovers named agent profiles that can be selected by | |
| 2 | + | // zot's swarm supervisor. Profiles use the common markdown/frontmatter layout: | |
| 3 | + | // a YAML-like metadata block followed by the agent's system prompt. | |
| 4 | + | // | |
| 5 | + | // Discovery prefers explicitly configured directories, then the shared | |
| 6 | + | // ~/.agents/agents directory, and finally the optional ~/.pi/agent/agents | |
| 7 | + | // compatibility directory. Profiles are metadata plus instructions; the main | |
| 8 | + | // agent sees only the compact manifest, while a selected child loads the full | |
| 9 | + | // profile itself. | |
| 10 | + | package subagents | |
| 11 | + | ||
| 12 | + | import ( | |
| 13 | + | "fmt" | |
| 14 | + | "os" | |
| 15 | + | "path/filepath" | |
| 16 | + | "sort" | |
| 17 | + | "strconv" | |
| 18 | + | "strings" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const maxProfileBytes = 1 << 20 | |
| 22 | + | ||
| 23 | + | // Profile is one discovered named subagent definition. | |
| 24 | + | type Profile struct { | |
| 25 | + | Name string | |
| 26 | + | Description string | |
| 27 | + | SystemPrompt string | |
| 28 | + | Tools []string | |
| 29 | + | Model string | |
| 30 | + | Provider string | |
| 31 | + | Thinking string | |
| 32 | + | ||
| 33 | + | // SystemPromptMode is "append" (the default) or "replace". | |
| 34 | + | SystemPromptMode string | |
| 35 | + | ||
| 36 | + | // Nil means the profile did not specify the inheritance behavior. | |
| 37 | + | InheritProjectContext *bool | |
| 38 | + | InheritSkills *bool | |
| 39 | + | ||
| 40 | + | Path string | |
| 41 | + | Source string | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Discover returns profiles in precedence order, sorted by name. A profile | |
| 45 | + | // with the same name in a higher-priority directory shadows lower-priority | |
| 46 | + | // copies. Missing directories are normal and do not produce errors. | |
| 47 | + | func Discover(cwd, userHome string) ([]*Profile, []error) { | |
| 48 | + | seen := make(map[string]*Profile) | |
| 49 | + | var errs []error | |
| 50 | + | ||
| 51 | + | for _, loc := range searchDirs(cwd, userHome) { | |
| 52 | + | entries, err := os.ReadDir(loc.dir) | |
| 53 | + | if err != nil { | |
| 54 | + | if !os.IsNotExist(err) { | |
| 55 | + | errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err)) | |
| 56 | + | } | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | for _, entry := range entries { | |
| 60 | + | if !entry.Type().IsRegular() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".md") { | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | path := filepath.Join(loc.dir, entry.Name()) | |
| 64 | + | profile, err := load(path, loc.label) | |
| 65 | + | if err != nil { | |
| 66 | + | if !os.IsNotExist(err) { | |
| 67 | + | errs = append(errs, fmt.Errorf("read subagent profile %s: %w", path, err)) | |
| 68 | + | } | |
| 69 | + | continue | |
| 70 | + | } | |
| 71 | + | if !validName(profile.Name) || profile.SystemPrompt == "" { | |
| 72 | + | continue | |
| 73 | + | } | |
| 74 | + | if _, exists := seen[profile.Name]; exists { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | seen[profile.Name] = profile | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | out := make([]*Profile, 0, len(seen)) | |
| 82 | + | for _, profile := range seen { | |
| 83 | + | out = append(out, profile) | |
| 84 | + | } | |
| 85 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 86 | + | return out, errs | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Find returns a profile by its exact name, or nil when it is unavailable. | |
| 90 | + | func Find(profiles []*Profile, name string) *Profile { | |
| 91 | + | name = strings.TrimSpace(name) | |
| 92 | + | for _, profile := range profiles { | |
| 93 | + | if profile != nil && profile.Name == name { | |
| 94 | + | return profile | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // ModelSelection splits a provider-qualified model value such as | |
| 101 | + | // "openai-codex/gpt-5.6-luna". A model id may itself contain slashes, so only | |
| 102 | + | // the first slash is treated as the provider separator. | |
| 103 | + | func (p *Profile) ModelSelection() (provider, model string) { | |
| 104 | + | if p == nil { | |
| 105 | + | return "", "" | |
| 106 | + | } | |
| 107 | + | provider = strings.TrimSpace(p.Provider) | |
| 108 | + | model = strings.TrimSpace(p.Model) | |
| 109 | + | if provider == "" { | |
| 110 | + | if before, after, ok := strings.Cut(model, "/"); ok { | |
| 111 | + | provider, model = strings.TrimSpace(before), strings.TrimSpace(after) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return provider, model | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // SystemPromptAddendum renders the compact [subagents_list] manifest for the | |
| 118 | + | // parent model. Full profile bodies are intentionally omitted: the selected | |
| 119 | + | // child loads its own instructions, and keeping them out of every parent turn | |
| 120 | + | // avoids unnecessary context and accidental instruction mixing. | |
| 121 | + | func SystemPromptAddendum(profiles []*Profile) string { | |
| 122 | + | if len(profiles) == 0 { | |
| 123 | + | return "" | |
| 124 | + | } | |
| 125 | + | var sb strings.Builder | |
| 126 | + | sb.WriteString("[subagents_list]\n") | |
| 127 | + | sb.WriteString("Named subagents available to swarm_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, and tool limits are applied to the child.\n") | |
| 128 | + | for _, profile := range profiles { | |
| 129 | + | if profile == nil { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | name := manifestValue(profile.Name) | |
| 133 | + | description := manifestValue(profile.Description) | |
| 134 | + | if description == "" { | |
| 135 | + | description = "(no description)" | |
| 136 | + | } | |
| 137 | + | metadata := make([]string, 0, 4) | |
| 138 | + | if model := manifestValue(profile.Model); model != "" { | |
| 139 | + | metadata = append(metadata, "model="+model) | |
| 140 | + | } | |
| 141 | + | if provider := manifestValue(profile.Provider); provider != "" { | |
| 142 | + | metadata = append(metadata, "provider="+provider) | |
| 143 | + | } | |
| 144 | + | if thinking := manifestValue(profile.Thinking); thinking != "" { | |
| 145 | + | metadata = append(metadata, "thinking="+thinking) | |
| 146 | + | } | |
| 147 | + | if len(profile.Tools) > 0 { | |
| 148 | + | metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ","))) | |
| 149 | + | } | |
| 150 | + | if len(metadata) > 0 { | |
| 151 | + | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | |
| 152 | + | } else { | |
| 153 | + | fmt.Fprintf(&sb, "- %s: %s\n", name, description) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | sb.WriteString("[/subagents_list]") | |
| 157 | + | return sb.String() | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | type location struct { | |
| 161 | + | dir string | |
| 162 | + | label string | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func searchDirs(cwd, userHome string) []location { | |
| 166 | + | var out []location | |
| 167 | + | add := func(dir, label string) { | |
| 168 | + | if strings.TrimSpace(dir) != "" { | |
| 169 | + | out = append(out, location{dir: dir, label: label}) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | if extra := os.Getenv("ZOT_AGENT_PROFILES"); extra != "" { | |
| 173 | + | for _, dir := range filepath.SplitList(extra) { | |
| 174 | + | add(dir, "configured") | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | if userHome != "" { | |
| 178 | + | add(filepath.Join(userHome, ".agents", "agents"), "global (agents)") | |
| 179 | + | // This is an additional compatibility location. The shared .agents | |
| 180 | + | // path above remains zot's documented default. | |
| 181 | + | add(filepath.Join(userHome, ".pi", "agent", "agents"), "global (pi)") | |
| 182 | + | } | |
| 183 | + | return out | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func load(path, source string) (*Profile, error) { | |
| 187 | + | info, err := os.Stat(path) | |
| 188 | + | if err != nil { | |
| 189 | + | return nil, err | |
| 190 | + | } | |
| 191 | + | if !info.Mode().IsRegular() { | |
| 192 | + | return nil, fmt.Errorf("not a regular file") | |
| 193 | + | } | |
| 194 | + | if info.Size() > maxProfileBytes { | |
| 195 | + | return nil, fmt.Errorf("profile is larger than %d bytes", maxProfileBytes) | |
| 196 | + | } | |
| 197 | + | raw, err := os.ReadFile(path) | |
| 198 | + | if err != nil { | |
| 199 | + | return nil, err | |
| 200 | + | } | |
| 201 | + | front, body := splitFrontmatter(string(raw)) | |
| 202 | + | values, lists := parseFrontmatter(front) | |
| 203 | + | name := unquote(values["name"]) | |
| 204 | + | if name == "" { | |
| 205 | + | name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | |
| 206 | + | } | |
| 207 | + | thinking := unquote(values["thinking"]) | |
| 208 | + | if thinking == "" { | |
| 209 | + | thinking = unquote(values["reasoning"]) | |
| 210 | + | } | |
| 211 | + | profile := &Profile{ | |
| 212 | + | Name: strings.TrimSpace(name), | |
| 213 | + | Description: strings.TrimSpace(unquote(values["description"])), | |
| 214 | + | SystemPrompt: strings.TrimSpace(body), | |
| 215 | + | Tools: lists["tools"], | |
| 216 | + | Model: unquote(values["model"]), | |
| 217 | + | Provider: unquote(values["provider"]), | |
| 218 | + | Thinking: thinking, | |
| 219 | + | Path: path, | |
| 220 | + | Source: source, | |
| 221 | + | } | |
| 222 | + | profile.SystemPromptMode = "append" | |
| 223 | + | if strings.EqualFold(unquote(values["systempromptmode"]), "replace") { | |
| 224 | + | profile.SystemPromptMode = "replace" | |
| 225 | + | } | |
| 226 | + | if value, ok := parseOptionalBool(values["inheritprojectcontext"]); ok { | |
| 227 | + | profile.InheritProjectContext = &value | |
| 228 | + | } | |
| 229 | + | if value, ok := parseOptionalBool(values["inheritskills"]); ok { | |
| 230 | + | profile.InheritSkills = &value | |
| 231 | + | } | |
| 232 | + | return profile, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func splitFrontmatter(raw string) (front, body string) { | |
| 236 | + | rest := strings.TrimLeft(raw, " \t\r\n") | |
| 237 | + | firstEnd := strings.IndexByte(rest, '\n') | |
| 238 | + | if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" { | |
| 239 | + | return "", raw | |
| 240 | + | } | |
| 241 | + | rest = rest[firstEnd+1:] | |
| 242 | + | for offset := 0; offset <= len(rest); { | |
| 243 | + | rel := strings.Index(rest[offset:], "\n---") | |
| 244 | + | if rel < 0 { | |
| 245 | + | // A document that starts a frontmatter block but never closes | |
| 246 | + | // it is malformed, not a body-only profile. | |
| 247 | + | return "", "" | |
| 248 | + | } | |
| 249 | + | end := offset + rel | |
| 250 | + | after := end + len("\n---") | |
| 251 | + | if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' { | |
| 252 | + | return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n") | |
| 253 | + | } | |
| 254 | + | offset = after | |
| 255 | + | } | |
| 256 | + | return "", raw | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func parseFrontmatter(front string) (map[string]string, map[string][]string) { | |
| 260 | + | values := make(map[string]string) | |
| 261 | + | lists := make(map[string][]string) | |
| 262 | + | lines := strings.Split(front, "\n") | |
| 263 | + | for i := 0; i < len(lines); i++ { | |
| 264 | + | line := lines[i] | |
| 265 | + | trimmed := strings.TrimSpace(line) | |
| 266 | + | if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { | |
| 267 | + | continue | |
| 268 | + | } | |
| 269 | + | colon := strings.IndexByte(trimmed, ':') | |
| 270 | + | if colon < 0 { | |
| 271 | + | continue | |
| 272 | + | } | |
| 273 | + | key := normalizeKey(trimmed[:colon]) | |
| 274 | + | value := strings.TrimSpace(trimmed[colon+1:]) | |
| 275 | + | if value == "" { | |
| 276 | + | var items []string | |
| 277 | + | for j := i + 1; j < len(lines); j++ { | |
| 278 | + | next := strings.TrimSpace(lines[j]) | |
| 279 | + | if next == "" { | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | if !strings.HasPrefix(next, "-") || (len(lines[j]) > 0 && !strings.HasPrefix(lines[j], " ") && !strings.HasPrefix(lines[j], "\t")) { | |
| 283 | + | break | |
| 284 | + | } | |
| 285 | + | items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-")))) | |
| 286 | + | i = j | |
| 287 | + | } | |
| 288 | + | if len(items) > 0 { | |
| 289 | + | lists[key] = items | |
| 290 | + | } | |
| 291 | + | continue | |
| 292 | + | } | |
| 293 | + | if key == "tools" { | |
| 294 | + | lists[key] = parseList(value) | |
| 295 | + | continue | |
| 296 | + | } | |
| 297 | + | values[key] = unquote(value) | |
| 298 | + | } | |
| 299 | + | return values, lists | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func normalizeKey(key string) string { | |
| 303 | + | key = strings.ToLower(strings.TrimSpace(key)) | |
| 304 | + | key = strings.ReplaceAll(key, "-", "") | |
| 305 | + | key = strings.ReplaceAll(key, "_", "") | |
| 306 | + | return key | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func parseList(value string) []string { | |
| 310 | + | value = strings.TrimSpace(value) | |
| 311 | + | value = strings.TrimPrefix(value, "[") | |
| 312 | + | value = strings.TrimSuffix(value, "]") | |
| 313 | + | if value == "" { | |
| 314 | + | return nil | |
| 315 | + | } | |
| 316 | + | parts := strings.Split(value, ",") | |
| 317 | + | out := make([]string, 0, len(parts)) | |
| 318 | + | for _, part := range parts { | |
| 319 | + | if item := strings.TrimSpace(unquote(part)); item != "" { | |
| 320 | + | out = append(out, item) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | return out | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func parseOptionalBool(value string) (bool, bool) { | |
| 327 | + | parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value))) | |
| 328 | + | return parsed, err == nil | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func validName(name string) bool { | |
| 332 | + | name = strings.TrimSpace(name) | |
| 333 | + | if name == "" || len(name) > 128 { | |
| 334 | + | return false | |
| 335 | + | } | |
| 336 | + | for _, r := range name { | |
| 337 | + | if r == '/' || r == '\\' || r == '\x00' || r < 0x20 { | |
| 338 | + | return false | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | return true | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func manifestValue(value string) string { | |
| 345 | + | return strings.Join(strings.Fields(value), " ") | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func unquote(value string) string { | |
| 349 | + | value = strings.TrimSpace(value) | |
| 350 | + | if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { | |
| 351 | + | return value[1 : len(value)-1] | |
| 352 | + | } | |
| 353 | + | return value | |
| 354 | + | } | |
| @@ -0,0 +1,354 @@ | |||||
| 1 | + | // Package subagents discovers named agent profiles that can be selected by | |||
| 2 | + | // zot's swarm supervisor. Profiles use the common markdown/frontmatter layout: | |||
| 3 | + | // a YAML-like metadata block followed by the agent's system prompt. | |||
| 4 | + | // | |||
| 5 | + | // Discovery prefers explicitly configured directories, then the shared | |||
| 6 | + | // ~/.agents/agents directory, and finally the optional ~/.pi/agent/agents | |||
| 7 | + | // compatibility directory. Profiles are metadata plus instructions; the main | |||
| 8 | + | // agent sees only the compact manifest, while a selected child loads the full | |||
| 9 | + | // profile itself. | |||
| 10 | + | package subagents | |||
| 11 | + | ||||
| 12 | + | import ( | |||
| 13 | + | "fmt" | |||
| 14 | + | "os" | |||
| 15 | + | "path/filepath" | |||
| 16 | + | "sort" | |||
| 17 | + | "strconv" | |||
| 18 | + | "strings" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | const maxProfileBytes = 1 << 20 | |||
| 22 | + | ||||
| 23 | + | // Profile is one discovered named subagent definition. | |||
| 24 | + | type Profile struct { | |||
| 25 | + | Name string | |||
| 26 | + | Description string | |||
| 27 | + | SystemPrompt string | |||
| 28 | + | Tools []string | |||
| 29 | + | Model string | |||
| 30 | + | Provider string | |||
| 31 | + | Thinking string | |||
| 32 | + | ||||
| 33 | + | // SystemPromptMode is "append" (the default) or "replace". | |||
| 34 | + | SystemPromptMode string | |||
| 35 | + | ||||
| 36 | + | // Nil means the profile did not specify the inheritance behavior. | |||
| 37 | + | InheritProjectContext *bool | |||
| 38 | + | InheritSkills *bool | |||
| 39 | + | ||||
| 40 | + | Path string | |||
| 41 | + | Source string | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Discover returns profiles in precedence order, sorted by name. A profile | |||
| 45 | + | // with the same name in a higher-priority directory shadows lower-priority | |||
| 46 | + | // copies. Missing directories are normal and do not produce errors. | |||
| 47 | + | func Discover(cwd, userHome string) ([]*Profile, []error) { | |||
| 48 | + | seen := make(map[string]*Profile) | |||
| 49 | + | var errs []error | |||
| 50 | + | ||||
| 51 | + | for _, loc := range searchDirs(cwd, userHome) { | |||
| 52 | + | entries, err := os.ReadDir(loc.dir) | |||
| 53 | + | if err != nil { | |||
| 54 | + | if !os.IsNotExist(err) { | |||
| 55 | + | errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err)) | |||
| 56 | + | } | |||
| 57 | + | continue | |||
| 58 | + | } | |||
| 59 | + | for _, entry := range entries { | |||
| 60 | + | if !entry.Type().IsRegular() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".md") { | |||
| 61 | + | continue | |||
| 62 | + | } | |||
| 63 | + | path := filepath.Join(loc.dir, entry.Name()) | |||
| 64 | + | profile, err := load(path, loc.label) | |||
| 65 | + | if err != nil { | |||
| 66 | + | if !os.IsNotExist(err) { | |||
| 67 | + | errs = append(errs, fmt.Errorf("read subagent profile %s: %w", path, err)) | |||
| 68 | + | } | |||
| 69 | + | continue | |||
| 70 | + | } | |||
| 71 | + | if !validName(profile.Name) || profile.SystemPrompt == "" { | |||
| 72 | + | continue | |||
| 73 | + | } | |||
| 74 | + | if _, exists := seen[profile.Name]; exists { | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | seen[profile.Name] = profile | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | out := make([]*Profile, 0, len(seen)) | |||
| 82 | + | for _, profile := range seen { | |||
| 83 | + | out = append(out, profile) | |||
| 84 | + | } | |||
| 85 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |||
| 86 | + | return out, errs | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Find returns a profile by its exact name, or nil when it is unavailable. | |||
| 90 | + | func Find(profiles []*Profile, name string) *Profile { | |||
| 91 | + | name = strings.TrimSpace(name) | |||
| 92 | + | for _, profile := range profiles { | |||
| 93 | + | if profile != nil && profile.Name == name { | |||
| 94 | + | return profile | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | return nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // ModelSelection splits a provider-qualified model value such as | |||
| 101 | + | // "openai-codex/gpt-5.6-luna". A model id may itself contain slashes, so only | |||
| 102 | + | // the first slash is treated as the provider separator. | |||
| 103 | + | func (p *Profile) ModelSelection() (provider, model string) { | |||
| 104 | + | if p == nil { | |||
| 105 | + | return "", "" | |||
| 106 | + | } | |||
| 107 | + | provider = strings.TrimSpace(p.Provider) | |||
| 108 | + | model = strings.TrimSpace(p.Model) | |||
| 109 | + | if provider == "" { | |||
| 110 | + | if before, after, ok := strings.Cut(model, "/"); ok { | |||
| 111 | + | provider, model = strings.TrimSpace(before), strings.TrimSpace(after) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | return provider, model | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // SystemPromptAddendum renders the compact [subagents_list] manifest for the | |||
| 118 | + | // parent model. Full profile bodies are intentionally omitted: the selected | |||
| 119 | + | // child loads its own instructions, and keeping them out of every parent turn | |||
| 120 | + | // avoids unnecessary context and accidental instruction mixing. | |||
| 121 | + | func SystemPromptAddendum(profiles []*Profile) string { | |||
| 122 | + | if len(profiles) == 0 { | |||
| 123 | + | return "" | |||
| 124 | + | } | |||
| 125 | + | var sb strings.Builder | |||
| 126 | + | sb.WriteString("[subagents_list]\n") | |||
| 127 | + | sb.WriteString("Named subagents available to swarm_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, and tool limits are applied to the child.\n") | |||
| 128 | + | for _, profile := range profiles { | |||
| 129 | + | if profile == nil { | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | name := manifestValue(profile.Name) | |||
| 133 | + | description := manifestValue(profile.Description) | |||
| 134 | + | if description == "" { | |||
| 135 | + | description = "(no description)" | |||
| 136 | + | } | |||
| 137 | + | metadata := make([]string, 0, 4) | |||
| 138 | + | if model := manifestValue(profile.Model); model != "" { | |||
| 139 | + | metadata = append(metadata, "model="+model) | |||
| 140 | + | } | |||
| 141 | + | if provider := manifestValue(profile.Provider); provider != "" { | |||
| 142 | + | metadata = append(metadata, "provider="+provider) | |||
| 143 | + | } | |||
| 144 | + | if thinking := manifestValue(profile.Thinking); thinking != "" { | |||
| 145 | + | metadata = append(metadata, "thinking="+thinking) | |||
| 146 | + | } | |||
| 147 | + | if len(profile.Tools) > 0 { | |||
| 148 | + | metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ","))) | |||
| 149 | + | } | |||
| 150 | + | if len(metadata) > 0 { | |||
| 151 | + | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | |||
| 152 | + | } else { | |||
| 153 | + | fmt.Fprintf(&sb, "- %s: %s\n", name, description) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | sb.WriteString("[/subagents_list]") | |||
| 157 | + | return sb.String() | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | type location struct { | |||
| 161 | + | dir string | |||
| 162 | + | label string | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func searchDirs(cwd, userHome string) []location { | |||
| 166 | + | var out []location | |||
| 167 | + | add := func(dir, label string) { | |||
| 168 | + | if strings.TrimSpace(dir) != "" { | |||
| 169 | + | out = append(out, location{dir: dir, label: label}) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | if extra := os.Getenv("ZOT_AGENT_PROFILES"); extra != "" { | |||
| 173 | + | for _, dir := range filepath.SplitList(extra) { | |||
| 174 | + | add(dir, "configured") | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | if userHome != "" { | |||
| 178 | + | add(filepath.Join(userHome, ".agents", "agents"), "global (agents)") | |||
| 179 | + | // This is an additional compatibility location. The shared .agents | |||
| 180 | + | // path above remains zot's documented default. | |||
| 181 | + | add(filepath.Join(userHome, ".pi", "agent", "agents"), "global (pi)") | |||
| 182 | + | } | |||
| 183 | + | return out | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func load(path, source string) (*Profile, error) { | |||
| 187 | + | info, err := os.Stat(path) | |||
| 188 | + | if err != nil { | |||
| 189 | + | return nil, err | |||
| 190 | + | } | |||
| 191 | + | if !info.Mode().IsRegular() { | |||
| 192 | + | return nil, fmt.Errorf("not a regular file") | |||
| 193 | + | } | |||
| 194 | + | if info.Size() > maxProfileBytes { | |||
| 195 | + | return nil, fmt.Errorf("profile is larger than %d bytes", maxProfileBytes) | |||
| 196 | + | } | |||
| 197 | + | raw, err := os.ReadFile(path) | |||
| 198 | + | if err != nil { | |||
| 199 | + | return nil, err | |||
| 200 | + | } | |||
| 201 | + | front, body := splitFrontmatter(string(raw)) | |||
| 202 | + | values, lists := parseFrontmatter(front) | |||
| 203 | + | name := unquote(values["name"]) | |||
| 204 | + | if name == "" { | |||
| 205 | + | name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | |||
| 206 | + | } | |||
| 207 | + | thinking := unquote(values["thinking"]) | |||
| 208 | + | if thinking == "" { | |||
| 209 | + | thinking = unquote(values["reasoning"]) | |||
| 210 | + | } | |||
| 211 | + | profile := &Profile{ | |||
| 212 | + | Name: strings.TrimSpace(name), | |||
| 213 | + | Description: strings.TrimSpace(unquote(values["description"])), | |||
| 214 | + | SystemPrompt: strings.TrimSpace(body), | |||
| 215 | + | Tools: lists["tools"], | |||
| 216 | + | Model: unquote(values["model"]), | |||
| 217 | + | Provider: unquote(values["provider"]), | |||
| 218 | + | Thinking: thinking, | |||
| 219 | + | Path: path, | |||
| 220 | + | Source: source, | |||
| 221 | + | } | |||
| 222 | + | profile.SystemPromptMode = "append" | |||
| 223 | + | if strings.EqualFold(unquote(values["systempromptmode"]), "replace") { | |||
| 224 | + | profile.SystemPromptMode = "replace" | |||
| 225 | + | } | |||
| 226 | + | if value, ok := parseOptionalBool(values["inheritprojectcontext"]); ok { | |||
| 227 | + | profile.InheritProjectContext = &value | |||
| 228 | + | } | |||
| 229 | + | if value, ok := parseOptionalBool(values["inheritskills"]); ok { | |||
| 230 | + | profile.InheritSkills = &value | |||
| 231 | + | } | |||
| 232 | + | return profile, nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func splitFrontmatter(raw string) (front, body string) { | |||
| 236 | + | rest := strings.TrimLeft(raw, " \t\r\n") | |||
| 237 | + | firstEnd := strings.IndexByte(rest, '\n') | |||
| 238 | + | if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" { | |||
| 239 | + | return "", raw | |||
| 240 | + | } | |||
| 241 | + | rest = rest[firstEnd+1:] | |||
| 242 | + | for offset := 0; offset <= len(rest); { | |||
| 243 | + | rel := strings.Index(rest[offset:], "\n---") | |||
| 244 | + | if rel < 0 { | |||
| 245 | + | // A document that starts a frontmatter block but never closes | |||
| 246 | + | // it is malformed, not a body-only profile. | |||
| 247 | + | return "", "" | |||
| 248 | + | } | |||
| 249 | + | end := offset + rel | |||
| 250 | + | after := end + len("\n---") | |||
| 251 | + | if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' { | |||
| 252 | + | return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n") | |||
| 253 | + | } | |||
| 254 | + | offset = after | |||
| 255 | + | } | |||
| 256 | + | return "", raw | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func parseFrontmatter(front string) (map[string]string, map[string][]string) { | |||
| 260 | + | values := make(map[string]string) | |||
| 261 | + | lists := make(map[string][]string) | |||
| 262 | + | lines := strings.Split(front, "\n") | |||
| 263 | + | for i := 0; i < len(lines); i++ { | |||
| 264 | + | line := lines[i] | |||
| 265 | + | trimmed := strings.TrimSpace(line) | |||
| 266 | + | if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { | |||
| 267 | + | continue | |||
| 268 | + | } | |||
| 269 | + | colon := strings.IndexByte(trimmed, ':') | |||
| 270 | + | if colon < 0 { | |||
| 271 | + | continue | |||
| 272 | + | } | |||
| 273 | + | key := normalizeKey(trimmed[:colon]) | |||
| 274 | + | value := strings.TrimSpace(trimmed[colon+1:]) | |||
| 275 | + | if value == "" { | |||
| 276 | + | var items []string | |||
| 277 | + | for j := i + 1; j < len(lines); j++ { | |||
| 278 | + | next := strings.TrimSpace(lines[j]) | |||
| 279 | + | if next == "" { | |||
| 280 | + | continue | |||
| 281 | + | } | |||
| 282 | + | if !strings.HasPrefix(next, "-") || (len(lines[j]) > 0 && !strings.HasPrefix(lines[j], " ") && !strings.HasPrefix(lines[j], "\t")) { | |||
| 283 | + | break | |||
| 284 | + | } | |||
| 285 | + | items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-")))) | |||
| 286 | + | i = j | |||
| 287 | + | } | |||
| 288 | + | if len(items) > 0 { | |||
| 289 | + | lists[key] = items | |||
| 290 | + | } | |||
| 291 | + | continue | |||
| 292 | + | } | |||
| 293 | + | if key == "tools" { | |||
| 294 | + | lists[key] = parseList(value) | |||
| 295 | + | continue | |||
| 296 | + | } | |||
| 297 | + | values[key] = unquote(value) | |||
| 298 | + | } | |||
| 299 | + | return values, lists | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func normalizeKey(key string) string { | |||
| 303 | + | key = strings.ToLower(strings.TrimSpace(key)) | |||
| 304 | + | key = strings.ReplaceAll(key, "-", "") | |||
| 305 | + | key = strings.ReplaceAll(key, "_", "") | |||
| 306 | + | return key | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func parseList(value string) []string { | |||
| 310 | + | value = strings.TrimSpace(value) | |||
| 311 | + | value = strings.TrimPrefix(value, "[") | |||
| 312 | + | value = strings.TrimSuffix(value, "]") | |||
| 313 | + | if value == "" { | |||
| 314 | + | return nil | |||
| 315 | + | } | |||
| 316 | + | parts := strings.Split(value, ",") | |||
| 317 | + | out := make([]string, 0, len(parts)) | |||
| 318 | + | for _, part := range parts { | |||
| 319 | + | if item := strings.TrimSpace(unquote(part)); item != "" { | |||
| 320 | + | out = append(out, item) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | return out | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func parseOptionalBool(value string) (bool, bool) { | |||
| 327 | + | parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value))) | |||
| 328 | + | return parsed, err == nil | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func validName(name string) bool { | |||
| 332 | + | name = strings.TrimSpace(name) | |||
| 333 | + | if name == "" || len(name) > 128 { | |||
| 334 | + | return false | |||
| 335 | + | } | |||
| 336 | + | for _, r := range name { | |||
| 337 | + | if r == '/' || r == '\\' || r == '\x00' || r < 0x20 { | |||
| 338 | + | return false | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | return true | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func manifestValue(value string) string { | |||
| 345 | + | return strings.Join(strings.Fields(value), " ") | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func unquote(value string) string { | |||
| 349 | + | value = strings.TrimSpace(value) | |||
| 350 | + | if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { | |||
| 351 | + | return value[1 : len(value)-1] | |||
| 352 | + | } | |||
| 353 | + | return value | |||
| 354 | + | } | |||
A packages/agent/subagents/profiles_test.go +147 -0
| @@ -0,0 +1,147 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDiscoverLoadsGlobalAgentsProfilesAndPiFallback(t *testing.T) { | |
| 11 | + | home := t.TempDir() | |
| 12 | + | agentsDir := filepath.Join(home, ".agents", "agents") | |
| 13 | + | piDir := filepath.Join(home, ".pi", "agent", "agents") | |
| 14 | + | if err := os.MkdirAll(agentsDir, 0o755); err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | if err := os.MkdirAll(piDir, 0o755); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | writeProfile(t, filepath.Join(agentsDir, "reviewer.md"), `--- | |
| 21 | + | name: reviewer | |
| 22 | + | description: Read-only code reviewer | |
| 23 | + | tools: read | |
| 24 | + | model: openai-codex/gpt-5.6-luna | |
| 25 | + | thinking: max | |
| 26 | + | systemPromptMode: replace | |
| 27 | + | inheritProjectContext: false | |
| 28 | + | inheritSkills: false | |
| 29 | + | --- | |
| 30 | + | Review the requested scope without editing it. | |
| 31 | + | `) | |
| 32 | + | writeProfile(t, filepath.Join(piDir, "implementer.md"), `--- | |
| 33 | + | name: implementer | |
| 34 | + | description: Primary implementation agent | |
| 35 | + | tools: [read, bash, edit, write] | |
| 36 | + | model: openai-codex/gpt-5.6-luna | |
| 37 | + | thinking: xhigh | |
| 38 | + | --- | |
| 39 | + | Implement the requested change. | |
| 40 | + | `) | |
| 41 | + | ||
| 42 | + | profiles, errs := Discover("", home) | |
| 43 | + | if len(errs) != 0 { | |
| 44 | + | t.Fatalf("Discover errors = %v", errs) | |
| 45 | + | } | |
| 46 | + | if len(profiles) != 2 { | |
| 47 | + | t.Fatalf("profiles = %d, want 2: %#v", len(profiles), profiles) | |
| 48 | + | } | |
| 49 | + | if profiles[0].Name != "implementer" || profiles[1].Name != "reviewer" { | |
| 50 | + | t.Fatalf("profiles not sorted by name: %#v", profiles) | |
| 51 | + | } | |
| 52 | + | reviewer := Find(profiles, "reviewer") | |
| 53 | + | if reviewer == nil { | |
| 54 | + | t.Fatal("reviewer profile missing") | |
| 55 | + | } | |
| 56 | + | if reviewer.SystemPromptMode != "replace" { | |
| 57 | + | t.Fatalf("system prompt mode = %q, want replace", reviewer.SystemPromptMode) | |
| 58 | + | } | |
| 59 | + | if reviewer.Model != "openai-codex/gpt-5.6-luna" || reviewer.Thinking != "max" { | |
| 60 | + | t.Fatalf("profile metadata = %#v", reviewer) | |
| 61 | + | } | |
| 62 | + | if reviewer.InheritProjectContext == nil || *reviewer.InheritProjectContext { | |
| 63 | + | t.Fatalf("inherit project context = %#v, want false", reviewer.InheritProjectContext) | |
| 64 | + | } | |
| 65 | + | if reviewer.InheritSkills == nil || *reviewer.InheritSkills { | |
| 66 | + | t.Fatalf("inherit skills = %#v, want false", reviewer.InheritSkills) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestDiscoverConfiguredDirectoryWinsAndRejectsUnsafeNames(t *testing.T) { | |
| 71 | + | configured := t.TempDir() | |
| 72 | + | home := t.TempDir() | |
| 73 | + | t.Setenv("ZOT_AGENT_PROFILES", configured) | |
| 74 | + | writeProfile(t, filepath.Join(configured, "reviewer.md"), `--- | |
| 75 | + | name: reviewer | |
| 76 | + | description: Configured reviewer | |
| 77 | + | --- | |
| 78 | + | Configured instructions. | |
| 79 | + | `) | |
| 80 | + | writeProfile(t, filepath.Join(filepath.Join(home, ".agents", "agents"), "reviewer.md"), `--- | |
| 81 | + | name: reviewer | |
| 82 | + | description: Global reviewer | |
| 83 | + | --- | |
| 84 | + | Global instructions. | |
| 85 | + | `) | |
| 86 | + | writeProfile(t, filepath.Join(configured, "unsafe.md"), `--- | |
| 87 | + | name: ../unsafe | |
| 88 | + | description: Should not load | |
| 89 | + | --- | |
| 90 | + | No. | |
| 91 | + | `) | |
| 92 | + | ||
| 93 | + | profiles, errs := Discover("", home) | |
| 94 | + | if len(errs) != 0 { | |
| 95 | + | t.Fatalf("Discover errors = %v", errs) | |
| 96 | + | } | |
| 97 | + | if len(profiles) != 1 || profiles[0].Description != "Configured reviewer" { | |
| 98 | + | t.Fatalf("configured precedence = %#v", profiles) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestProfileModelSelection(t *testing.T) { | |
| 103 | + | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | |
| 104 | + | provider, model := profile.ModelSelection() | |
| 105 | + | if provider != "openrouter" || model != "anthropic/claude-sonnet" { | |
| 106 | + | t.Fatalf("selection = (%q, %q)", provider, model) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | profile = &Profile{Provider: "openai", Model: "gpt-5"} | |
| 110 | + | provider, model = profile.ModelSelection() | |
| 111 | + | if provider != "openai" || model != "gpt-5" { | |
| 112 | + | t.Fatalf("explicit selection = (%q, %q)", provider, model) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) { | |
| 117 | + | profiles := []*Profile{{ | |
| 118 | + | Name: "reviewer", | |
| 119 | + | Description: "Read-only\nreviewer", | |
| 120 | + | SystemPrompt: "secret instructions that belong only to the child", | |
| 121 | + | Model: "openai-codex/gpt-5", | |
| 122 | + | Thinking: "max", | |
| 123 | + | Tools: []string{"read", "bash"}, | |
| 124 | + | Path: "/private/profile.md", | |
| 125 | + | }} | |
| 126 | + | got := SystemPromptAddendum(profiles) | |
| 127 | + | for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max"} { | |
| 128 | + | if !strings.Contains(got, want) { | |
| 129 | + | t.Fatalf("addendum missing %q:\n%s", want, got) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | for _, unwanted := range []string{"secret instructions", "/private/profile.md", "\nreviewer"} { | |
| 133 | + | if strings.Contains(got, unwanted) { | |
| 134 | + | t.Fatalf("addendum contains %q:\n%s", unwanted, got) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func writeProfile(t *testing.T, path, content string) { | |
| 140 | + | t.Helper() | |
| 141 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | if err := os.WriteFile(path, []byte(content), 0o600); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | } | |
| @@ -0,0 +1,147 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestDiscoverLoadsGlobalAgentsProfilesAndPiFallback(t *testing.T) { | |||
| 11 | + | home := t.TempDir() | |||
| 12 | + | agentsDir := filepath.Join(home, ".agents", "agents") | |||
| 13 | + | piDir := filepath.Join(home, ".pi", "agent", "agents") | |||
| 14 | + | if err := os.MkdirAll(agentsDir, 0o755); err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | if err := os.MkdirAll(piDir, 0o755); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | writeProfile(t, filepath.Join(agentsDir, "reviewer.md"), `--- | |||
| 21 | + | name: reviewer | |||
| 22 | + | description: Read-only code reviewer | |||
| 23 | + | tools: read | |||
| 24 | + | model: openai-codex/gpt-5.6-luna | |||
| 25 | + | thinking: max | |||
| 26 | + | systemPromptMode: replace | |||
| 27 | + | inheritProjectContext: false | |||
| 28 | + | inheritSkills: false | |||
| 29 | + | --- | |||
| 30 | + | Review the requested scope without editing it. | |||
| 31 | + | `) | |||
| 32 | + | writeProfile(t, filepath.Join(piDir, "implementer.md"), `--- | |||
| 33 | + | name: implementer | |||
| 34 | + | description: Primary implementation agent | |||
| 35 | + | tools: [read, bash, edit, write] | |||
| 36 | + | model: openai-codex/gpt-5.6-luna | |||
| 37 | + | thinking: xhigh | |||
| 38 | + | --- | |||
| 39 | + | Implement the requested change. | |||
| 40 | + | `) | |||
| 41 | + | ||||
| 42 | + | profiles, errs := Discover("", home) | |||
| 43 | + | if len(errs) != 0 { | |||
| 44 | + | t.Fatalf("Discover errors = %v", errs) | |||
| 45 | + | } | |||
| 46 | + | if len(profiles) != 2 { | |||
| 47 | + | t.Fatalf("profiles = %d, want 2: %#v", len(profiles), profiles) | |||
| 48 | + | } | |||
| 49 | + | if profiles[0].Name != "implementer" || profiles[1].Name != "reviewer" { | |||
| 50 | + | t.Fatalf("profiles not sorted by name: %#v", profiles) | |||
| 51 | + | } | |||
| 52 | + | reviewer := Find(profiles, "reviewer") | |||
| 53 | + | if reviewer == nil { | |||
| 54 | + | t.Fatal("reviewer profile missing") | |||
| 55 | + | } | |||
| 56 | + | if reviewer.SystemPromptMode != "replace" { | |||
| 57 | + | t.Fatalf("system prompt mode = %q, want replace", reviewer.SystemPromptMode) | |||
| 58 | + | } | |||
| 59 | + | if reviewer.Model != "openai-codex/gpt-5.6-luna" || reviewer.Thinking != "max" { | |||
| 60 | + | t.Fatalf("profile metadata = %#v", reviewer) | |||
| 61 | + | } | |||
| 62 | + | if reviewer.InheritProjectContext == nil || *reviewer.InheritProjectContext { | |||
| 63 | + | t.Fatalf("inherit project context = %#v, want false", reviewer.InheritProjectContext) | |||
| 64 | + | } | |||
| 65 | + | if reviewer.InheritSkills == nil || *reviewer.InheritSkills { | |||
| 66 | + | t.Fatalf("inherit skills = %#v, want false", reviewer.InheritSkills) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestDiscoverConfiguredDirectoryWinsAndRejectsUnsafeNames(t *testing.T) { | |||
| 71 | + | configured := t.TempDir() | |||
| 72 | + | home := t.TempDir() | |||
| 73 | + | t.Setenv("ZOT_AGENT_PROFILES", configured) | |||
| 74 | + | writeProfile(t, filepath.Join(configured, "reviewer.md"), `--- | |||
| 75 | + | name: reviewer | |||
| 76 | + | description: Configured reviewer | |||
| 77 | + | --- | |||
| 78 | + | Configured instructions. | |||
| 79 | + | `) | |||
| 80 | + | writeProfile(t, filepath.Join(filepath.Join(home, ".agents", "agents"), "reviewer.md"), `--- | |||
| 81 | + | name: reviewer | |||
| 82 | + | description: Global reviewer | |||
| 83 | + | --- | |||
| 84 | + | Global instructions. | |||
| 85 | + | `) | |||
| 86 | + | writeProfile(t, filepath.Join(configured, "unsafe.md"), `--- | |||
| 87 | + | name: ../unsafe | |||
| 88 | + | description: Should not load | |||
| 89 | + | --- | |||
| 90 | + | No. | |||
| 91 | + | `) | |||
| 92 | + | ||||
| 93 | + | profiles, errs := Discover("", home) | |||
| 94 | + | if len(errs) != 0 { | |||
| 95 | + | t.Fatalf("Discover errors = %v", errs) | |||
| 96 | + | } | |||
| 97 | + | if len(profiles) != 1 || profiles[0].Description != "Configured reviewer" { | |||
| 98 | + | t.Fatalf("configured precedence = %#v", profiles) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestProfileModelSelection(t *testing.T) { | |||
| 103 | + | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | |||
| 104 | + | provider, model := profile.ModelSelection() | |||
| 105 | + | if provider != "openrouter" || model != "anthropic/claude-sonnet" { | |||
| 106 | + | t.Fatalf("selection = (%q, %q)", provider, model) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | profile = &Profile{Provider: "openai", Model: "gpt-5"} | |||
| 110 | + | provider, model = profile.ModelSelection() | |||
| 111 | + | if provider != "openai" || model != "gpt-5" { | |||
| 112 | + | t.Fatalf("explicit selection = (%q, %q)", provider, model) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) { | |||
| 117 | + | profiles := []*Profile{{ | |||
| 118 | + | Name: "reviewer", | |||
| 119 | + | Description: "Read-only\nreviewer", | |||
| 120 | + | SystemPrompt: "secret instructions that belong only to the child", | |||
| 121 | + | Model: "openai-codex/gpt-5", | |||
| 122 | + | Thinking: "max", | |||
| 123 | + | Tools: []string{"read", "bash"}, | |||
| 124 | + | Path: "/private/profile.md", | |||
| 125 | + | }} | |||
| 126 | + | got := SystemPromptAddendum(profiles) | |||
| 127 | + | for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max"} { | |||
| 128 | + | if !strings.Contains(got, want) { | |||
| 129 | + | t.Fatalf("addendum missing %q:\n%s", want, got) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | for _, unwanted := range []string{"secret instructions", "/private/profile.md", "\nreviewer"} { | |||
| 133 | + | if strings.Contains(got, unwanted) { | |||
| 134 | + | t.Fatalf("addendum contains %q:\n%s", unwanted, got) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func writeProfile(t *testing.T, path, content string) { | |||
| 140 | + | t.Helper() | |||
| 141 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | if err := os.WriteFile(path, []byte(content), 0o600); err != nil { | |||
| 145 | + | t.Fatal(err) | |||
| 146 | + | } | |||
| 147 | + | } | |||
M packages/agent/swarm/agent.go +4 -2
| @@ -22,8 +22,10 @@ type Agent struct { | |||
| 22 | 22 | // the child resolves on its own from config / env / flags" — | |
| 23 | 23 | // the historical behaviour. Persisted in meta.json so Resume | |
| 24 | 24 | // keeps using the same model across zot restarts. | |
| 25 | - | Model string | |
| 26 | - | Provider string | |
| 25 | + | Model string | |
| 26 | + | Provider string | |
| 27 | + | Reasoning string | |
| 28 | + | Subagent string | |
| 27 | 29 | ||
| 28 | 30 | // SessionID, when non-empty, scopes the agent to a particular | |
| 29 | 31 | // host zot session: the dashboard only surfaces agents whose | |
| @@ -22,8 +22,10 @@ type Agent struct { | |||||
| 22 | // the child resolves on its own from config / env / flags" — | 22 | // the child resolves on its own from config / env / flags" — | ||
| 23 | // the historical behaviour. Persisted in meta.json so Resume | 23 | // the historical behaviour. Persisted in meta.json so Resume | ||
| 24 | // keeps using the same model across zot restarts. | 24 | // keeps using the same model across zot restarts. | ||
| 25 | - | Model string | 25 | + | Model string |
| 26 | - | Provider string | 26 | + | Provider string |
| 27 | + | Reasoning string | |||
| 28 | + | Subagent string | |||
| 27 | 29 | ||||
| 28 | // SessionID, when non-empty, scopes the agent to a particular | 30 | // SessionID, when non-empty, scopes the agent to a particular | ||
| 29 | // host zot session: the dashboard only surfaces agents whose | 31 | // host zot session: the dashboard only surfaces agents whose | ||
M packages/agent/swarm/persist.go +9 -0
| @@ -44,6 +44,8 @@ type agentMeta struct { | |||
| 44 | 44 | Started time.Time `json:"started"` | |
| 45 | 45 | Model string `json:"model,omitempty"` | |
| 46 | 46 | Provider string `json:"provider,omitempty"` | |
| 47 | + | Reasoning string `json:"reasoning,omitempty"` | |
| 48 | + | Subagent string `json:"subagent,omitempty"` | |
| 47 | 49 | InboxPath string `json:"inbox_path"` | |
| 48 | 50 | EventLogPath string `json:"event_log_path"` | |
| 49 | 51 | SessionPath string `json:"session_path"` | |
| @@ -71,6 +73,8 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 71 | 73 | Started: a.Started, | |
| 72 | 74 | Model: a.Model, | |
| 73 | 75 | Provider: a.Provider, | |
| 76 | + | Reasoning: a.Reasoning, | |
| 77 | + | Subagent: a.Subagent, | |
| 74 | 78 | InboxPath: a.InboxPath, | |
| 75 | 79 | EventLogPath: a.EventLogPath, | |
| 76 | 80 | SessionPath: a.SessionPath, | |
| @@ -205,6 +209,8 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 205 | 209 | Started: m.Started, | |
| 206 | 210 | Model: m.Model, | |
| 207 | 211 | Provider: m.Provider, | |
| 212 | + | Reasoning: m.Reasoning, | |
| 213 | + | Subagent: m.Subagent, | |
| 208 | 214 | InboxPath: m.InboxPath, | |
| 209 | 215 | EventLogPath: m.EventLogPath, | |
| 210 | 216 | SessionPath: m.SessionPath, | |
| @@ -346,6 +352,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 346 | 352 | ID: existing.ID, Task: existing.Task, | |
| 347 | 353 | Dir: existing.Dir, Started: existing.Started, | |
| 348 | 354 | Model: existing.Model, Provider: existing.Provider, | |
| 355 | + | Reasoning: existing.Reasoning, Subagent: existing.Subagent, | |
| 349 | 356 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |
| 350 | 357 | SessionPath: existing.SessionPath, | |
| 351 | 358 | } | |
| @@ -357,6 +364,8 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 357 | 364 | Started: m.Started, | |
| 358 | 365 | Model: m.Model, | |
| 359 | 366 | Provider: m.Provider, | |
| 367 | + | Reasoning: m.Reasoning, | |
| 368 | + | Subagent: m.Subagent, | |
| 360 | 369 | InboxPath: m.InboxPath, | |
| 361 | 370 | EventLogPath: m.EventLogPath, | |
| 362 | 371 | SessionPath: m.SessionPath, | |
| @@ -44,6 +44,8 @@ type agentMeta struct { | |||||
| 44 | Started time.Time `json:"started"` | 44 | Started time.Time `json:"started"` | ||
| 45 | Model string `json:"model,omitempty"` | 45 | Model string `json:"model,omitempty"` | ||
| 46 | Provider string `json:"provider,omitempty"` | 46 | Provider string `json:"provider,omitempty"` | ||
| 47 | + | Reasoning string `json:"reasoning,omitempty"` | |||
| 48 | + | Subagent string `json:"subagent,omitempty"` | |||
| 47 | InboxPath string `json:"inbox_path"` | 49 | InboxPath string `json:"inbox_path"` | ||
| 48 | EventLogPath string `json:"event_log_path"` | 50 | EventLogPath string `json:"event_log_path"` | ||
| 49 | SessionPath string `json:"session_path"` | 51 | SessionPath string `json:"session_path"` | ||
| @@ -71,6 +73,8 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||||
| 71 | Started: a.Started, | 73 | Started: a.Started, | ||
| 72 | Model: a.Model, | 74 | Model: a.Model, | ||
| 73 | Provider: a.Provider, | 75 | Provider: a.Provider, | ||
| 76 | + | Reasoning: a.Reasoning, | |||
| 77 | + | Subagent: a.Subagent, | |||
| 74 | InboxPath: a.InboxPath, | 78 | InboxPath: a.InboxPath, | ||
| 75 | EventLogPath: a.EventLogPath, | 79 | EventLogPath: a.EventLogPath, | ||
| 76 | SessionPath: a.SessionPath, | 80 | SessionPath: a.SessionPath, | ||
| @@ -205,6 +209,8 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 205 | Started: m.Started, | 209 | Started: m.Started, | ||
| 206 | Model: m.Model, | 210 | Model: m.Model, | ||
| 207 | Provider: m.Provider, | 211 | Provider: m.Provider, | ||
| 212 | + | Reasoning: m.Reasoning, | |||
| 213 | + | Subagent: m.Subagent, | |||
| 208 | InboxPath: m.InboxPath, | 214 | InboxPath: m.InboxPath, | ||
| 209 | EventLogPath: m.EventLogPath, | 215 | EventLogPath: m.EventLogPath, | ||
| 210 | SessionPath: m.SessionPath, | 216 | SessionPath: m.SessionPath, | ||
| @@ -346,6 +352,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 346 | ID: existing.ID, Task: existing.Task, | 352 | ID: existing.ID, Task: existing.Task, | ||
| 347 | Dir: existing.Dir, Started: existing.Started, | 353 | Dir: existing.Dir, Started: existing.Started, | ||
| 348 | Model: existing.Model, Provider: existing.Provider, | 354 | Model: existing.Model, Provider: existing.Provider, | ||
| 355 | + | Reasoning: existing.Reasoning, Subagent: existing.Subagent, | |||
| 349 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | 356 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | ||
| 350 | SessionPath: existing.SessionPath, | 357 | SessionPath: existing.SessionPath, | ||
| 351 | } | 358 | } | ||
| @@ -357,6 +364,8 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 357 | Started: m.Started, | 364 | Started: m.Started, | ||
| 358 | Model: m.Model, | 365 | Model: m.Model, | ||
| 359 | Provider: m.Provider, | 366 | Provider: m.Provider, | ||
| 367 | + | Reasoning: m.Reasoning, | |||
| 368 | + | Subagent: m.Subagent, | |||
| 360 | InboxPath: m.InboxPath, | 369 | InboxPath: m.InboxPath, | ||
| 361 | EventLogPath: m.EventLogPath, | 370 | EventLogPath: m.EventLogPath, | ||
| 362 | SessionPath: m.SessionPath, | 371 | SessionPath: m.SessionPath, | ||
M packages/agent/swarm/persist_test.go +9 -8
| @@ -538,16 +538,17 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 538 | 538 | }) | |
| 539 | 539 | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | |
| 540 | 540 | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | |
| 541 | + | Reasoning: "high", Subagent: "reviewer", | |
| 541 | 542 | }) | |
| 542 | 543 | if err != nil { | |
| 543 | 544 | t.Fatalf("spawn: %v", err) | |
| 544 | 545 | } | |
| 545 | - | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" { | |
| 546 | - | t.Fatalf("agent fields = (%q,%q); want (claude-sonnet-4-5, anthropic)", a.Model, a.Provider) | |
| 546 | + | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" { | |
| 547 | + | t.Fatalf("agent fields = (%q,%q,%q,%q); want model/provider/reasoning/profile", a.Model, a.Provider, a.Reasoning, a.Subagent) | |
| 547 | 548 | } | |
| 548 | 549 | snap := a.Snapshot() | |
| 549 | - | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" { | |
| 550 | - | t.Fatalf("snapshot = (%q,%q); model fields not surfaced", snap.Model, snap.Provider) | |
| 550 | + | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" { | |
| 551 | + | t.Fatalf("snapshot = (%q,%q,%q,%q); child fields not surfaced", snap.Model, snap.Provider, snap.Reasoning, snap.Subagent) | |
| 551 | 552 | } | |
| 552 | 553 | ||
| 553 | 554 | // Stop so we can read meta.json without racing the run loop. | |
| @@ -564,8 +565,8 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 564 | 565 | if err := json.Unmarshal(metaBytes, &got); err != nil { | |
| 565 | 566 | t.Fatal(err) | |
| 566 | 567 | } | |
| 567 | - | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" { | |
| 568 | - | t.Errorf("meta = (%q,%q); want model + provider persisted", got.Model, got.Provider) | |
| 568 | + | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" { | |
| 569 | + | t.Errorf("meta = (%q,%q,%q,%q); want model/provider/reasoning/profile persisted", got.Model, got.Provider, got.Reasoning, got.Subagent) | |
| 569 | 570 | } | |
| 570 | 571 | ||
| 571 | 572 | // Reload in a fresh Swarm and confirm the detached agent still | |
| @@ -579,8 +580,8 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 579 | 580 | if re == nil { | |
| 580 | 581 | t.Fatal("reloaded agent missing") | |
| 581 | 582 | } | |
| 582 | - | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" { | |
| 583 | - | t.Errorf("reloaded fields = (%q,%q); want preserved", re.Model, re.Provider) | |
| 583 | + | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" { | |
| 584 | + | t.Errorf("reloaded fields = (%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent) | |
| 584 | 585 | } | |
| 585 | 586 | } | |
| 586 | 587 | ||
| @@ -538,16 +538,17 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 538 | }) | 538 | }) | ||
| 539 | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | 539 | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | ||
| 540 | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | 540 | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | ||
| 541 | + | Reasoning: "high", Subagent: "reviewer", | |||
| 541 | }) | 542 | }) | ||
| 542 | if err != nil { | 543 | if err != nil { | ||
| 543 | t.Fatalf("spawn: %v", err) | 544 | t.Fatalf("spawn: %v", err) | ||
| 544 | } | 545 | } | ||
| 545 | - | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" { | 546 | + | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" { |
| 546 | - | t.Fatalf("agent fields = (%q,%q); want (claude-sonnet-4-5, anthropic)", a.Model, a.Provider) | 547 | + | t.Fatalf("agent fields = (%q,%q,%q,%q); want model/provider/reasoning/profile", a.Model, a.Provider, a.Reasoning, a.Subagent) |
| 547 | } | 548 | } | ||
| 548 | snap := a.Snapshot() | 549 | snap := a.Snapshot() | ||
| 549 | - | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" { | 550 | + | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" { |
| 550 | - | t.Fatalf("snapshot = (%q,%q); model fields not surfaced", snap.Model, snap.Provider) | 551 | + | t.Fatalf("snapshot = (%q,%q,%q,%q); child fields not surfaced", snap.Model, snap.Provider, snap.Reasoning, snap.Subagent) |
| 551 | } | 552 | } | ||
| 552 | 553 | ||||
| 553 | // Stop so we can read meta.json without racing the run loop. | 554 | // Stop so we can read meta.json without racing the run loop. | ||
| @@ -564,8 +565,8 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 564 | if err := json.Unmarshal(metaBytes, &got); err != nil { | 565 | if err := json.Unmarshal(metaBytes, &got); err != nil { | ||
| 565 | t.Fatal(err) | 566 | t.Fatal(err) | ||
| 566 | } | 567 | } | ||
| 567 | - | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" { | 568 | + | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" { |
| 568 | - | t.Errorf("meta = (%q,%q); want model + provider persisted", got.Model, got.Provider) | 569 | + | t.Errorf("meta = (%q,%q,%q,%q); want model/provider/reasoning/profile persisted", got.Model, got.Provider, got.Reasoning, got.Subagent) |
| 569 | } | 570 | } | ||
| 570 | 571 | ||||
| 571 | // Reload in a fresh Swarm and confirm the detached agent still | 572 | // Reload in a fresh Swarm and confirm the detached agent still | ||
| @@ -579,8 +580,8 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 579 | if re == nil { | 580 | if re == nil { | ||
| 580 | t.Fatal("reloaded agent missing") | 581 | t.Fatal("reloaded agent missing") | ||
| 581 | } | 582 | } | ||
| 582 | - | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" { | 583 | + | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" { |
| 583 | - | t.Errorf("reloaded fields = (%q,%q); want preserved", re.Model, re.Provider) | 584 | + | t.Errorf("reloaded fields = (%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent) |
| 584 | } | 585 | } | ||
| 585 | } | 586 | } | ||
| 586 | 587 | ||||
M packages/agent/swarm/runner.go +14 -5
| @@ -54,11 +54,10 @@ type execRunner struct { | |||
| 54 | 54 | SessionPath string | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | // swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs | |
| 58 | - | // so future per-agent overrides (e.g. tools, reasoning) can be added | |
| 59 | - | // without churning the signature. The fields map 1:1 onto child CLI | |
| 60 | - | // flags; empty values omit the flag entirely and let the child | |
| 61 | - | // resolve a default the same way a normal `zot` invocation does. | |
| 57 | + | // swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs. | |
| 58 | + | // The fields map 1:1 onto child CLI flags; empty values omit the flag | |
| 59 | + | // entirely and let the child resolve a default the same way a normal | |
| 60 | + | // `zot` invocation does. | |
| 62 | 61 | type swarmAgentArgsOpts struct { | |
| 63 | 62 | Exe string | |
| 64 | 63 | Dir string | |
| @@ -67,6 +66,8 @@ type swarmAgentArgsOpts struct { | |||
| 67 | 66 | Task string | |
| 68 | 67 | Model string | |
| 69 | 68 | Provider string | |
| 69 | + | Reasoning string | |
| 70 | + | Subagent string | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 72 | 73 | // defaultChildArgs builds the argv execRunner uses when its Command | |
| @@ -96,6 +97,8 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||
| 96 | 97 | Task: task, | |
| 97 | 98 | Model: a.Model, | |
| 98 | 99 | Provider: a.Provider, | |
| 100 | + | Reasoning: a.Reasoning, | |
| 101 | + | Subagent: a.Subagent, | |
| 99 | 102 | }) | |
| 100 | 103 | } | |
| 101 | 104 | ||
| @@ -115,6 +118,12 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||
| 115 | 118 | if opts.Provider != "" { | |
| 116 | 119 | args = append(args, "--provider", opts.Provider) | |
| 117 | 120 | } | |
| 121 | + | if opts.Reasoning != "" { | |
| 122 | + | args = append(args, "--reasoning", opts.Reasoning) | |
| 123 | + | } | |
| 124 | + | if opts.Subagent != "" { | |
| 125 | + | args = append(args, "--subagent", opts.Subagent) | |
| 126 | + | } | |
| 118 | 127 | if opts.Task != "" { | |
| 119 | 128 | // First task is positional so the child treats it as the | |
| 120 | 129 | // initial user turn; subsequent turns arrive via the inbox. | |
| @@ -54,11 +54,10 @@ type execRunner struct { | |||||
| 54 | SessionPath string | 54 | SessionPath string | ||
| 55 | } | 55 | } | ||
| 56 | 56 | ||||
| 57 | - | // swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs | 57 | + | // swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs. |
| 58 | - | // so future per-agent overrides (e.g. tools, reasoning) can be added | 58 | + | // The fields map 1:1 onto child CLI flags; empty values omit the flag |
| 59 | - | // without churning the signature. The fields map 1:1 onto child CLI | 59 | + | // entirely and let the child resolve a default the same way a normal |
| 60 | - | // flags; empty values omit the flag entirely and let the child | 60 | + | // `zot` invocation does. |
| 61 | - | // resolve a default the same way a normal `zot` invocation does. | |||
| 62 | type swarmAgentArgsOpts struct { | 61 | type swarmAgentArgsOpts struct { | ||
| 63 | Exe string | 62 | Exe string | ||
| 64 | Dir string | 63 | Dir string | ||
| @@ -67,6 +66,8 @@ type swarmAgentArgsOpts struct { | |||||
| 67 | Task string | 66 | Task string | ||
| 68 | Model string | 67 | Model string | ||
| 69 | Provider string | 68 | Provider string | ||
| 69 | + | Reasoning string | |||
| 70 | + | Subagent string | |||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| 72 | // defaultChildArgs builds the argv execRunner uses when its Command | 73 | // defaultChildArgs builds the argv execRunner uses when its Command | ||
| @@ -96,6 +97,8 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||||
| 96 | Task: task, | 97 | Task: task, | ||
| 97 | Model: a.Model, | 98 | Model: a.Model, | ||
| 98 | Provider: a.Provider, | 99 | Provider: a.Provider, | ||
| 100 | + | Reasoning: a.Reasoning, | |||
| 101 | + | Subagent: a.Subagent, | |||
| 99 | }) | 102 | }) | ||
| 100 | } | 103 | } | ||
| 101 | 104 | ||||
| @@ -115,6 +118,12 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||||
| 115 | if opts.Provider != "" { | 118 | if opts.Provider != "" { | ||
| 116 | args = append(args, "--provider", opts.Provider) | 119 | args = append(args, "--provider", opts.Provider) | ||
| 117 | } | 120 | } | ||
| 121 | + | if opts.Reasoning != "" { | |||
| 122 | + | args = append(args, "--reasoning", opts.Reasoning) | |||
| 123 | + | } | |||
| 124 | + | if opts.Subagent != "" { | |||
| 125 | + | args = append(args, "--subagent", opts.Subagent) | |||
| 126 | + | } | |||
| 118 | if opts.Task != "" { | 127 | if opts.Task != "" { | ||
| 119 | // First task is positional so the child treats it as the | 128 | // First task is positional so the child treats it as the | ||
| 120 | // initial user turn; subsequent turns arrive via the inbox. | 129 | // initial user turn; subsequent turns arrive via the inbox. | ||
M packages/agent/swarm/runner_test.go +4 -0
| @@ -113,6 +113,8 @@ func TestSwarmAgentArgs(t *testing.T) { | |||
| 113 | 113 | SessionPath: "/tmp/state/session.json", | |
| 114 | 114 | InboxPath: "/tmp/state/in.sock", | |
| 115 | 115 | Task: "do the thing", | |
| 116 | + | Reasoning: "high", | |
| 117 | + | Subagent: "reviewer", | |
| 116 | 118 | }) | |
| 117 | 119 | if len(args) < 7 { | |
| 118 | 120 | t.Fatalf("argv unexpectedly short: %v", args) | |
| @@ -130,6 +132,8 @@ func TestSwarmAgentArgs(t *testing.T) { | |||
| 130 | 132 | "--swarm-agent": "/tmp/state/in.sock", | |
| 131 | 133 | "--session": "/tmp/state/session.json", | |
| 132 | 134 | "--cwd": "/tmp/worktree", | |
| 135 | + | "--reasoning": "high", | |
| 136 | + | "--subagent": "reviewer", | |
| 133 | 137 | } | |
| 134 | 138 | for flag, value := range mustHave { | |
| 135 | 139 | i := indexOf(args, flag) | |
| @@ -113,6 +113,8 @@ func TestSwarmAgentArgs(t *testing.T) { | |||||
| 113 | SessionPath: "/tmp/state/session.json", | 113 | SessionPath: "/tmp/state/session.json", | ||
| 114 | InboxPath: "/tmp/state/in.sock", | 114 | InboxPath: "/tmp/state/in.sock", | ||
| 115 | Task: "do the thing", | 115 | Task: "do the thing", | ||
| 116 | + | Reasoning: "high", | |||
| 117 | + | Subagent: "reviewer", | |||
| 116 | }) | 118 | }) | ||
| 117 | if len(args) < 7 { | 119 | if len(args) < 7 { | ||
| 118 | t.Fatalf("argv unexpectedly short: %v", args) | 120 | t.Fatalf("argv unexpectedly short: %v", args) | ||
| @@ -130,6 +132,8 @@ func TestSwarmAgentArgs(t *testing.T) { | |||||
| 130 | "--swarm-agent": "/tmp/state/in.sock", | 132 | "--swarm-agent": "/tmp/state/in.sock", | ||
| 131 | "--session": "/tmp/state/session.json", | 133 | "--session": "/tmp/state/session.json", | ||
| 132 | "--cwd": "/tmp/worktree", | 134 | "--cwd": "/tmp/worktree", | ||
| 135 | + | "--reasoning": "high", | |||
| 136 | + | "--subagent": "reviewer", | |||
| 133 | } | 137 | } | ||
| 134 | for flag, value := range mustHave { | 138 | for flag, value := range mustHave { | ||
| 135 | i := indexOf(args, flag) | 139 | i := indexOf(args, flag) | ||
M packages/agent/swarm/swarm.go +22 -10
| @@ -177,11 +177,16 @@ func (f *Swarm) agentStateDir(id string) string { | |||
| 177 | 177 | // SpawnRequest configures a Spawn. Only Task is required; the rest | |
| 178 | 178 | // are optional. Model + Provider, when set, get baked into the | |
| 179 | 179 | // child argv as --model / --provider so the agent runs against the | |
| 180 | - | // chosen model regardless of the parent's current selection. | |
| 180 | + | // chosen model regardless of the parent's current selection. A named | |
| 181 | + | // Subagent is passed to the child as --subagent, where it resolves the | |
| 182 | + | // markdown profile and applies its instructions. Reasoning is passed | |
| 183 | + | // as --reasoning when set. | |
| 181 | 184 | type SpawnRequest struct { | |
| 182 | - | Task string | |
| 183 | - | Model string // optional override; child resolves default if empty | |
| 184 | - | Provider string // optional override; usually paired with Model | |
| 185 | + | Task string | |
| 186 | + | Model string // optional override; child resolves default if empty | |
| 187 | + | Provider string // optional override; usually paired with Model | |
| 188 | + | Reasoning string // optional reasoning/thinking level override | |
| 189 | + | Subagent string // optional named markdown profile | |
| 185 | 190 | } | |
| 186 | 191 | ||
| 187 | 192 | // Spawn creates a new Agent for the given task, allocates its | |
| @@ -238,6 +243,8 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 238 | 243 | Started: f.cfg.Now(), | |
| 239 | 244 | Model: strings.TrimSpace(req.Model), | |
| 240 | 245 | Provider: strings.TrimSpace(req.Provider), | |
| 246 | + | Reasoning: strings.TrimSpace(req.Reasoning), | |
| 247 | + | Subagent: strings.TrimSpace(req.Subagent), | |
| 241 | 248 | SessionID: sessionID, | |
| 242 | 249 | InboxPath: inboxPath, | |
| 243 | 250 | EventLogPath: logPath, | |
| @@ -444,12 +451,15 @@ type AgentSnapshot struct { | |||
| 444 | 451 | Lines []string // full transcript (already capped by Agent.appendTranscript) | |
| 445 | 452 | LastAssistant string // complete final assistant message, without role formatting | |
| 446 | 453 | ||
| 447 | - | // Model and Provider expose the per-agent overrides set at | |
| 448 | - | // Spawn time (empty when the agent inherits the child's default | |
| 449 | - | // resolution). The dashboard surfaces these so the user can | |
| 450 | - | // confirm which model an agent is running against. | |
| 451 | - | Model string | |
| 452 | - | Provider string | |
| 454 | + | // Model, Provider, and Reasoning expose the per-agent overrides set | |
| 455 | + | // at Spawn time (empty when the agent inherits the child's default | |
| 456 | + | // resolution). Subagent is the selected named markdown profile. | |
| 457 | + | // The dashboard surfaces these so the user can confirm the child | |
| 458 | + | // configuration. | |
| 459 | + | Model string | |
| 460 | + | Provider string | |
| 461 | + | Reasoning string | |
| 462 | + | Subagent string | |
| 453 | 463 | ||
| 454 | 464 | // Paths to the agent's durable state. Surface them in the | |
| 455 | 465 | // snapshot so the dashboard / /swarm open can read events.jsonl | |
| @@ -479,6 +489,8 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 479 | 489 | LastAssistant: a.lastAssistant, | |
| 480 | 490 | Model: a.Model, | |
| 481 | 491 | Provider: a.Provider, | |
| 492 | + | Reasoning: a.Reasoning, | |
| 493 | + | Subagent: a.Subagent, | |
| 482 | 494 | InboxPath: a.InboxPath, | |
| 483 | 495 | EventLogPath: a.EventLogPath, | |
| 484 | 496 | SessionPath: a.SessionPath, | |
| @@ -177,11 +177,16 @@ func (f *Swarm) agentStateDir(id string) string { | |||||
| 177 | // SpawnRequest configures a Spawn. Only Task is required; the rest | 177 | // SpawnRequest configures a Spawn. Only Task is required; the rest | ||
| 178 | // are optional. Model + Provider, when set, get baked into the | 178 | // are optional. Model + Provider, when set, get baked into the | ||
| 179 | // child argv as --model / --provider so the agent runs against the | 179 | // child argv as --model / --provider so the agent runs against the | ||
| 180 | - | // chosen model regardless of the parent's current selection. | 180 | + | // chosen model regardless of the parent's current selection. A named |
| 181 | + | // Subagent is passed to the child as --subagent, where it resolves the | |||
| 182 | + | // markdown profile and applies its instructions. Reasoning is passed | |||
| 183 | + | // as --reasoning when set. | |||
| 181 | type SpawnRequest struct { | 184 | type SpawnRequest struct { | ||
| 182 | - | Task string | 185 | + | Task string |
| 183 | - | Model string // optional override; child resolves default if empty | 186 | + | Model string // optional override; child resolves default if empty |
| 184 | - | Provider string // optional override; usually paired with Model | 187 | + | Provider string // optional override; usually paired with Model |
| 188 | + | Reasoning string // optional reasoning/thinking level override | |||
| 189 | + | Subagent string // optional named markdown profile | |||
| 185 | } | 190 | } | ||
| 186 | 191 | ||||
| 187 | // Spawn creates a new Agent for the given task, allocates its | 192 | // Spawn creates a new Agent for the given task, allocates its | ||
| @@ -238,6 +243,8 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 238 | Started: f.cfg.Now(), | 243 | Started: f.cfg.Now(), | ||
| 239 | Model: strings.TrimSpace(req.Model), | 244 | Model: strings.TrimSpace(req.Model), | ||
| 240 | Provider: strings.TrimSpace(req.Provider), | 245 | Provider: strings.TrimSpace(req.Provider), | ||
| 246 | + | Reasoning: strings.TrimSpace(req.Reasoning), | |||
| 247 | + | Subagent: strings.TrimSpace(req.Subagent), | |||
| 241 | SessionID: sessionID, | 248 | SessionID: sessionID, | ||
| 242 | InboxPath: inboxPath, | 249 | InboxPath: inboxPath, | ||
| 243 | EventLogPath: logPath, | 250 | EventLogPath: logPath, | ||
| @@ -444,12 +451,15 @@ type AgentSnapshot struct { | |||||
| 444 | Lines []string // full transcript (already capped by Agent.appendTranscript) | 451 | Lines []string // full transcript (already capped by Agent.appendTranscript) | ||
| 445 | LastAssistant string // complete final assistant message, without role formatting | 452 | LastAssistant string // complete final assistant message, without role formatting | ||
| 446 | 453 | ||||
| 447 | - | // Model and Provider expose the per-agent overrides set at | 454 | + | // Model, Provider, and Reasoning expose the per-agent overrides set |
| 448 | - | // Spawn time (empty when the agent inherits the child's default | 455 | + | // at Spawn time (empty when the agent inherits the child's default |
| 449 | - | // resolution). The dashboard surfaces these so the user can | 456 | + | // resolution). Subagent is the selected named markdown profile. |
| 450 | - | // confirm which model an agent is running against. | 457 | + | // The dashboard surfaces these so the user can confirm the child |
| 451 | - | Model string | 458 | + | // configuration. |
| 452 | - | Provider string | 459 | + | Model string |
| 460 | + | Provider string | |||
| 461 | + | Reasoning string | |||
| 462 | + | Subagent string | |||
| 453 | 463 | ||||
| 454 | // Paths to the agent's durable state. Surface them in the | 464 | // Paths to the agent's durable state. Surface them in the | ||
| 455 | // snapshot so the dashboard / /swarm open can read events.jsonl | 465 | // snapshot so the dashboard / /swarm open can read events.jsonl | ||
| @@ -479,6 +489,8 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 479 | LastAssistant: a.lastAssistant, | 489 | LastAssistant: a.lastAssistant, | ||
| 480 | Model: a.Model, | 490 | Model: a.Model, | ||
| 481 | Provider: a.Provider, | 491 | Provider: a.Provider, | ||
| 492 | + | Reasoning: a.Reasoning, | |||
| 493 | + | Subagent: a.Subagent, | |||
| 482 | InboxPath: a.InboxPath, | 494 | InboxPath: a.InboxPath, | ||
| 483 | EventLogPath: a.EventLogPath, | 495 | EventLogPath: a.EventLogPath, | ||
| 484 | SessionPath: a.SessionPath, | 496 | SessionPath: a.SessionPath, | ||
M packages/agent/tools/swarm_spawn.go +121 -20
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 9 | 10 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/core" | |
| 11 | 12 | "github.com/patriceckhart/zot/packages/provider" | |
| @@ -33,22 +34,30 @@ type SwarmSpawnTool struct { | |||
| 33 | 34 | ||
| 34 | 35 | // DefaultModel and DefaultProvider return the host agent's resolved | |
| 35 | 36 | // model and provider. They are used when the tool call omits both | |
| 36 | - | // fields, so auto-swarm follows the same auth route as the user sees | |
| 37 | - | // in the parent session. | |
| 38 | - | DefaultModel func() string | |
| 39 | - | DefaultProvider func() string | |
| 37 | + | // fields and does not select a named profile, so auto-swarm follows | |
| 38 | + | // the same auth route as the user sees in the parent session. | |
| 39 | + | DefaultModel func() string | |
| 40 | + | DefaultProvider func() string | |
| 41 | + | DefaultReasoning func() string | |
| 42 | + | ||
| 43 | + | // ResolveSubagent validates and resolves a named markdown profile. | |
| 44 | + | // The child receives only the name and loads the profile itself. | |
| 45 | + | ResolveSubagent func(name string) (*subagents.Profile, error) | |
| 40 | 46 | ||
| 41 | 47 | // OnSpawned, if set, is called after every successful spawn with | |
| 42 | 48 | // the new agent + the task it was started with. Used by the | |
| 43 | 49 | // interactive host to track agents and surface a summary back | |
| 44 | - | // in the main chat once they all finish. | |
| 50 | + | // in chat when all sub-agents finish. | |
| 45 | 51 | OnSpawned func(agent *swarm.Agent, task string) | |
| 46 | 52 | } | |
| 47 | 53 | ||
| 48 | 54 | type swarmSpawnArgs struct { | |
| 49 | - | Task string `json:"task"` | |
| 50 | - | Model string `json:"model,omitempty"` | |
| 51 | - | Provider string `json:"provider,omitempty"` | |
| 55 | + | Task string `json:"task"` | |
| 56 | + | Agent string `json:"agent,omitempty"` | |
| 57 | + | Model string `json:"model,omitempty"` | |
| 58 | + | Provider string `json:"provider,omitempty"` | |
| 59 | + | Reasoning string `json:"reasoning,omitempty"` | |
| 60 | + | Thinking string `json:"thinking,omitempty"` | |
| 52 | 61 | } | |
| 53 | 62 | ||
| 54 | 63 | const swarmSpawnSchema = `{ | |
| @@ -56,15 +65,29 @@ const swarmSpawnSchema = `{ | |||
| 56 | 65 | "properties": { | |
| 57 | 66 | "task": { | |
| 58 | 67 | "type": "string", | |
| 59 | - | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same tools (read/write/edit/bash) and shares this working directory, but starts with NO context from this conversation." | |
| 68 | + | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same read/write/edit/bash tools as the main agent and shares this working directory, but starts with NO context from this conversation." | |
| 69 | + | }, | |
| 70 | + | "agent": { | |
| 71 | + | "type": "string", | |
| 72 | + | "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, and tool limits. Omit for a generic child." | |
| 60 | 73 | }, | |
| 61 | 74 | "model": { | |
| 62 | 75 | "type": "string", | |
| 63 | - | "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider." | |
| 76 | + | "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider." | |
| 64 | 77 | }, | |
| 65 | 78 | "provider": { | |
| 66 | 79 | "type": "string", | |
| 67 | 80 | "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth." | |
| 81 | + | }, | |
| 82 | + | "reasoning": { | |
| 83 | + | "type": "string", | |
| 84 | + | "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"], | |
| 85 | + | "description": "Optional reasoning/thinking level for the child. Overrides the selected profile's thinking level when provided." | |
| 86 | + | }, | |
| 87 | + | "thinking": { | |
| 88 | + | "type": "string", | |
| 89 | + | "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"], | |
| 90 | + | "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available." | |
| 68 | 91 | } | |
| 69 | 92 | }, | |
| 70 | 93 | "required": ["task"] | |
| @@ -72,7 +95,7 @@ const swarmSpawnSchema = `{ | |||
| 72 | 95 | ||
| 73 | 96 | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | |
| 74 | 97 | func (t *SwarmSpawnTool) Description() string { | |
| 75 | - | return "Spawn a background sub-agent to work on a parallel sub-task. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. Useful for splitting independent work (write tests while implementing a feature, refactor module A while drafting module B). The sub-agent shares this working directory and has the same tools." | |
| 98 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." | |
| 76 | 99 | } | |
| 77 | 100 | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | |
| 78 | 101 | ||
| @@ -92,12 +115,39 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 92 | 115 | return toolErr("swarm_spawn: task is required"), nil | |
| 93 | 116 | } | |
| 94 | 117 | ||
| 118 | + | agentName := strings.TrimSpace(a.Agent) | |
| 119 | + | var profile *subagents.Profile | |
| 120 | + | if agentName != "" { | |
| 121 | + | if t.ResolveSubagent == nil { | |
| 122 | + | return toolErr("swarm_spawn: named subagent profiles are unavailable"), nil | |
| 123 | + | } | |
| 124 | + | var err error | |
| 125 | + | profile, err = t.ResolveSubagent(agentName) | |
| 126 | + | if err != nil { | |
| 127 | + | return toolErr("swarm_spawn: " + err.Error()), nil | |
| 128 | + | } | |
| 129 | + | if profile == nil { | |
| 130 | + | return toolErr("swarm_spawn: unknown subagent profile " + agentName), nil | |
| 131 | + | } | |
| 132 | + | agentName = profile.Name | |
| 133 | + | } | |
| 134 | + | ||
| 95 | 135 | model := strings.TrimSpace(a.Model) | |
| 96 | 136 | providerID := strings.TrimSpace(a.Provider) | |
| 97 | 137 | if (model == "") != (providerID == "") { | |
| 98 | - | return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil | |
| 138 | + | return toolErr("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly"), nil | |
| 99 | 139 | } | |
| 100 | - | if model == "" && providerID == "" { | |
| 140 | + | if model == "" && providerID == "" && profile != nil { | |
| 141 | + | // A fully-qualified profile model is useful metadata for the | |
| 142 | + | // supervisor and also lets it inherit the right credential route. | |
| 143 | + | // Bare profile model ids remain child-resolved so the configured | |
| 144 | + | // provider is preserved. | |
| 145 | + | profileProvider, profileModel := profile.ModelSelection() | |
| 146 | + | if profileProvider != "" && profileModel != "" { | |
| 147 | + | providerID, model = profileProvider, profileModel | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | if model == "" && providerID == "" && profile == nil { | |
| 101 | 151 | if t.DefaultModel != nil { | |
| 102 | 152 | model = strings.TrimSpace(t.DefaultModel()) | |
| 103 | 153 | } | |
| @@ -106,10 +156,29 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 106 | 156 | } | |
| 107 | 157 | } | |
| 108 | 158 | ||
| 159 | + | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | |
| 160 | + | if err != nil { | |
| 161 | + | return toolErr("swarm_spawn: " + err.Error()), nil | |
| 162 | + | } | |
| 163 | + | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | |
| 164 | + | reasoning, err = reasoningOverride(profile.Thinking, "") | |
| 165 | + | if err != nil { | |
| 166 | + | return toolErr("swarm_spawn: profile " + profile.Name + ": " + err.Error()), nil | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | if reasoning == "" && t.DefaultReasoning != nil { | |
| 170 | + | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | |
| 171 | + | if err != nil { | |
| 172 | + | return toolErr("swarm_spawn: host " + err.Error()), nil | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 109 | 176 | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 110 | - | Task: task, | |
| 111 | - | Model: model, | |
| 112 | - | Provider: providerID, | |
| 177 | + | Task: task, | |
| 178 | + | Model: model, | |
| 179 | + | Provider: providerID, | |
| 180 | + | Reasoning: reasoning, | |
| 181 | + | Subagent: agentName, | |
| 113 | 182 | }) | |
| 114 | 183 | if err != nil { | |
| 115 | 184 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | |
| @@ -121,25 +190,57 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 121 | 190 | var sb strings.Builder | |
| 122 | 191 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | |
| 123 | 192 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |
| 193 | + | if agentName != "" { | |
| 194 | + | fmt.Fprintf(&sb, "agent: %s\n", agentName) | |
| 195 | + | } | |
| 124 | 196 | if model != "" { | |
| 125 | 197 | fmt.Fprintf(&sb, "model: %s\n", model) | |
| 126 | 198 | } | |
| 127 | 199 | if providerID != "" { | |
| 128 | 200 | fmt.Fprintf(&sb, "provider: %s\n", providerID) | |
| 129 | 201 | } | |
| 202 | + | if reasoning != "" { | |
| 203 | + | fmt.Fprintf(&sb, "reasoning: %s\n", reasoning) | |
| 204 | + | } | |
| 130 | 205 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | |
| 131 | 206 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |
| 132 | 207 | return core.ToolResult{ | |
| 133 | 208 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 134 | 209 | Details: map[string]any{ | |
| 135 | - | "agent_id": agent.ID, | |
| 136 | - | "task": task, | |
| 137 | - | "model": model, | |
| 138 | - | "provider": providerID, | |
| 210 | + | "agent_id": agent.ID, | |
| 211 | + | "task": task, | |
| 212 | + | "agent": agentName, | |
| 213 | + | "model": model, | |
| 214 | + | "provider": providerID, | |
| 215 | + | "reasoning": reasoning, | |
| 139 | 216 | }, | |
| 140 | 217 | }, nil | |
| 141 | 218 | } | |
| 142 | 219 | ||
| 220 | + | func reasoningOverride(reasoning, thinking string) (string, error) { | |
| 221 | + | reasoning = strings.TrimSpace(reasoning) | |
| 222 | + | thinking = strings.TrimSpace(thinking) | |
| 223 | + | if reasoning != "" && thinking != "" && provider.NormalizeReasoning(reasoning) != provider.NormalizeReasoning(thinking) { | |
| 224 | + | return "", fmt.Errorf("reasoning and thinking disagree; provide only one") | |
| 225 | + | } | |
| 226 | + | value := reasoning | |
| 227 | + | if value == "" { | |
| 228 | + | value = thinking | |
| 229 | + | } | |
| 230 | + | if value == "" { | |
| 231 | + | return "", nil | |
| 232 | + | } | |
| 233 | + | normalized := provider.NormalizeReasoning(value) | |
| 234 | + | switch normalized { | |
| 235 | + | case "": | |
| 236 | + | return "off", nil | |
| 237 | + | case "minimum", "low", "medium", "high", "xhigh", "max": | |
| 238 | + | return normalized, nil | |
| 239 | + | default: | |
| 240 | + | return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max") | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 143 | 244 | func toolErr(msg string) core.ToolResult { | |
| 144 | 245 | return core.ToolResult{ | |
| 145 | 246 | Content: []provider.Content{provider.TextBlock{Text: msg}}, | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 9 | "github.com/patriceckhart/zot/packages/agent/swarm" | 10 | "github.com/patriceckhart/zot/packages/agent/swarm" | ||
| 10 | "github.com/patriceckhart/zot/packages/core" | 11 | "github.com/patriceckhart/zot/packages/core" | ||
| 11 | "github.com/patriceckhart/zot/packages/provider" | 12 | "github.com/patriceckhart/zot/packages/provider" | ||
| @@ -33,22 +34,30 @@ type SwarmSpawnTool struct { | |||||
| 33 | 34 | ||||
| 34 | // DefaultModel and DefaultProvider return the host agent's resolved | 35 | // DefaultModel and DefaultProvider return the host agent's resolved | ||
| 35 | // model and provider. They are used when the tool call omits both | 36 | // model and provider. They are used when the tool call omits both | ||
| 36 | - | // fields, so auto-swarm follows the same auth route as the user sees | 37 | + | // fields and does not select a named profile, so auto-swarm follows |
| 37 | - | // in the parent session. | 38 | + | // the same auth route as the user sees in the parent session. |
| 38 | - | DefaultModel func() string | 39 | + | DefaultModel func() string |
| 39 | - | DefaultProvider func() string | 40 | + | DefaultProvider func() string |
| 41 | + | DefaultReasoning func() string | |||
| 42 | + | ||||
| 43 | + | // ResolveSubagent validates and resolves a named markdown profile. | |||
| 44 | + | // The child receives only the name and loads the profile itself. | |||
| 45 | + | ResolveSubagent func(name string) (*subagents.Profile, error) | |||
| 40 | 46 | ||||
| 41 | // OnSpawned, if set, is called after every successful spawn with | 47 | // OnSpawned, if set, is called after every successful spawn with | ||
| 42 | // the new agent + the task it was started with. Used by the | 48 | // the new agent + the task it was started with. Used by the | ||
| 43 | // interactive host to track agents and surface a summary back | 49 | // interactive host to track agents and surface a summary back | ||
| 44 | - | // in the main chat once they all finish. | 50 | + | // in chat when all sub-agents finish. |
| 45 | OnSpawned func(agent *swarm.Agent, task string) | 51 | OnSpawned func(agent *swarm.Agent, task string) | ||
| 46 | } | 52 | } | ||
| 47 | 53 | ||||
| 48 | type swarmSpawnArgs struct { | 54 | type swarmSpawnArgs struct { | ||
| 49 | - | Task string `json:"task"` | 55 | + | Task string `json:"task"` |
| 50 | - | Model string `json:"model,omitempty"` | 56 | + | Agent string `json:"agent,omitempty"` |
| 51 | - | Provider string `json:"provider,omitempty"` | 57 | + | Model string `json:"model,omitempty"` |
| 58 | + | Provider string `json:"provider,omitempty"` | |||
| 59 | + | Reasoning string `json:"reasoning,omitempty"` | |||
| 60 | + | Thinking string `json:"thinking,omitempty"` | |||
| 52 | } | 61 | } | ||
| 53 | 62 | ||||
| 54 | const swarmSpawnSchema = `{ | 63 | const swarmSpawnSchema = `{ | ||
| @@ -56,15 +65,29 @@ const swarmSpawnSchema = `{ | |||||
| 56 | "properties": { | 65 | "properties": { | ||
| 57 | "task": { | 66 | "task": { | ||
| 58 | "type": "string", | 67 | "type": "string", | ||
| 59 | - | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same tools (read/write/edit/bash) and shares this working directory, but starts with NO context from this conversation." | 68 | + | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same read/write/edit/bash tools as the main agent and shares this working directory, but starts with NO context from this conversation." |
| 69 | + | }, | |||
| 70 | + | "agent": { | |||
| 71 | + | "type": "string", | |||
| 72 | + | "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, and tool limits. Omit for a generic child." | |||
| 60 | }, | 73 | }, | ||
| 61 | "model": { | 74 | "model": { | ||
| 62 | "type": "string", | 75 | "type": "string", | ||
| 63 | - | "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider." | 76 | + | "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider." |
| 64 | }, | 77 | }, | ||
| 65 | "provider": { | 78 | "provider": { | ||
| 66 | "type": "string", | 79 | "type": "string", | ||
| 67 | "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth." | 80 | "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth." | ||
| 81 | + | }, | |||
| 82 | + | "reasoning": { | |||
| 83 | + | "type": "string", | |||
| 84 | + | "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"], | |||
| 85 | + | "description": "Optional reasoning/thinking level for the child. Overrides the selected profile's thinking level when provided." | |||
| 86 | + | }, | |||
| 87 | + | "thinking": { | |||
| 88 | + | "type": "string", | |||
| 89 | + | "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"], | |||
| 90 | + | "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available." | |||
| 68 | } | 91 | } | ||
| 69 | }, | 92 | }, | ||
| 70 | "required": ["task"] | 93 | "required": ["task"] | ||
| @@ -72,7 +95,7 @@ const swarmSpawnSchema = `{ | |||||
| 72 | 95 | ||||
| 73 | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | 96 | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | ||
| 74 | func (t *SwarmSpawnTool) Description() string { | 97 | func (t *SwarmSpawnTool) Description() string { | ||
| 75 | - | return "Spawn a background sub-agent to work on a parallel sub-task. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. Useful for splitting independent work (write tests while implementing a feature, refactor module A while drafting module B). The sub-agent shares this working directory and has the same tools." | 98 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." |
| 76 | } | 99 | } | ||
| 77 | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | 100 | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | ||
| 78 | 101 | ||||
| @@ -92,12 +115,39 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 92 | return toolErr("swarm_spawn: task is required"), nil | 115 | return toolErr("swarm_spawn: task is required"), nil | ||
| 93 | } | 116 | } | ||
| 94 | 117 | ||||
| 118 | + | agentName := strings.TrimSpace(a.Agent) | |||
| 119 | + | var profile *subagents.Profile | |||
| 120 | + | if agentName != "" { | |||
| 121 | + | if t.ResolveSubagent == nil { | |||
| 122 | + | return toolErr("swarm_spawn: named subagent profiles are unavailable"), nil | |||
| 123 | + | } | |||
| 124 | + | var err error | |||
| 125 | + | profile, err = t.ResolveSubagent(agentName) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return toolErr("swarm_spawn: " + err.Error()), nil | |||
| 128 | + | } | |||
| 129 | + | if profile == nil { | |||
| 130 | + | return toolErr("swarm_spawn: unknown subagent profile " + agentName), nil | |||
| 131 | + | } | |||
| 132 | + | agentName = profile.Name | |||
| 133 | + | } | |||
| 134 | + | ||||
| 95 | model := strings.TrimSpace(a.Model) | 135 | model := strings.TrimSpace(a.Model) | ||
| 96 | providerID := strings.TrimSpace(a.Provider) | 136 | providerID := strings.TrimSpace(a.Provider) | ||
| 97 | if (model == "") != (providerID == "") { | 137 | if (model == "") != (providerID == "") { | ||
| 98 | - | return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil | 138 | + | return toolErr("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly"), nil |
| 99 | } | 139 | } | ||
| 100 | - | if model == "" && providerID == "" { | 140 | + | if model == "" && providerID == "" && profile != nil { |
| 141 | + | // A fully-qualified profile model is useful metadata for the | |||
| 142 | + | // supervisor and also lets it inherit the right credential route. | |||
| 143 | + | // Bare profile model ids remain child-resolved so the configured | |||
| 144 | + | // provider is preserved. | |||
| 145 | + | profileProvider, profileModel := profile.ModelSelection() | |||
| 146 | + | if profileProvider != "" && profileModel != "" { | |||
| 147 | + | providerID, model = profileProvider, profileModel | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if model == "" && providerID == "" && profile == nil { | |||
| 101 | if t.DefaultModel != nil { | 151 | if t.DefaultModel != nil { | ||
| 102 | model = strings.TrimSpace(t.DefaultModel()) | 152 | model = strings.TrimSpace(t.DefaultModel()) | ||
| 103 | } | 153 | } | ||
| @@ -106,10 +156,29 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 106 | } | 156 | } | ||
| 107 | } | 157 | } | ||
| 108 | 158 | ||||
| 159 | + | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | |||
| 160 | + | if err != nil { | |||
| 161 | + | return toolErr("swarm_spawn: " + err.Error()), nil | |||
| 162 | + | } | |||
| 163 | + | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | |||
| 164 | + | reasoning, err = reasoningOverride(profile.Thinking, "") | |||
| 165 | + | if err != nil { | |||
| 166 | + | return toolErr("swarm_spawn: profile " + profile.Name + ": " + err.Error()), nil | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | if reasoning == "" && t.DefaultReasoning != nil { | |||
| 170 | + | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | |||
| 171 | + | if err != nil { | |||
| 172 | + | return toolErr("swarm_spawn: host " + err.Error()), nil | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 109 | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | 176 | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | ||
| 110 | - | Task: task, | 177 | + | Task: task, |
| 111 | - | Model: model, | 178 | + | Model: model, |
| 112 | - | Provider: providerID, | 179 | + | Provider: providerID, |
| 180 | + | Reasoning: reasoning, | |||
| 181 | + | Subagent: agentName, | |||
| 113 | }) | 182 | }) | ||
| 114 | if err != nil { | 183 | if err != nil { | ||
| 115 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | 184 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | ||
| @@ -121,25 +190,57 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 121 | var sb strings.Builder | 190 | var sb strings.Builder | ||
| 122 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | 191 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | ||
| 123 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | 192 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | ||
| 193 | + | if agentName != "" { | |||
| 194 | + | fmt.Fprintf(&sb, "agent: %s\n", agentName) | |||
| 195 | + | } | |||
| 124 | if model != "" { | 196 | if model != "" { | ||
| 125 | fmt.Fprintf(&sb, "model: %s\n", model) | 197 | fmt.Fprintf(&sb, "model: %s\n", model) | ||
| 126 | } | 198 | } | ||
| 127 | if providerID != "" { | 199 | if providerID != "" { | ||
| 128 | fmt.Fprintf(&sb, "provider: %s\n", providerID) | 200 | fmt.Fprintf(&sb, "provider: %s\n", providerID) | ||
| 129 | } | 201 | } | ||
| 202 | + | if reasoning != "" { | |||
| 203 | + | fmt.Fprintf(&sb, "reasoning: %s\n", reasoning) | |||
| 204 | + | } | |||
| 130 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | 205 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | ||
| 131 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | 206 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | ||
| 132 | return core.ToolResult{ | 207 | return core.ToolResult{ | ||
| 133 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | 208 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | ||
| 134 | Details: map[string]any{ | 209 | Details: map[string]any{ | ||
| 135 | - | "agent_id": agent.ID, | 210 | + | "agent_id": agent.ID, |
| 136 | - | "task": task, | 211 | + | "task": task, |
| 137 | - | "model": model, | 212 | + | "agent": agentName, |
| 138 | - | "provider": providerID, | 213 | + | "model": model, |
| 214 | + | "provider": providerID, | |||
| 215 | + | "reasoning": reasoning, | |||
| 139 | }, | 216 | }, | ||
| 140 | }, nil | 217 | }, nil | ||
| 141 | } | 218 | } | ||
| 142 | 219 | ||||
| 220 | + | func reasoningOverride(reasoning, thinking string) (string, error) { | |||
| 221 | + | reasoning = strings.TrimSpace(reasoning) | |||
| 222 | + | thinking = strings.TrimSpace(thinking) | |||
| 223 | + | if reasoning != "" && thinking != "" && provider.NormalizeReasoning(reasoning) != provider.NormalizeReasoning(thinking) { | |||
| 224 | + | return "", fmt.Errorf("reasoning and thinking disagree; provide only one") | |||
| 225 | + | } | |||
| 226 | + | value := reasoning | |||
| 227 | + | if value == "" { | |||
| 228 | + | value = thinking | |||
| 229 | + | } | |||
| 230 | + | if value == "" { | |||
| 231 | + | return "", nil | |||
| 232 | + | } | |||
| 233 | + | normalized := provider.NormalizeReasoning(value) | |||
| 234 | + | switch normalized { | |||
| 235 | + | case "": | |||
| 236 | + | return "off", nil | |||
| 237 | + | case "minimum", "low", "medium", "high", "xhigh", "max": | |||
| 238 | + | return normalized, nil | |||
| 239 | + | default: | |||
| 240 | + | return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max") | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 143 | func toolErr(msg string) core.ToolResult { | 244 | func toolErr(msg string) core.ToolResult { | ||
| 144 | return core.ToolResult{ | 245 | return core.ToolResult{ | ||
| 145 | Content: []provider.Content{provider.TextBlock{Text: msg}}, | 246 | Content: []provider.Content{provider.TextBlock{Text: msg}}, | ||
M packages/agent/tools/swarm_spawn_test.go +67 -7
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "strings" | |
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 11 | 12 | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | 13 | ) | |
| @@ -29,10 +30,11 @@ func newTestSwarm(t *testing.T) *swarm.Swarm { | |||
| 29 | 30 | ||
| 30 | 31 | func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |
| 31 | 32 | tool := &SwarmSpawnTool{ | |
| 32 | - | Swarm: newTestSwarm(t), | |
| 33 | - | Enabled: func() bool { return true }, | |
| 34 | - | DefaultModel: func() string { return "gpt-5" }, | |
| 35 | - | DefaultProvider: func() string { return "openai-codex" }, | |
| 33 | + | Swarm: newTestSwarm(t), | |
| 34 | + | Enabled: func() bool { return true }, | |
| 35 | + | DefaultModel: func() string { return "gpt-5" }, | |
| 36 | + | DefaultProvider: func() string { return "openai-codex" }, | |
| 37 | + | DefaultReasoning: func() string { return "medium" }, | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 38 | 40 | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | |
| @@ -52,8 +54,11 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||
| 52 | 54 | if got := details["provider"]; got != "openai-codex" { | |
| 53 | 55 | t.Fatalf("provider detail = %v, want openai-codex", got) | |
| 54 | 56 | } | |
| 57 | + | if got := details["reasoning"]; got != "medium" { | |
| 58 | + | t.Fatalf("reasoning detail = %v, want medium", got) | |
| 59 | + | } | |
| 55 | 60 | text := textResult(res.Content) | |
| 56 | - | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") { | |
| 61 | + | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") { | |
| 57 | 62 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | |
| 58 | 63 | } | |
| 59 | 64 | ||
| @@ -61,8 +66,63 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||
| 61 | 66 | if len(agents) != 1 { | |
| 62 | 67 | t.Fatalf("spawned agents = %d, want 1", len(agents)) | |
| 63 | 68 | } | |
| 64 | - | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" { | |
| 65 | - | t.Fatalf("agent model/provider = %q/%q, want gpt-5/openai-codex", agents[0].Model, agents[0].Provider) | |
| 69 | + | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { | |
| 70 | + | t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |
| 75 | + | tool := &SwarmSpawnTool{ | |
| 76 | + | Swarm: newTestSwarm(t), | |
| 77 | + | Enabled: func() bool { return true }, | |
| 78 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 79 | + | if name != "reviewer" { | |
| 80 | + | return nil, nil | |
| 81 | + | } | |
| 82 | + | return &subagents.Profile{ | |
| 83 | + | Name: "reviewer", | |
| 84 | + | Model: "openai-codex/gpt-5.6-luna", | |
| 85 | + | Thinking: "low", | |
| 86 | + | }, nil | |
| 87 | + | }, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer","reasoning":"high"}`), nil) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | if res.IsError { | |
| 95 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 96 | + | } | |
| 97 | + | text := textResult(res.Content) | |
| 98 | + | for _, want := range []string{"agent: reviewer", "model: gpt-5.6-luna", "provider: openai-codex", "reasoning: high"} { | |
| 99 | + | if !strings.Contains(text, want) { | |
| 100 | + | t.Fatalf("result text missing %q:\n%s", want, text) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | agents := tool.Swarm.List() | |
| 104 | + | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | |
| 105 | + | t.Fatalf("agent profile/reasoning = %#v", agents) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) { | |
| 110 | + | tool := &SwarmSpawnTool{ | |
| 111 | + | Swarm: newTestSwarm(t), | |
| 112 | + | Enabled: func() bool { return true }, | |
| 113 | + | ResolveSubagent: func(string) (*subagents.Profile, error) { | |
| 114 | + | return nil, nil | |
| 115 | + | }, | |
| 116 | + | } | |
| 117 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") { | |
| 122 | + | t.Fatalf("result = %#v, want unknown profile error", res) | |
| 123 | + | } | |
| 124 | + | if got := len(tool.Swarm.List()); got != 0 { | |
| 125 | + | t.Fatalf("spawned agents = %d, want 0", got) | |
| 66 | 126 | } | |
| 67 | 127 | } | |
| 68 | 128 | ||
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 10 | "github.com/patriceckhart/zot/packages/agent/swarm" | 11 | "github.com/patriceckhart/zot/packages/agent/swarm" | ||
| 11 | "github.com/patriceckhart/zot/packages/provider" | 12 | "github.com/patriceckhart/zot/packages/provider" | ||
| 12 | ) | 13 | ) | ||
| @@ -29,10 +30,11 @@ func newTestSwarm(t *testing.T) *swarm.Swarm { | |||||
| 29 | 30 | ||||
| 30 | func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | 31 | func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | ||
| 31 | tool := &SwarmSpawnTool{ | 32 | tool := &SwarmSpawnTool{ | ||
| 32 | - | Swarm: newTestSwarm(t), | 33 | + | Swarm: newTestSwarm(t), |
| 33 | - | Enabled: func() bool { return true }, | 34 | + | Enabled: func() bool { return true }, |
| 34 | - | DefaultModel: func() string { return "gpt-5" }, | 35 | + | DefaultModel: func() string { return "gpt-5" }, |
| 35 | - | DefaultProvider: func() string { return "openai-codex" }, | 36 | + | DefaultProvider: func() string { return "openai-codex" }, |
| 37 | + | DefaultReasoning: func() string { return "medium" }, | |||
| 36 | } | 38 | } | ||
| 37 | 39 | ||||
| 38 | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | 40 | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | ||
| @@ -52,8 +54,11 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||||
| 52 | if got := details["provider"]; got != "openai-codex" { | 54 | if got := details["provider"]; got != "openai-codex" { | ||
| 53 | t.Fatalf("provider detail = %v, want openai-codex", got) | 55 | t.Fatalf("provider detail = %v, want openai-codex", got) | ||
| 54 | } | 56 | } | ||
| 57 | + | if got := details["reasoning"]; got != "medium" { | |||
| 58 | + | t.Fatalf("reasoning detail = %v, want medium", got) | |||
| 59 | + | } | |||
| 55 | text := textResult(res.Content) | 60 | text := textResult(res.Content) | ||
| 56 | - | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") { | 61 | + | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") { |
| 57 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | 62 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | ||
| 58 | } | 63 | } | ||
| 59 | 64 | ||||
| @@ -61,8 +66,63 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||||
| 61 | if len(agents) != 1 { | 66 | if len(agents) != 1 { | ||
| 62 | t.Fatalf("spawned agents = %d, want 1", len(agents)) | 67 | t.Fatalf("spawned agents = %d, want 1", len(agents)) | ||
| 63 | } | 68 | } | ||
| 64 | - | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" { | 69 | + | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { |
| 65 | - | t.Fatalf("agent model/provider = %q/%q, want gpt-5/openai-codex", agents[0].Model, agents[0].Provider) | 70 | + | t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning) |
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |||
| 75 | + | tool := &SwarmSpawnTool{ | |||
| 76 | + | Swarm: newTestSwarm(t), | |||
| 77 | + | Enabled: func() bool { return true }, | |||
| 78 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |||
| 79 | + | if name != "reviewer" { | |||
| 80 | + | return nil, nil | |||
| 81 | + | } | |||
| 82 | + | return &subagents.Profile{ | |||
| 83 | + | Name: "reviewer", | |||
| 84 | + | Model: "openai-codex/gpt-5.6-luna", | |||
| 85 | + | Thinking: "low", | |||
| 86 | + | }, nil | |||
| 87 | + | }, | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer","reasoning":"high"}`), nil) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | if res.IsError { | |||
| 95 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 96 | + | } | |||
| 97 | + | text := textResult(res.Content) | |||
| 98 | + | for _, want := range []string{"agent: reviewer", "model: gpt-5.6-luna", "provider: openai-codex", "reasoning: high"} { | |||
| 99 | + | if !strings.Contains(text, want) { | |||
| 100 | + | t.Fatalf("result text missing %q:\n%s", want, text) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | agents := tool.Swarm.List() | |||
| 104 | + | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | |||
| 105 | + | t.Fatalf("agent profile/reasoning = %#v", agents) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) { | |||
| 110 | + | tool := &SwarmSpawnTool{ | |||
| 111 | + | Swarm: newTestSwarm(t), | |||
| 112 | + | Enabled: func() bool { return true }, | |||
| 113 | + | ResolveSubagent: func(string) (*subagents.Profile, error) { | |||
| 114 | + | return nil, nil | |||
| 115 | + | }, | |||
| 116 | + | } | |||
| 117 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") { | |||
| 122 | + | t.Fatalf("result = %#v, want unknown profile error", res) | |||
| 123 | + | } | |||
| 124 | + | if got := len(tool.Swarm.List()); got != 0 { | |||
| 125 | + | t.Fatalf("spawned agents = %d, want 0", got) | |||
| 66 | } | 126 | } | ||
| 67 | } | 127 | } | ||
| 68 | 128 | ||||