detached e6dacd2

feat(agent): add named subagent profiles

commit
e6dacd21bde48123c0f1be3749fb9aac6e1dc555
author
brice · · 2026-08-04 04:18 UTC
parent
e455c59
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +1232 -121
M .gitignore +1 -0
@@ -72,4 +72,5 @@ logs/
7272 *.rej
7373 .tmp/
7474 tmp/
75+.agent-reviews/
7576 npm/
@@ -72,4 +72,5 @@ logs/
72 *.rej72 *.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.
2626 - user and extension themes via JSON; see [docs/themes.md](docs/themes.md).
2727 - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd).
2828 - 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).
2930 - portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md).
3031
3132 ## Install
@@ -280,7 +281,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
280281 | `/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`. |
281282 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
282283 | `/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). |
284285 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
285286 | `/compact` | Summarize the transcript into one message to free up context. |
286287 | `/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
341342 ```
342343 /swarm # open the dashboard
343344 /swarm new <task> # spawn an agent
345+/swarm new --agent reviewer <task> # use a named markdown profile
344346 /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
345348 /swarm logs <id> # jump straight into one agent's transcript
346349 /swarm send <id> <text> # send a follow-up without opening the dashboard
347350 /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
376379
377380 **`/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.
378381
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.
380387
381388 ### `/settings`
382389
383390 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:
384391
385392 - **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.
387394 - **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.
388395 - **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.
389396 - **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
10261033 packages/agent/modes/telegram/ telegram adapter, api client, daemon
10271034 packages/agent/tools/ read, write, edit, bash, sandbox
10281035 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1036+packages/agent/subagents/ named markdown profile discovery and dispatch
10291037 packages/agent/swarm/ background subagent runtime
10301038 packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk)
10311039 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 ## Install32 ## 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 dashboard343 /swarm # open the dashboard
343 /swarm new <task> # spawn an agent344 /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 model346 /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 transcript348 /swarm logs <id> # jump straight into one agent's transcript
346 /swarm send <id> <text> # send a follow-up without opening the dashboard349 /swarm send <id> <text> # send a follow-up without opening the dashboard
347 /swarm resume # pick a stopped agent to bring back350 /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, daemon1033 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1027 packages/agent/tools/ read, write, edit, bash, sandbox1034 packages/agent/tools/ read, write, edit, bash, sandbox
1028 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1035 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 runtime1037 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 {
119119 // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
120120 SwarmAgent string
121121
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+
122128 // AgentName/AgentDataDir/PermissionSet are populated by `zot run`
123129 // for local zotfile agents. They scope sessions and enforce the
124130 // manifest's declared file/bash permissions.
@@ -273,6 +279,12 @@ func ParseArgs(in []string) (Args, error) {
273279 }
274280 a.SwarmAgent = v
275281 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
276288 case "--tools":
277289 v, err := want(&i, arg)
278290 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 string120 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 the129 // 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 = v280 a.SwarmAgent = v
275 a.Mode = ModeSwarmAgent281 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
22
33 import "testing"
44
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+
515 func TestParseArgsTemperatureAllowsZero(t *testing.T) {
616 args, err := ParseArgs([]string{"--temperature", "0"})
717 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 (
1010
1111 zotdocs "github.com/patriceckhart/zot"
1212 "github.com/patriceckhart/zot/packages/agent/skills"
13+ "github.com/patriceckhart/zot/packages/agent/subagents"
1314 "github.com/patriceckhart/zot/packages/agent/tools"
1415 "github.com/patriceckhart/zot/packages/core"
1516 "github.com/patriceckhart/zot/packages/provider"
@@ -302,6 +303,44 @@ func canonicalProvider(name string) string {
302303 return n
303304 }
304305
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+
305344 // Resolve merges args, config, and env into a Resolved set.
306345 //
307346 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -310,6 +349,15 @@ func canonicalProvider(name string) string {
310349 // hard error (used by print/json modes).
311350 func Resolve(args Args, requireCred bool) (Resolved, error) {
312351 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+ }
313361
314362 // User-requested provider (explicit > config > default).
315363 // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
@@ -586,7 +634,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
586634 skillTool *skills.Tool
587635 skillAddendum string
588636 )
589- if !args.NoSkill {
637+ if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) {
590638 homeDir, _ := os.UserHomeDir()
591639 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
592640 if len(discovered) > 0 {
@@ -599,7 +647,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
599647
600648 summaries := toolSummaries(reg, args)
601649
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+ }
603654 append_ := append([]string(nil), args.AppendSystemPrompt...)
604655 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
605656 append_ = append(append_, agentsAddendum)
@@ -607,16 +658,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
607658 if skillAddendum != "" {
608659 append_ = append(append_, skillAddendum)
609660 }
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+ }
611668 append_ = append(append_, AutoSwarmSystemAddendum)
612669 }
670+ if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
671+ append_ = append(append_, selectedProfile.SystemPrompt)
672+ }
613673
614674 // Custom system prompt resolution order:
615675 // 1. --system-prompt flag (highest priority; ad-hoc per run)
616676 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
617677 // 3. built-in default (defaultIdentity + defaultGuidelines)
618678 custom := args.SystemPrompt
619- if custom == "" {
679+ if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" {
680+ custom = selectedProfile.SystemPrompt
681+ } else if custom == "" {
620682 custom = readUserSystemPrompt(ZotHome())
621683 }
622684
@@ -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 n303 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 for346 // 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.Tool634 skillTool *skills.Tool
587 skillAddendum string635 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.SystemPrompt678 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) {
5959 }
6060 }
6161
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+
62153 func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
63154 files := loadAgentsContext(t.TempDir(), t.TempDir())
64155 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 (
1919 "github.com/patriceckhart/zot/packages/agent/extproto"
2020 "github.com/patriceckhart/zot/packages/agent/modes"
2121 "github.com/patriceckhart/zot/packages/agent/skills"
22+ "github.com/patriceckhart/zot/packages/agent/subagents"
2223 "github.com/patriceckhart/zot/packages/agent/swarm"
2324 "github.com/patriceckhart/zot/packages/agent/tools"
2425 "github.com/patriceckhart/zot/packages/core"
@@ -729,6 +730,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
729730 iv.TrackSwarmAgent(a, task)
730731 }
731732 }
733+ resolveSubagent := func(name string) (*subagents.Profile, error) {
734+ return findSubagentProfile(r.CWD, name)
735+ }
732736
733737 // Inject the swarm_spawn auto-swarm tool only when /settings ->
734738 // auto-swarm is currently enabled. Registering it unconditionally
@@ -744,11 +748,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
744748 return reg
745749 }
746750 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,
752758 }
753759 return reg
754760 }
@@ -1247,6 +1253,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12471253 if r.SkillTool != nil {
12481254 startupSkills = r.SkillTool.Skills()
12491255 }
1256+ homeDir, _ := os.UserHomeDir()
1257+ discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
1258+ subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
12501259
12511260 iv = modes.NewInteractive(modes.InteractiveConfig{
12521261 Terminal: term,
@@ -1267,6 +1276,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12671276 CompactUser: initialCfg.CompactUserInput(),
12681277 ExtensionThemes: extMgr.ThemeOptions,
12691278 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1279+ SubagentsSystemAddendum: subagentsAddendum,
12701280 SettingsStore: configSettingsStore{},
12711281 Model: r.Model,
12721282 Provider: r.Provider,
@@ -1360,9 +1370,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13601370 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
13611371 sessBaselineMsgs = len(currentAg.Messages())
13621372 },
1363- Extensions: extMgr,
1364- Swarm: swarmMgr,
1365- ChangelogChan: changelogCh,
1373+ Extensions: extMgr,
1374+ Swarm: swarmMgr,
1375+ ResolveSubagent: resolveSubagent,
1376+ ChangelogChan: changelogCh,
13661377 OnChangelogDismiss: func() {
13671378 // For dev builds (0.0.0) store the actual release version
13681379 // 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 unconditionally738 // auto-swarm is currently enabled. Registering it unconditionally
@@ -744,11 +748,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
744 return reg748 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 reg759 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 version1378 // 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 (
1111 "github.com/patriceckhart/zot/packages/core"
1212 )
1313
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+
1439 func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
1540 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
1641 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 (
1616 "github.com/patriceckhart/zot/packages/agent/extproto"
1717 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
1818 "github.com/patriceckhart/zot/packages/agent/skills"
19+ "github.com/patriceckhart/zot/packages/agent/subagents"
1920 "github.com/patriceckhart/zot/packages/agent/swarm"
2021 "github.com/patriceckhart/zot/packages/agent/tools"
2122 "github.com/patriceckhart/zot/packages/core"
@@ -123,6 +124,10 @@ type InteractiveConfig struct {
123124 // Plumbed in from the cli so this package doesn't have to import
124125 // agent (cycle).
125126 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
126131 SettingsStore SettingsStore
127132
128133 // Agent is optional. If nil, zot opens without credentials; the
@@ -262,6 +267,11 @@ type InteractiveConfig struct {
262267 // subprocesses).
263268 Swarm *swarm.Swarm
264269
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+
265275 // SkillSnapshot, if non-nil, returns the current list of
266276 // discovered SKILL.md files. Re-invoked each time /skills opens
267277 // so the picker reflects edits made during the session.
@@ -3052,9 +3062,14 @@ func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider
30523062 }
30533063
30543064 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+
30553069 i.mu.Lock()
30563070 i.agent = ag
30573071 i.cfg.CWD = cwd
3072+ i.cfg.SubagentsSystemAddendum = subagentsAddendum
30583073 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
30593074 i.view.StartupContextPaths = nil
30603075 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
@@ -6487,28 +6502,48 @@ func truncateForSummary(s string, n int) string {
64876502 }
64886503
64896504 // 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.
64946508 func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
64956509 if i.agent == nil {
64966510 return
64976511 }
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 {
65006520 return
65016521 }
6522+
65026523 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
65086533 }
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"
65126547 }
65136548 }
65146549
@@ -6535,11 +6570,13 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
65356570 }
65366571 if active && i.cfg.Swarm != nil {
65376572 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,
65436580 }
65446581 }
65456582 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 import124 // Plumbed in from the cli so this package doesn't have to import
124 // agent (cycle).125 // agent (cycle).
125 AutoSwarmSystemAddendum string126 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 SettingsStore131 SettingsStore SettingsStore
127 132
128 // Agent is optional. If nil, zot opens without credentials; the133 // Agent is optional. If nil, zot opens without credentials; the
@@ -262,6 +267,11 @@ type InteractiveConfig struct {
262 // subprocesses).267 // subprocesses).
263 Swarm *swarm.Swarm268 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 of275 // SkillSnapshot, if non-nil, returns the current list of
266 // discovered SKILL.md files. Re-invoked each time /skills opens276 // 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 = ag3070 i.agent = ag
3057 i.cfg.CWD = cwd3071 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 = nil3074 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 strips6504 // applyAutoSwarmSystemPrompt appends (active=true) or strips
6490-// (active=false) the auto-swarm system-prompt block on the running6505+// (active=false) the auto-swarm prompt blocks on the running agent.
6491-// agent so the model proactively considers swarm_spawn when the user6506+// The profile manifest and delegation guidance are managed together so
6492-// flips the toggle. The block lives at the tail of agent.System so6507+// 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 return6510 return
6497 }6511 }
6498- addendum := i.cfg.AutoSwarmSystemAddendum6512+ 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 return6520 return
6501 }6521 }
6522+
6502 sys := i.agent.System6523 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 + addendum6534+ 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
275275 if a.Model != "" {
276276 row++
277277 }
278+ if a.Subagent != "" {
279+ row++
280+ }
281+ if a.Reasoning != "" {
282+ row++
283+ }
278284 if a.Err != "" {
279285 row++
280286 }
@@ -1095,6 +1101,12 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
10951101 }
10961102 header = append(header, " "+th.FG256(th.Muted, modelLine))
10971103 }
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+ }
10981110 if a.Err != "" {
10991111 header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err))
11001112 }
@@ -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 (
1313 //
1414 // /swarm -> open the dashboard
1515 // /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
1818 // /swarm kill <id> -> stop a running agent
1919 // /swarm remove <id> -> tear down a terminated agent
2020 // /swarm logs <id> -> open the scrollable transcript view
@@ -53,7 +53,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
5353 // feed the dialog identical callbacks.
5454 spawnAdapter := func(task, model, provider string) error {
5555 _, 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,
5757 })
5858 return err
5959 }
@@ -93,30 +93,53 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
9393 i.swarmStatus("", "/swarm new <task>: missing task")
9494 return
9595 }
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)
104101 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)")
106103 return
107104 }
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+ }
108129 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,
110131 })
111132 if err != nil {
112133 i.swarmStatus("", "spawn: "+err.Error())
113134 return
114135 }
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 + ")"
119141 }
142+ i.swarmStatus(status, "")
120143 case "kill", "stop":
121144 if rest == "" {
122145 i.swarmStatus("", "/swarm kill <id>: missing id")
@@ -220,24 +243,40 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
220243 }
221244 }
222245
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.
225248 // We deliberately only honour LEADING flags so a task like "check
226249 // --model lookup" doesn't accidentally swallow part of its prose as
227250 // the model name.
228251 //
229252 // Recognised forms:
230253 //
254+// --agent X two-token form
255+// --agent=X single-token form
231256 // --model X two-token form
232257 // --model=X single-token form
233258 // --provider X two-token form
234259 // --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) {
236263 fields := strings.Fields(s)
237264 i := 0
238265 for i < len(fields) {
239266 f := fields[i]
240267 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
241280 case f == "--model":
242281 // Consume the flag even when no value follows so a
243282 // dangling "--model" doesn't leak into the task. The
@@ -265,6 +304,22 @@ func parseSpawnFlags(s string) (model, provider, task string) {
265304 provider = strings.TrimPrefix(f, "--provider=")
266305 i++
267306 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
268323 }
269324 break
270325 }
@@ -13,8 +13,8 @@ import (
13 //13 //
14 // /swarm -> open the dashboard14 // /swarm -> open the dashboard
15 // /swarm list -> open the dashboard15 // /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 agent18 // /swarm kill <id> -> stop a running agent
19 // /swarm remove <id> -> tear down a terminated agent19 // /swarm remove <id> -> tear down a terminated agent
20 // /swarm logs <id> -> open the scrollable transcript view20 // /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 err58 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 return94 return
95 }95 }
96- // Permit `--model X --provider Y` flags before the task so96+ // 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 parsing98+ // Anything that isn't a recognised flag terminates parsing and
99- // and the rest becomes the task; this keeps `/swarm new99+ // 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 return103 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 return134 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 "check248 // We deliberately only honour LEADING flags so a task like "check
226 // --model lookup" doesn't accidentally swallow part of its prose as249 // --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 form256 // --model X two-token form
232 // --model=X single-token form257 // --model=X single-token form
233 // --provider X two-token form258 // --provider X two-token form
234 // --provider=X single-token form259 // --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 := 0264 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 a281 // Consume the flag even when no value follows so a
243 // dangling "--model" doesn't leak into the task. The282 // 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 continue306 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 break324 break
270 }325 }
M packages/agent/modes/swarm_slash_test.go +17 -14
@@ -177,26 +177,29 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
177177
178178 func TestParseSpawnFlags(t *testing.T) {
179179 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
183184 }{
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"},
189192 // Only LEADING flags are consumed.
190- {"do --model x", "", "", "do --model x"},
193+ {"do --model x", "", "", "", "", "do --model x"},
191194 // Missing value: --model with no follow-up token leaves model empty
192195 // and the next field starts the task.
193- {"--model", "", "", ""},
196+ {"--model", "", "", "", "", ""},
194197 }
195198 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)
200203 }
201204 }
202205 }
@@ -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 string180+ in string
181- wantModel, wantProv string181+ wantModel, wantProv string
182- wantTask string182+ 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 empty194 // 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 {
183183 // by name. Kept short so it doesn't bloat the cached prompt prefix.
184184 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.
185185
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.
187187
188188 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.
189189
@@ -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 {
2222 // the child resolves on its own from config / env / flags" —
2323 // the historical behaviour. Persisted in meta.json so Resume
2424 // 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
2729
2830 // SessionID, when non-empty, scopes the agent to a particular
2931 // 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 Resume23 // 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 string25+ Model string
26- Provider string26+ Provider string
27+ Reasoning string
28+ Subagent string
27 29
28 // SessionID, when non-empty, scopes the agent to a particular30 // SessionID, when non-empty, scopes the agent to a particular
29 // host zot session: the dashboard only surfaces agents whose31 // host zot session: the dashboard only surfaces agents whose
M packages/agent/swarm/persist.go +9 -0
@@ -44,6 +44,8 @@ type agentMeta struct {
4444 Started time.Time `json:"started"`
4545 Model string `json:"model,omitempty"`
4646 Provider string `json:"provider,omitempty"`
47+ Reasoning string `json:"reasoning,omitempty"`
48+ Subagent string `json:"subagent,omitempty"`
4749 InboxPath string `json:"inbox_path"`
4850 EventLogPath string `json:"event_log_path"`
4951 SessionPath string `json:"session_path"`
@@ -71,6 +73,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
7173 Started: a.Started,
7274 Model: a.Model,
7375 Provider: a.Provider,
76+ Reasoning: a.Reasoning,
77+ Subagent: a.Subagent,
7478 InboxPath: a.InboxPath,
7579 EventLogPath: a.EventLogPath,
7680 SessionPath: a.SessionPath,
@@ -205,6 +209,8 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
205209 Started: m.Started,
206210 Model: m.Model,
207211 Provider: m.Provider,
212+ Reasoning: m.Reasoning,
213+ Subagent: m.Subagent,
208214 InboxPath: m.InboxPath,
209215 EventLogPath: m.EventLogPath,
210216 SessionPath: m.SessionPath,
@@ -346,6 +352,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
346352 ID: existing.ID, Task: existing.Task,
347353 Dir: existing.Dir, Started: existing.Started,
348354 Model: existing.Model, Provider: existing.Provider,
355+ Reasoning: existing.Reasoning, Subagent: existing.Subagent,
349356 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
350357 SessionPath: existing.SessionPath,
351358 }
@@ -357,6 +364,8 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
357364 Started: m.Started,
358365 Model: m.Model,
359366 Provider: m.Provider,
367+ Reasoning: m.Reasoning,
368+ Subagent: m.Subagent,
360369 InboxPath: m.InboxPath,
361370 EventLogPath: m.EventLogPath,
362371 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) {
538538 })
539539 a, err := f.SpawnReq(context.Background(), SpawnRequest{
540540 Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",
541+ Reasoning: "high", Subagent: "reviewer",
541542 })
542543 if err != nil {
543544 t.Fatalf("spawn: %v", err)
544545 }
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)
547548 }
548549 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)
551552 }
552553
553554 // Stop so we can read meta.json without racing the run loop.
@@ -564,8 +565,8 @@ func TestSpawnReqPersistsModel(t *testing.T) {
564565 if err := json.Unmarshal(metaBytes, &got); err != nil {
565566 t.Fatal(err)
566567 }
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)
569570 }
570571
571572 // Reload in a fresh Swarm and confirm the detached agent still
@@ -579,8 +580,8 @@ func TestSpawnReqPersistsModel(t *testing.T) {
579580 if re == nil {
580581 t.Fatal("reloaded agent missing")
581582 }
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)
584585 }
585586 }
586587
@@ -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 still572 // 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 {
5454 SessionPath string
5555 }
5656
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.
6261 type swarmAgentArgsOpts struct {
6362 Exe string
6463 Dir string
@@ -67,6 +66,8 @@ type swarmAgentArgsOpts struct {
6766 Task string
6867 Model string
6968 Provider string
69+ Reasoning string
70+ Subagent string
7071 }
7172
7273 // defaultChildArgs builds the argv execRunner uses when its Command
@@ -96,6 +97,8 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
9697 Task: task,
9798 Model: a.Model,
9899 Provider: a.Provider,
100+ Reasoning: a.Reasoning,
101+ Subagent: a.Subagent,
99102 })
100103 }
101104
@@ -115,6 +118,12 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
115118 if opts.Provider != "" {
116119 args = append(args, "--provider", opts.Provider)
117120 }
121+ if opts.Reasoning != "" {
122+ args = append(args, "--reasoning", opts.Reasoning)
123+ }
124+ if opts.Subagent != "" {
125+ args = append(args, "--subagent", opts.Subagent)
126+ }
118127 if opts.Task != "" {
119128 // First task is positional so the child treats it as the
120129 // initial user turn; subsequent turns arrive via the inbox.
@@ -54,11 +54,10 @@ type execRunner struct {
54 SessionPath string54 SessionPath string
55 }55 }
56 56
57-// swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs57+// swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs.
58-// so future per-agent overrides (e.g. tools, reasoning) can be added58+// 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 CLI59+// entirely and let the child resolve a default the same way a normal
60-// flags; empty values omit the flag entirely and let the child60+// `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 string62 Exe string
64 Dir string63 Dir string
@@ -67,6 +66,8 @@ type swarmAgentArgsOpts struct {
67 Task string66 Task string
68 Model string67 Model string
69 Provider string68 Provider string
69+ Reasoning string
70+ Subagent string
70 }71 }
71 72
72 // defaultChildArgs builds the argv execRunner uses when its Command73 // 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 the128 // 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) {
113113 SessionPath: "/tmp/state/session.json",
114114 InboxPath: "/tmp/state/in.sock",
115115 Task: "do the thing",
116+ Reasoning: "high",
117+ Subagent: "reviewer",
116118 })
117119 if len(args) < 7 {
118120 t.Fatalf("argv unexpectedly short: %v", args)
@@ -130,6 +132,8 @@ func TestSwarmAgentArgs(t *testing.T) {
130132 "--swarm-agent": "/tmp/state/in.sock",
131133 "--session": "/tmp/state/session.json",
132134 "--cwd": "/tmp/worktree",
135+ "--reasoning": "high",
136+ "--subagent": "reviewer",
133137 }
134138 for flag, value := range mustHave {
135139 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 {
177177 // SpawnRequest configures a Spawn. Only Task is required; the rest
178178 // are optional. Model + Provider, when set, get baked into the
179179 // 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.
181184 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
185190 }
186191
187192 // 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)
238243 Started: f.cfg.Now(),
239244 Model: strings.TrimSpace(req.Model),
240245 Provider: strings.TrimSpace(req.Provider),
246+ Reasoning: strings.TrimSpace(req.Reasoning),
247+ Subagent: strings.TrimSpace(req.Subagent),
241248 SessionID: sessionID,
242249 InboxPath: inboxPath,
243250 EventLogPath: logPath,
@@ -444,12 +451,15 @@ type AgentSnapshot struct {
444451 Lines []string // full transcript (already capped by Agent.appendTranscript)
445452 LastAssistant string // complete final assistant message, without role formatting
446453
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
453463
454464 // Paths to the agent's durable state. Surface them in the
455465 // snapshot so the dashboard / /swarm open can read events.jsonl
@@ -479,6 +489,8 @@ func (a *Agent) Snapshot() AgentSnapshot {
479489 LastAssistant: a.lastAssistant,
480490 Model: a.Model,
481491 Provider: a.Provider,
492+ Reasoning: a.Reasoning,
493+ Subagent: a.Subagent,
482494 InboxPath: a.InboxPath,
483495 EventLogPath: a.EventLogPath,
484496 SessionPath: a.SessionPath,
@@ -177,11 +177,16 @@ func (f *Swarm) agentStateDir(id string) string {
177 // SpawnRequest configures a Spawn. Only Task is required; the rest177 // SpawnRequest configures a Spawn. Only Task is required; the rest
178 // are optional. Model + Provider, when set, get baked into the178 // are optional. Model + Provider, when set, get baked into the
179 // child argv as --model / --provider so the agent runs against the179 // 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 string185+ Task string
183- Model string // optional override; child resolves default if empty186+ Model string // optional override; child resolves default if empty
184- Provider string // optional override; usually paired with Model187+ 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 its192 // 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 formatting452 LastAssistant string // complete final assistant message, without role formatting
446 453
447- // Model and Provider expose the per-agent overrides set at454+ // Model, Provider, and Reasoning expose the per-agent overrides set
448- // Spawn time (empty when the agent inherits the child's default455+ // at Spawn time (empty when the agent inherits the child's default
449- // resolution). The dashboard surfaces these so the user can456+ // 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 string458+ // configuration.
452- Provider string459+ Model string
460+ Provider string
461+ Reasoning string
462+ Subagent string
453 463
454 // Paths to the agent's durable state. Surface them in the464 // Paths to the agent's durable state. Surface them in the
455 // snapshot so the dashboard / /swarm open can read events.jsonl465 // 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 (
66 "fmt"
77 "strings"
88
9+ "github.com/patriceckhart/zot/packages/agent/subagents"
910 "github.com/patriceckhart/zot/packages/agent/swarm"
1011 "github.com/patriceckhart/zot/packages/core"
1112 "github.com/patriceckhart/zot/packages/provider"
@@ -33,22 +34,30 @@ type SwarmSpawnTool struct {
3334
3435 // DefaultModel and DefaultProvider return the host agent's resolved
3536 // 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)
4046
4147 // OnSpawned, if set, is called after every successful spawn with
4248 // the new agent + the task it was started with. Used by the
4349 // 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.
4551 OnSpawned func(agent *swarm.Agent, task string)
4652 }
4753
4854 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"`
5261 }
5362
5463 const swarmSpawnSchema = `{
@@ -56,15 +65,29 @@ const swarmSpawnSchema = `{
5665 "properties": {
5766 "task": {
5867 "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."
6073 },
6174 "model": {
6275 "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."
6477 },
6578 "provider": {
6679 "type": "string",
6780 "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."
6891 }
6992 },
7093 "required": ["task"]
@@ -72,7 +95,7 @@ const swarmSpawnSchema = `{
7295
7396 func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" }
7497 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."
7699 }
77100 func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }
78101
@@ -92,12 +115,39 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
92115 return toolErr("swarm_spawn: task is required"), nil
93116 }
94117
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+
95135 model := strings.TrimSpace(a.Model)
96136 providerID := strings.TrimSpace(a.Provider)
97137 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
99139 }
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 {
101151 if t.DefaultModel != nil {
102152 model = strings.TrimSpace(t.DefaultModel())
103153 }
@@ -106,10 +156,29 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
106156 }
107157 }
108158
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+
109176 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,
113182 })
114183 if err != nil {
115184 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
@@ -121,25 +190,57 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
121190 var sb strings.Builder
122191 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
123192 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
193+ if agentName != "" {
194+ fmt.Fprintf(&sb, "agent: %s\n", agentName)
195+ }
124196 if model != "" {
125197 fmt.Fprintf(&sb, "model: %s\n", model)
126198 }
127199 if providerID != "" {
128200 fmt.Fprintf(&sb, "provider: %s\n", providerID)
129201 }
202+ if reasoning != "" {
203+ fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
204+ }
130205 sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
131206 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
132207 return core.ToolResult{
133208 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
134209 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,
139216 },
140217 }, nil
141218 }
142219
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+
143244 func toolErr(msg string) core.ToolResult {
144245 return core.ToolResult{
145246 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 resolved35 // DefaultModel and DefaultProvider return the host agent's resolved
35 // model and provider. They are used when the tool call omits both36 // 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 sees37+ // 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() string39+ DefaultModel func() string
39- DefaultProvider func() string40+ 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 with47 // OnSpawned, if set, is called after every successful spawn with
42 // the new agent + the task it was started with. Used by the48 // the new agent + the task it was started with. Used by the
43 // interactive host to track agents and surface a summary back49 // 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"), nil115 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"), nil138+ 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.Builder190 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 }, nil217 }, 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 (
77 "strings"
88 "testing"
99
10+ "github.com/patriceckhart/zot/packages/agent/subagents"
1011 "github.com/patriceckhart/zot/packages/agent/swarm"
1112 "github.com/patriceckhart/zot/packages/provider"
1213 )
@@ -29,10 +30,11 @@ func newTestSwarm(t *testing.T) *swarm.Swarm {
2930
3031 func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
3132 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" },
3638 }
3739
3840 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
@@ -52,8 +54,11 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
5254 if got := details["provider"]; got != "openai-codex" {
5355 t.Fatalf("provider detail = %v, want openai-codex", got)
5456 }
57+ if got := details["reasoning"]; got != "medium" {
58+ t.Fatalf("reasoning detail = %v, want medium", got)
59+ }
5560 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") {
5762 t.Fatalf("result text missing inherited model/provider:\n%s", text)
5863 }
5964
@@ -61,8 +66,63 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
6166 if len(agents) != 1 {
6267 t.Fatalf("spawned agents = %d, want 1", len(agents))
6368 }
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)
66126 }
67127 }
68128
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close