detached 9eef239

Merge pull request #1 from bnema/feat/named-subagent-profiles

feat(agent): add named subagent profiles
commit
9eef2390647a08f472759c00bf6aa0d035e0f7ca
author
bnema · · 2026-08-04 05:16 UTC
committer
GitHub · · 2026-08-04 05:16 UTC
merge of
e455c59 + bbb672b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
25 files · +1673 -184
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, write, edit, bash
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, write, edit, bash
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 +69 -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,47 @@ 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, discoveryErrors := subagents.Discover(cwd, home)
313+ if len(discoveryErrors) > 0 {
314+ return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors))
315+ }
316+ profile := subagents.Find(profiles, name)
317+ if profile == nil {
318+ return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name)
319+ }
320+ return profile, nil
321+}
322+
323+func applySubagentProfile(args *Args, profile *subagents.Profile) {
324+ if args == nil || profile == nil {
325+ return
326+ }
327+ profileProvider, profileModel := profile.ModelSelection()
328+ if args.Provider == "" && profileProvider != "" {
329+ args.Provider = profileProvider
330+ }
331+ if args.Model == "" && profileModel != "" {
332+ args.Model = profileModel
333+ }
334+ if args.Reasoning == "" && profile.Thinking != "" {
335+ // Keep an explicit "off" value non-empty so it overrides a
336+ // persisted global reasoning setting when the child resolves.
337+ args.Reasoning = strings.TrimSpace(profile.Thinking)
338+ }
339+ if !args.NoTools && len(args.Tools) == 0 && len(profile.Tools) > 0 {
340+ args.Tools = append([]string(nil), profile.Tools...)
341+ }
342+ if profile.InheritSkills != nil && !*profile.InheritSkills {
343+ args.NoSkill = true
344+ }
345+}
346+
305347 // Resolve merges args, config, and env into a Resolved set.
306348 //
307349 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -310,6 +352,15 @@ func canonicalProvider(name string) string {
310352 // hard error (used by print/json modes).
311353 func Resolve(args Args, requireCred bool) (Resolved, error) {
312354 cfg, _ := LoadConfig()
355+ var selectedProfile *subagents.Profile
356+ if strings.TrimSpace(args.Subagent) != "" {
357+ var err error
358+ selectedProfile, err = findSubagentProfile(args.CWD, args.Subagent)
359+ if err != nil {
360+ return Resolved{}, err
361+ }
362+ applySubagentProfile(&args, selectedProfile)
363+ }
313364
314365 // User-requested provider (explicit > config > default).
315366 // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
@@ -586,7 +637,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
586637 skillTool *skills.Tool
587638 skillAddendum string
588639 )
589- if !args.NoSkill {
640+ if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) {
590641 homeDir, _ := os.UserHomeDir()
591642 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
592643 if len(discovered) > 0 {
@@ -599,7 +650,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
599650
600651 summaries := toolSummaries(reg, args)
601652
602- contextFiles := loadAgentsContext(args.CWD, ZotHome())
653+ contextFiles := []ContextFile(nil)
654+ if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {
655+ contextFiles = loadAgentsContext(args.CWD, ZotHome())
656+ }
603657 append_ := append([]string(nil), args.AppendSystemPrompt...)
604658 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
605659 append_ = append(append_, agentsAddendum)
@@ -607,16 +661,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
607661 if skillAddendum != "" {
608662 append_ = append(append_, skillAddendum)
609663 }
610- if AutoSwarmEnabled() {
664+ interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
665+ if selectedProfile == nil && interactiveMode && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
666+ homeDir, _ := os.UserHomeDir()
667+ profiles, _ := subagents.Discover(args.CWD, homeDir)
668+ if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
669+ append_ = append(append_, subagentsAddendum)
670+ }
611671 append_ = append(append_, AutoSwarmSystemAddendum)
612672 }
673+ if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
674+ append_ = append(append_, selectedProfile.SystemPrompt)
675+ }
613676
614677 // Custom system prompt resolution order:
615678 // 1. --system-prompt flag (highest priority; ad-hoc per run)
616679 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
617680 // 3. built-in default (defaultIdentity + defaultGuidelines)
618681 custom := args.SystemPrompt
619- if custom == "" {
682+ if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" {
683+ custom = selectedProfile.SystemPrompt
684+ } else if custom == "" {
620685 custom = readUserSystemPrompt(ZotHome())
621686 }
622687
@@ -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,47 @@ 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, discoveryErrors := subagents.Discover(cwd, home)
313+ if len(discoveryErrors) > 0 {
314+ return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors))
315+ }
316+ profile := subagents.Find(profiles, name)
317+ if profile == nil {
318+ return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name)
319+ }
320+ return profile, nil
321+}
322+
323+func applySubagentProfile(args *Args, profile *subagents.Profile) {
324+ if args == nil || profile == nil {
325+ return
326+ }
327+ profileProvider, profileModel := profile.ModelSelection()
328+ if args.Provider == "" && profileProvider != "" {
329+ args.Provider = profileProvider
330+ }
331+ if args.Model == "" && profileModel != "" {
332+ args.Model = profileModel
333+ }
334+ if args.Reasoning == "" && profile.Thinking != "" {
335+ // Keep an explicit "off" value non-empty so it overrides a
336+ // persisted global reasoning setting when the child resolves.
337+ args.Reasoning = strings.TrimSpace(profile.Thinking)
338+ }
339+ if !args.NoTools && len(args.Tools) == 0 && len(profile.Tools) > 0 {
340+ args.Tools = append([]string(nil), profile.Tools...)
341+ }
342+ if profile.InheritSkills != nil && !*profile.InheritSkills {
343+ args.NoSkill = true
344+ }
345+}
346+
305 // Resolve merges args, config, and env into a Resolved set.347 // Resolve merges args, config, and env into a Resolved set.
306 //348 //
307 // Unlike the earlier version, Resolve NEVER returns an error for349 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -310,6 +352,15 @@ func canonicalProvider(name string) string {
310 // hard error (used by print/json modes).352 // hard error (used by print/json modes).
311 func Resolve(args Args, requireCred bool) (Resolved, error) {353 func Resolve(args Args, requireCred bool) (Resolved, error) {
312 cfg, _ := LoadConfig()354 cfg, _ := LoadConfig()
355+ var selectedProfile *subagents.Profile
356+ if strings.TrimSpace(args.Subagent) != "" {
357+ var err error
358+ selectedProfile, err = findSubagentProfile(args.CWD, args.Subagent)
359+ if err != nil {
360+ return Resolved{}, err
361+ }
362+ applySubagentProfile(&args, selectedProfile)
363+ }
313 364
314 // User-requested provider (explicit > config > default).365 // User-requested provider (explicit > config > default).
315 // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")366 // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
@@ -586,7 +637,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
586 skillTool *skills.Tool637 skillTool *skills.Tool
587 skillAddendum string638 skillAddendum string
588 )639 )
589- if !args.NoSkill {640+ if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) {
590 homeDir, _ := os.UserHomeDir()641 homeDir, _ := os.UserHomeDir()
591 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)642 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
592 if len(discovered) > 0 {643 if len(discovered) > 0 {
@@ -599,7 +650,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
599 650
600 summaries := toolSummaries(reg, args)651 summaries := toolSummaries(reg, args)
601 652
602- contextFiles := loadAgentsContext(args.CWD, ZotHome())653+ contextFiles := []ContextFile(nil)
654+ if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {
655+ contextFiles = loadAgentsContext(args.CWD, ZotHome())
656+ }
603 append_ := append([]string(nil), args.AppendSystemPrompt...)657 append_ := append([]string(nil), args.AppendSystemPrompt...)
604 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {658 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
605 append_ = append(append_, agentsAddendum)659 append_ = append(append_, agentsAddendum)
@@ -607,16 +661,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
607 if skillAddendum != "" {661 if skillAddendum != "" {
608 append_ = append(append_, skillAddendum)662 append_ = append(append_, skillAddendum)
609 }663 }
610- if AutoSwarmEnabled() {664+ interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
665+ if selectedProfile == nil && interactiveMode && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
666+ homeDir, _ := os.UserHomeDir()
667+ profiles, _ := subagents.Discover(args.CWD, homeDir)
668+ if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
669+ append_ = append(append_, subagentsAddendum)
670+ }
611 append_ = append(append_, AutoSwarmSystemAddendum)671 append_ = append(append_, AutoSwarmSystemAddendum)
612 }672 }
673+ if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
674+ append_ = append(append_, selectedProfile.SystemPrompt)
675+ }
613 676
614 // Custom system prompt resolution order:677 // Custom system prompt resolution order:
615 // 1. --system-prompt flag (highest priority; ad-hoc per run)678 // 1. --system-prompt flag (highest priority; ad-hoc per run)
616 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)679 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
617 // 3. built-in default (defaultIdentity + defaultGuidelines)680 // 3. built-in default (defaultIdentity + defaultGuidelines)
618 custom := args.SystemPrompt681 custom := args.SystemPrompt
619- if custom == "" {682+ if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" {
683+ custom = selectedProfile.SystemPrompt
684+ } else if custom == "" {
620 custom = readUserSystemPrompt(ZotHome())685 custom = readUserSystemPrompt(ZotHome())
621 }686 }
622 687
M packages/agent/build_test.go +112 -0
@@ -59,6 +59,118 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
5959 }
6060 }
6161
62+func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
63+ profileSource := filepath.Join(t.TempDir(), "profiles.md")
64+ if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
65+ t.Fatal(err)
66+ }
67+ t.Setenv("ZOT_AGENT_PROFILES", profileSource)
68+
69+ profile, err := findSubagentProfile("", "reviewer")
70+ if profile != nil {
71+ t.Fatalf("profile = %#v, want nil", profile)
72+ }
73+ if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
74+ t.Fatalf("error = %v, want discovery failure", err)
75+ }
76+ if strings.Contains(err.Error(), profileSource) {
77+ t.Fatalf("error leaked profile path: %v", err)
78+ }
79+}
80+
81+func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
82+ root := t.TempDir()
83+ home := filepath.Join(root, "home")
84+ zotHome := filepath.Join(root, "zot-home")
85+ project := filepath.Join(root, "repo")
86+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
87+ t.Fatal(err)
88+ }
89+ if err := os.MkdirAll(project, 0o755); err != nil {
90+ t.Fatal(err)
91+ }
92+ t.Setenv("HOME", home)
93+ t.Setenv("ZOT_HOME", zotHome)
94+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
95+ enabled := true
96+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil {
97+ t.Fatal(err)
98+ }
99+ profile := `---
100+name: reviewer
101+description: Read-only code reviewer
102+thinking: high
103+---
104+Review without editing.
105+`
106+ if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
107+ t.Fatal(err)
108+ }
109+
110+ r, err := Resolve(Args{CWD: project}, false)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} {
115+ if !strings.Contains(r.SystemPrompt, want) {
116+ t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
117+ }
118+ }
119+}
120+
121+func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
122+ root := t.TempDir()
123+ home := filepath.Join(root, "home")
124+ zotHome := filepath.Join(root, "zot-home")
125+ project := filepath.Join(root, "repo")
126+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
127+ t.Fatal(err)
128+ }
129+ if err := os.MkdirAll(project, 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ t.Setenv("HOME", home)
133+ t.Setenv("ZOT_HOME", zotHome)
134+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
135+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
139+ t.Fatal(err)
140+ }
141+ profile := `---
142+name: reviewer
143+description: Read-only code reviewer
144+tools: read
145+thinking: off
146+systemPromptMode: replace
147+inheritProjectContext: false
148+inheritSkills: false
149+---
150+You are a read-only reviewer.
151+`
152+ if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
153+ t.Fatal(err)
154+ }
155+
156+ r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if r.Reasoning != "" {
161+ t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
162+ }
163+ if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
164+ t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
165+ }
166+ if len(r.ContextFiles) != 0 {
167+ t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
168+ }
169+ if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
170+ t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
171+ }
172+}
173+
62174 func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
63175 files := loadAgentsContext(t.TempDir(), t.TempDir())
64176 if len(files) != 0 {
@@ -59,6 +59,118 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
59 }59 }
60 }60 }
61 61
62+func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
63+ profileSource := filepath.Join(t.TempDir(), "profiles.md")
64+ if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
65+ t.Fatal(err)
66+ }
67+ t.Setenv("ZOT_AGENT_PROFILES", profileSource)
68+
69+ profile, err := findSubagentProfile("", "reviewer")
70+ if profile != nil {
71+ t.Fatalf("profile = %#v, want nil", profile)
72+ }
73+ if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
74+ t.Fatalf("error = %v, want discovery failure", err)
75+ }
76+ if strings.Contains(err.Error(), profileSource) {
77+ t.Fatalf("error leaked profile path: %v", err)
78+ }
79+}
80+
81+func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
82+ root := t.TempDir()
83+ home := filepath.Join(root, "home")
84+ zotHome := filepath.Join(root, "zot-home")
85+ project := filepath.Join(root, "repo")
86+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
87+ t.Fatal(err)
88+ }
89+ if err := os.MkdirAll(project, 0o755); err != nil {
90+ t.Fatal(err)
91+ }
92+ t.Setenv("HOME", home)
93+ t.Setenv("ZOT_HOME", zotHome)
94+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
95+ enabled := true
96+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil {
97+ t.Fatal(err)
98+ }
99+ profile := `---
100+name: reviewer
101+description: Read-only code reviewer
102+thinking: high
103+---
104+Review without editing.
105+`
106+ if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
107+ t.Fatal(err)
108+ }
109+
110+ r, err := Resolve(Args{CWD: project}, false)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} {
115+ if !strings.Contains(r.SystemPrompt, want) {
116+ t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
117+ }
118+ }
119+}
120+
121+func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
122+ root := t.TempDir()
123+ home := filepath.Join(root, "home")
124+ zotHome := filepath.Join(root, "zot-home")
125+ project := filepath.Join(root, "repo")
126+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
127+ t.Fatal(err)
128+ }
129+ if err := os.MkdirAll(project, 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ t.Setenv("HOME", home)
133+ t.Setenv("ZOT_HOME", zotHome)
134+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
135+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
139+ t.Fatal(err)
140+ }
141+ profile := `---
142+name: reviewer
143+description: Read-only code reviewer
144+tools: read
145+thinking: off
146+systemPromptMode: replace
147+inheritProjectContext: false
148+inheritSkills: false
149+---
150+You are a read-only reviewer.
151+`
152+ if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
153+ t.Fatal(err)
154+ }
155+
156+ r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if r.Reasoning != "" {
161+ t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
162+ }
163+ if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
164+ t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
165+ }
166+ if len(r.ContextFiles) != 0 {
167+ t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
168+ }
169+ if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
170+ t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
171+ }
172+}
173+
62 func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {174 func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
63 files := loadAgentsContext(t.TempDir(), t.TempDir())175 files := loadAgentsContext(t.TempDir(), t.TempDir())
64 if len(files) != 0 {176 if len(files) != 0 {
M packages/agent/cli.go +79 -43
@@ -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 }
@@ -1048,17 +1054,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10481054 //
10491055 // Steps, in order:
10501056 // 1. resolve + validate the new path (~ expansion, abs/rel)
1051- // 2. close the current session, flushing pending messages
1052- // 3. mutate captured args.CWD + r.CWD so future buildAgent
1053- // calls bind to the new cwd
1054- // 4. re-root the shared sandbox («·«) so /jail follows the
1055- // session into the new cwd instead of widening or silently
1056- // dropping
1057- // 5. rebuild the agent via buildAgent() so tools, AGENTS.md
1058- // addendum, system prompt, sessions dir all bind correctly
1059- // 6. open a fresh session in the new cwd's bucket
1060- // 7. push the new state into the running Interactive
1061- // 8. re-scope the swarm dashboard to the freshly-opened session
1057+ // 2. stage the candidate cwd, sandbox, and swarm state
1058+ // 3. rebuild the agent and allocate its new session
1059+ // 4. commit all new state together, closing the old session last
1060+ // 5. push the committed state into the running Interactive
10621061 //
10631062 // The /jail state is preserved verbatim: if the sandbox was locked
10641063 // to the old cwd, it stays locked, just re-pointed at the new one.
@@ -1098,30 +1097,51 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10981097 return fmt.Errorf("no agent running; log in first")
10991098 }
11001099
1101- // Close the current session before we drop the reference.
1102- // Per-message persistence keeps it current already; this is
1103- // a defensive flush + final fsync via Close.
1104- persistMu.Lock()
1105- if sess != nil {
1106- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1107- _ = sess.Close()
1108- sess = nil
1100+ // Stage the candidate state while retaining a complete rollback
1101+ // snapshot. A failed rebuild or session allocation must leave the
1102+ // current agent, session, sandbox, and swarm root usable.
1103+ oldCWD := args.CWD
1104+ oldResolvedCWD := r.CWD
1105+ oldPermissionSet := args.PermissionSet
1106+ oldSwarmRoot := ""
1107+ if swarmMgr != nil {
1108+ oldSwarmRoot = swarmMgr.RepoRoot()
1109+ }
1110+ oldSandboxRoot := ""
1111+ oldSandboxLocked := false
1112+ if sharedSandbox != nil {
1113+ oldSandboxRoot = sharedSandbox.Root
1114+ oldSandboxLocked = sharedSandbox.Locked()
1115+ }
1116+ rollback := func() {
1117+ args.CWD = oldCWD
1118+ r.CWD = oldResolvedCWD
1119+ args.PermissionSet = oldPermissionSet
1120+ if swarmMgr != nil {
1121+ swarmMgr.SetRepoRoot(oldSwarmRoot)
1122+ }
1123+ if sharedSandbox != nil {
1124+ sharedSandbox.Root = oldSandboxRoot
1125+ if oldSandboxLocked {
1126+ sharedSandbox.Lock()
1127+ } else {
1128+ sharedSandbox.Unlock()
1129+ }
1130+ }
11091131 }
1110- sessBaselineMsgs = 0
1111- persistMu.Unlock()
11121132
1113- // Mutate captured state so subsequent agent rebuilds and
1114- // session opens see the new cwd.
1115- wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
11161133 args.CWD = absPath
11171134 r.CWD = absPath
1135+ if swarmMgr != nil {
1136+ swarmMgr.SetRepoRoot(absPath)
1137+ }
11181138 if args.PermissionSet != nil {
11191139 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
11201140 args.PermissionSet = &expanded
11211141 }
11221142 if sharedSandbox != nil {
11231143 sharedSandbox.Root = absPath
1124- if wasJailed {
1144+ if oldSandboxLocked {
11251145 sharedSandbox.Lock()
11261146 } else {
11271147 sharedSandbox.Unlock()
@@ -1132,27 +1152,38 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11321152 // re-bind to the new cwd. buildAgent() reads from args + r.
11331153 newAg, newProvider, newModel, berr := buildAgent()
11341154 if berr != nil {
1135- return fmt.Errorf("rebuild agent: %v", berr)
1155+ rollback()
1156+ return fmt.Errorf("rebuild agent: %w", berr)
11361157 }
1137- ag = newAg
11381158
11391159 // Fresh session in the new cwd's bucket. We bypass
11401160 // openOrCreateSession's --continue / --resume branches
11411161 // because /cd's semantics are "start fresh here", matching
11421162 // what relaunching `zot --cwd <path>` would do today.
1163+ var newSess *core.Session
11431164 if !args.NoSess {
11441165 newRoot := agentSessionsRoot(ZotHome(), args)
11451166 core.PruneEmptySessions(newRoot, absPath)
1146- newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version)
1167+ var serr error
1168+ newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
11471169 if serr != nil {
1148- return fmt.Errorf("open session in %s: %v", absPath, serr)
1170+ rollback()
1171+ return fmt.Errorf("open session in %s: %w", absPath, serr)
11491172 }
1150- persistMu.Lock()
1151- sess = newSess
1152- sessBaselineMsgs = 0
1153- persistMu.Unlock()
11541173 }
11551174
1175+ // Commit only after every fallible operation has succeeded. Close
1176+ // the old session last so rollback never has to resurrect it.
1177+ persistMu.Lock()
1178+ if sess != nil {
1179+ writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1180+ _ = sess.Close()
1181+ }
1182+ sess = newSess
1183+ sessBaselineMsgs = 0
1184+ persistMu.Unlock()
1185+ ag = newAg
1186+
11561187 // Push the new state into the running Interactive.
11571188 if iv != nil {
11581189 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
@@ -1160,8 +1191,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11601191 }
11611192
11621193 // Re-scope the swarm dashboard to the new session.
1163- if swarmMgr != nil && sess != nil {
1164- swarmMgr.SetActiveSession(sess.ID)
1194+ if swarmMgr != nil && newSess != nil {
1195+ swarmMgr.SetActiveSession(newSess.ID)
11651196 }
11661197 return nil
11671198 }
@@ -1247,6 +1278,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12471278 if r.SkillTool != nil {
12481279 startupSkills = r.SkillTool.Skills()
12491280 }
1281+ homeDir, _ := os.UserHomeDir()
1282+ discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
1283+ subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
12501284
12511285 iv = modes.NewInteractive(modes.InteractiveConfig{
12521286 Terminal: term,
@@ -1267,6 +1301,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12671301 CompactUser: initialCfg.CompactUserInput(),
12681302 ExtensionThemes: extMgr.ThemeOptions,
12691303 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1304+ SubagentsSystemAddendum: subagentsAddendum,
12701305 SettingsStore: configSettingsStore{},
12711306 Model: r.Model,
12721307 Provider: r.Provider,
@@ -1360,9 +1395,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13601395 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
13611396 sessBaselineMsgs = len(currentAg.Messages())
13621397 },
1363- Extensions: extMgr,
1364- Swarm: swarmMgr,
1365- ChangelogChan: changelogCh,
1398+ Extensions: extMgr,
1399+ Swarm: swarmMgr,
1400+ ResolveSubagent: resolveSubagent,
1401+ ChangelogChan: changelogCh,
13661402 OnChangelogDismiss: func() {
13671403 // For dev builds (0.0.0) store the actual release version
13681404 // 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 }
@@ -1048,17 +1054,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1048 //1054 //
1049 // Steps, in order:1055 // Steps, in order:
1050 // 1. resolve + validate the new path (~ expansion, abs/rel)1056 // 1. resolve + validate the new path (~ expansion, abs/rel)
1051- // 2. close the current session, flushing pending messages1057+ // 2. stage the candidate cwd, sandbox, and swarm state
1052- // 3. mutate captured args.CWD + r.CWD so future buildAgent1058+ // 3. rebuild the agent and allocate its new session
1053- // calls bind to the new cwd1059+ // 4. commit all new state together, closing the old session last
1054- // 4. re-root the shared sandbox («·«) so /jail follows the1060+ // 5. push the committed state into the running Interactive
1055- // session into the new cwd instead of widening or silently
1056- // dropping
1057- // 5. rebuild the agent via buildAgent() so tools, AGENTS.md
1058- // addendum, system prompt, sessions dir all bind correctly
1059- // 6. open a fresh session in the new cwd's bucket
1060- // 7. push the new state into the running Interactive
1061- // 8. re-scope the swarm dashboard to the freshly-opened session
1062 //1061 //
1063 // The /jail state is preserved verbatim: if the sandbox was locked1062 // The /jail state is preserved verbatim: if the sandbox was locked
1064 // to the old cwd, it stays locked, just re-pointed at the new one.1063 // to the old cwd, it stays locked, just re-pointed at the new one.
@@ -1098,30 +1097,51 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1098 return fmt.Errorf("no agent running; log in first")1097 return fmt.Errorf("no agent running; log in first")
1099 }1098 }
1100 1099
1101- // Close the current session before we drop the reference.1100+ // Stage the candidate state while retaining a complete rollback
1102- // Per-message persistence keeps it current already; this is1101+ // snapshot. A failed rebuild or session allocation must leave the
1103- // a defensive flush + final fsync via Close.1102+ // current agent, session, sandbox, and swarm root usable.
1104- persistMu.Lock()1103+ oldCWD := args.CWD
1105- if sess != nil {1104+ oldResolvedCWD := r.CWD
1106- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1105+ oldPermissionSet := args.PermissionSet
1107- _ = sess.Close()1106+ oldSwarmRoot := ""
1108- sess = nil1107+ if swarmMgr != nil {
1108+ oldSwarmRoot = swarmMgr.RepoRoot()
1109+ }
1110+ oldSandboxRoot := ""
1111+ oldSandboxLocked := false
1112+ if sharedSandbox != nil {
1113+ oldSandboxRoot = sharedSandbox.Root
1114+ oldSandboxLocked = sharedSandbox.Locked()
1115+ }
1116+ rollback := func() {
1117+ args.CWD = oldCWD
1118+ r.CWD = oldResolvedCWD
1119+ args.PermissionSet = oldPermissionSet
1120+ if swarmMgr != nil {
1121+ swarmMgr.SetRepoRoot(oldSwarmRoot)
1122+ }
1123+ if sharedSandbox != nil {
1124+ sharedSandbox.Root = oldSandboxRoot
1125+ if oldSandboxLocked {
1126+ sharedSandbox.Lock()
1127+ } else {
1128+ sharedSandbox.Unlock()
1129+ }
1130+ }
1109 }1131 }
1110- sessBaselineMsgs = 0
1111- persistMu.Unlock()
1112 1132
1113- // Mutate captured state so subsequent agent rebuilds and
1114- // session opens see the new cwd.
1115- wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
1116 args.CWD = absPath1133 args.CWD = absPath
1117 r.CWD = absPath1134 r.CWD = absPath
1135+ if swarmMgr != nil {
1136+ swarmMgr.SetRepoRoot(absPath)
1137+ }
1118 if args.PermissionSet != nil {1138 if args.PermissionSet != nil {
1119 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)1139 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
1120 args.PermissionSet = &expanded1140 args.PermissionSet = &expanded
1121 }1141 }
1122 if sharedSandbox != nil {1142 if sharedSandbox != nil {
1123 sharedSandbox.Root = absPath1143 sharedSandbox.Root = absPath
1124- if wasJailed {1144+ if oldSandboxLocked {
1125 sharedSandbox.Lock()1145 sharedSandbox.Lock()
1126 } else {1146 } else {
1127 sharedSandbox.Unlock()1147 sharedSandbox.Unlock()
@@ -1132,27 +1152,38 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1132 // re-bind to the new cwd. buildAgent() reads from args + r.1152 // re-bind to the new cwd. buildAgent() reads from args + r.
1133 newAg, newProvider, newModel, berr := buildAgent()1153 newAg, newProvider, newModel, berr := buildAgent()
1134 if berr != nil {1154 if berr != nil {
1135- return fmt.Errorf("rebuild agent: %v", berr)1155+ rollback()
1156+ return fmt.Errorf("rebuild agent: %w", berr)
1136 }1157 }
1137- ag = newAg
1138 1158
1139 // Fresh session in the new cwd's bucket. We bypass1159 // Fresh session in the new cwd's bucket. We bypass
1140 // openOrCreateSession's --continue / --resume branches1160 // openOrCreateSession's --continue / --resume branches
1141 // because /cd's semantics are "start fresh here", matching1161 // because /cd's semantics are "start fresh here", matching
1142 // what relaunching `zot --cwd <path>` would do today.1162 // what relaunching `zot --cwd <path>` would do today.
1163+ var newSess *core.Session
1143 if !args.NoSess {1164 if !args.NoSess {
1144 newRoot := agentSessionsRoot(ZotHome(), args)1165 newRoot := agentSessionsRoot(ZotHome(), args)
1145 core.PruneEmptySessions(newRoot, absPath)1166 core.PruneEmptySessions(newRoot, absPath)
1146- newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version)1167+ var serr error
1168+ newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
1147 if serr != nil {1169 if serr != nil {
1148- return fmt.Errorf("open session in %s: %v", absPath, serr)1170+ rollback()
1171+ return fmt.Errorf("open session in %s: %w", absPath, serr)
1149 }1172 }
1150- persistMu.Lock()
1151- sess = newSess
1152- sessBaselineMsgs = 0
1153- persistMu.Unlock()
1154 }1173 }
1155 1174
1175+ // Commit only after every fallible operation has succeeded. Close
1176+ // the old session last so rollback never has to resurrect it.
1177+ persistMu.Lock()
1178+ if sess != nil {
1179+ writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1180+ _ = sess.Close()
1181+ }
1182+ sess = newSess
1183+ sessBaselineMsgs = 0
1184+ persistMu.Unlock()
1185+ ag = newAg
1186+
1156 // Push the new state into the running Interactive.1187 // Push the new state into the running Interactive.
1157 if iv != nil {1188 if iv != nil {
1158 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))1189 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
@@ -1160,8 +1191,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1160 }1191 }
1161 1192
1162 // Re-scope the swarm dashboard to the new session.1193 // Re-scope the swarm dashboard to the new session.
1163- if swarmMgr != nil && sess != nil {1194+ if swarmMgr != nil && newSess != nil {
1164- swarmMgr.SetActiveSession(sess.ID)1195+ swarmMgr.SetActiveSession(newSess.ID)
1165 }1196 }
1166 return nil1197 return nil
1167 }1198 }
@@ -1247,6 +1278,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1247 if r.SkillTool != nil {1278 if r.SkillTool != nil {
1248 startupSkills = r.SkillTool.Skills()1279 startupSkills = r.SkillTool.Skills()
1249 }1280 }
1281+ homeDir, _ := os.UserHomeDir()
1282+ discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
1283+ subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
1250 1284
1251 iv = modes.NewInteractive(modes.InteractiveConfig{1285 iv = modes.NewInteractive(modes.InteractiveConfig{
1252 Terminal: term,1286 Terminal: term,
@@ -1267,6 +1301,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1267 CompactUser: initialCfg.CompactUserInput(),1301 CompactUser: initialCfg.CompactUserInput(),
1268 ExtensionThemes: extMgr.ThemeOptions,1302 ExtensionThemes: extMgr.ThemeOptions,
1269 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,1303 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1304+ SubagentsSystemAddendum: subagentsAddendum,
1270 SettingsStore: configSettingsStore{},1305 SettingsStore: configSettingsStore{},
1271 Model: r.Model,1306 Model: r.Model,
1272 Provider: r.Provider,1307 Provider: r.Provider,
@@ -1360,9 +1395,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1360 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1395 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1361 sessBaselineMsgs = len(currentAg.Messages())1396 sessBaselineMsgs = len(currentAg.Messages())
1362 },1397 },
1363- Extensions: extMgr,1398+ Extensions: extMgr,
1364- Swarm: swarmMgr,1399+ Swarm: swarmMgr,
1365- ChangelogChan: changelogCh,1400+ ResolveSubagent: resolveSubagent,
1401+ ChangelogChan: changelogCh,
1366 OnChangelogDismiss: func() {1402 OnChangelogDismiss: func() {
1367 // For dev builds (0.0.0) store the actual release version1403 // For dev builds (0.0.0) store the actual release version
1368 // so the same changelog doesn't show again next launch.1404 // so the same changelog doesn't show again next launch.
M packages/agent/modes/auto_swarm_test.go +47 -0
@@ -11,6 +11,53 @@ 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+
39+func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
40+ manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
41+ guidance := "auto-swarm guidance"
42+ base := "base system\n\n" + manifest + "\n\n" + guidance
43+ iv := &Interactive{
44+ agent: &core.Agent{System: base},
45+ cfg: InteractiveConfig{
46+ AutoSwarmSystemAddendum: guidance,
47+ SubagentsSystemAddendum: manifest,
48+ },
49+ }
50+
51+ iv.applyAutoSwarmSystemPrompt(true)
52+ if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
53+ t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
54+ }
55+ iv.applyAutoSwarmSystemPrompt(false)
56+ if got := strings.TrimSpace(iv.agent.System); got != base {
57+ t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)
58+ }
59+}
60+
1461 func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
1562 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
1663 mgr := swarm.New(swarm.Config{
@@ -11,6 +11,53 @@ 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+
39+func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
40+ manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
41+ guidance := "auto-swarm guidance"
42+ base := "base system\n\n" + manifest + "\n\n" + guidance
43+ iv := &Interactive{
44+ agent: &core.Agent{System: base},
45+ cfg: InteractiveConfig{
46+ AutoSwarmSystemAddendum: guidance,
47+ SubagentsSystemAddendum: manifest,
48+ },
49+ }
50+
51+ iv.applyAutoSwarmSystemPrompt(true)
52+ if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
53+ t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
54+ }
55+ iv.applyAutoSwarmSystemPrompt(false)
56+ if got := strings.TrimSpace(iv.agent.System); got != base {
57+ t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)
58+ }
59+}
60+
14 func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {61 func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
15 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"62 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
16 mgr := swarm.New(swarm.Config{63 mgr := swarm.New(swarm.Config{
M packages/agent/modes/interactive.go +98 -24
@@ -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.
@@ -358,10 +368,14 @@ type Interactive struct {
358368 ed *tui.Editor
359369 rend *tui.Renderer
360370
361- mu sync.Mutex
362- agent *core.Agent
363- streaming strings.Builder // what's currently painted on screen
364- streamOn bool
371+ mu sync.Mutex
372+ agent *core.Agent
373+ // managedAutoSwarmAddenda records exact prompt blocks appended by
374+ // auto-swarm. Disable only removes these owned occurrences, leaving
375+ // identical text that came from the user's base prompt untouched.
376+ managedAutoSwarmAddenda []string
377+ streaming strings.Builder // what's currently painted on screen
378+ streamOn bool
365379
366380 // streamPending is the runes buffered after each EvTextDelta that
367381 // haven't yet been promoted into `streaming` for rendering. It
@@ -656,6 +670,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
656670 baseURL, _, err := cfg.LlamaCPPConfig()
657671 i.llamaConfigured = err == nil && baseURL != ""
658672 }
673+ if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
674+ i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg)
675+ }
659676 if cfg.Agent != nil {
660677 i.agent = cfg.Agent
661678 i.view.Messages = cfg.Agent.Messages()
@@ -3052,9 +3069,19 @@ func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider
30523069 }
30533070
30543071 func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3072+ home, _ := os.UserHomeDir()
3073+ profiles, _ := subagents.Discover(cwd, home)
3074+ subagentsAddendum := subagents.SystemPromptAddendum(profiles)
3075+
30553076 i.mu.Lock()
30563077 i.agent = ag
30573078 i.cfg.CWD = cwd
3079+ i.cfg.SubagentsSystemAddendum = subagentsAddendum
3080+ if i.autoSwarmEnabled() {
3081+ i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg)
3082+ } else {
3083+ i.managedAutoSwarmAddenda = nil
3084+ }
30583085 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
30593086 i.view.StartupContextPaths = nil
30603087 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
@@ -6486,29 +6513,74 @@ func truncateForSummary(s string, n int) string {
64866513 return s[:n-3] + "..."
64876514 }
64886515
6516+// autoSwarmAddenda returns the prompt blocks owned by the auto-swarm
6517+// toggle, in the same order Resolve appends them to a new agent.
6518+func autoSwarmAddenda(cfg InteractiveConfig) []string {
6519+ addenda := make([]string, 0, 2)
6520+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
6521+ addenda = append(addenda, addendum)
6522+ }
6523+ if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" {
6524+ addenda = append(addenda, addendum)
6525+ }
6526+ return addenda
6527+}
6528+
6529+func containsAutoSwarmAddendum(addenda []string, want string) bool {
6530+ for _, addendum := range addenda {
6531+ if addendum == want {
6532+ return true
6533+ }
6534+ }
6535+ return false
6536+}
6537+
6538+// removeLastAutoSwarmAddendum removes one occurrence of a block known to
6539+// have been appended by auto-swarm. Resolve appends owned blocks at the
6540+// end, so removing the last occurrence preserves an identical base block.
6541+func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) {
6542+ idx := strings.LastIndex(system, addendum)
6543+ if idx < 0 {
6544+ return system, false
6545+ }
6546+ return system[:idx] + system[idx+len(addendum):], true
6547+}
6548+
64896549 // 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.
6550+// (active=false) the auto-swarm prompt blocks on the running agent.
6551+// The profile manifest and delegation guidance are managed together so
6552+// toggling auto-swarm never leaves the model with names but no tool.
64946553 func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
64956554 if i.agent == nil {
64966555 return
64976556 }
6498- addendum := i.cfg.AutoSwarmSystemAddendum
6499- if addendum == "" {
6557+ addenda := autoSwarmAddenda(i.cfg)
6558+ if active {
6559+ sys := i.agent.System
6560+ for _, addendum := range addenda {
6561+ if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) {
6562+ continue
6563+ }
6564+ if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6565+ sys += "\n\n"
6566+ }
6567+ sys += addendum
6568+ i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum)
6569+ }
6570+ i.agent.System = sys
65006571 return
65016572 }
6573+
65026574 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"
6508- }
6509- i.agent.System = sys + addendum
6510- case !active && has:
6511- i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
6575+ changed := false
6576+ for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- {
6577+ var removed bool
6578+ sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx])
6579+ changed = changed || removed
6580+ }
6581+ i.managedAutoSwarmAddenda = nil
6582+ if changed {
6583+ i.agent.System = strings.TrimRight(sys, "\n") + "\n"
65126584 }
65136585 }
65146586
@@ -6535,11 +6607,13 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
65356607 }
65366608 if active && i.cfg.Swarm != nil {
65376609 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,
6610+ Swarm: i.cfg.Swarm,
6611+ Enabled: func() bool { return true },
6612+ DefaultModel: func() string { return i.cfg.Model },
6613+ DefaultProvider: func() string { return i.cfg.Provider },
6614+ DefaultReasoning: func() string { return i.cfg.Reasoning },
6615+ ResolveSubagent: i.cfg.ResolveSubagent,
6616+ OnSpawned: i.trackSwarmAgent,
65436617 }
65446618 }
65456619 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.
@@ -358,10 +368,14 @@ type Interactive struct {
358 ed *tui.Editor368 ed *tui.Editor
359 rend *tui.Renderer369 rend *tui.Renderer
360 370
361- mu sync.Mutex371+ mu sync.Mutex
362- agent *core.Agent372+ agent *core.Agent
363- streaming strings.Builder // what's currently painted on screen373+ // managedAutoSwarmAddenda records exact prompt blocks appended by
364- streamOn bool374+ // auto-swarm. Disable only removes these owned occurrences, leaving
375+ // identical text that came from the user's base prompt untouched.
376+ managedAutoSwarmAddenda []string
377+ streaming strings.Builder // what's currently painted on screen
378+ streamOn bool
365 379
366 // streamPending is the runes buffered after each EvTextDelta that380 // streamPending is the runes buffered after each EvTextDelta that
367 // haven't yet been promoted into `streaming` for rendering. It381 // haven't yet been promoted into `streaming` for rendering. It
@@ -656,6 +670,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
656 baseURL, _, err := cfg.LlamaCPPConfig()670 baseURL, _, err := cfg.LlamaCPPConfig()
657 i.llamaConfigured = err == nil && baseURL != ""671 i.llamaConfigured = err == nil && baseURL != ""
658 }672 }
673+ if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
674+ i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg)
675+ }
659 if cfg.Agent != nil {676 if cfg.Agent != nil {
660 i.agent = cfg.Agent677 i.agent = cfg.Agent
661 i.view.Messages = cfg.Agent.Messages()678 i.view.Messages = cfg.Agent.Messages()
@@ -3052,9 +3069,19 @@ func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider
3052 }3069 }
3053 3070
3054 func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {3071 func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3072+ home, _ := os.UserHomeDir()
3073+ profiles, _ := subagents.Discover(cwd, home)
3074+ subagentsAddendum := subagents.SystemPromptAddendum(profiles)
3075+
3055 i.mu.Lock()3076 i.mu.Lock()
3056 i.agent = ag3077 i.agent = ag
3057 i.cfg.CWD = cwd3078 i.cfg.CWD = cwd
3079+ i.cfg.SubagentsSystemAddendum = subagentsAddendum
3080+ if i.autoSwarmEnabled() {
3081+ i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg)
3082+ } else {
3083+ i.managedAutoSwarmAddenda = nil
3084+ }
3058 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)3085 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3059 i.view.StartupContextPaths = nil3086 i.view.StartupContextPaths = nil
3060 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {3087 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
@@ -6486,29 +6513,74 @@ func truncateForSummary(s string, n int) string {
6486 return s[:n-3] + "..."6513 return s[:n-3] + "..."
6487 }6514 }
6488 6515
6516+// autoSwarmAddenda returns the prompt blocks owned by the auto-swarm
6517+// toggle, in the same order Resolve appends them to a new agent.
6518+func autoSwarmAddenda(cfg InteractiveConfig) []string {
6519+ addenda := make([]string, 0, 2)
6520+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
6521+ addenda = append(addenda, addendum)
6522+ }
6523+ if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" {
6524+ addenda = append(addenda, addendum)
6525+ }
6526+ return addenda
6527+}
6528+
6529+func containsAutoSwarmAddendum(addenda []string, want string) bool {
6530+ for _, addendum := range addenda {
6531+ if addendum == want {
6532+ return true
6533+ }
6534+ }
6535+ return false
6536+}
6537+
6538+// removeLastAutoSwarmAddendum removes one occurrence of a block known to
6539+// have been appended by auto-swarm. Resolve appends owned blocks at the
6540+// end, so removing the last occurrence preserves an identical base block.
6541+func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) {
6542+ idx := strings.LastIndex(system, addendum)
6543+ if idx < 0 {
6544+ return system, false
6545+ }
6546+ return system[:idx] + system[idx+len(addendum):], true
6547+}
6548+
6489 // applyAutoSwarmSystemPrompt appends (active=true) or strips6549 // applyAutoSwarmSystemPrompt appends (active=true) or strips
6490-// (active=false) the auto-swarm system-prompt block on the running6550+// (active=false) the auto-swarm prompt blocks on the running agent.
6491-// agent so the model proactively considers swarm_spawn when the user6551+// The profile manifest and delegation guidance are managed together so
6492-// flips the toggle. The block lives at the tail of agent.System so6552+// 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) {6553 func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6495 if i.agent == nil {6554 if i.agent == nil {
6496 return6555 return
6497 }6556 }
6498- addendum := i.cfg.AutoSwarmSystemAddendum6557+ addenda := autoSwarmAddenda(i.cfg)
6499- if addendum == "" {6558+ if active {
6559+ sys := i.agent.System
6560+ for _, addendum := range addenda {
6561+ if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) {
6562+ continue
6563+ }
6564+ if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6565+ sys += "\n\n"
6566+ }
6567+ sys += addendum
6568+ i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum)
6569+ }
6570+ i.agent.System = sys
6500 return6571 return
6501 }6572 }
6573+
6502 sys := i.agent.System6574 sys := i.agent.System
6503- has := strings.Contains(sys, addendum)6575+ changed := false
6504- switch {6576+ for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- {
6505- case active && !has:6577+ var removed bool
6506- if sys != "" && !strings.HasSuffix(sys, "\n\n") {6578+ sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx])
6507- sys += "\n\n"6579+ changed = changed || removed
6508- }6580+ }
6509- i.agent.System = sys + addendum6581+ i.managedAutoSwarmAddenda = nil
6510- case !active && has:6582+ if changed {
6511- i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"6583+ i.agent.System = strings.TrimRight(sys, "\n") + "\n"
6512 }6584 }
6513 }6585 }
6514 6586
@@ -6535,11 +6607,13 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
6535 }6607 }
6536 if active && i.cfg.Swarm != nil {6608 if active && i.cfg.Swarm != nil {
6537 next["swarm_spawn"] = &tools.SwarmSpawnTool{6609 next["swarm_spawn"] = &tools.SwarmSpawnTool{
6538- Swarm: i.cfg.Swarm,6610+ Swarm: i.cfg.Swarm,
6539- Enabled: func() bool { return true },6611+ Enabled: func() bool { return true },
6540- DefaultModel: func() string { return i.cfg.Model },6612+ DefaultModel: func() string { return i.cfg.Model },
6541- DefaultProvider: func() string { return i.cfg.Provider },6613+ DefaultProvider: func() string { return i.cfg.Provider },
6542- OnSpawned: i.trackSwarmAgent,6614+ DefaultReasoning: func() string { return i.cfg.Reasoning },
6615+ ResolveSubagent: i.cfg.ResolveSubagent,
6616+ OnSpawned: i.trackSwarmAgent,
6543 }6617 }
6544 }6618 }
6545 i.agent.SetTools(next)6619 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 +101 -32
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/provider"
910 "github.com/patriceckhart/zot/packages/tui"
1011 )
1112
@@ -13,8 +14,8 @@ import (
1314 //
1415 // /swarm -> open the dashboard
1516 // /swarm list -> open the dashboard
16-// /swarm new [--model M] [--provider P] <task...>
17-// -> spawn an agent (optionally pinned to a model)
17+// /swarm new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
18+// -> spawn an agent with optional profile/model overrides
1819 // /swarm kill <id> -> stop a running agent
1920 // /swarm remove <id> -> tear down a terminated agent
2021 // /swarm logs <id> -> open the scrollable transcript view
@@ -53,7 +54,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
5354 // feed the dialog identical callbacks.
5455 spawnAdapter := func(task, model, provider string) error {
5556 _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
56- Task: task, Model: model, Provider: provider,
57+ Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning,
5758 })
5859 return err
5960 }
@@ -93,30 +94,65 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
9394 i.swarmStatus("", "/swarm new <task>: missing task")
9495 return
9596 }
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)
97+ // Permit profile/model/provider/reasoning flags before the task
98+ // so scripts can pin a child without going through the dialog.
99+ // Anything that isn't a recognised flag terminates parsing and
100+ // the rest becomes the task; only leading flags are consumed.
101+ model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
104102 if task == "" {
105- i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)")
103+ i.swarmStatus("", "/swarm new: missing task (after any spawn flags)")
106104 return
107105 }
106+ if subagent != "" {
107+ if i.cfg.ResolveSubagent == nil {
108+ i.swarmStatus("", "spawn: named subagent profiles are unavailable")
109+ return
110+ }
111+ profile, err := i.cfg.ResolveSubagent(subagent)
112+ if err != nil {
113+ i.swarmStatus("", "spawn: "+err.Error())
114+ return
115+ }
116+ if profile == nil {
117+ i.swarmStatus("", "spawn: unknown subagent profile "+subagent)
118+ return
119+ }
120+ subagent = profile.Name
121+ if model == "" && provider == "" {
122+ profileProvider, profileModel := profile.ModelSelection()
123+ if profileProvider != "" && profileModel != "" {
124+ provider, model = profileProvider, profileModel
125+ }
126+ }
127+ if reasoning == "" {
128+ reasoning = profile.Thinking
129+ }
130+ }
131+ if reasoning == "" {
132+ reasoning = i.cfg.Reasoning
133+ }
134+ if reasoning != "" {
135+ normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
136+ if err != nil {
137+ i.swarmStatus("", "spawn: "+err.Error())
138+ return
139+ }
140+ reasoning = normalizedReasoning
141+ }
108142 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
109- Task: task, Model: model, Provider: provider,
143+ Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent,
110144 })
111145 if err != nil {
112146 i.swarmStatus("", "spawn: "+err.Error())
113147 return
114148 }
115- if model != "" {
116- i.swarmStatus("spawned "+a.ID+" (model "+model+")", "")
117- } else {
118- i.swarmStatus("spawned "+a.ID, "")
149+ status := "spawned " + a.ID
150+ if subagent != "" {
151+ status += " (agent " + subagent + ")"
152+ } else if model != "" {
153+ status += " (model " + model + ")"
119154 }
155+ i.swarmStatus(status, "")
120156 case "kill", "stop":
121157 if rest == "" {
122158 i.swarmStatus("", "/swarm kill <id>: missing id")
@@ -220,29 +256,46 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
220256 }
221257 }
222258
223-// parseSpawnFlags consumes any leading `--model X` / `--provider Y`
224-// flags from s and returns them along with the remaining task body.
225-// We deliberately only honour LEADING flags so a task like "check
226-// --model lookup" doesn't accidentally swallow part of its prose as
227-// the model name.
228-//
229-// Recognised forms:
230-//
231-// --model X two-token form
232-// --model=X single-token form
233-// --provider X two-token form
234-// --provider=X single-token form
235-func parseSpawnFlags(s string) (model, provider, task string) {
259+// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
260+// accepted by ParseArgs before a child process is launched.
261+func normalizeSpawnReasoning(value string) (string, error) {
262+ raw := strings.ToLower(strings.TrimSpace(value))
263+ switch raw {
264+ case "", "off":
265+ return raw, nil
266+ case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
267+ return provider.NormalizeReasoning(raw), nil
268+ default:
269+ return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
270+ }
271+}
272+
273+// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
274+// using both separate-value and equals forms. Flags after the task remain
275+// part of the task text.
276+func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
236277 fields := strings.Fields(s)
237278 i := 0
238279 for i < len(fields) {
239280 f := fields[i]
240281 switch {
282+ case f == "--agent":
283+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
284+ subagent = fields[i+1]
285+ i += 2
286+ } else {
287+ i++
288+ }
289+ continue
290+ case strings.HasPrefix(f, "--agent="):
291+ subagent = strings.TrimPrefix(f, "--agent=")
292+ i++
293+ continue
241294 case f == "--model":
242295 // Consume the flag even when no value follows so a
243296 // dangling "--model" doesn't leak into the task. The
244297 // caller surfaces "missing task" instead.
245- if i+1 < len(fields) {
298+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
246299 model = fields[i+1]
247300 i += 2
248301 } else {
@@ -254,7 +307,7 @@ func parseSpawnFlags(s string) (model, provider, task string) {
254307 i++
255308 continue
256309 case f == "--provider":
257- if i+1 < len(fields) {
310+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
258311 provider = fields[i+1]
259312 i += 2
260313 } else {
@@ -265,6 +318,22 @@ func parseSpawnFlags(s string) (model, provider, task string) {
265318 provider = strings.TrimPrefix(f, "--provider=")
266319 i++
267320 continue
321+ case f == "--reasoning" || f == "--thinking":
322+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
323+ reasoning = fields[i+1]
324+ i += 2
325+ } else {
326+ i++
327+ }
328+ continue
329+ case strings.HasPrefix(f, "--reasoning="):
330+ reasoning = strings.TrimPrefix(f, "--reasoning=")
331+ i++
332+ continue
333+ case strings.HasPrefix(f, "--thinking="):
334+ reasoning = strings.TrimPrefix(f, "--thinking=")
335+ i++
336+ continue
268337 }
269338 break
270339 }
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/patriceckhart/zot/packages/agent/swarm"8 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/provider"
9 "github.com/patriceckhart/zot/packages/tui"10 "github.com/patriceckhart/zot/packages/tui"
10 )11 )
11 12
@@ -13,8 +14,8 @@ import (
13 //14 //
14 // /swarm -> open the dashboard15 // /swarm -> open the dashboard
15 // /swarm list -> open the dashboard16 // /swarm list -> open the dashboard
16-// /swarm new [--model M] [--provider P] <task...>17+// /swarm new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
17-// -> spawn an agent (optionally pinned to a model)18+// -> spawn an agent with optional profile/model overrides
18 // /swarm kill <id> -> stop a running agent19 // /swarm kill <id> -> stop a running agent
19 // /swarm remove <id> -> tear down a terminated agent20 // /swarm remove <id> -> tear down a terminated agent
20 // /swarm logs <id> -> open the scrollable transcript view21 // /swarm logs <id> -> open the scrollable transcript view
@@ -53,7 +54,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
53 // feed the dialog identical callbacks.54 // feed the dialog identical callbacks.
54 spawnAdapter := func(task, model, provider string) error {55 spawnAdapter := func(task, model, provider string) error {
55 _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{56 _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
56- Task: task, Model: model, Provider: provider,57+ Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning,
57 })58 })
58 return err59 return err
59 }60 }
@@ -93,30 +94,65 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
93 i.swarmStatus("", "/swarm new <task>: missing task")94 i.swarmStatus("", "/swarm new <task>: missing task")
94 return95 return
95 }96 }
96- // Permit `--model X --provider Y` flags before the task so97+ // Permit profile/model/provider/reasoning flags before the task
97- // scripts can pin a model without going through the dialog.98+ // so scripts can pin a child without going through the dialog.
98- // Anything that isn't a recognised flag terminates parsing99+ // Anything that isn't a recognised flag terminates parsing and
99- // and the rest becomes the task; this keeps `/swarm new100+ // the rest becomes the task; only leading flags are consumed.
100- // --model foo do a thing` and `/swarm new do --model thing`101+ 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 == "" {102 if task == "" {
105- i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)")103+ i.swarmStatus("", "/swarm new: missing task (after any spawn flags)")
106 return104 return
107 }105 }
106+ if subagent != "" {
107+ if i.cfg.ResolveSubagent == nil {
108+ i.swarmStatus("", "spawn: named subagent profiles are unavailable")
109+ return
110+ }
111+ profile, err := i.cfg.ResolveSubagent(subagent)
112+ if err != nil {
113+ i.swarmStatus("", "spawn: "+err.Error())
114+ return
115+ }
116+ if profile == nil {
117+ i.swarmStatus("", "spawn: unknown subagent profile "+subagent)
118+ return
119+ }
120+ subagent = profile.Name
121+ if model == "" && provider == "" {
122+ profileProvider, profileModel := profile.ModelSelection()
123+ if profileProvider != "" && profileModel != "" {
124+ provider, model = profileProvider, profileModel
125+ }
126+ }
127+ if reasoning == "" {
128+ reasoning = profile.Thinking
129+ }
130+ }
131+ if reasoning == "" {
132+ reasoning = i.cfg.Reasoning
133+ }
134+ if reasoning != "" {
135+ normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
136+ if err != nil {
137+ i.swarmStatus("", "spawn: "+err.Error())
138+ return
139+ }
140+ reasoning = normalizedReasoning
141+ }
108 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{142 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
109- Task: task, Model: model, Provider: provider,143+ Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent,
110 })144 })
111 if err != nil {145 if err != nil {
112 i.swarmStatus("", "spawn: "+err.Error())146 i.swarmStatus("", "spawn: "+err.Error())
113 return147 return
114 }148 }
115- if model != "" {149+ status := "spawned " + a.ID
116- i.swarmStatus("spawned "+a.ID+" (model "+model+")", "")150+ if subagent != "" {
117- } else {151+ status += " (agent " + subagent + ")"
118- i.swarmStatus("spawned "+a.ID, "")152+ } else if model != "" {
153+ status += " (model " + model + ")"
119 }154 }
155+ i.swarmStatus(status, "")
120 case "kill", "stop":156 case "kill", "stop":
121 if rest == "" {157 if rest == "" {
122 i.swarmStatus("", "/swarm kill <id>: missing id")158 i.swarmStatus("", "/swarm kill <id>: missing id")
@@ -220,29 +256,46 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
220 }256 }
221 }257 }
222 258
223-// parseSpawnFlags consumes any leading `--model X` / `--provider Y`259+// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
224-// flags from s and returns them along with the remaining task body.260+// accepted by ParseArgs before a child process is launched.
225-// We deliberately only honour LEADING flags so a task like "check261+func normalizeSpawnReasoning(value string) (string, error) {
226-// --model lookup" doesn't accidentally swallow part of its prose as262+ raw := strings.ToLower(strings.TrimSpace(value))
227-// the model name.263+ switch raw {
228-//264+ case "", "off":
229-// Recognised forms:265+ return raw, nil
230-//266+ case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
231-// --model X two-token form267+ return provider.NormalizeReasoning(raw), nil
232-// --model=X single-token form268+ default:
233-// --provider X two-token form269+ return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
234-// --provider=X single-token form270+ }
235-func parseSpawnFlags(s string) (model, provider, task string) {271+}
272+
273+// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
274+// using both separate-value and equals forms. Flags after the task remain
275+// part of the task text.
276+func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
236 fields := strings.Fields(s)277 fields := strings.Fields(s)
237 i := 0278 i := 0
238 for i < len(fields) {279 for i < len(fields) {
239 f := fields[i]280 f := fields[i]
240 switch {281 switch {
282+ case f == "--agent":
283+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
284+ subagent = fields[i+1]
285+ i += 2
286+ } else {
287+ i++
288+ }
289+ continue
290+ case strings.HasPrefix(f, "--agent="):
291+ subagent = strings.TrimPrefix(f, "--agent=")
292+ i++
293+ continue
241 case f == "--model":294 case f == "--model":
242 // Consume the flag even when no value follows so a295 // Consume the flag even when no value follows so a
243 // dangling "--model" doesn't leak into the task. The296 // dangling "--model" doesn't leak into the task. The
244 // caller surfaces "missing task" instead.297 // caller surfaces "missing task" instead.
245- if i+1 < len(fields) {298+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
246 model = fields[i+1]299 model = fields[i+1]
247 i += 2300 i += 2
248 } else {301 } else {
@@ -254,7 +307,7 @@ func parseSpawnFlags(s string) (model, provider, task string) {
254 i++307 i++
255 continue308 continue
256 case f == "--provider":309 case f == "--provider":
257- if i+1 < len(fields) {310+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
258 provider = fields[i+1]311 provider = fields[i+1]
259 i += 2312 i += 2
260 } else {313 } else {
@@ -265,6 +318,22 @@ func parseSpawnFlags(s string) (model, provider, task string) {
265 provider = strings.TrimPrefix(f, "--provider=")318 provider = strings.TrimPrefix(f, "--provider=")
266 i++319 i++
267 continue320 continue
321+ case f == "--reasoning" || f == "--thinking":
322+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
323+ reasoning = fields[i+1]
324+ i += 2
325+ } else {
326+ i++
327+ }
328+ continue
329+ case strings.HasPrefix(f, "--reasoning="):
330+ reasoning = strings.TrimPrefix(f, "--reasoning=")
331+ i++
332+ continue
333+ case strings.HasPrefix(f, "--thinking="):
334+ reasoning = strings.TrimPrefix(f, "--thinking=")
335+ i++
336+ continue
268 }337 }
269 break338 break
270 }339 }
M packages/agent/modes/swarm_slash_test.go +58 -14
@@ -3,6 +3,7 @@ package modes
33 import (
44 "context"
55 "runtime"
6+ "strings"
67 "testing"
78 "time"
89
@@ -82,6 +83,19 @@ func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) {
8283 }
8384 }
8485
86+func TestRunSwarmRejectsNamedProfileWithoutResolver(t *testing.T) {
87+ iv, f := newInteractiveForSwarmTest(t)
88+ defer f.StopAll()
89+
90+ iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
91+ if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") {
92+ t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr)
93+ }
94+ if got := len(f.List()); got != 0 {
95+ t.Fatalf("spawned agents = %d, want 0", got)
96+ }
97+}
98+
8599 func TestRunSwarmNewSpawnsAgent(t *testing.T) {
86100 iv, f := newInteractiveForSwarmTest(t)
87101 defer f.StopAll()
@@ -175,28 +189,58 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
175189 }
176190 }
177191
192+func TestNormalizeSpawnReasoning(t *testing.T) {
193+ cases := []struct {
194+ in, want string
195+ ok bool
196+ }{
197+ {in: "OFF", want: "off", ok: true},
198+ {in: "minimal", want: "minimum", ok: true},
199+ {in: "maximum", want: "xhigh", ok: true},
200+ {in: "MAX", want: "max", ok: true},
201+ {in: "bogus", ok: false},
202+ }
203+ for _, tc := range cases {
204+ got, err := normalizeSpawnReasoning(tc.in)
205+ if tc.ok {
206+ if err != nil || got != tc.want {
207+ t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
208+ }
209+ continue
210+ }
211+ if err == nil {
212+ t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
213+ }
214+ }
215+}
216+
178217 func TestParseSpawnFlags(t *testing.T) {
179218 cases := []struct {
180- in string
181- wantModel, wantProv string
182- wantTask string
219+ in string
220+ wantModel, wantProv string
221+ wantReasoning, wantSubagent string
222+ wantTask string
183223 }{
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"},
224+ {"do x", "", "", "", "", "do x"},
225+ {"--model claude do x", "claude", "", "", "", "do x"},
226+ {"--model=claude do x", "claude", "", "", "", "do x"},
227+ {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
228+ {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
229+ {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
230+ {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
231+ {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
232+ {"--thinking=max do x", "", "", "max", "", "do x"},
189233 // Only LEADING flags are consumed.
190- {"do --model x", "", "", "do --model x"},
234+ {"do --model x", "", "", "", "", "do --model x"},
191235 // Missing value: --model with no follow-up token leaves model empty
192236 // and the next field starts the task.
193- {"--model", "", "", ""},
237+ {"--model", "", "", "", "", ""},
194238 }
195239 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)
240+ m, p, r, a, task := parseSpawnFlags(c.in)
241+ if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask {
242+ t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)",
243+ c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask)
200244 }
201245 }
202246 }
@@ -3,6 +3,7 @@ package modes
3 import (3 import (
4 "context"4 "context"
5 "runtime"5 "runtime"
6+ "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -82,6 +83,19 @@ func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) {
82 }83 }
83 }84 }
84 85
86+func TestRunSwarmRejectsNamedProfileWithoutResolver(t *testing.T) {
87+ iv, f := newInteractiveForSwarmTest(t)
88+ defer f.StopAll()
89+
90+ iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
91+ if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") {
92+ t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr)
93+ }
94+ if got := len(f.List()); got != 0 {
95+ t.Fatalf("spawned agents = %d, want 0", got)
96+ }
97+}
98+
85 func TestRunSwarmNewSpawnsAgent(t *testing.T) {99 func TestRunSwarmNewSpawnsAgent(t *testing.T) {
86 iv, f := newInteractiveForSwarmTest(t)100 iv, f := newInteractiveForSwarmTest(t)
87 defer f.StopAll()101 defer f.StopAll()
@@ -175,28 +189,58 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
175 }189 }
176 }190 }
177 191
192+func TestNormalizeSpawnReasoning(t *testing.T) {
193+ cases := []struct {
194+ in, want string
195+ ok bool
196+ }{
197+ {in: "OFF", want: "off", ok: true},
198+ {in: "minimal", want: "minimum", ok: true},
199+ {in: "maximum", want: "xhigh", ok: true},
200+ {in: "MAX", want: "max", ok: true},
201+ {in: "bogus", ok: false},
202+ }
203+ for _, tc := range cases {
204+ got, err := normalizeSpawnReasoning(tc.in)
205+ if tc.ok {
206+ if err != nil || got != tc.want {
207+ t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
208+ }
209+ continue
210+ }
211+ if err == nil {
212+ t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
213+ }
214+ }
215+}
216+
178 func TestParseSpawnFlags(t *testing.T) {217 func TestParseSpawnFlags(t *testing.T) {
179 cases := []struct {218 cases := []struct {
180- in string219+ in string
181- wantModel, wantProv string220+ wantModel, wantProv string
182- wantTask string221+ wantReasoning, wantSubagent string
222+ wantTask string
183 }{223 }{
184- {"do x", "", "", "do x"},224+ {"do x", "", "", "", "", "do x"},
185- {"--model claude do x", "claude", "", "do x"},225+ {"--model claude do x", "claude", "", "", "", "do x"},
186- {"--model=claude do x", "claude", "", "do x"},226+ {"--model=claude do x", "claude", "", "", "", "do x"},
187- {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"},227+ {"--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"},228+ {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
229+ {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
230+ {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
231+ {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
232+ {"--thinking=max do x", "", "", "max", "", "do x"},
189 // Only LEADING flags are consumed.233 // Only LEADING flags are consumed.
190- {"do --model x", "", "", "do --model x"},234+ {"do --model x", "", "", "", "", "do --model x"},
191 // Missing value: --model with no follow-up token leaves model empty235 // Missing value: --model with no follow-up token leaves model empty
192 // and the next field starts the task.236 // and the next field starts the task.
193- {"--model", "", "", ""},237+ {"--model", "", "", "", "", ""},
194 }238 }
195 for _, c := range cases {239 for _, c := range cases {
196- m, p, task := parseSpawnFlags(c.in)240+ m, p, r, a, task := parseSpawnFlags(c.in)
197- if m != c.wantModel || p != c.wantProv || task != c.wantTask {241+ 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)",242+ 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)243+ c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask)
200 }244 }
201 }245 }
202 }246 }
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 +405 -0
@@ -0,0 +1,405 @@
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". Explicit provider metadata must be paired
102+// with an unqualified model; model IDs may contain slashes when no provider
103+// metadata is supplied.
104+func (p *Profile) ModelSelection() (provider, model string) {
105+ if p == nil {
106+ return "", ""
107+ }
108+ provider = strings.TrimSpace(p.Provider)
109+ model = strings.TrimSpace(p.Model)
110+ if provider == "" {
111+ if before, after, ok := strings.Cut(model, "/"); ok {
112+ provider, model = strings.TrimSpace(before), strings.TrimSpace(after)
113+ }
114+ }
115+ return provider, model
116+}
117+
118+// SystemPromptAddendum renders the compact [subagents_list] manifest for the
119+// parent model. Full profile bodies are intentionally omitted: the selected
120+// child loads its own instructions, and keeping them out of every parent turn
121+// avoids unnecessary context and accidental instruction mixing.
122+func SystemPromptAddendum(profiles []*Profile) string {
123+ if len(profiles) == 0 {
124+ return ""
125+ }
126+ var sb strings.Builder
127+ sb.WriteString("[subagents_list]\n")
128+ 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")
129+ for _, profile := range profiles {
130+ if profile == nil {
131+ continue
132+ }
133+ name := manifestValue(profile.Name)
134+ description := manifestValue(profile.Description)
135+ if description == "" {
136+ description = "(no description)"
137+ }
138+ metadata := make([]string, 0, 4)
139+ if model := manifestValue(profile.Model); model != "" {
140+ metadata = append(metadata, "model="+model)
141+ }
142+ if provider := manifestValue(profile.Provider); provider != "" {
143+ metadata = append(metadata, "provider="+provider)
144+ }
145+ if thinking := manifestValue(profile.Thinking); thinking != "" {
146+ metadata = append(metadata, "thinking="+thinking)
147+ }
148+ if len(profile.Tools) > 0 {
149+ metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ",")))
150+ }
151+ if len(metadata) > 0 {
152+ fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)
153+ } else {
154+ fmt.Fprintf(&sb, "- %s: %s\n", name, description)
155+ }
156+ }
157+ sb.WriteString("[/subagents_list]")
158+ return sb.String()
159+}
160+
161+type location struct {
162+ dir string
163+ label string
164+}
165+
166+func searchDirs(cwd, userHome string) []location {
167+ var out []location
168+ add := func(dir, label string) {
169+ if strings.TrimSpace(dir) != "" {
170+ out = append(out, location{dir: dir, label: label})
171+ }
172+ }
173+ if extra := os.Getenv("ZOT_AGENT_PROFILES"); extra != "" {
174+ for _, dir := range filepath.SplitList(extra) {
175+ dir = strings.TrimSpace(dir)
176+ if dir != "" && !filepath.IsAbs(dir) && strings.TrimSpace(cwd) != "" {
177+ dir = filepath.Join(cwd, dir)
178+ }
179+ add(dir, "configured")
180+ }
181+ }
182+ if userHome != "" {
183+ add(filepath.Join(userHome, ".agents", "agents"), "global (agents)")
184+ // This is an additional compatibility location. The shared .agents
185+ // path above remains zot's documented default.
186+ add(filepath.Join(userHome, ".pi", "agent", "agents"), "global (pi)")
187+ }
188+ return out
189+}
190+
191+func load(path, source string) (*Profile, error) {
192+ info, err := os.Stat(path)
193+ if err != nil {
194+ return nil, err
195+ }
196+ if !info.Mode().IsRegular() {
197+ return nil, fmt.Errorf("not a regular file")
198+ }
199+ if info.Size() > maxProfileBytes {
200+ return nil, fmt.Errorf("profile is larger than %d bytes", maxProfileBytes)
201+ }
202+ raw, err := os.ReadFile(path)
203+ if err != nil {
204+ return nil, err
205+ }
206+ front, body, err := splitFrontmatter(string(raw))
207+ if err != nil {
208+ return nil, err
209+ }
210+ values, lists := parseFrontmatter(front)
211+ name := unquote(values["name"])
212+ if name == "" {
213+ name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
214+ }
215+ thinking, err := parseThinkingValue(values["thinking"], "thinking")
216+ if err != nil {
217+ return nil, err
218+ }
219+ reasoning, err := parseThinkingValue(values["reasoning"], "reasoning")
220+ if err != nil {
221+ return nil, err
222+ }
223+ if thinking == "" {
224+ thinking = reasoning
225+ }
226+ model := strings.TrimSpace(unquote(values["model"]))
227+ provider := strings.TrimSpace(unquote(values["provider"]))
228+ if provider != "" && strings.Contains(model, "/") {
229+ return nil, fmt.Errorf("model must not include a provider when provider is set")
230+ }
231+ profile := &Profile{
232+ Name: strings.TrimSpace(name),
233+ Description: strings.TrimSpace(unquote(values["description"])),
234+ SystemPrompt: strings.TrimSpace(body),
235+ Tools: lists["tools"],
236+ Model: model,
237+ Provider: provider,
238+ Thinking: thinking,
239+ Path: path,
240+ Source: source,
241+ }
242+ profile.SystemPromptMode = "append"
243+ if mode, ok := values["systempromptmode"]; ok {
244+ switch strings.ToLower(strings.TrimSpace(unquote(mode))) {
245+ case "", "append":
246+ case "replace":
247+ profile.SystemPromptMode = "replace"
248+ default:
249+ return nil, fmt.Errorf("systemPromptMode must be append or replace")
250+ }
251+ }
252+ if raw, ok := values["inheritprojectcontext"]; ok {
253+ value, parsed := parseOptionalBool(raw)
254+ if !parsed {
255+ return nil, fmt.Errorf("inheritProjectContext must be true or false")
256+ }
257+ profile.InheritProjectContext = &value
258+ }
259+ if raw, ok := values["inheritskills"]; ok {
260+ value, parsed := parseOptionalBool(raw)
261+ if !parsed {
262+ return nil, fmt.Errorf("inheritSkills must be true or false")
263+ }
264+ profile.InheritSkills = &value
265+ }
266+ return profile, nil
267+}
268+
269+func splitFrontmatter(raw string) (front, body string, err error) {
270+ rest := strings.TrimLeft(raw, " \t\r\n")
271+ firstEnd := strings.IndexByte(rest, '\n')
272+ if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" {
273+ return "", raw, nil
274+ }
275+ rest = rest[firstEnd+1:]
276+ for offset := 0; offset <= len(rest); {
277+ rel := strings.Index(rest[offset:], "\n---")
278+ if rel < 0 {
279+ // A document that starts a frontmatter block but never closes
280+ // it is malformed, not a body-only profile.
281+ return "", "", fmt.Errorf("frontmatter: missing closing delimiter")
282+ }
283+ end := offset + rel
284+ after := end + len("\n---")
285+ if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
286+ return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil
287+ }
288+ offset = after
289+ }
290+ return "", raw, nil
291+}
292+
293+func parseFrontmatter(front string) (map[string]string, map[string][]string) {
294+ values := make(map[string]string)
295+ lists := make(map[string][]string)
296+ lines := strings.Split(front, "\n")
297+ for i := 0; i < len(lines); i++ {
298+ line := lines[i]
299+ trimmed := strings.TrimSpace(line)
300+ if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
301+ continue
302+ }
303+ colon := strings.IndexByte(trimmed, ':')
304+ if colon < 0 {
305+ continue
306+ }
307+ key := normalizeKey(trimmed[:colon])
308+ value := strings.TrimSpace(trimmed[colon+1:])
309+ if value == "" {
310+ var items []string
311+ for j := i + 1; j < len(lines); j++ {
312+ next := strings.TrimSpace(lines[j])
313+ if next == "" {
314+ continue
315+ }
316+ if !strings.HasPrefix(next, "-") || (len(lines[j]) > 0 && !strings.HasPrefix(lines[j], " ") && !strings.HasPrefix(lines[j], "\t")) {
317+ break
318+ }
319+ items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-"))))
320+ i = j
321+ }
322+ if len(items) > 0 {
323+ lists[key] = items
324+ }
325+ continue
326+ }
327+ if key == "tools" {
328+ lists[key] = parseList(value)
329+ continue
330+ }
331+ values[key] = unquote(value)
332+ }
333+ return values, lists
334+}
335+
336+func normalizeKey(key string) string {
337+ key = strings.ToLower(strings.TrimSpace(key))
338+ key = strings.ReplaceAll(key, "-", "")
339+ key = strings.ReplaceAll(key, "_", "")
340+ return key
341+}
342+
343+func parseList(value string) []string {
344+ value = strings.TrimSpace(value)
345+ value = strings.TrimPrefix(value, "[")
346+ value = strings.TrimSuffix(value, "]")
347+ if value == "" {
348+ return nil
349+ }
350+ parts := strings.Split(value, ",")
351+ out := make([]string, 0, len(parts))
352+ for _, part := range parts {
353+ if item := strings.TrimSpace(unquote(part)); item != "" {
354+ out = append(out, item)
355+ }
356+ }
357+ return out
358+}
359+
360+func parseThinkingValue(value, field string) (string, error) {
361+ switch strings.ToLower(strings.TrimSpace(unquote(value))) {
362+ case "":
363+ return "", nil
364+ case "off":
365+ return "off", nil
366+ case "minimal", "minimum":
367+ return "minimum", nil
368+ case "low", "medium", "high", "xhigh", "max":
369+ return strings.ToLower(strings.TrimSpace(unquote(value))), nil
370+ case "maximum":
371+ return "xhigh", nil
372+ default:
373+ return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field)
374+ }
375+}
376+
377+func parseOptionalBool(value string) (bool, bool) {
378+ parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value)))
379+ return parsed, err == nil
380+}
381+
382+func validName(name string) bool {
383+ name = strings.TrimSpace(name)
384+ if name == "" || len(name) > 128 {
385+ return false
386+ }
387+ for _, r := range name {
388+ if r == '/' || r == '\\' || r == '\x00' || r < 0x20 {
389+ return false
390+ }
391+ }
392+ return true
393+}
394+
395+func manifestValue(value string) string {
396+ return strings.Join(strings.Fields(value), " ")
397+}
398+
399+func unquote(value string) string {
400+ value = strings.TrimSpace(value)
401+ if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
402+ return value[1 : len(value)-1]
403+ }
404+ return value
405+}
@@ -0,0 +1,405 @@
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". Explicit provider metadata must be paired
102+// with an unqualified model; model IDs may contain slashes when no provider
103+// metadata is supplied.
104+func (p *Profile) ModelSelection() (provider, model string) {
105+ if p == nil {
106+ return "", ""
107+ }
108+ provider = strings.TrimSpace(p.Provider)
109+ model = strings.TrimSpace(p.Model)
110+ if provider == "" {
111+ if before, after, ok := strings.Cut(model, "/"); ok {
112+ provider, model = strings.TrimSpace(before), strings.TrimSpace(after)
113+ }
114+ }
115+ return provider, model
116+}
117+
118+// SystemPromptAddendum renders the compact [subagents_list] manifest for the
119+// parent model. Full profile bodies are intentionally omitted: the selected
120+// child loads its own instructions, and keeping them out of every parent turn
121+// avoids unnecessary context and accidental instruction mixing.
122+func SystemPromptAddendum(profiles []*Profile) string {
123+ if len(profiles) == 0 {
124+ return ""
125+ }
126+ var sb strings.Builder
127+ sb.WriteString("[subagents_list]\n")
128+ 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")
129+ for _, profile := range profiles {
130+ if profile == nil {
131+ continue
132+ }
133+ name := manifestValue(profile.Name)
134+ description := manifestValue(profile.Description)
135+ if description == "" {
136+ description = "(no description)"
137+ }
138+ metadata := make([]string, 0, 4)
139+ if model := manifestValue(profile.Model); model != "" {
140+ metadata = append(metadata, "model="+model)
141+ }
142+ if provider := manifestValue(profile.Provider); provider != "" {
143+ metadata = append(metadata, "provider="+provider)
144+ }
145+ if thinking := manifestValue(profile.Thinking); thinking != "" {
146+ metadata = append(metadata, "thinking="+thinking)
147+ }
148+ if len(profile.Tools) > 0 {
149+ metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ",")))
150+ }
151+ if len(metadata) > 0 {
152+ fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)
153+ } else {
154+ fmt.Fprintf(&sb, "- %s: %s\n", name, description)
155+ }
156+ }
157+ sb.WriteString("[/subagents_list]")
158+ return sb.String()
159+}
160+
161+type location struct {
162+ dir string
163+ label string
164+}
165+
166+func searchDirs(cwd, userHome string) []location {
167+ var out []location
168+ add := func(dir, label string) {
169+ if strings.TrimSpace(dir) != "" {
170+ out = append(out, location{dir: dir, label: label})
171+ }
172+ }
173+ if extra := os.Getenv("ZOT_AGENT_PROFILES"); extra != "" {
174+ for _, dir := range filepath.SplitList(extra) {
175+ dir = strings.TrimSpace(dir)
176+ if dir != "" && !filepath.IsAbs(dir) && strings.TrimSpace(cwd) != "" {
177+ dir = filepath.Join(cwd, dir)
178+ }
179+ add(dir, "configured")
180+ }
181+ }
182+ if userHome != "" {
183+ add(filepath.Join(userHome, ".agents", "agents"), "global (agents)")
184+ // This is an additional compatibility location. The shared .agents
185+ // path above remains zot's documented default.
186+ add(filepath.Join(userHome, ".pi", "agent", "agents"), "global (pi)")
187+ }
188+ return out
189+}
190+
191+func load(path, source string) (*Profile, error) {
192+ info, err := os.Stat(path)
193+ if err != nil {
194+ return nil, err
195+ }
196+ if !info.Mode().IsRegular() {
197+ return nil, fmt.Errorf("not a regular file")
198+ }
199+ if info.Size() > maxProfileBytes {
200+ return nil, fmt.Errorf("profile is larger than %d bytes", maxProfileBytes)
201+ }
202+ raw, err := os.ReadFile(path)
203+ if err != nil {
204+ return nil, err
205+ }
206+ front, body, err := splitFrontmatter(string(raw))
207+ if err != nil {
208+ return nil, err
209+ }
210+ values, lists := parseFrontmatter(front)
211+ name := unquote(values["name"])
212+ if name == "" {
213+ name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
214+ }
215+ thinking, err := parseThinkingValue(values["thinking"], "thinking")
216+ if err != nil {
217+ return nil, err
218+ }
219+ reasoning, err := parseThinkingValue(values["reasoning"], "reasoning")
220+ if err != nil {
221+ return nil, err
222+ }
223+ if thinking == "" {
224+ thinking = reasoning
225+ }
226+ model := strings.TrimSpace(unquote(values["model"]))
227+ provider := strings.TrimSpace(unquote(values["provider"]))
228+ if provider != "" && strings.Contains(model, "/") {
229+ return nil, fmt.Errorf("model must not include a provider when provider is set")
230+ }
231+ profile := &Profile{
232+ Name: strings.TrimSpace(name),
233+ Description: strings.TrimSpace(unquote(values["description"])),
234+ SystemPrompt: strings.TrimSpace(body),
235+ Tools: lists["tools"],
236+ Model: model,
237+ Provider: provider,
238+ Thinking: thinking,
239+ Path: path,
240+ Source: source,
241+ }
242+ profile.SystemPromptMode = "append"
243+ if mode, ok := values["systempromptmode"]; ok {
244+ switch strings.ToLower(strings.TrimSpace(unquote(mode))) {
245+ case "", "append":
246+ case "replace":
247+ profile.SystemPromptMode = "replace"
248+ default:
249+ return nil, fmt.Errorf("systemPromptMode must be append or replace")
250+ }
251+ }
252+ if raw, ok := values["inheritprojectcontext"]; ok {
253+ value, parsed := parseOptionalBool(raw)
254+ if !parsed {
255+ return nil, fmt.Errorf("inheritProjectContext must be true or false")
256+ }
257+ profile.InheritProjectContext = &value
258+ }
259+ if raw, ok := values["inheritskills"]; ok {
260+ value, parsed := parseOptionalBool(raw)
261+ if !parsed {
262+ return nil, fmt.Errorf("inheritSkills must be true or false")
263+ }
264+ profile.InheritSkills = &value
265+ }
266+ return profile, nil
267+}
268+
269+func splitFrontmatter(raw string) (front, body string, err error) {
270+ rest := strings.TrimLeft(raw, " \t\r\n")
271+ firstEnd := strings.IndexByte(rest, '\n')
272+ if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" {
273+ return "", raw, nil
274+ }
275+ rest = rest[firstEnd+1:]
276+ for offset := 0; offset <= len(rest); {
277+ rel := strings.Index(rest[offset:], "\n---")
278+ if rel < 0 {
279+ // A document that starts a frontmatter block but never closes
280+ // it is malformed, not a body-only profile.
281+ return "", "", fmt.Errorf("frontmatter: missing closing delimiter")
282+ }
283+ end := offset + rel
284+ after := end + len("\n---")
285+ if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
286+ return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil
287+ }
288+ offset = after
289+ }
290+ return "", raw, nil
291+}
292+
293+func parseFrontmatter(front string) (map[string]string, map[string][]string) {
294+ values := make(map[string]string)
295+ lists := make(map[string][]string)
296+ lines := strings.Split(front, "\n")
297+ for i := 0; i < len(lines); i++ {
298+ line := lines[i]
299+ trimmed := strings.TrimSpace(line)
300+ if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
301+ continue
302+ }
303+ colon := strings.IndexByte(trimmed, ':')
304+ if colon < 0 {
305+ continue
306+ }
307+ key := normalizeKey(trimmed[:colon])
308+ value := strings.TrimSpace(trimmed[colon+1:])
309+ if value == "" {
310+ var items []string
311+ for j := i + 1; j < len(lines); j++ {
312+ next := strings.TrimSpace(lines[j])
313+ if next == "" {
314+ continue
315+ }
316+ if !strings.HasPrefix(next, "-") || (len(lines[j]) > 0 && !strings.HasPrefix(lines[j], " ") && !strings.HasPrefix(lines[j], "\t")) {
317+ break
318+ }
319+ items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-"))))
320+ i = j
321+ }
322+ if len(items) > 0 {
323+ lists[key] = items
324+ }
325+ continue
326+ }
327+ if key == "tools" {
328+ lists[key] = parseList(value)
329+ continue
330+ }
331+ values[key] = unquote(value)
332+ }
333+ return values, lists
334+}
335+
336+func normalizeKey(key string) string {
337+ key = strings.ToLower(strings.TrimSpace(key))
338+ key = strings.ReplaceAll(key, "-", "")
339+ key = strings.ReplaceAll(key, "_", "")
340+ return key
341+}
342+
343+func parseList(value string) []string {
344+ value = strings.TrimSpace(value)
345+ value = strings.TrimPrefix(value, "[")
346+ value = strings.TrimSuffix(value, "]")
347+ if value == "" {
348+ return nil
349+ }
350+ parts := strings.Split(value, ",")
351+ out := make([]string, 0, len(parts))
352+ for _, part := range parts {
353+ if item := strings.TrimSpace(unquote(part)); item != "" {
354+ out = append(out, item)
355+ }
356+ }
357+ return out
358+}
359+
360+func parseThinkingValue(value, field string) (string, error) {
361+ switch strings.ToLower(strings.TrimSpace(unquote(value))) {
362+ case "":
363+ return "", nil
364+ case "off":
365+ return "off", nil
366+ case "minimal", "minimum":
367+ return "minimum", nil
368+ case "low", "medium", "high", "xhigh", "max":
369+ return strings.ToLower(strings.TrimSpace(unquote(value))), nil
370+ case "maximum":
371+ return "xhigh", nil
372+ default:
373+ return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field)
374+ }
375+}
376+
377+func parseOptionalBool(value string) (bool, bool) {
378+ parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value)))
379+ return parsed, err == nil
380+}
381+
382+func validName(name string) bool {
383+ name = strings.TrimSpace(name)
384+ if name == "" || len(name) > 128 {
385+ return false
386+ }
387+ for _, r := range name {
388+ if r == '/' || r == '\\' || r == '\x00' || r < 0x20 {
389+ return false
390+ }
391+ }
392+ return true
393+}
394+
395+func manifestValue(value string) string {
396+ return strings.Join(strings.Fields(value), " ")
397+}
398+
399+func unquote(value string) string {
400+ value = strings.TrimSpace(value)
401+ if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
402+ return value[1 : len(value)-1]
403+ }
404+ return value
405+}
A packages/agent/subagents/profiles_test.go +215 -0
@@ -0,0 +1,215 @@
1+package subagents
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestDiscoverLoadsGlobalAgentsProfilesAndPiFallback(t *testing.T) {
12+ home := t.TempDir()
13+ agentsDir := filepath.Join(home, ".agents", "agents")
14+ piDir := filepath.Join(home, ".pi", "agent", "agents")
15+ if err := os.MkdirAll(agentsDir, 0o755); err != nil {
16+ t.Fatal(err)
17+ }
18+ if err := os.MkdirAll(piDir, 0o755); err != nil {
19+ t.Fatal(err)
20+ }
21+ writeProfile(t, filepath.Join(agentsDir, "reviewer.md"), `---
22+name: reviewer
23+description: Read-only code reviewer
24+tools: read
25+model: openai-codex/gpt-5.6-luna
26+thinking: max
27+systemPromptMode: replace
28+inheritProjectContext: false
29+inheritSkills: false
30+---
31+Review the requested scope without editing it.
32+`)
33+ writeProfile(t, filepath.Join(piDir, "implementer.md"), `---
34+name: implementer
35+description: Primary implementation agent
36+tools: [read, bash, edit, write]
37+model: openai-codex/gpt-5.6-luna
38+thinking: xhigh
39+---
40+Implement the requested change.
41+`)
42+
43+ profiles, errs := Discover("", home)
44+ if len(errs) != 0 {
45+ t.Fatalf("Discover errors = %v", errs)
46+ }
47+ if len(profiles) != 2 {
48+ t.Fatalf("profiles = %d, want 2: %#v", len(profiles), profiles)
49+ }
50+ if profiles[0].Name != "implementer" || profiles[1].Name != "reviewer" {
51+ t.Fatalf("profiles not sorted by name: %#v", profiles)
52+ }
53+ reviewer := Find(profiles, "reviewer")
54+ if reviewer == nil {
55+ t.Fatal("reviewer profile missing")
56+ }
57+ if reviewer.SystemPromptMode != "replace" {
58+ t.Fatalf("system prompt mode = %q, want replace", reviewer.SystemPromptMode)
59+ }
60+ if reviewer.Model != "openai-codex/gpt-5.6-luna" || reviewer.Thinking != "max" {
61+ t.Fatalf("profile metadata = %#v", reviewer)
62+ }
63+ if reviewer.InheritProjectContext == nil || *reviewer.InheritProjectContext {
64+ t.Fatalf("inherit project context = %#v, want false", reviewer.InheritProjectContext)
65+ }
66+ if reviewer.InheritSkills == nil || *reviewer.InheritSkills {
67+ t.Fatalf("inherit skills = %#v, want false", reviewer.InheritSkills)
68+ }
69+}
70+
71+func TestDiscoverResolvesConfiguredRelativePathAgainstCWD(t *testing.T) {
72+ first := t.TempDir()
73+ second := t.TempDir()
74+ t.Setenv("ZOT_AGENT_PROFILES", ".agents")
75+ writeProfile(t, filepath.Join(first, ".agents", "reviewer.md"), `---
76+name: reviewer
77+description: First session profile
78+---
79+First.
80+`)
81+ writeProfile(t, filepath.Join(second, ".agents", "reviewer.md"), `---
82+name: reviewer
83+description: Second session profile
84+---
85+Second.
86+`)
87+
88+ for _, tc := range []struct {
89+ cwd string
90+ description string
91+ }{
92+ {cwd: first, description: "First session profile"},
93+ {cwd: second, description: "Second session profile"},
94+ } {
95+ profiles, errs := Discover(tc.cwd, t.TempDir())
96+ if len(errs) != 0 {
97+ t.Fatalf("Discover(%q) errors = %v", tc.cwd, errs)
98+ }
99+ if len(profiles) != 1 || profiles[0].Description != tc.description {
100+ t.Fatalf("Discover(%q) = %#v, want %q", tc.cwd, profiles, tc.description)
101+ }
102+ }
103+}
104+
105+func TestDiscoverConfiguredDirectoryWinsAndRejectsUnsafeNames(t *testing.T) {
106+ configured := t.TempDir()
107+ home := t.TempDir()
108+ t.Setenv("ZOT_AGENT_PROFILES", configured)
109+ writeProfile(t, filepath.Join(configured, "reviewer.md"), `---
110+name: reviewer
111+description: Configured reviewer
112+---
113+Configured instructions.
114+`)
115+ writeProfile(t, filepath.Join(filepath.Join(home, ".agents", "agents"), "reviewer.md"), `---
116+name: reviewer
117+description: Global reviewer
118+---
119+Global instructions.
120+`)
121+ writeProfile(t, filepath.Join(configured, "unsafe.md"), `---
122+name: ../unsafe
123+description: Should not load
124+---
125+No.
126+`)
127+
128+ profiles, errs := Discover("", home)
129+ if len(errs) != 0 {
130+ t.Fatalf("Discover errors = %v", errs)
131+ }
132+ if len(profiles) != 1 || profiles[0].Description != "Configured reviewer" {
133+ t.Fatalf("configured precedence = %#v", profiles)
134+ }
135+}
136+
137+func TestLoadRejectsUnclosedFrontmatter(t *testing.T) {
138+ path := filepath.Join(t.TempDir(), "profile.md")
139+ if err := os.WriteFile(path, []byte("---\nname: reviewer\nInstructions without a closing delimiter.\n"), 0o600); err != nil {
140+ t.Fatal(err)
141+ }
142+ if _, err := load(path, "test"); err == nil || !strings.Contains(err.Error(), "missing closing delimiter") {
143+ t.Fatalf("load error = %v, want missing closing delimiter", err)
144+ }
145+}
146+
147+func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
148+ cases := []struct {
149+ name string
150+ field string
151+ }{
152+ {name: "invalid-thinking", field: "thinking: extreme"},
153+ {name: "invalid-reasoning", field: "reasoning: extreme"},
154+ {name: "qualified-model-with-provider", field: "provider: openai\nmodel: openai-codex/gpt-5"},
155+ {name: "invalid-prompt-mode", field: "systemPromptMode: merge"},
156+ {name: "invalid-project-context", field: "inheritProjectContext: sometimes"},
157+ {name: "invalid-skills", field: "inheritSkills: sometimes"},
158+ }
159+ for _, tc := range cases {
160+ t.Run(tc.name, func(t *testing.T) {
161+ path := filepath.Join(t.TempDir(), "profile.md")
162+ writeProfile(t, path, fmt.Sprintf("---\nname: test\n%s\n---\nInstructions.\n", tc.field))
163+ if _, err := load(path, "test"); err == nil {
164+ t.Fatalf("load succeeded for invalid metadata %q", tc.field)
165+ }
166+ })
167+ }
168+}
169+
170+func TestProfileModelSelection(t *testing.T) {
171+ profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}
172+ provider, model := profile.ModelSelection()
173+ if provider != "openrouter" || model != "anthropic/claude-sonnet" {
174+ t.Fatalf("selection = (%q, %q)", provider, model)
175+ }
176+
177+ profile = &Profile{Provider: "openai", Model: "gpt-5"}
178+ provider, model = profile.ModelSelection()
179+ if provider != "openai" || model != "gpt-5" {
180+ t.Fatalf("explicit selection = (%q, %q)", provider, model)
181+ }
182+}
183+
184+func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) {
185+ profiles := []*Profile{{
186+ Name: "reviewer",
187+ Description: "Read-only\nreviewer",
188+ SystemPrompt: "secret instructions that belong only to the child",
189+ Model: "openai-codex/gpt-5",
190+ Thinking: "max",
191+ Tools: []string{"read", "bash"},
192+ Path: "/private/profile.md",
193+ }}
194+ got := SystemPromptAddendum(profiles)
195+ for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max"} {
196+ if !strings.Contains(got, want) {
197+ t.Fatalf("addendum missing %q:\n%s", want, got)
198+ }
199+ }
200+ for _, unwanted := range []string{"secret instructions", "/private/profile.md", "\nreviewer"} {
201+ if strings.Contains(got, unwanted) {
202+ t.Fatalf("addendum contains %q:\n%s", unwanted, got)
203+ }
204+ }
205+}
206+
207+func writeProfile(t *testing.T, path, content string) {
208+ t.Helper()
209+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
213+ t.Fatal(err)
214+ }
215+}
@@ -0,0 +1,215 @@
1+package subagents
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestDiscoverLoadsGlobalAgentsProfilesAndPiFallback(t *testing.T) {
12+ home := t.TempDir()
13+ agentsDir := filepath.Join(home, ".agents", "agents")
14+ piDir := filepath.Join(home, ".pi", "agent", "agents")
15+ if err := os.MkdirAll(agentsDir, 0o755); err != nil {
16+ t.Fatal(err)
17+ }
18+ if err := os.MkdirAll(piDir, 0o755); err != nil {
19+ t.Fatal(err)
20+ }
21+ writeProfile(t, filepath.Join(agentsDir, "reviewer.md"), `---
22+name: reviewer
23+description: Read-only code reviewer
24+tools: read
25+model: openai-codex/gpt-5.6-luna
26+thinking: max
27+systemPromptMode: replace
28+inheritProjectContext: false
29+inheritSkills: false
30+---
31+Review the requested scope without editing it.
32+`)
33+ writeProfile(t, filepath.Join(piDir, "implementer.md"), `---
34+name: implementer
35+description: Primary implementation agent
36+tools: [read, bash, edit, write]
37+model: openai-codex/gpt-5.6-luna
38+thinking: xhigh
39+---
40+Implement the requested change.
41+`)
42+
43+ profiles, errs := Discover("", home)
44+ if len(errs) != 0 {
45+ t.Fatalf("Discover errors = %v", errs)
46+ }
47+ if len(profiles) != 2 {
48+ t.Fatalf("profiles = %d, want 2: %#v", len(profiles), profiles)
49+ }
50+ if profiles[0].Name != "implementer" || profiles[1].Name != "reviewer" {
51+ t.Fatalf("profiles not sorted by name: %#v", profiles)
52+ }
53+ reviewer := Find(profiles, "reviewer")
54+ if reviewer == nil {
55+ t.Fatal("reviewer profile missing")
56+ }
57+ if reviewer.SystemPromptMode != "replace" {
58+ t.Fatalf("system prompt mode = %q, want replace", reviewer.SystemPromptMode)
59+ }
60+ if reviewer.Model != "openai-codex/gpt-5.6-luna" || reviewer.Thinking != "max" {
61+ t.Fatalf("profile metadata = %#v", reviewer)
62+ }
63+ if reviewer.InheritProjectContext == nil || *reviewer.InheritProjectContext {
64+ t.Fatalf("inherit project context = %#v, want false", reviewer.InheritProjectContext)
65+ }
66+ if reviewer.InheritSkills == nil || *reviewer.InheritSkills {
67+ t.Fatalf("inherit skills = %#v, want false", reviewer.InheritSkills)
68+ }
69+}
70+
71+func TestDiscoverResolvesConfiguredRelativePathAgainstCWD(t *testing.T) {
72+ first := t.TempDir()
73+ second := t.TempDir()
74+ t.Setenv("ZOT_AGENT_PROFILES", ".agents")
75+ writeProfile(t, filepath.Join(first, ".agents", "reviewer.md"), `---
76+name: reviewer
77+description: First session profile
78+---
79+First.
80+`)
81+ writeProfile(t, filepath.Join(second, ".agents", "reviewer.md"), `---
82+name: reviewer
83+description: Second session profile
84+---
85+Second.
86+`)
87+
88+ for _, tc := range []struct {
89+ cwd string
90+ description string
91+ }{
92+ {cwd: first, description: "First session profile"},
93+ {cwd: second, description: "Second session profile"},
94+ } {
95+ profiles, errs := Discover(tc.cwd, t.TempDir())
96+ if len(errs) != 0 {
97+ t.Fatalf("Discover(%q) errors = %v", tc.cwd, errs)
98+ }
99+ if len(profiles) != 1 || profiles[0].Description != tc.description {
100+ t.Fatalf("Discover(%q) = %#v, want %q", tc.cwd, profiles, tc.description)
101+ }
102+ }
103+}
104+
105+func TestDiscoverConfiguredDirectoryWinsAndRejectsUnsafeNames(t *testing.T) {
106+ configured := t.TempDir()
107+ home := t.TempDir()
108+ t.Setenv("ZOT_AGENT_PROFILES", configured)
109+ writeProfile(t, filepath.Join(configured, "reviewer.md"), `---
110+name: reviewer
111+description: Configured reviewer
112+---
113+Configured instructions.
114+`)
115+ writeProfile(t, filepath.Join(filepath.Join(home, ".agents", "agents"), "reviewer.md"), `---
116+name: reviewer
117+description: Global reviewer
118+---
119+Global instructions.
120+`)
121+ writeProfile(t, filepath.Join(configured, "unsafe.md"), `---
122+name: ../unsafe
123+description: Should not load
124+---
125+No.
126+`)
127+
128+ profiles, errs := Discover("", home)
129+ if len(errs) != 0 {
130+ t.Fatalf("Discover errors = %v", errs)
131+ }
132+ if len(profiles) != 1 || profiles[0].Description != "Configured reviewer" {
133+ t.Fatalf("configured precedence = %#v", profiles)
134+ }
135+}
136+
137+func TestLoadRejectsUnclosedFrontmatter(t *testing.T) {
138+ path := filepath.Join(t.TempDir(), "profile.md")
139+ if err := os.WriteFile(path, []byte("---\nname: reviewer\nInstructions without a closing delimiter.\n"), 0o600); err != nil {
140+ t.Fatal(err)
141+ }
142+ if _, err := load(path, "test"); err == nil || !strings.Contains(err.Error(), "missing closing delimiter") {
143+ t.Fatalf("load error = %v, want missing closing delimiter", err)
144+ }
145+}
146+
147+func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
148+ cases := []struct {
149+ name string
150+ field string
151+ }{
152+ {name: "invalid-thinking", field: "thinking: extreme"},
153+ {name: "invalid-reasoning", field: "reasoning: extreme"},
154+ {name: "qualified-model-with-provider", field: "provider: openai\nmodel: openai-codex/gpt-5"},
155+ {name: "invalid-prompt-mode", field: "systemPromptMode: merge"},
156+ {name: "invalid-project-context", field: "inheritProjectContext: sometimes"},
157+ {name: "invalid-skills", field: "inheritSkills: sometimes"},
158+ }
159+ for _, tc := range cases {
160+ t.Run(tc.name, func(t *testing.T) {
161+ path := filepath.Join(t.TempDir(), "profile.md")
162+ writeProfile(t, path, fmt.Sprintf("---\nname: test\n%s\n---\nInstructions.\n", tc.field))
163+ if _, err := load(path, "test"); err == nil {
164+ t.Fatalf("load succeeded for invalid metadata %q", tc.field)
165+ }
166+ })
167+ }
168+}
169+
170+func TestProfileModelSelection(t *testing.T) {
171+ profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}
172+ provider, model := profile.ModelSelection()
173+ if provider != "openrouter" || model != "anthropic/claude-sonnet" {
174+ t.Fatalf("selection = (%q, %q)", provider, model)
175+ }
176+
177+ profile = &Profile{Provider: "openai", Model: "gpt-5"}
178+ provider, model = profile.ModelSelection()
179+ if provider != "openai" || model != "gpt-5" {
180+ t.Fatalf("explicit selection = (%q, %q)", provider, model)
181+ }
182+}
183+
184+func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) {
185+ profiles := []*Profile{{
186+ Name: "reviewer",
187+ Description: "Read-only\nreviewer",
188+ SystemPrompt: "secret instructions that belong only to the child",
189+ Model: "openai-codex/gpt-5",
190+ Thinking: "max",
191+ Tools: []string{"read", "bash"},
192+ Path: "/private/profile.md",
193+ }}
194+ got := SystemPromptAddendum(profiles)
195+ for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max"} {
196+ if !strings.Contains(got, want) {
197+ t.Fatalf("addendum missing %q:\n%s", want, got)
198+ }
199+ }
200+ for _, unwanted := range []string{"secret instructions", "/private/profile.md", "\nreviewer"} {
201+ if strings.Contains(got, unwanted) {
202+ t.Fatalf("addendum contains %q:\n%s", unwanted, got)
203+ }
204+ }
205+}
206+
207+func writeProfile(t *testing.T, path, content string) {
208+ t.Helper()
209+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
213+ t.Fatal(err)
214+ }
215+}
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 +11 -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,8 @@ 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,
356+ SessionID: existing.SessionID,
349357 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
350358 SessionPath: existing.SessionPath,
351359 }
@@ -357,6 +365,9 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
357365 Started: m.Started,
358366 Model: m.Model,
359367 Provider: m.Provider,
368+ Reasoning: m.Reasoning,
369+ Subagent: m.Subagent,
370+ SessionID: m.SessionID,
360371 InboxPath: m.InboxPath,
361372 EventLogPath: m.EventLogPath,
362373 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,8 @@ 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,
356+ SessionID: existing.SessionID,
349 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,357 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
350 SessionPath: existing.SessionPath,358 SessionPath: existing.SessionPath,
351 }359 }
@@ -357,6 +365,9 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
357 Started: m.Started,365 Started: m.Started,
358 Model: m.Model,366 Model: m.Model,
359 Provider: m.Provider,367 Provider: m.Provider,
368+ Reasoning: m.Reasoning,
369+ Subagent: m.Subagent,
370+ SessionID: m.SessionID,
360 InboxPath: m.InboxPath,371 InboxPath: m.InboxPath,
361 EventLogPath: m.EventLogPath,372 EventLogPath: m.EventLogPath,
362 SessionPath: m.SessionPath,373 SessionPath: m.SessionPath,
M packages/agent/swarm/persist_test.go +28 -9
@@ -536,18 +536,20 @@ func TestSpawnReqPersistsModel(t *testing.T) {
536536 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
537537 },
538538 })
539+ f.SetActiveSession("host-session")
539540 a, err := f.SpawnReq(context.Background(), SpawnRequest{
540541 Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",
542+ Reasoning: "high", Subagent: "reviewer",
541543 })
542544 if err != nil {
543545 t.Fatalf("spawn: %v", err)
544546 }
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)
547+ if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" || a.SessionID != "host-session" {
548+ t.Fatalf("agent fields = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session", a.Model, a.Provider, a.Reasoning, a.Subagent, a.SessionID)
547549 }
548550 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)
551+ if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" {
552+ t.Fatalf("snapshot = (%q,%q,%q,%q); child fields not surfaced", snap.Model, snap.Provider, snap.Reasoning, snap.Subagent)
551553 }
552554
553555 // Stop so we can read meta.json without racing the run loop.
@@ -564,14 +566,19 @@ func TestSpawnReqPersistsModel(t *testing.T) {
564566 if err := json.Unmarshal(metaBytes, &got); err != nil {
565567 t.Fatal(err)
566568 }
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)
569+ if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" || got.SessionID != "host-session" {
570+ t.Errorf("meta = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session persisted", got.Model, got.Provider, got.Reasoning, got.Subagent, got.SessionID)
569571 }
570572
571573 // Reload in a fresh Swarm and confirm the detached agent still
572574 // carries the model/provider so Resume can route the child
573575 // subprocess back to the same model.
574- g := New(Config{Root: root, RepoRoot: root})
576+ g := New(Config{
577+ Root: root, RepoRoot: root,
578+ NewRunner: func(a *Agent) Runner {
579+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
580+ },
581+ })
575582 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
576583 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
577584 }
@@ -579,9 +586,21 @@ func TestSpawnReqPersistsModel(t *testing.T) {
579586 if re == nil {
580587 t.Fatal("reloaded agent missing")
581588 }
582- if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" {
583- t.Errorf("reloaded fields = (%q,%q); want preserved", re.Model, re.Provider)
589+ if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" || re.SessionID != "host-session" {
590+ t.Errorf("reloaded fields = (%q,%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent, re.SessionID)
591+ }
592+
593+ resumed, err := g.Resume(context.Background(), re.ID)
594+ if err != nil {
595+ t.Fatalf("resume: %v", err)
596+ }
597+ if resumed.Reasoning != "high" || resumed.Subagent != "reviewer" || resumed.SessionID != "host-session" {
598+ t.Errorf("resumed fields = (%q,%q,%q); want reasoning/profile/session preserved", resumed.Reasoning, resumed.Subagent, resumed.SessionID)
599+ }
600+ if err := g.Stop(resumed.ID); err != nil {
601+ t.Fatalf("stop resumed agent: %v", err)
584602 }
603+ resumed.Wait()
585604 }
586605
587606 // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that
@@ -536,18 +536,20 @@ func TestSpawnReqPersistsModel(t *testing.T) {
536 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })536 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
537 },537 },
538 })538 })
539+ f.SetActiveSession("host-session")
539 a, err := f.SpawnReq(context.Background(), SpawnRequest{540 a, err := f.SpawnReq(context.Background(), SpawnRequest{
540 Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",541 Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",
542+ Reasoning: "high", Subagent: "reviewer",
541 })543 })
542 if err != nil {544 if err != nil {
543 t.Fatalf("spawn: %v", err)545 t.Fatalf("spawn: %v", err)
544 }546 }
545- if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" {547+ if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" || a.SessionID != "host-session" {
546- t.Fatalf("agent fields = (%q,%q); want (claude-sonnet-4-5, anthropic)", a.Model, a.Provider)548+ t.Fatalf("agent fields = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session", a.Model, a.Provider, a.Reasoning, a.Subagent, a.SessionID)
547 }549 }
548 snap := a.Snapshot()550 snap := a.Snapshot()
549- if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" {551+ 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)552+ t.Fatalf("snapshot = (%q,%q,%q,%q); child fields not surfaced", snap.Model, snap.Provider, snap.Reasoning, snap.Subagent)
551 }553 }
552 554
553 // Stop so we can read meta.json without racing the run loop.555 // Stop so we can read meta.json without racing the run loop.
@@ -564,14 +566,19 @@ func TestSpawnReqPersistsModel(t *testing.T) {
564 if err := json.Unmarshal(metaBytes, &got); err != nil {566 if err := json.Unmarshal(metaBytes, &got); err != nil {
565 t.Fatal(err)567 t.Fatal(err)
566 }568 }
567- if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" {569+ if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" || got.SessionID != "host-session" {
568- t.Errorf("meta = (%q,%q); want model + provider persisted", got.Model, got.Provider)570+ t.Errorf("meta = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session persisted", got.Model, got.Provider, got.Reasoning, got.Subagent, got.SessionID)
569 }571 }
570 572
571 // Reload in a fresh Swarm and confirm the detached agent still573 // Reload in a fresh Swarm and confirm the detached agent still
572 // carries the model/provider so Resume can route the child574 // carries the model/provider so Resume can route the child
573 // subprocess back to the same model.575 // subprocess back to the same model.
574- g := New(Config{Root: root, RepoRoot: root})576+ g := New(Config{
577+ Root: root, RepoRoot: root,
578+ NewRunner: func(a *Agent) Runner {
579+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
580+ },
581+ })
575 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {582 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
576 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)583 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
577 }584 }
@@ -579,9 +586,21 @@ func TestSpawnReqPersistsModel(t *testing.T) {
579 if re == nil {586 if re == nil {
580 t.Fatal("reloaded agent missing")587 t.Fatal("reloaded agent missing")
581 }588 }
582- if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" {589+ if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" || re.SessionID != "host-session" {
583- t.Errorf("reloaded fields = (%q,%q); want preserved", re.Model, re.Provider)590+ t.Errorf("reloaded fields = (%q,%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent, re.SessionID)
591+ }
592+
593+ resumed, err := g.Resume(context.Background(), re.ID)
594+ if err != nil {
595+ t.Fatalf("resume: %v", err)
596+ }
597+ if resumed.Reasoning != "high" || resumed.Subagent != "reviewer" || resumed.SessionID != "host-session" {
598+ t.Errorf("resumed fields = (%q,%q,%q); want reasoning/profile/session preserved", resumed.Reasoning, resumed.Subagent, resumed.SessionID)
599+ }
600+ if err := g.Stop(resumed.ID); err != nil {
601+ t.Fatalf("stop resumed agent: %v", err)
584 }602 }
603+ resumed.Wait()
585 }604 }
586 605
587 // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that606 // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that
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 +9 -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)
@@ -167,6 +171,11 @@ func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {
167171 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
168172 t.Fatalf("argv ends on a flag with no value: %v", args)
169173 }
174+ for _, flag := range []string{"--reasoning", "--subagent"} {
175+ if indexOf(args, flag) >= 0 {
176+ t.Fatalf("empty child options should omit %s: %v", flag, args)
177+ }
178+ }
170179 }
171180
172181 // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
@@ -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)
@@ -167,6 +171,11 @@ func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {
167 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {171 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
168 t.Fatalf("argv ends on a flag with no value: %v", args)172 t.Fatalf("argv ends on a flag with no value: %v", args)
169 }173 }
174+ for _, flag := range []string{"--reasoning", "--subagent"} {
175+ if indexOf(args, flag) >= 0 {
176+ t.Fatalf("empty child options should omit %s: %v", flag, args)
177+ }
178+ }
170 }179 }
171 180
172 // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a181 // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
M packages/agent/swarm/swarm.go +45 -17
@@ -139,6 +139,23 @@ func New(cfg Config) *Swarm {
139139 }
140140 }
141141
142+// SetRepoRoot updates the shared working directory for subsequently
143+// spawned agents. Existing agents keep the directory they were started
144+// with; callers use this when the host session changes cwd.
145+func (f *Swarm) SetRepoRoot(root string) {
146+ f.mu.Lock()
147+ defer f.mu.Unlock()
148+ f.cfg.RepoRoot = root
149+}
150+
151+// RepoRoot returns the working directory used by subsequently spawned
152+// agents.
153+func (f *Swarm) RepoRoot() string {
154+ f.mu.Lock()
155+ defer f.mu.Unlock()
156+ return f.cfg.RepoRoot
157+}
158+
142159 // SetActiveSession scopes the dashboard view (and Spawn stamping)
143160 // to a particular host zot session id. Pass empty to clear the
144161 // scope and revert to "show every agent" (the original behaviour).
@@ -177,11 +194,16 @@ func (f *Swarm) agentStateDir(id string) string {
177194 // SpawnRequest configures a Spawn. Only Task is required; the rest
178195 // are optional. Model + Provider, when set, get baked into the
179196 // child argv as --model / --provider so the agent runs against the
180-// chosen model regardless of the parent's current selection.
197+// chosen model regardless of the parent's current selection. A named
198+// Subagent is passed to the child as --subagent, where it resolves the
199+// markdown profile and applies its instructions. Reasoning is passed
200+// as --reasoning when set.
181201 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
202+ Task string
203+ Model string // optional override; child resolves default if empty
204+ Provider string // optional override; usually paired with Model
205+ Reasoning string // optional reasoning/thinking level override
206+ Subagent string // optional named markdown profile
185207 }
186208
187209 // Spawn creates a new Agent for the given task, allocates its
@@ -208,7 +230,13 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
208230 return nil, errors.New("swarm: empty task")
209231 }
210232 id := newAgentID(task, f.cfg.Now())
233+
234+ // Snapshot the shared cwd together with the active session so a
235+ // concurrent /cd cannot race a spawn or produce a mixed snapshot.
236+ f.mu.Lock()
211237 dir := f.cfg.RepoRoot
238+ sessionID := f.activeSession
239+ f.mu.Unlock()
212240
213241 stateDir := f.agentStateDir(id)
214242 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -224,13 +252,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
224252 return nil, fmt.Errorf("swarm inbox path: %w", err)
225253 }
226254
227- // Snapshot activeSession under the lock; we use it twice (struct
228- // init below + persistence). Reading it without the lock would
229- // race a concurrent SetActiveSession call.
230- f.mu.Lock()
231- sessionID := f.activeSession
232- f.mu.Unlock()
233-
234255 a := &Agent{
235256 ID: id,
236257 Task: task,
@@ -238,6 +259,8 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
238259 Started: f.cfg.Now(),
239260 Model: strings.TrimSpace(req.Model),
240261 Provider: strings.TrimSpace(req.Provider),
262+ Reasoning: strings.TrimSpace(req.Reasoning),
263+ Subagent: strings.TrimSpace(req.Subagent),
241264 SessionID: sessionID,
242265 InboxPath: inboxPath,
243266 EventLogPath: logPath,
@@ -444,12 +467,15 @@ type AgentSnapshot struct {
444467 Lines []string // full transcript (already capped by Agent.appendTranscript)
445468 LastAssistant string // complete final assistant message, without role formatting
446469
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
470+ // Model, Provider, and Reasoning expose the per-agent overrides set
471+ // at Spawn time (empty when the agent inherits the child's default
472+ // resolution). Subagent is the selected named markdown profile.
473+ // The dashboard surfaces these so the user can confirm the child
474+ // configuration.
475+ Model string
476+ Provider string
477+ Reasoning string
478+ Subagent string
453479
454480 // Paths to the agent's durable state. Surface them in the
455481 // snapshot so the dashboard / /swarm open can read events.jsonl
@@ -479,6 +505,8 @@ func (a *Agent) Snapshot() AgentSnapshot {
479505 LastAssistant: a.lastAssistant,
480506 Model: a.Model,
481507 Provider: a.Provider,
508+ Reasoning: a.Reasoning,
509+ Subagent: a.Subagent,
482510 InboxPath: a.InboxPath,
483511 EventLogPath: a.EventLogPath,
484512 SessionPath: a.SessionPath,
@@ -139,6 +139,23 @@ func New(cfg Config) *Swarm {
139 }139 }
140 }140 }
141 141
142+// SetRepoRoot updates the shared working directory for subsequently
143+// spawned agents. Existing agents keep the directory they were started
144+// with; callers use this when the host session changes cwd.
145+func (f *Swarm) SetRepoRoot(root string) {
146+ f.mu.Lock()
147+ defer f.mu.Unlock()
148+ f.cfg.RepoRoot = root
149+}
150+
151+// RepoRoot returns the working directory used by subsequently spawned
152+// agents.
153+func (f *Swarm) RepoRoot() string {
154+ f.mu.Lock()
155+ defer f.mu.Unlock()
156+ return f.cfg.RepoRoot
157+}
158+
142 // SetActiveSession scopes the dashboard view (and Spawn stamping)159 // SetActiveSession scopes the dashboard view (and Spawn stamping)
143 // to a particular host zot session id. Pass empty to clear the160 // to a particular host zot session id. Pass empty to clear the
144 // scope and revert to "show every agent" (the original behaviour).161 // scope and revert to "show every agent" (the original behaviour).
@@ -177,11 +194,16 @@ func (f *Swarm) agentStateDir(id string) string {
177 // SpawnRequest configures a Spawn. Only Task is required; the rest194 // SpawnRequest configures a Spawn. Only Task is required; the rest
178 // are optional. Model + Provider, when set, get baked into the195 // are optional. Model + Provider, when set, get baked into the
179 // child argv as --model / --provider so the agent runs against the196 // child argv as --model / --provider so the agent runs against the
180-// chosen model regardless of the parent's current selection.197+// chosen model regardless of the parent's current selection. A named
198+// Subagent is passed to the child as --subagent, where it resolves the
199+// markdown profile and applies its instructions. Reasoning is passed
200+// as --reasoning when set.
181 type SpawnRequest struct {201 type SpawnRequest struct {
182- Task string202+ Task string
183- Model string // optional override; child resolves default if empty203+ Model string // optional override; child resolves default if empty
184- Provider string // optional override; usually paired with Model204+ Provider string // optional override; usually paired with Model
205+ Reasoning string // optional reasoning/thinking level override
206+ Subagent string // optional named markdown profile
185 }207 }
186 208
187 // Spawn creates a new Agent for the given task, allocates its209 // Spawn creates a new Agent for the given task, allocates its
@@ -208,7 +230,13 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
208 return nil, errors.New("swarm: empty task")230 return nil, errors.New("swarm: empty task")
209 }231 }
210 id := newAgentID(task, f.cfg.Now())232 id := newAgentID(task, f.cfg.Now())
233+
234+ // Snapshot the shared cwd together with the active session so a
235+ // concurrent /cd cannot race a spawn or produce a mixed snapshot.
236+ f.mu.Lock()
211 dir := f.cfg.RepoRoot237 dir := f.cfg.RepoRoot
238+ sessionID := f.activeSession
239+ f.mu.Unlock()
212 240
213 stateDir := f.agentStateDir(id)241 stateDir := f.agentStateDir(id)
214 if err := os.MkdirAll(stateDir, 0o755); err != nil {242 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -224,13 +252,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
224 return nil, fmt.Errorf("swarm inbox path: %w", err)252 return nil, fmt.Errorf("swarm inbox path: %w", err)
225 }253 }
226 254
227- // Snapshot activeSession under the lock; we use it twice (struct
228- // init below + persistence). Reading it without the lock would
229- // race a concurrent SetActiveSession call.
230- f.mu.Lock()
231- sessionID := f.activeSession
232- f.mu.Unlock()
233-
234 a := &Agent{255 a := &Agent{
235 ID: id,256 ID: id,
236 Task: task,257 Task: task,
@@ -238,6 +259,8 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
238 Started: f.cfg.Now(),259 Started: f.cfg.Now(),
239 Model: strings.TrimSpace(req.Model),260 Model: strings.TrimSpace(req.Model),
240 Provider: strings.TrimSpace(req.Provider),261 Provider: strings.TrimSpace(req.Provider),
262+ Reasoning: strings.TrimSpace(req.Reasoning),
263+ Subagent: strings.TrimSpace(req.Subagent),
241 SessionID: sessionID,264 SessionID: sessionID,
242 InboxPath: inboxPath,265 InboxPath: inboxPath,
243 EventLogPath: logPath,266 EventLogPath: logPath,
@@ -444,12 +467,15 @@ type AgentSnapshot struct {
444 Lines []string // full transcript (already capped by Agent.appendTranscript)467 Lines []string // full transcript (already capped by Agent.appendTranscript)
445 LastAssistant string // complete final assistant message, without role formatting468 LastAssistant string // complete final assistant message, without role formatting
446 469
447- // Model and Provider expose the per-agent overrides set at470+ // Model, Provider, and Reasoning expose the per-agent overrides set
448- // Spawn time (empty when the agent inherits the child's default471+ // at Spawn time (empty when the agent inherits the child's default
449- // resolution). The dashboard surfaces these so the user can472+ // resolution). Subagent is the selected named markdown profile.
450- // confirm which model an agent is running against.473+ // The dashboard surfaces these so the user can confirm the child
451- Model string474+ // configuration.
452- Provider string475+ Model string
476+ Provider string
477+ Reasoning string
478+ Subagent string
453 479
454 // Paths to the agent's durable state. Surface them in the480 // Paths to the agent's durable state. Surface them in the
455 // snapshot so the dashboard / /swarm open can read events.jsonl481 // snapshot so the dashboard / /swarm open can read events.jsonl
@@ -479,6 +505,8 @@ func (a *Agent) Snapshot() AgentSnapshot {
479 LastAssistant: a.lastAssistant,505 LastAssistant: a.lastAssistant,
480 Model: a.Model,506 Model: a.Model,
481 Provider: a.Provider,507 Provider: a.Provider,
508+ Reasoning: a.Reasoning,
509+ Subagent: a.Subagent,
482 InboxPath: a.InboxPath,510 InboxPath: a.InboxPath,
483 EventLogPath: a.EventLogPath,511 EventLogPath: a.EventLogPath,
484 SessionPath: a.SessionPath,512 SessionPath: a.SessionPath,
M packages/agent/swarm/swarm_test.go +16 -0
@@ -78,6 +78,22 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) {
7878 }
7979 }
8080
81+func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
82+ f := newTestSwarm(t, func(a *Agent) Runner {
83+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
84+ })
85+ want := t.TempDir()
86+ f.SetRepoRoot(want)
87+ a, err := f.Spawn(context.Background(), "use new root")
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ a.Wait()
92+ if a.Dir != want {
93+ t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want)
94+ }
95+}
96+
8197 func TestSpawnEmptyTaskFails(t *testing.T) {
8298 f := newTestSwarm(t, func(a *Agent) Runner {
8399 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
@@ -78,6 +78,22 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
82+ f := newTestSwarm(t, func(a *Agent) Runner {
83+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
84+ })
85+ want := t.TempDir()
86+ f.SetRepoRoot(want)
87+ a, err := f.Spawn(context.Background(), "use new root")
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ a.Wait()
92+ if a.Dir != want {
93+ t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want)
94+ }
95+}
96+
81 func TestSpawnEmptyTaskFails(t *testing.T) {97 func TestSpawnEmptyTaskFails(t *testing.T) {
82 f := newTestSwarm(t, func(a *Agent) Runner {98 f := newTestSwarm(t, func(a *Agent) Runner {
83 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })99 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
M packages/agent/tools/swarm_spawn.go +131 -23
@@ -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,16 +95,16 @@ 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
79102 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
80103 if t.Swarm == nil {
81- return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil
104+ return protocolToolError("swarm_spawn: swarm supervisor not available in this mode")
82105 }
83106 if t.Enabled == nil || !t.Enabled() {
84- return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil
107+ return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")
85108 }
86109 var a swarmSpawnArgs
87110 if err := json.Unmarshal(raw, &a); err != nil {
@@ -89,15 +112,42 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
89112 }
90113 task := strings.TrimSpace(a.Task)
91114 if task == "" {
92- return toolErr("swarm_spawn: task is required"), nil
115+ return protocolToolError("swarm_spawn: task is required")
116+ }
117+
118+ agentName := strings.TrimSpace(a.Agent)
119+ var profile *subagents.Profile
120+ if agentName != "" {
121+ if t.ResolveSubagent == nil {
122+ return protocolToolError("swarm_spawn: named subagent profiles are unavailable")
123+ }
124+ var err error
125+ profile, err = t.ResolveSubagent(agentName)
126+ if err != nil {
127+ return protocolToolError("swarm_spawn: " + err.Error())
128+ }
129+ if profile == nil {
130+ return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)
131+ }
132+ agentName = profile.Name
93133 }
94134
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 protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly")
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 protocolToolError("swarm_spawn: " + err.Error())
162+ }
163+ if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
164+ reasoning, err = reasoningOverride(profile.Thinking, "")
165+ if err != nil {
166+ return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error())
167+ }
168+ }
169+ if reasoning == "" && t.DefaultReasoning != nil {
170+ reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
171+ if err != nil {
172+ return protocolToolError("swarm_spawn: host " + err.Error())
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,64 @@ 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+
244+// protocolToolError keeps model-visible validation failures in the
245+// ToolResult channel rather than treating them as host execution errors.
246+func protocolToolError(msg string) (core.ToolResult, error) {
247+ //nolint:nilerr // ToolResult.IsError is the established tool protocol.
248+ return toolErr(msg), nil
249+}
250+
143251 func toolErr(msg string) core.ToolResult {
144252 return core.ToolResult{
145253 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,16 +95,16 @@ 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
79 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {102 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
80 if t.Swarm == nil {103 if t.Swarm == nil {
81- return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil104+ return protocolToolError("swarm_spawn: swarm supervisor not available in this mode")
82 }105 }
83 if t.Enabled == nil || !t.Enabled() {106 if t.Enabled == nil || !t.Enabled() {
84- return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil107+ return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")
85 }108 }
86 var a swarmSpawnArgs109 var a swarmSpawnArgs
87 if err := json.Unmarshal(raw, &a); err != nil {110 if err := json.Unmarshal(raw, &a); err != nil {
@@ -89,15 +112,42 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
89 }112 }
90 task := strings.TrimSpace(a.Task)113 task := strings.TrimSpace(a.Task)
91 if task == "" {114 if task == "" {
92- return toolErr("swarm_spawn: task is required"), nil115+ return protocolToolError("swarm_spawn: task is required")
116+ }
117+
118+ agentName := strings.TrimSpace(a.Agent)
119+ var profile *subagents.Profile
120+ if agentName != "" {
121+ if t.ResolveSubagent == nil {
122+ return protocolToolError("swarm_spawn: named subagent profiles are unavailable")
123+ }
124+ var err error
125+ profile, err = t.ResolveSubagent(agentName)
126+ if err != nil {
127+ return protocolToolError("swarm_spawn: " + err.Error())
128+ }
129+ if profile == nil {
130+ return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)
131+ }
132+ agentName = profile.Name
93 }133 }
94 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 protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly")
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 protocolToolError("swarm_spawn: " + err.Error())
162+ }
163+ if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
164+ reasoning, err = reasoningOverride(profile.Thinking, "")
165+ if err != nil {
166+ return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error())
167+ }
168+ }
169+ if reasoning == "" && t.DefaultReasoning != nil {
170+ reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
171+ if err != nil {
172+ return protocolToolError("swarm_spawn: host " + err.Error())
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,64 @@ 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+
244+// protocolToolError keeps model-visible validation failures in the
245+// ToolResult channel rather than treating them as host execution errors.
246+func protocolToolError(msg string) (core.ToolResult, error) {
247+ //nolint:nilerr // ToolResult.IsError is the established tool protocol.
248+ return toolErr(msg), nil
249+}
250+
143 func toolErr(msg string) core.ToolResult {251 func toolErr(msg string) core.ToolResult {
144 return core.ToolResult{252 return core.ToolResult{
145 Content: []provider.Content{provider.TextBlock{Text: msg}},253 Content: []provider.Content{provider.TextBlock{Text: msg}},
M packages/agent/tools/swarm_spawn_test.go +99 -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,95 @@ 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+ if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" {
108+ t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider)
109+ }
110+}
111+
112+func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
113+ tool := &SwarmSpawnTool{
114+ Swarm: newTestSwarm(t),
115+ Enabled: func() bool { return true },
116+ ResolveSubagent: func(name string) (*subagents.Profile, error) {
117+ if name != "reviewer" {
118+ return nil, nil
119+ }
120+ return &subagents.Profile{
121+ Name: "reviewer",
122+ Model: "openai-codex/gpt-5.6-luna",
123+ Thinking: "low",
124+ }, nil
125+ },
126+ }
127+
128+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if res.IsError {
133+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
134+ }
135+ agents := tool.Swarm.List()
136+ if len(agents) != 1 || agents[0].Reasoning != "low" {
137+ t.Fatalf("agent reasoning = %#v, want low", agents)
138+ }
139+}
140+
141+func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {
142+ tool := &SwarmSpawnTool{
143+ Swarm: newTestSwarm(t),
144+ Enabled: func() bool { return true },
145+ ResolveSubagent: func(string) (*subagents.Profile, error) {
146+ return nil, nil
147+ },
148+ }
149+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
154+ t.Fatalf("result = %#v, want unknown profile error", res)
155+ }
156+ if got := len(tool.Swarm.List()); got != 0 {
157+ t.Fatalf("spawned agents = %d, want 0", got)
66158 }
67159 }
68160
@@ -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,95 @@ 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+ if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" {
108+ t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider)
109+ }
110+}
111+
112+func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
113+ tool := &SwarmSpawnTool{
114+ Swarm: newTestSwarm(t),
115+ Enabled: func() bool { return true },
116+ ResolveSubagent: func(name string) (*subagents.Profile, error) {
117+ if name != "reviewer" {
118+ return nil, nil
119+ }
120+ return &subagents.Profile{
121+ Name: "reviewer",
122+ Model: "openai-codex/gpt-5.6-luna",
123+ Thinking: "low",
124+ }, nil
125+ },
126+ }
127+
128+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if res.IsError {
133+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
134+ }
135+ agents := tool.Swarm.List()
136+ if len(agents) != 1 || agents[0].Reasoning != "low" {
137+ t.Fatalf("agent reasoning = %#v, want low", agents)
138+ }
139+}
140+
141+func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {
142+ tool := &SwarmSpawnTool{
143+ Swarm: newTestSwarm(t),
144+ Enabled: func() bool { return true },
145+ ResolveSubagent: func(string) (*subagents.Profile, error) {
146+ return nil, nil
147+ },
148+ }
149+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
154+ t.Fatalf("result = %#v, want unknown profile error", res)
155+ }
156+ if got := len(tool.Swarm.List()); got != 0 {
157+ t.Fatalf("spawned agents = %d, want 0", got)
66 }158 }
67 }159 }
68 160

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