Merge pull request #12 from bnema/feat/subagents-v1
feat: add durable subagents supervisor
82 files · +11836 -4140
M AGENTS.md +1 -1
| @@ -42,7 +42,7 @@ Put behavior in the package that owns the concern: | |||||
| 42 | | `packages/agent/extproto` | Extension wire protocol | | 42 | | `packages/agent/extproto` | Extension wire protocol | | ||
| 43 | | `packages/agent/ext` | Public extension SDK | | 43 | | `packages/agent/ext` | Public extension SDK | | ||
| 44 | | `packages/agent/skills` | Skill discovery and loading | | 44 | | `packages/agent/skills` | Skill discovery and loading | | ||
| 45 | - | | `packages/agent/swarm` | Background-agent state, supervision, and persistence | | 45 | + | | `packages/agent/subagents` | Background-agent state, supervision, and persistence | |
| 46 | | `packages/agent/tools` | Built-in tools, permission checks, and jail behavior | | 46 | | `packages/agent/tools` | Built-in tools, permission checks, and jail behavior | | ||
| 47 | | `packages/tui` | Terminal input, layout, rendering, markdown, themes, and images | | 47 | | `packages/tui` | Terminal input, layout, rendering, markdown, themes, and images | | ||
| 48 | | `packages/ignore` | Ignore-pattern matching | | 48 | | `packages/ignore` | Ignore-pattern matching | | ||
M CONTRIBUTING.md +1 -1
| @@ -46,7 +46,7 @@ The major ownership boundaries are: | |||||
| 46 | - `packages/agent/modes`: interactive and non-interactive user modes | 46 | - `packages/agent/modes`: interactive and non-interactive user modes | ||
| 47 | - `packages/agent/extensions`, `extproto`, and `ext`: extension hosting, protocol, and SDK | 47 | - `packages/agent/extensions`, `extproto`, and `ext`: extension hosting, protocol, and SDK | ||
| 48 | - `packages/agent/skills`: reusable instruction discovery | 48 | - `packages/agent/skills`: reusable instruction discovery | ||
| 49 | - | - `packages/agent/swarm`: background-agent supervision and state | 49 | + | - `packages/agent/subagents`: background-agent supervision and state |
| 50 | - `packages/agent/tools`: built-in tools, permissions, and jail checks | 50 | - `packages/agent/tools`: built-in tools, permissions, and jail checks | ||
| 51 | - `packages/tui`: terminal mechanics and rendering | 51 | - `packages/tui`: terminal mechanics and rendering | ||
| 52 | - `packages/ignore`: ignore matching | 52 | - `packages/ignore`: ignore matching | ||
M README.md +25 -27
| @@ -290,7 +290,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 290 | | `/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`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | 290 | | `/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`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | ||
| 291 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 291 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| 292 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | 292 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | ||
| 293 | - | | `/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). | | 293 | + | | `/subagents` | Spawn, monitor, and chat with background subagents. Each is a separate long-lived process with bounded lifecycle state, structured results, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | |
| 294 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | 294 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | ||
| 295 | | `/compact` | Summarize the transcript into one message to free up context. | | 295 | | `/compact` | Summarize the transcript into one message to free up context. | | ||
| 296 | | `/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`. | | 296 | | `/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`. | | ||
| @@ -298,7 +298,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 298 | | `/unjail` | Allow tools to touch paths outside again. | | 298 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 299 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 299 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 300 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | 300 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | ||
| 301 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | 301 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | |
| 302 | | `/clear` | Clear the chat transcript. | | 302 | | `/clear` | Clear the chat transcript. | | ||
| 303 | | `/exit` | Exit zot. | | 303 | | `/exit` | Exit zot. | | ||
| 304 | 304 | ||||
| @@ -318,7 +318,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r | |||||
| 318 | 318 | ||||
| 319 | - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout. | 319 | - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout. | ||
| 320 | 320 | ||||
| 321 | - | **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually. | 321 | + | **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually. |
| 322 | - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically). | 322 | - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically). | ||
| 323 | - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments. | 323 | - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments. | ||
| 324 | - **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots. | 324 | - **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots. | ||
| @@ -346,28 +346,27 @@ Each question runs an isolated agent turn against `system + main transcript + si | |||||
| 346 | 346 | ||||
| 347 | Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file. | 347 | Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file. | ||
| 348 | 348 | ||||
| 349 | - | ### `/swarm` | 349 | + | ### `/subagents` |
| 350 | 350 | ||||
| 351 | - | Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, its own persistent session file, and its own chat in the dashboard — but they all run in **the same working directory as the host**, so they see and edit the same files you do. Spawn one for a side task (“draft the migration”, “investigate this stack trace”, “write the test harness for module X”), keep going in the main thread, check in on it whenever you want. | 351 | + | Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, persistent session file, versioned JSONL control protocol, structured turn result, and lifecycle state. Shared checkout mode preserves the historical behavior; worktree mode creates a temporary detached Git worktree, captures a patch and changed-file list, and never merges automatically. |
| 352 | 352 | ||||
| 353 | - | > **Agents edit the same files you do.** They use the same `read` / `write` / `edit` / `bash` tools as the main agent against the host's working directory. There's no per-agent worktree or branch. If you need parallel edits on isolated checkouts, set that up yourself with `git worktree` outside zot. | 353 | + | > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox. |
| 354 | 354 | ||||
| 355 | - | ``` | 355 | + | ```text |
| 356 | - | /swarm # open the dashboard | 356 | + | /subagents # open the dashboard |
| 357 | - | /swarm new <task> # spawn an agent | 357 | + | /subagents new <task> # spawn an agent (shared mode) |
| 358 | - | /swarm new --agent reviewer <task> # use a named markdown profile | 358 | + | /subagents new --agent reviewer <task> # use a named markdown profile |
| 359 | - | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | 359 | + | /subagents new --reasoning high <task> # set child reasoning |
| 360 | - | /swarm new --reasoning high <task> # set the child reasoning level | 360 | + | /subagents logs <id> # open one agent's transcript |
| 361 | - | /swarm logs <id> # jump straight into one agent's transcript | 361 | + | /subagents send <id> <text> # send a follow-up turn |
| 362 | - | /swarm send <id> <text> # send a follow-up without opening the dashboard | 362 | + | /subagents result <id> # show the structured result reference |
| 363 | - | /swarm resume # pick a stopped agent to bring back | 363 | + | /subagents resume-session <id> # continue without replaying the task |
| 364 | - | /swarm resume <id> # bring a specific agent back | 364 | + | /subagents restart-task <id> # intentionally run the stored task again |
| 365 | - | /swarm kill <id> # stop a running agent (its state stays) | 365 | + | /subagents kill <id> # cancel gracefully, then force-stop |
| 366 | - | /swarm remove <id> # delete the agent's session and state | 366 | + | /subagents remove <id> # delete durable state |
| 367 | - | /swarm list # alias for opening the dashboard | |||
| 368 | ``` | 367 | ``` | ||
| 369 | 368 | ||||
| 370 | - | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys: | 369 | + | **Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with process/turn state, queue position, age, workspace mode, and current activity. Keys: |
| 371 | 370 | ||||
| 372 | | Key | Action | | 371 | | Key | Action | | ||
| 373 | |---|---| | 372 | |---|---| | ||
| @@ -386,17 +385,17 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 386 | 385 | ||||
| 387 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | 386 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | ||
| 388 | 387 | ||||
| 389 | - | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. | 388 | + | **Persistence across zot restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZOT_HOME/subagents/agents/<id>/`. On the next `zot` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. |
| 390 | 389 | ||||
| 391 | - | **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. | 390 | + | **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZOT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. |
| 392 | 391 | ||||
| 393 | **`/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. | 392 | **`/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. | ||
| 394 | 393 | ||||
| 395 | - | **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`. | 394 | + | **Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The tool returns the child id and lifecycle state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`. |
| 396 | 395 | ||||
| 397 | ### Named subagent profiles | 396 | ### Named subagent profiles | ||
| 398 | 397 | ||||
| 399 | - | 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. | 398 | + | zot discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-subagents enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `subagent_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. |
| 400 | 399 | ||||
| 401 | ### `/settings` | 400 | ### `/settings` | ||
| 402 | 401 | ||||
| @@ -405,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 405 | - **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. | 404 | - **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. | ||
| 406 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | 405 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | ||
| 407 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | 406 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | ||
| 408 | - | - **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. | 407 | + | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. |
| 409 | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | 408 | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | ||
| 410 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | 409 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | ||
| 411 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | 410 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | ||
| @@ -1068,8 +1067,7 @@ packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter i | |||||
| 1068 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | 1067 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | ||
| 1069 | packages/agent/tools/ read, write, edit, bash, lsp, sandbox | 1068 | packages/agent/tools/ read, write, edit, bash, lsp, sandbox | ||
| 1070 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1069 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1071 | - | packages/agent/subagents/ named markdown profile discovery and dispatch | 1070 | + | packages/agent/subagents/ named profiles, supervisor, and background runtime |
| 1072 | - | packages/agent/swarm/ background subagent runtime | |||
| 1073 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | 1071 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | ||
| 1074 | packages/agent/ext/ public Go SDK for writing extensions (package ext) | 1072 | packages/agent/ext/ public Go SDK for writing extensions (package ext) | ||
| 1075 | ``` | 1073 | ``` | ||
M docs/lsp.md +1 -1
| @@ -120,7 +120,7 @@ diagnostics allows a later recurrence to be surfaced again. | |||||
| 120 | 120 | ||||
| 121 | - **lsp in main session** (`lsp_enabled`) controls the main agent and applies | 121 | - **lsp in main session** (`lsp_enabled`) controls the main agent and applies | ||
| 122 | immediately to the live tool registry. | 122 | immediately to the live tool registry. | ||
| 123 | - | - **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned swarm | 123 | + | - **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned subagent |
| 124 | child processes. | 124 | child processes. | ||
| 125 | 125 | ||||
| 126 | The sub-agent setting does not grant access to a child whose profile or | 126 | The sub-agent setting does not grant access to a child whose profile or | ||
M docs/providers.md +1 -1
| @@ -123,7 +123,7 @@ off by default and is stored as `fast_mode` in `$ZOT_HOME/config.json`. | |||||
| 123 | 123 | ||||
| 124 | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | 124 | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | ||
| 125 | using another provider returns an error instead of silently changing that | 125 | using another provider returns an error instead of silently changing that | ||
| 126 | - | provider's request. Swarm children inherit the setting from their parent. | 126 | + | provider's request. Subagent children inherit the setting from their parent. |
| 127 | 127 | ||||
| 128 | Example: | 128 | Example: | ||
| 129 | 129 | ||||
M docs/subagents.md +27 -6
| @@ -39,7 +39,7 @@ Supported metadata: | |||||
| 39 | 39 | ||||
| 40 | | Field | Meaning | | 40 | | Field | Meaning | | ||
| 41 | |---|---| | 41 | |---|---| | ||
| 42 | - | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | 42 | + | | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. | |
| 43 | | `description` | Short description shown to the main agent. | | 43 | | `description` | Short description shown to the main agent. | | ||
| 44 | | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | 44 | | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | ||
| 45 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | 45 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | ||
| @@ -50,11 +50,11 @@ Supported metadata: | |||||
| 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | ||
| 51 | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | 51 | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | ||
| 52 | 52 | ||||
| 53 | - | 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. | 53 | + | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. |
| 54 | 54 | ||||
| 55 | ## Selecting a profile | 55 | ## Selecting a profile | ||
| 56 | 56 | ||||
| 57 | - | 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: | 57 | + | When **auto-subagents** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile for `subagent_spawn`: |
| 58 | 58 | ||||
| 59 | ```json | 59 | ```json | ||
| 60 | { | 60 | { | ||
| @@ -80,8 +80,29 @@ A per-spawn reasoning override is also accepted: | |||||
| 80 | The interactive command also supports the same selection explicitly: | 80 | The interactive command also supports the same selection explicitly: | ||
| 81 | 81 | ||||
| 82 | ```text | 82 | ```text | ||
| 83 | - | /swarm new --agent reviewer Review the authentication package | 83 | + | /subagents new --agent reviewer Review the authentication package |
| 84 | - | /swarm new --agent implementer --reasoning high Implement the parser change | 84 | + | /subagents new --agent implementer --reasoning high Implement the parser change |
| 85 | ``` | 85 | ``` | ||
| 86 | 86 | ||||
| 87 | - | 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. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | 87 | + | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zot creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. |
| 88 | + | ||||
| 89 | + | ## Lifecycle, results, and recovery | |||
| 90 | + | ||||
| 91 | + | Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, and logical result references. | |||
| 92 | + | ||||
| 93 | + | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol. | |||
| 94 | + | ||||
| 95 | + | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | |||
| 96 | + | ||||
| 97 | + | ```text | |||
| 98 | + | subagent://<id> | |||
| 99 | + | subagent://<id>/history | |||
| 100 | + | subagent://<id>/result | |||
| 101 | + | subagent://<id>/patch | |||
| 102 | + | ``` | |||
| 103 | + | ||||
| 104 | + | Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period. | |||
| 105 | + | ||||
| 106 | + | ## Resource policy | |||
| 107 | + | ||||
| 108 | + | The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zot run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. | |||
M packages/agent/args.go +38 -19
| @@ -20,13 +20,13 @@ const ( | |||||
| 20 | ModeStream Mode = "stream" | 20 | ModeStream Mode = "stream" | ||
| 21 | ModeJSON Mode = "json" | 21 | ModeJSON Mode = "json" | ||
| 22 | ModeRPC Mode = "rpc" | 22 | ModeRPC Mode = "rpc" | ||
| 23 | - | // ModeSwarmAgent is the long-lived, headless daemon mode used by | 23 | + | // ModeSubagentWorker is the long-lived, headless daemon mode used by |
| 24 | - | // swarm-spawned agents. The binary opens a unix-socket inbox at | 24 | + | // subagent-spawned agents. The binary opens a unix-socket inbox at |
| 25 | - | // the path provided by --swarm-agent, reads supervisor messages | 25 | + | // the path provided by --subagent-worker, reads versioned JSONL |
| 26 | - | // off it ("user ...", "cancel", "shutdown"), runs each user turn | 26 | + | // supervisor messages, runs each user turn against a persistent session, |
| 27 | - | // against a persistent session, and streams JSONL events on | 27 | + | // and streams JSONL events on |
| 28 | - | // stdout. See packages/agent/swarm/inbox.go for the wire protocol. | 28 | + | // stdout. |
| 29 | - | ModeSwarmAgent Mode = "swarm-agent" | 29 | + | ModeSubagentWorker Mode = "subagent-worker" |
| 30 | ) | 30 | ) | ||
| 31 | 31 | ||||
| 32 | // Args holds parsed command-line options. | 32 | // Args holds parsed command-line options. | ||
| @@ -36,7 +36,7 @@ type Args struct { | |||||
| 36 | Model string | 36 | Model string | ||
| 37 | APIKey string | 37 | APIKey string | ||
| 38 | 38 | ||||
| 39 | - | // inheritedCredential is populated only by swarm-agent mode from | 39 | + | // inheritedCredential is populated only by subagent-worker mode from |
| 40 | // the supervisor's stdin. It is never accepted as a CLI argument. | 40 | // the supervisor's stdin. It is never accepted as a CLI argument. | ||
| 41 | inheritedCredential string | 41 | inheritedCredential string | ||
| 42 | inheritedAuthMethod string | 42 | inheritedAuthMethod string | ||
| @@ -48,11 +48,11 @@ type Args struct { | |||||
| 48 | Reasoning string | 48 | Reasoning string | ||
| 49 | Temperature *float32 | 49 | Temperature *float32 | ||
| 50 | 50 | ||||
| 51 | - | // FastMode is an internal swarm-child propagation flag. Normal users | 51 | + | // FastMode is an internal subagent-child propagation flag. Normal users |
| 52 | // enable fast mode through the persisted config /settings toggle. | 52 | // enable fast mode through the persisted config /settings toggle. | ||
| 53 | FastMode bool | 53 | FastMode bool | ||
| 54 | // FastModeSet distinguishes an explicit child override (including | 54 | // FastModeSet distinguishes an explicit child override (including | ||
| 55 | - | // --no-fast-mode) from an omitted flag so persisted swarm settings | 55 | + | // --no-fast-mode) from an omitted flag so persisted subagent settings |
| 56 | // survive a parent config change. | 56 | // survive a parent config change. | ||
| 57 | FastModeSet bool | 57 | FastModeSet bool | ||
| 58 | 58 | ||||
| @@ -123,13 +123,16 @@ type Args struct { | |||||
| 123 | // mode auto-submits it once at startup before InitialInput handling. | 123 | // mode auto-submits it once at startup before InitialInput handling. | ||
| 124 | StartupPre string | 124 | StartupPre string | ||
| 125 | 125 | ||||
| 126 | - | // SwarmAgent is the inbox-socket path when this process is a | 126 | + | // SubagentWorker is the inbox-socket path when this process is a |
| 127 | - | // swarm-spawned agent. Empty in every other mode. Set by | 127 | + | // subagent worker. Empty in every other mode. Set by either |
| 128 | - | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | 128 | + | // --subagent-worker. |
| 129 | - | SwarmAgent string | 129 | + | SubagentWorker string |
| 130 | 130 | ||||
| 131 | - | // Subagent selects a named markdown profile for a swarm child. | 131 | + | // SubagentMaxTurns limits prompt-level turns in worker mode. |
| 132 | - | // It is intentionally an internal child flag; the parent swarm tool | 132 | + | SubagentMaxTurns int |
| 133 | + | ||||
| 134 | + | // Subagent selects a named markdown profile for a subagent child. | |||
| 135 | + | // It is intentionally an internal child flag; the parent subagent tool | |||
| 133 | // passes only the profile name and the child discovers the definition | 136 | // passes only the profile name and the child discovers the definition | ||
| 134 | // locally. | 137 | // locally. | ||
| 135 | Subagent string | 138 | Subagent string | ||
| @@ -158,6 +161,12 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 158 | for i := 0; i < len(in); i++ { | 161 | for i := 0; i < len(in); i++ { | ||
| 159 | arg := in[i] | 162 | arg := in[i] | ||
| 160 | switch arg { | 163 | switch arg { | ||
| 164 | + | case "--": | |||
| 165 | + | // Everything after the terminator is prompt text, including | |||
| 166 | + | // values that begin with a dash. This is used by subagent | |||
| 167 | + | // workers and is also a conventional CLI escape hatch. | |||
| 168 | + | positional = append(positional, in[i+1:]...) | |||
| 169 | + | i = len(in) | |||
| 161 | case "-h", "--help": | 170 | case "-h", "--help": | ||
| 162 | a.Help = true | 171 | a.Help = true | ||
| 163 | case "-v", "--version": | 172 | case "-v", "--version": | ||
| @@ -289,13 +298,13 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 289 | return a, err | 298 | return a, err | ||
| 290 | } | 299 | } | ||
| 291 | a.CWD = v | 300 | a.CWD = v | ||
| 292 | - | case "--swarm-agent": | 301 | + | case "--subagent-worker": |
| 293 | v, err := want(&i, arg) | 302 | v, err := want(&i, arg) | ||
| 294 | if err != nil { | 303 | if err != nil { | ||
| 295 | return a, err | 304 | return a, err | ||
| 296 | } | 305 | } | ||
| 297 | - | a.SwarmAgent = v | 306 | + | a.SubagentWorker = v |
| 298 | - | a.Mode = ModeSwarmAgent | 307 | + | a.Mode = ModeSubagentWorker |
| 299 | case "--subagent": | 308 | case "--subagent": | ||
| 300 | v, err := want(&i, arg) | 309 | v, err := want(&i, arg) | ||
| 301 | if err != nil { | 310 | if err != nil { | ||
| @@ -323,6 +332,16 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 323 | return a, fmt.Errorf("--max-steps must be a positive integer") | 332 | return a, fmt.Errorf("--max-steps must be a positive integer") | ||
| 324 | } | 333 | } | ||
| 325 | a.MaxSteps = n | 334 | a.MaxSteps = n | ||
| 335 | + | case "--max-turns": | |||
| 336 | + | v, err := want(&i, arg) | |||
| 337 | + | if err != nil { | |||
| 338 | + | return a, err | |||
| 339 | + | } | |||
| 340 | + | var n int | |||
| 341 | + | if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 { | |||
| 342 | + | return a, fmt.Errorf("--max-turns must be a positive integer") | |||
| 343 | + | } | |||
| 344 | + | a.SubagentMaxTurns = n | |||
| 326 | default: | 345 | default: | ||
| 327 | if strings.HasPrefix(arg, "-") && arg != "-" { | 346 | if strings.HasPrefix(arg, "-") && arg != "-" { | ||
| 328 | return a, fmt.Errorf("unknown flag %q", arg) | 347 | return a, fmt.Errorf("unknown flag %q", arg) | ||
M packages/agent/args_test.go +12 -2
| @@ -3,11 +3,11 @@ package agent | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestParseArgsSubagentAndReasoning(t *testing.T) { | 5 | func TestParseArgsSubagentAndReasoning(t *testing.T) { | ||
| 6 | - | args, err := ParseArgs([]string{"--swarm-agent", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"}) | 6 | + | args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"}) |
| 7 | if err != nil { | 7 | if err != nil { | ||
| 8 | t.Fatal(err) | 8 | t.Fatal(err) | ||
| 9 | } | 9 | } | ||
| 10 | - | if args.Mode != ModeSwarmAgent || args.Subagent != "reviewer" || args.Reasoning != "high" || args.Prompt != "task" { | 10 | + | if args.Mode != ModeSubagentWorker || 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) | 11 | t.Fatalf("parsed args = mode=%q subagent=%q reasoning=%q prompt=%q", args.Mode, args.Subagent, args.Reasoning, args.Prompt) | ||
| 12 | } | 12 | } | ||
| 13 | } | 13 | } | ||
| @@ -22,6 +22,16 @@ func TestParseArgsNoLSP(t *testing.T) { | |||||
| 22 | } | 22 | } | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | + | func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) { | |||
| 26 | + | args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"}) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if args.Prompt != "--inspect auth flow" { | |||
| 31 | + | t.Fatalf("prompt = %q, want leading-dash task preserved", args.Prompt) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 25 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | 35 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | ||
| 26 | args, err := ParseArgs([]string{"--temperature", "0"}) | 36 | args, err := ParseArgs([]string{"--temperature", "0"}) | ||
| 27 | if err != nil { | 37 | if err != nil { | ||
M packages/agent/build.go +18 -3
| @@ -691,7 +691,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 691 | if args.PermissionSet != nil { | 691 | if args.PermissionSet != nil { | ||
| 692 | sandbox.SetPermissions(args.PermissionSet) | 692 | sandbox.SetPermissions(args.PermissionSet) | ||
| 693 | } | 693 | } | ||
| 694 | - | subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != "" | 694 | + | subagentSession := args.Mode == ModeSubagentWorker || strings.TrimSpace(args.Subagent) != "" |
| 695 | lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession) | 695 | lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession) | ||
| 696 | reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession)) | 696 | reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession)) | ||
| 697 | 697 | ||||
| @@ -737,13 +737,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 737 | append_ = append(append_, skillAddendum) | 737 | append_ = append(append_, skillAddendum) | ||
| 738 | } | 738 | } | ||
| 739 | interactiveMode := args.Mode == "" || args.Mode == ModeInteractive | 739 | interactiveMode := args.Mode == "" || args.Mode == ModeInteractive | ||
| 740 | - | if selectedProfile == nil && interactiveMode && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | 740 | + | if selectedProfile == nil && interactiveMode && autoSubagentsToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { |
| 741 | homeDir, _ := os.UserHomeDir() | 741 | homeDir, _ := os.UserHomeDir() | ||
| 742 | profiles, _ := subagents.Discover(args.CWD, homeDir) | 742 | profiles, _ := subagents.Discover(args.CWD, homeDir) | ||
| 743 | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | 743 | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | ||
| 744 | append_ = append(append_, subagentsAddendum) | 744 | append_ = append(append_, subagentsAddendum) | ||
| 745 | } | 745 | } | ||
| 746 | - | append_ = append(append_, AutoSwarmSystemAddendum) | 746 | + | append_ = append(append_, AutoSubagentsSystemAddendum) |
| 747 | } | 747 | } | ||
| 748 | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | 748 | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | ||
| 749 | append_ = append(append_, selectedProfile.SystemPrompt) | 749 | append_ = append(append_, selectedProfile.SystemPrompt) | ||
| @@ -1188,6 +1188,21 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo | |||||
| 1188 | return false | 1188 | return false | ||
| 1189 | } | 1189 | } | ||
| 1190 | 1190 | ||||
| 1191 | + | func autoSubagentsToolAllowed(args Args) bool { | |||
| 1192 | + | if args.NoTools || args.PermissionSet != nil { | |||
| 1193 | + | return false | |||
| 1194 | + | } | |||
| 1195 | + | if len(args.Tools) == 0 { | |||
| 1196 | + | return true | |||
| 1197 | + | } | |||
| 1198 | + | for _, name := range args.Tools { | |||
| 1199 | + | if name == "subagent_spawn" { | |||
| 1200 | + | return true | |||
| 1201 | + | } | |||
| 1202 | + | } | |||
| 1203 | + | return false | |||
| 1204 | + | } | |||
| 1205 | + | ||||
| 1191 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1206 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1192 | order := []string{"read", "write", "edit", "bash", "lsp"} | 1207 | order := []string{"read", "write", "edit", "bash", "lsp"} | ||
| 1193 | var out []ToolSummary | 1208 | var out []ToolSummary | ||
M packages/agent/build_test.go +3 -3
| @@ -156,7 +156,7 @@ func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) { | |||||
| 156 | } | 156 | } | ||
| 157 | } | 157 | } | ||
| 158 | 158 | ||||
| 159 | - | func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | 159 | + | func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) { |
| 160 | root := t.TempDir() | 160 | root := t.TempDir() | ||
| 161 | home := filepath.Join(root, "home") | 161 | home := filepath.Join(root, "home") | ||
| 162 | zotHome := filepath.Join(root, "zot-home") | 162 | zotHome := filepath.Join(root, "zot-home") | ||
| @@ -171,7 +171,7 @@ func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | |||||
| 171 | t.Setenv("ZOT_HOME", zotHome) | 171 | t.Setenv("ZOT_HOME", zotHome) | ||
| 172 | t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents")) | 172 | t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents")) | ||
| 173 | enabled := true | 173 | enabled := true | ||
| 174 | - | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil { | 174 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil { |
| 175 | t.Fatal(err) | 175 | t.Fatal(err) | ||
| 176 | } | 176 | } | ||
| 177 | profile := `--- | 177 | profile := `--- | ||
| @@ -510,7 +510,7 @@ func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) { | |||||
| 510 | } | 510 | } | ||
| 511 | } | 511 | } | ||
| 512 | 512 | ||||
| 513 | - | func TestResolveUsesInheritedSwarmCredential(t *testing.T) { | 513 | + | func TestResolveUsesInheritedSupervisorCredential(t *testing.T) { |
| 514 | t.Setenv("ZOT_HOME", t.TempDir()) | 514 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 515 | t.Setenv("OPENAI_API_KEY", "") | 515 | t.Setenv("OPENAI_API_KEY", "") | ||
| 516 | 516 | ||||
M packages/agent/cli.go +194 -112
| @@ -21,7 +21,6 @@ import ( | |||||
| 21 | "github.com/patriceckhart/zot/packages/agent/modes" | 21 | "github.com/patriceckhart/zot/packages/agent/modes" | ||
| 22 | "github.com/patriceckhart/zot/packages/agent/skills" | 22 | "github.com/patriceckhart/zot/packages/agent/skills" | ||
| 23 | "github.com/patriceckhart/zot/packages/agent/subagents" | 23 | "github.com/patriceckhart/zot/packages/agent/subagents" | ||
| 24 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 25 | "github.com/patriceckhart/zot/packages/agent/tools" | 24 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 26 | "github.com/patriceckhart/zot/packages/core" | 25 | "github.com/patriceckhart/zot/packages/core" | ||
| 27 | "github.com/patriceckhart/zot/packages/provider" | 26 | "github.com/patriceckhart/zot/packages/provider" | ||
| @@ -737,8 +736,8 @@ func runWithArgs(args Args, version string) error { | |||||
| 737 | return runJSONMode(ctx, args, version) | 736 | return runJSONMode(ctx, args, version) | ||
| 738 | case ModeRPC: | 737 | case ModeRPC: | ||
| 739 | return runRPCMode(ctx, args, version) | 738 | return runRPCMode(ctx, args, version) | ||
| 740 | - | case ModeSwarmAgent: | 739 | + | case ModeSubagentWorker: |
| 741 | - | return runSwarmAgentMode(ctx, args, version) | 740 | + | return runSubagentWorkerMode(ctx, args, version) |
| 742 | default: | 741 | default: | ||
| 743 | return runInteractive(ctx, args, version) | 742 | return runInteractive(ctx, args, version) | ||
| 744 | } | 743 | } | ||
| @@ -1142,7 +1141,7 @@ func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.S | |||||
| 1142 | // skills and currently loaded extension tools) and updates the live | 1141 | // skills and currently loaded extension tools) and updates the live | ||
| 1143 | // agent's registry and system prompt. Used after /reload-ext and after | 1142 | // agent's registry and system prompt. Used after /reload-ext and after | ||
| 1144 | // zotfile entry.pre installs new skills or extensions. | 1143 | // zotfile entry.pre installs new skills or extensions. | ||
| 1145 | - | // mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn). | 1144 | + | // mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn). |
| 1146 | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) { | 1145 | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) { | ||
| 1147 | if ag == nil { | 1146 | if ag == nil { | ||
| 1148 | return | 1147 | return | ||
| @@ -1183,7 +1182,37 @@ func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *exte | |||||
| 1183 | 1182 | ||||
| 1184 | // ---- interactive mode: opens the TUI even without credentials ---- | 1183 | // ---- interactive mode: opens the TUI even without credentials ---- | ||
| 1185 | 1184 | ||||
| 1185 | + | func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||
| 1186 | + | parseDuration := func(value string) time.Duration { | |||
| 1187 | + | if strings.TrimSpace(value) == "" { | |||
| 1188 | + | return 0 | |||
| 1189 | + | } | |||
| 1190 | + | parsed, err := time.ParseDuration(strings.TrimSpace(value)) | |||
| 1191 | + | if err != nil || parsed <= 0 { | |||
| 1192 | + | return 0 | |||
| 1193 | + | } | |||
| 1194 | + | return parsed | |||
| 1195 | + | } | |||
| 1196 | + | return subagents.SubagentPolicy{ | |||
| 1197 | + | MaxConcurrent: cfg.MaxConcurrent, | |||
| 1198 | + | MaxConcurrentPerParent: cfg.MaxConcurrentPerParent, | |||
| 1199 | + | MaxTotalSpawned: cfg.MaxTotalSpawned, | |||
| 1200 | + | QueueTimeout: parseDuration(cfg.QueueTimeout), | |||
| 1201 | + | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | |||
| 1202 | + | MaxTurns: cfg.MaxTurns, | |||
| 1203 | + | MaxOutputBytes: cfg.MaxOutputBytes, | |||
| 1204 | + | MaxOutputLines: cfg.MaxOutputLines, | |||
| 1205 | + | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |||
| 1206 | + | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |||
| 1207 | + | HeartbeatInterval: parseDuration(cfg.HeartbeatInterval), | |||
| 1208 | + | IdleTimeout: parseDuration(cfg.IdleTimeout), | |||
| 1209 | + | ReconnectTimeout: parseDuration(cfg.ReconnectTimeout), | |||
| 1210 | + | CancelGracePeriod: parseDuration(cfg.CancelGracePeriod), | |||
| 1211 | + | } | |||
| 1212 | + | } | |||
| 1213 | + | ||||
| 1186 | func runInteractive(ctx context.Context, args Args, version string) error { | 1214 | func runInteractive(ctx context.Context, args Args, version string) error { | ||
| 1215 | + | initialCfg, _ := LoadConfig() | |||
| 1187 | // Resolve WITHOUT requiring credentials. | 1216 | // Resolve WITHOUT requiring credentials. | ||
| 1188 | r, err := Resolve(args, false) | 1217 | r, err := Resolve(args, false) | ||
| 1189 | if err != nil { | 1218 | if err != nil { | ||
| @@ -1201,7 +1230,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1201 | 1230 | ||||
| 1202 | // persistMu guards the active session and selection across the agent | 1231 | // persistMu guards the active session and selection across the agent | ||
| 1203 | // loop, TUI callbacks, and session swaps. Declare it before tool | 1232 | // loop, TUI callbacks, and session swaps. Declare it before tool | ||
| 1204 | - | // construction because swarm defaults must read the live selection too. | 1233 | + | // construction because subagent defaults must read the live selection too. |
| 1205 | var persistMu sync.Mutex | 1234 | var persistMu sync.Mutex | ||
| 1206 | // Serialize session transitions with persistence callbacks so a candidate | 1235 | // Serialize session transitions with persistence callbacks so a candidate | ||
| 1207 | // is read only after the old agent is flushed and cannot be overwritten by | 1236 | // is read only after the old agent is flushed and cannot be overwritten by | ||
| @@ -1245,70 +1274,99 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1245 | extToolAdapter := &extToolAdapter{mgr: extMgr} | 1274 | extToolAdapter := &extToolAdapter{mgr: extMgr} | ||
| 1246 | r.MergeExtensionTools(extToolAdapter) | 1275 | r.MergeExtensionTools(extToolAdapter) | ||
| 1247 | 1276 | ||||
| 1248 | - | // Build the swarm supervisor BEFORE the agent so the auto-swarm | 1277 | + | // Build the subagent supervisor BEFORE the agent so the auto-subagents |
| 1249 | // tool can reference it during tool-registry construction. State | 1278 | // tool can reference it during tool-registry construction. State | ||
| 1250 | - | // lives under ZotHome/swarm so per-agent meta/events survive | 1279 | + | // lives under ZotHome/subagents so per-agent meta/events survive |
| 1251 | // restarts; the user can hunt orphaned agents down with | 1280 | // restarts; the user can hunt orphaned agents down with | ||
| 1252 | // `git worktree list` if anything misbehaves. | 1281 | // `git worktree list` if anything misbehaves. | ||
| 1253 | // | 1282 | // | ||
| 1254 | - | // swarmMgr is also captured by loadSession / changeCWD closures | 1283 | + | // subagentsMgr is also captured by loadSession / changeCWD closures |
| 1255 | // further down the function, which is why we keep the variable | 1284 | // further down the function, which is why we keep the variable | ||
| 1256 | // in this outer scope rather than scoping it tighter. | 1285 | // in this outer scope rather than scoping it tighter. | ||
| 1257 | - | var swarmMgr *swarm.Swarm | 1286 | + | var subagentsMgr *subagents.Supervisor |
| 1258 | - | swarmMgr = swarm.New(swarm.Config{ | 1287 | + | subagentsMgr = subagents.New(subagents.Config{ |
| 1259 | - | Root: filepath.Join(ZotHome(), "swarm"), | 1288 | + | Context: ctx, |
| 1260 | - | RepoRoot: r.CWD, | 1289 | + | Root: filepath.Join(ZotHome(), "subagents"), |
| 1261 | - | FastMode: r.FastMode, | 1290 | + | RepoRoot: r.CWD, |
| 1262 | - | ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) { | 1291 | + | Provider: r.Provider, |
| 1263 | - | if providerID == "ollama" { | 1292 | + | FastMode: r.FastMode, |
| 1264 | - | return swarm.Credential{Value: "ollama", Method: "apikey"}, nil | 1293 | + | BaseURL: r.BaseURL, |
| 1294 | + | InsecureTLS: r.InsecureTLS, | |||
| 1295 | + | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | |||
| 1296 | + | // Keep an explicit launch key in this callback only. Supervisor persists | |||
| 1297 | + | // connection settings, but never stores credentials in Agent metadata | |||
| 1298 | + | // or puts them in the worker's argv/environment. | |||
| 1299 | + | ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) { | |||
| 1300 | + | childProvider := canonicalProvider(providerID) | |||
| 1301 | + | persistMu.Lock() | |||
| 1302 | + | hostProvider := canonicalProvider(activeProvider) | |||
| 1303 | + | persistMu.Unlock() | |||
| 1304 | + | if childProvider == "" { | |||
| 1305 | + | childProvider = hostProvider | |||
| 1306 | + | } | |||
| 1307 | + | explicit := "" | |||
| 1308 | + | // A launch-time API key is scoped to the provider selected at | |||
| 1309 | + | // startup. Never send it to an explicitly different child | |||
| 1310 | + | // provider; resolve that provider's own credential instead. | |||
| 1311 | + | if args.APIKey != "" && childProvider == hostProvider { | |||
| 1312 | + | explicit = args.APIKey | |||
| 1313 | + | } | |||
| 1314 | + | if childProvider == "ollama" && explicit == "" { | |||
| 1315 | + | return subagents.Credential{Value: "ollama", Method: "apikey"}, nil | |||
| 1265 | } | 1316 | } | ||
| 1266 | - | credential, method, accountID, err := ResolveCredentialFullContext(ctx, providerID, "") | 1317 | + | credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit) |
| 1267 | if err != nil { | 1318 | if err != nil { | ||
| 1268 | // Providers backed only by a local endpoint do not need a key. | 1319 | // Providers backed only by a local endpoint do not need a key. | ||
| 1269 | // Leave stdin untouched and let the child resolve that endpoint. | 1320 | // Leave stdin untouched and let the child resolve that endpoint. | ||
| 1270 | - | if !CredentialAvailable(providerID) { | 1321 | + | if !CredentialAvailable(childProvider) { |
| 1271 | - | return swarm.Credential{}, nil | 1322 | + | return subagents.Credential{}, nil |
| 1272 | } | 1323 | } | ||
| 1273 | - | return swarm.Credential{}, err | 1324 | + | return subagents.Credential{}, err |
| 1274 | } | 1325 | } | ||
| 1275 | - | return swarm.Credential{Value: credential, Method: method, AccountID: accountID}, nil | 1326 | + | return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil |
| 1276 | }, | 1327 | }, | ||
| 1277 | }) | 1328 | }) | ||
| 1278 | // Pull any previously-spawned agents off disk so the dashboard | 1329 | // Pull any previously-spawned agents off disk so the dashboard | ||
| 1279 | // shows them as detached and the user can resume / remove them. | 1330 | // shows them as detached and the user can resume / remove them. | ||
| 1280 | - | _, _ = swarmMgr.Reload() | 1331 | + | _, _ = subagentsMgr.Reload() |
| 1332 | + | if args.PermissionSet != nil { | |||
| 1333 | + | // Packaged zotfile agents have an explicit capability ceiling. A | |||
| 1334 | + | // child process would otherwise rebuild a fresh unrestricted sandbox, | |||
| 1335 | + | // so do not expose either slash or tool-based delegation from this | |||
| 1336 | + | // restricted host until worker capability propagation exists. | |||
| 1337 | + | subagentsMgr = nil | |||
| 1338 | + | } | |||
| 1281 | 1339 | ||||
| 1282 | - | // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool | 1340 | + | // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool |
| 1283 | // fires after every successful spawn. It hands the agent off to | 1341 | // fires after every successful spawn. It hands the agent off to | ||
| 1284 | // the running Interactive so the watcher can flush a summary back | 1342 | // the running Interactive so the watcher can flush a summary back | ||
| 1285 | // into chat when all sub-agents finish. Reads `iv` lazily because | 1343 | // into chat when all sub-agents finish. Reads `iv` lazily because | ||
| 1286 | // the Interactive is constructed after the agent. | 1344 | // the Interactive is constructed after the agent. | ||
| 1287 | - | onSpawnedSwarm := func(a *swarm.Agent, task string) { | 1345 | + | onSpawnedSupervisor := func(a *subagents.Agent, task string) { |
| 1288 | if iv != nil { | 1346 | if iv != nil { | ||
| 1289 | - | iv.TrackSwarmAgent(a, task) | 1347 | + | iv.TrackSubagentWorker(a, task) |
| 1290 | } | 1348 | } | ||
| 1291 | } | 1349 | } | ||
| 1292 | resolveSubagent := func(name string) (*subagents.Profile, error) { | 1350 | resolveSubagent := func(name string) (*subagents.Profile, error) { | ||
| 1293 | return findSubagentProfile(r.CWD, name) | 1351 | return findSubagentProfile(r.CWD, name) | ||
| 1294 | } | 1352 | } | ||
| 1295 | 1353 | ||||
| 1296 | - | // Inject the swarm_spawn auto-swarm tool only when /settings -> | 1354 | + | // Inject the subagent_spawn auto-subagents tool only when /settings -> |
| 1297 | - | // auto-swarm is currently enabled. Registering it unconditionally | 1355 | + | // auto-subagents is currently enabled. Registering it unconditionally |
| 1298 | // leaves the model trying to call it (and getting a polite error) | 1356 | // leaves the model trying to call it (and getting a polite error) | ||
| 1299 | // even when the user has switched the feature off. The /settings | 1357 | // even when the user has switched the feature off. The /settings | ||
| 1300 | // toggle live-mutates the running agent's registry separately so | 1358 | // toggle live-mutates the running agent's registry separately so | ||
| 1301 | // flipping the flag mid-session takes effect on the next turn. | 1359 | // flipping the flag mid-session takes effect on the next turn. | ||
| 1302 | - | injectSwarmSpawn := func(reg core.Registry) core.Registry { | 1360 | + | injectSubagentSpawn := func(reg core.Registry) core.Registry { |
| 1303 | - | if reg == nil { | 1361 | + | if reg == nil || subagentsMgr == nil || !autoSubagentsToolAllowed(args) { |
| 1304 | return reg | 1362 | return reg | ||
| 1305 | } | 1363 | } | ||
| 1306 | - | if !AutoSwarmEnabled() { | 1364 | + | if !AutoSubagentsEnabled() { |
| 1307 | return reg | 1365 | return reg | ||
| 1308 | } | 1366 | } | ||
| 1309 | - | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | 1367 | + | canonical := &tools.SubagentSpawnTool{ |
| 1310 | - | Swarm: swarmMgr, | 1368 | + | Supervisor: subagentsMgr, |
| 1311 | - | Enabled: AutoSwarmEnabled, | 1369 | + | Enabled: AutoSubagentsEnabled, |
| 1312 | DefaultModel: func() string { | 1370 | DefaultModel: func() string { | ||
| 1313 | persistMu.Lock() | 1371 | persistMu.Lock() | ||
| 1314 | defer persistMu.Unlock() | 1372 | defer persistMu.Unlock() | ||
| @@ -1321,11 +1379,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1321 | }, | 1379 | }, | ||
| 1322 | DefaultReasoning: func() string { return r.Reasoning }, | 1380 | DefaultReasoning: func() string { return r.Reasoning }, | ||
| 1323 | ResolveSubagent: resolveSubagent, | 1381 | ResolveSubagent: resolveSubagent, | ||
| 1324 | - | OnSpawned: onSpawnedSwarm, | 1382 | + | OnSpawned: onSpawnedSupervisor, |
| 1325 | } | 1383 | } | ||
| 1384 | + | reg[canonical.Name()] = canonical | |||
| 1326 | return reg | 1385 | return reg | ||
| 1327 | } | 1386 | } | ||
| 1328 | - | injectSwarmSpawn(r.ToolRegistry) | 1387 | + | injectSubagentSpawn(r.ToolRegistry) |
| 1329 | 1388 | ||||
| 1330 | // Confirmation gate: when --no-yolo is on, the agent must ask | 1389 | // Confirmation gate: when --no-yolo is on, the agent must ask | ||
| 1331 | // the user before every tool call. In interactive mode the TUI | 1390 | // the user before every tool call. In interactive mode the TUI | ||
| @@ -1407,8 +1466,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1407 | return nil, "", "", err | 1466 | return nil, "", "", err | ||
| 1408 | } | 1467 | } | ||
| 1409 | resolved.UseSandbox(sharedSandbox) | 1468 | resolved.UseSandbox(sharedSandbox) | ||
| 1469 | + | if subagentsMgr != nil { | |||
| 1470 | + | subagentsMgr.SetProvider(resolved.Provider) | |||
| 1471 | + | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |||
| 1472 | + | } | |||
| 1410 | resolved.MergeExtensionTools(extToolAdapter) | 1473 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 1411 | - | injectSwarmSpawn(resolved.ToolRegistry) | 1474 | + | injectSubagentSpawn(resolved.ToolRegistry) |
| 1412 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 1475 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 1413 | } | 1476 | } | ||
| 1414 | 1477 | ||||
| @@ -1426,8 +1489,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1426 | return nil, "", "", err | 1489 | return nil, "", "", err | ||
| 1427 | } | 1490 | } | ||
| 1428 | resolved.UseSandbox(sharedSandbox) | 1491 | resolved.UseSandbox(sharedSandbox) | ||
| 1492 | + | if subagentsMgr != nil { | |||
| 1493 | + | subagentsMgr.SetProvider(resolved.Provider) | |||
| 1494 | + | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |||
| 1495 | + | } | |||
| 1429 | resolved.MergeExtensionTools(extToolAdapter) | 1496 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 1430 | - | injectSwarmSpawn(resolved.ToolRegistry) | 1497 | + | injectSubagentSpawn(resolved.ToolRegistry) |
| 1431 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 1498 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 1432 | } | 1499 | } | ||
| 1433 | 1500 | ||||
| @@ -1453,8 +1520,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1453 | return nil, "", "", err | 1520 | return nil, "", "", err | ||
| 1454 | } | 1521 | } | ||
| 1455 | resolved.UseSandbox(sharedSandbox) | 1522 | resolved.UseSandbox(sharedSandbox) | ||
| 1523 | + | if subagentsMgr != nil { | |||
| 1524 | + | subagentsMgr.SetProvider(resolved.Provider) | |||
| 1525 | + | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |||
| 1526 | + | } | |||
| 1456 | resolved.MergeExtensionTools(extToolAdapter) | 1527 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 1457 | - | injectSwarmSpawn(resolved.ToolRegistry) | 1528 | + | injectSubagentSpawn(resolved.ToolRegistry) |
| 1458 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 1529 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 1459 | } | 1530 | } | ||
| 1460 | 1531 | ||||
| @@ -1473,7 +1544,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1473 | if current == nil { | 1544 | if current == nil { | ||
| 1474 | return | 1545 | return | ||
| 1475 | } | 1546 | } | ||
| 1476 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | 1547 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) |
| 1477 | }) | 1548 | }) | ||
| 1478 | 1549 | ||||
| 1479 | var sess *core.Session | 1550 | var sess *core.Session | ||
| @@ -1696,10 +1767,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1696 | } | 1767 | } | ||
| 1697 | activeProvider = candidate.provider | 1768 | activeProvider = candidate.provider | ||
| 1698 | activeModel = candidate.model | 1769 | activeModel = candidate.model | ||
| 1699 | - | if swarmMgr != nil && candidate.session != nil { | 1770 | + | if subagentsMgr != nil && candidate.session != nil { |
| 1700 | // Keep the dashboard scope in the same commit as the session, | 1771 | // Keep the dashboard scope in the same commit as the session, | ||
| 1701 | // agent, usage, and persistence baseline. | 1772 | // agent, usage, and persistence baseline. | ||
| 1702 | - | swarmMgr.SetActiveSession(candidate.session.ID) | 1773 | + | subagentsMgr.SetActiveSession(candidate.session.ID) |
| 1703 | } | 1774 | } | ||
| 1704 | committed = true | 1775 | committed = true | ||
| 1705 | persistMu.Unlock() | 1776 | persistMu.Unlock() | ||
| @@ -1718,7 +1789,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1718 | // | 1789 | // | ||
| 1719 | // Steps, in order: | 1790 | // Steps, in order: | ||
| 1720 | // 1. resolve + validate the new path (~ expansion, abs/rel) | 1791 | // 1. resolve + validate the new path (~ expansion, abs/rel) | ||
| 1721 | - | // 2. stage the candidate cwd, sandbox, and swarm state | 1792 | + | // 2. stage the candidate cwd, sandbox, and subagent state |
| 1722 | // 3. rebuild the agent and allocate its new session | 1793 | // 3. rebuild the agent and allocate its new session | ||
| 1723 | // 4. commit all new state together, closing the old session last | 1794 | // 4. commit all new state together, closing the old session last | ||
| 1724 | // 5. push the committed state into the running Interactive | 1795 | // 5. push the committed state into the running Interactive | ||
| @@ -1765,7 +1836,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1765 | 1836 | ||||
| 1766 | // Stage the candidate state while retaining a complete rollback | 1837 | // Stage the candidate state while retaining a complete rollback | ||
| 1767 | // snapshot. A failed rebuild or session allocation must leave the | 1838 | // snapshot. A failed rebuild or session allocation must leave the | ||
| 1768 | - | // current agent, session, sandbox, and swarm root usable. | 1839 | + | // current agent, session, sandbox, and subagent root usable. |
| 1769 | oldCWD := args.CWD | 1840 | oldCWD := args.CWD | ||
| 1770 | oldResolvedCWD := r.CWD | 1841 | oldResolvedCWD := r.CWD | ||
| 1771 | oldPermissionSet := args.PermissionSet | 1842 | oldPermissionSet := args.PermissionSet | ||
| @@ -1773,9 +1844,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1773 | oldActiveProvider := activeProvider | 1844 | oldActiveProvider := activeProvider | ||
| 1774 | oldActiveModel := activeModel | 1845 | oldActiveModel := activeModel | ||
| 1775 | persistMu.Unlock() | 1846 | persistMu.Unlock() | ||
| 1776 | - | oldSwarmRoot := "" | 1847 | + | oldSupervisorRoot := "" |
| 1777 | - | if swarmMgr != nil { | 1848 | + | if subagentsMgr != nil { |
| 1778 | - | oldSwarmRoot = swarmMgr.RepoRoot() | 1849 | + | oldSupervisorRoot = subagentsMgr.RepoRoot() |
| 1779 | } | 1850 | } | ||
| 1780 | oldSandboxRoot := "" | 1851 | oldSandboxRoot := "" | ||
| 1781 | oldSandboxLocked := false | 1852 | oldSandboxLocked := false | ||
| @@ -1791,8 +1862,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1791 | activeProvider = oldActiveProvider | 1862 | activeProvider = oldActiveProvider | ||
| 1792 | activeModel = oldActiveModel | 1863 | activeModel = oldActiveModel | ||
| 1793 | persistMu.Unlock() | 1864 | persistMu.Unlock() | ||
| 1794 | - | if swarmMgr != nil { | 1865 | + | if subagentsMgr != nil { |
| 1795 | - | swarmMgr.SetRepoRoot(oldSwarmRoot) | 1866 | + | subagentsMgr.SetRepoRoot(oldSupervisorRoot) |
| 1796 | } | 1867 | } | ||
| 1797 | if sharedSandbox != nil { | 1868 | if sharedSandbox != nil { | ||
| 1798 | sharedSandbox.Root = oldSandboxRoot | 1869 | sharedSandbox.Root = oldSandboxRoot | ||
| @@ -1806,8 +1877,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1806 | 1877 | ||||
| 1807 | args.CWD = absPath | 1878 | args.CWD = absPath | ||
| 1808 | r.CWD = absPath | 1879 | r.CWD = absPath | ||
| 1809 | - | if swarmMgr != nil { | 1880 | + | if subagentsMgr != nil { |
| 1810 | - | swarmMgr.SetRepoRoot(absPath) | 1881 | + | subagentsMgr.SetRepoRoot(absPath) |
| 1811 | } | 1882 | } | ||
| 1812 | if args.PermissionSet != nil { | 1883 | if args.PermissionSet != nil { | ||
| 1813 | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | 1884 | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | ||
| @@ -1880,9 +1951,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1880 | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | 1951 | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | ||
| 1881 | } | 1952 | } | ||
| 1882 | 1953 | ||||
| 1883 | - | // Re-scope the swarm dashboard to the new session. | 1954 | + | // Re-scope the subagent dashboard to the new session. |
| 1884 | - | if swarmMgr != nil && newSess != nil { | 1955 | + | if subagentsMgr != nil && newSess != nil { |
| 1885 | - | swarmMgr.SetActiveSession(newSess.ID) | 1956 | + | subagentsMgr.SetActiveSession(newSess.ID) |
| 1886 | } | 1957 | } | ||
| 1887 | if newSess != nil { | 1958 | if newSess != nil { | ||
| 1888 | extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates) | 1959 | extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates) | ||
| @@ -1943,7 +2014,6 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1943 | }() | 2014 | }() | ||
| 1944 | } | 2015 | } | ||
| 1945 | 2016 | ||||
| 1946 | - | initialCfg, _ := LoadConfig() | |||
| 1947 | fastMode := r.FastMode | 2017 | fastMode := r.FastMode | ||
| 1948 | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | 2018 | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | ||
| 1949 | for idx, s := range initialCfg.QuickModelShortcuts { | 2019 | for idx, s := range initialCfg.QuickModelShortcuts { | ||
| @@ -1958,18 +2028,20 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1958 | } | 2028 | } | ||
| 1959 | } | 2029 | } | ||
| 1960 | 2030 | ||||
| 1961 | - | // swarmMgr was constructed and reloaded earlier (before the agent | 2031 | + | // subagentsMgr was constructed and reloaded earlier (before the agent |
| 1962 | - | // build, so the auto-swarm tool could capture it). Here we just | 2032 | + | // build, so the auto-subagents tool could capture it). Here we just |
| 1963 | - | // scope the dashboard to the active host session so /swarm only | 2033 | + | // scope the dashboard to the active host session so /subagents only |
| 1964 | // shows agents this session spawned (and any pre-upgrade unscoped | 2034 | // shows agents this session spawned (and any pre-upgrade unscoped | ||
| 1965 | // agents — see SnapshotAll docs). Updated again whenever the | 2035 | // agents — see SnapshotAll docs). Updated again whenever the | ||
| 1966 | // user swaps sessions via loadSession below. | 2036 | // user swaps sessions via loadSession below. | ||
| 1967 | - | if sess != nil { | 2037 | + | if sess != nil && subagentsMgr != nil { |
| 1968 | - | swarmMgr.SetActiveSession(sess.ID) | 2038 | + | subagentsMgr.SetActiveSession(sess.ID) |
| 1969 | } | 2039 | } | ||
| 1970 | // Best-effort shutdown on interactive exit: stop all running | 2040 | // Best-effort shutdown on interactive exit: stop all running | ||
| 1971 | // agents so they don't outlive their parent zot. | 2041 | // agents so they don't outlive their parent zot. | ||
| 1972 | - | defer swarmMgr.StopAll() | 2042 | + | if subagentsMgr != nil { |
| 2043 | + | defer subagentsMgr.StopAll() | |||
| 2044 | + | } | |||
| 1973 | 2045 | ||||
| 1974 | var startupSkills []*skills.Skill | 2046 | var startupSkills []*skills.Skill | ||
| 1975 | if r.SkillTool != nil { | 2047 | if r.SkillTool != nil { | ||
| @@ -1978,54 +2050,56 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1978 | homeDir, _ := os.UserHomeDir() | 2050 | homeDir, _ := os.UserHomeDir() | ||
| 1979 | discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir) | 2051 | discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir) | ||
| 1980 | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | 2052 | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | ||
| 2053 | + | autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args) | |||
| 1981 | 2054 | ||||
| 1982 | iv = modes.NewInteractive(modes.InteractiveConfig{ | 2055 | iv = modes.NewInteractive(modes.InteractiveConfig{ | ||
| 1983 | - | Terminal: term, | 2056 | + | Terminal: term, |
| 1984 | - | Theme: theme, | 2057 | + | Theme: theme, |
| 1985 | - | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 2058 | + | InlineImagesEnabled: initialCfg.InlineImagesEnabled, |
| 1986 | - | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | 2059 | + | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, |
| 1987 | - | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | 2060 | + | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, |
| 1988 | - | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 2061 | + | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, |
| 1989 | - | FastMode: &fastMode, | 2062 | + | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, |
| 1990 | - | LSPEnabled: initialCfg.LSPEnabled, | 2063 | + | FastMode: &fastMode, |
| 1991 | - | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | 2064 | + | LSPEnabled: initialCfg.LSPEnabled, |
| 1992 | - | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | 2065 | + | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, |
| 1993 | - | JailByDefault: initialCfg.JailByDefault, | 2066 | + | AutoCompactThreshold: initialCfg.AutoCompactThreshold, |
| 1994 | - | QuickModelShortcuts: quickModelShortcuts, | 2067 | + | JailByDefault: initialCfg.JailByDefault, |
| 1995 | - | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 2068 | + | QuickModelShortcuts: quickModelShortcuts, |
| 1996 | - | RespectGitignore: initialCfg.RespectGitignore, | 2069 | + | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, |
| 1997 | - | CompactMode: initialCfg.CompactMode, | 2070 | + | RespectGitignore: initialCfg.RespectGitignore, |
| 1998 | - | TUIInputStyle: initialCfg.TUIInputStyle, | 2071 | + | CompactMode: initialCfg.CompactMode, |
| 1999 | - | TUIStatusPosition: initialCfg.TUIStatusPosition, | 2072 | + | TUIInputStyle: initialCfg.TUIInputStyle, |
| 2000 | - | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | 2073 | + | TUIStatusPosition: initialCfg.TUIStatusPosition, |
| 2001 | - | ThemeName: initialCfg.Theme, | 2074 | + | TUIWorkingPosition: initialCfg.TUIWorkingPosition, |
| 2002 | - | FlatTools: initialCfg.FlatToolRender(), | 2075 | + | ThemeName: initialCfg.Theme, |
| 2003 | - | CompactUser: initialCfg.CompactUserInput(), | 2076 | + | FlatTools: initialCfg.FlatToolRender(), |
| 2004 | - | ExtensionThemes: extMgr.ThemeOptions, | 2077 | + | CompactUser: initialCfg.CompactUserInput(), |
| 2005 | - | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | 2078 | + | ExtensionThemes: extMgr.ThemeOptions, |
| 2006 | - | SubagentsSystemAddendum: subagentsAddendum, | 2079 | + | AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum, |
| 2007 | - | SettingsStore: configSettingsStore{}, | 2080 | + | SubagentsSystemAddendum: subagentsAddendum, |
| 2008 | - | Model: r.Model, | 2081 | + | SettingsStore: configSettingsStore{}, |
| 2009 | - | Provider: r.Provider, | 2082 | + | Model: r.Model, |
| 2010 | - | AuthMethod: r.AuthMethod, | 2083 | + | Provider: r.Provider, |
| 2011 | - | BaseURL: r.BaseURL, | 2084 | + | AuthMethod: r.AuthMethod, |
| 2012 | - | Reasoning: r.Reasoning, | 2085 | + | BaseURL: r.BaseURL, |
| 2013 | - | SystemPrompt: r.SystemPrompt, | 2086 | + | Reasoning: r.Reasoning, |
| 2014 | - | Tools: r.ToolRegistry, | 2087 | + | SystemPrompt: r.SystemPrompt, |
| 2015 | - | MaxSteps: r.MaxSteps, | 2088 | + | Tools: r.ToolRegistry, |
| 2016 | - | CWD: r.CWD, | 2089 | + | MaxSteps: r.MaxSteps, |
| 2017 | - | StartupAgentName: args.AgentName, | 2090 | + | CWD: r.CWD, |
| 2018 | - | StartupContextPaths: instructionContextPaths(r.ContextFiles), | 2091 | + | StartupAgentName: args.AgentName, |
| 2019 | - | StartupExtensionNames: startupExtensionNames(extMgr.All()), | 2092 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), |
| 2020 | - | StartupExtensionErrors: startupExtensionErrors, | 2093 | + | StartupExtensionNames: startupExtensionNames(extMgr.All()), |
| 2021 | - | StartupSkillNames: startupSkillNames(startupSkills), | 2094 | + | StartupExtensionErrors: startupExtensionErrors, |
| 2022 | - | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | 2095 | + | StartupSkillNames: startupSkillNames(startupSkills), |
| 2023 | - | ZotHome: ZotHome(), | 2096 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, |
| 2024 | - | SessionsRoot: agentSessionsRoot(ZotHome(), args), | 2097 | + | ZotHome: ZotHome(), |
| 2025 | - | Version: version, | 2098 | + | SessionsRoot: agentSessionsRoot(ZotHome(), args), |
| 2026 | - | UpdateInfoChan: updateCh, | 2099 | + | Version: version, |
| 2027 | - | Sandbox: sharedSandbox, | 2100 | + | UpdateInfoChan: updateCh, |
| 2028 | - | Agent: ag, | 2101 | + | Sandbox: sharedSandbox, |
| 2102 | + | Agent: ag, | |||
| 2029 | InitialSessionTitle: func() string { | 2103 | InitialSessionTitle: func() string { | ||
| 2030 | if sess == nil || sessionTitlePending { | 2104 | if sess == nil || sessionTitlePending { | ||
| 2031 | return "" | 2105 | return "" | ||
| @@ -2043,12 +2117,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2043 | _ = extMgr.Reload(context.Background(), 2*time.Second) | 2117 | _ = extMgr.Reload(context.Background(), 2*time.Second) | ||
| 2044 | } | 2118 | } | ||
| 2045 | if current != nil { | 2119 | if current != nil { | ||
| 2046 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | 2120 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) |
| 2047 | } | 2121 | } | ||
| 2048 | }, | 2122 | }, | ||
| 2049 | RefreshTools: func() { | 2123 | RefreshTools: func() { | ||
| 2050 | current := liveInteractiveAgent(iv, ag) | 2124 | current := liveInteractiveAgent(iv, ag) | ||
| 2051 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | 2125 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) |
| 2052 | }, | 2126 | }, | ||
| 2053 | AuthManager: mgr, | 2127 | AuthManager: mgr, | ||
| 2054 | LlamaCPPConfig: ResolveLlamaCPPConfig, | 2128 | LlamaCPPConfig: ResolveLlamaCPPConfig, | ||
| @@ -2114,7 +2188,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2114 | }, | 2188 | }, | ||
| 2115 | SessionTransition: newSessionTransition(&sessionTransitionMu), | 2189 | SessionTransition: newSessionTransition(&sessionTransitionMu), | ||
| 2116 | Extensions: extMgr, | 2190 | Extensions: extMgr, | ||
| 2117 | - | Swarm: swarmMgr, | 2191 | + | Supervisor: subagentsMgr, |
| 2118 | ResolveSubagent: resolveSubagent, | 2192 | ResolveSubagent: resolveSubagent, | ||
| 2119 | ChangelogChan: changelogCh, | 2193 | ChangelogChan: changelogCh, | ||
| 2120 | OnChangelogDismiss: func() { | 2194 | OnChangelogDismiss: func() { | ||
| @@ -2238,9 +2312,17 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2238 | } | 2312 | } | ||
| 2239 | closeAgentLSP(finalAg) | 2313 | closeAgentLSP(finalAg) | ||
| 2240 | closeResolvedLSP(r) | 2314 | closeResolvedLSP(r) | ||
| 2315 | + | // Stop supervised workers before exiting. os.Exit skips deferred | |||
| 2316 | + | // cleanup, so relying only on the normal runInteractive defer would | |||
| 2317 | + | // leave child daemons (and their descendants) alive after a TERM or | |||
| 2318 | + | // HUP delivered to the host. | |||
| 2319 | + | if subagentsMgr != nil { | |||
| 2320 | + | subagentsMgr.StopAll() | |||
| 2321 | + | } | |||
| 2241 | // Exit cleanly. Re-raising the signal would skip os.Exit's | 2322 | // Exit cleanly. Re-raising the signal would skip os.Exit's | ||
| 2242 | // at-exit hooks; explicit exit is fine because we've already | 2323 | // at-exit hooks; explicit exit is fine because we've already | ||
| 2243 | - | // flushed the only at-risk state (the session file). | 2324 | + | // flushed the only at-risk state (the session file) and stopped |
| 2325 | + | // supervised workers. | |||
| 2244 | os.Exit(0) | 2326 | os.Exit(0) | ||
| 2245 | }() | 2327 | }() | ||
| 2246 | 2328 | ||||
| @@ -2284,10 +2366,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 2284 | switch { | 2366 | switch { | ||
| 2285 | case args.Session != "": | 2367 | case args.Session != "": | ||
| 2286 | s, msgs, err = core.OpenSession(args.Session) | 2368 | s, msgs, err = core.OpenSession(args.Session) | ||
| 2287 | - | // The swarm-agent child passes a fixed --session path that | 2369 | + | // The subagent-worker child passes a fixed --session path that |
| 2288 | // may not exist yet on first Spawn. Treat ENOENT as "create | 2370 | // may not exist yet on first Spawn. Treat ENOENT as "create | ||
| 2289 | // a fresh session AT THIS PATH" so the conversation actually | 2371 | // a fresh session AT THIS PATH" so the conversation actually | ||
| 2290 | - | // gets persisted; without this fallback the swarm child runs | 2372 | + | // gets persisted; without this fallback the subagent child runs |
| 2291 | // with sess==nil and every Resume re-starts with no memory | 2373 | // with sess==nil and every Resume re-starts with no memory | ||
| 2292 | // of the prior turns. Other openers (--continue / --resume / | 2374 | // of the prior turns. Other openers (--continue / --resume / | ||
| 2293 | // the picker) never see ENOENT here because they only choose | 2375 | // the picker) never see ENOENT here because they only choose | ||
M packages/agent/config.go +27 -5
| @@ -22,6 +22,25 @@ type QuickModelShortcut struct { | |||||
| 22 | Model string `json:"model"` | 22 | Model string `json:"model"` | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | + | // SubagentsConfig contains the persisted supervisor policy. Duration values | |||
| 26 | + | // use Go strings such as "20m" so config files remain readable. | |||
| 27 | + | type SubagentsConfig struct { | |||
| 28 | + | MaxConcurrent int `json:"max_concurrent,omitempty"` | |||
| 29 | + | MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"` | |||
| 30 | + | MaxTotalSpawned int `json:"max_total_spawned,omitempty"` | |||
| 31 | + | QueueTimeout string `json:"queue_timeout,omitempty"` | |||
| 32 | + | DefaultTimeout string `json:"default_timeout,omitempty"` | |||
| 33 | + | MaxTurns int `json:"max_turns,omitempty"` | |||
| 34 | + | MaxOutputBytes int `json:"max_output_bytes,omitempty"` | |||
| 35 | + | MaxOutputLines int `json:"max_output_lines,omitempty"` | |||
| 36 | + | AllowedTools []string `json:"allowed_tools,omitempty"` | |||
| 37 | + | AllowedRoots []string `json:"allowed_roots,omitempty"` | |||
| 38 | + | HeartbeatInterval string `json:"heartbeat_interval,omitempty"` | |||
| 39 | + | IdleTimeout string `json:"idle_timeout,omitempty"` | |||
| 40 | + | ReconnectTimeout string `json:"reconnect_timeout,omitempty"` | |||
| 41 | + | CancelGracePeriod string `json:"cancel_grace_period,omitempty"` | |||
| 42 | + | } | |||
| 43 | + | ||||
| 25 | // Config is the persisted user configuration. | 44 | // Config is the persisted user configuration. | ||
| 26 | type Config struct { | 45 | type Config struct { | ||
| 27 | Provider string `json:"provider"` | 46 | Provider string `json:"provider"` | ||
| @@ -68,17 +87,20 @@ type Config struct { | |||||
| 68 | // both so no extra provider request is made. Toggle from /settings. | 87 | // both so no extra provider request is made. Toggle from /settings. | ||
| 69 | TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"` | 88 | TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"` | ||
| 70 | 89 | ||||
| 71 | - | // AutoSwarmEnabled lets the main agent spawn background sub-agents | 90 | + | // Subagents controls manager-owned child lifecycle and resource policy. |
| 72 | - | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | 91 | + | Subagents SubagentsConfig `json:"subagents,omitempty"` |
| 92 | + | ||||
| 93 | + | // AutoSubagentsEnabled lets the main agent spawn background sub-agents | |||
| 94 | + | // for parallel sub-tasks via a built-in subagent_spawn tool. Off by | |||
| 73 | // default; nil/missing means disabled. Toggle from /settings. | 95 | // default; nil/missing means disabled. Toggle from /settings. | ||
| 74 | - | AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` | 96 | + | AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"` |
| 75 | 97 | ||||
| 76 | // LSPEnabled controls the built-in lsp tool and write-time diagnostics | 98 | // LSPEnabled controls the built-in lsp tool and write-time diagnostics | ||
| 77 | // for the main session. nil/missing means enabled. Toggle from | 99 | // for the main session. nil/missing means enabled. Toggle from | ||
| 78 | // /settings. | 100 | // /settings. | ||
| 79 | LSPEnabled *bool `json:"lsp_enabled,omitempty"` | 101 | LSPEnabled *bool `json:"lsp_enabled,omitempty"` | ||
| 80 | 102 | ||||
| 81 | - | // SubagentLSPEnabled controls LSP availability in swarm sub-agent | 103 | + | // SubagentLSPEnabled controls LSP availability in subagent worker |
| 82 | // processes. nil/missing means enabled. Toggle from /settings. | 104 | // processes. nil/missing means enabled. Toggle from /settings. | ||
| 83 | SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"` | 105 | SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"` | ||
| 84 | 106 | ||||
| @@ -213,7 +235,7 @@ func (c Config) CompactUserInput() bool { | |||||
| 213 | return c.CompactInput != nil && *c.CompactInput | 235 | return c.CompactInput != nil && *c.CompactInput | ||
| 214 | } | 236 | } | ||
| 215 | 237 | ||||
| 216 | - | // LSPEnabledFor reports whether LSP is enabled for a main or swarm | 238 | + | // LSPEnabledFor reports whether LSP is enabled for a main or subagent |
| 217 | // sub-agent session. Both settings default to true so new installations | 239 | // sub-agent session. Both settings default to true so new installations | ||
| 218 | // get code intelligence without a migration or generated config file. | 240 | // get code intelligence without a migration or generated config file. | ||
| 219 | func (c Config) LSPEnabledFor(subagent bool) bool { | 241 | func (c Config) LSPEnabledFor(subagent bool) bool { | ||
A packages/agent/modes/auto_subagents_policy_test.go +178 -0
| @@ -0,0 +1,178 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) { | |||
| 15 | + | trueValue := true | |||
| 16 | + | falseValue := false | |||
| 17 | + | ||||
| 18 | + | tests := []struct { | |||
| 19 | + | name string | |||
| 20 | + | supervisor *subagents.Supervisor | |||
| 21 | + | allowed *bool | |||
| 22 | + | wantHint string | |||
| 23 | + | }{ | |||
| 24 | + | { | |||
| 25 | + | name: "supervisor unavailable", | |||
| 26 | + | allowed: &trueValue, | |||
| 27 | + | wantHint: "subagent supervisor not available in this mode", | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | name: "launch policy excludes tool", | |||
| 31 | + | supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}), | |||
| 32 | + | allowed: &falseValue, | |||
| 33 | + | wantHint: "launch-time tool policy excludes subagent_spawn", | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | if tt.supervisor != nil { | |||
| 39 | + | t.Cleanup(tt.supervisor.StopAll) | |||
| 40 | + | } | |||
| 41 | + | iv := &Interactive{ | |||
| 42 | + | cfg: InteractiveConfig{ | |||
| 43 | + | AutoSubagentsEnabled: &trueValue, | |||
| 44 | + | AutoSubagentsToolAllowed: tt.allowed, | |||
| 45 | + | Supervisor: tt.supervisor, | |||
| 46 | + | }, | |||
| 47 | + | settingsDialog: newSettingsDialog(), | |||
| 48 | + | } | |||
| 49 | + | iv.openSettingsDialog() | |||
| 50 | + | ||||
| 51 | + | var item settingsItem | |||
| 52 | + | for _, candidate := range iv.settingsDialog.items { | |||
| 53 | + | if candidate.key == "auto_subagents_enabled" { | |||
| 54 | + | item = candidate | |||
| 55 | + | break | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | if !item.disabled { | |||
| 59 | + | t.Fatal("auto-subagents setting is not disabled") | |||
| 60 | + | } | |||
| 61 | + | if item.value { | |||
| 62 | + | t.Fatal("disabled auto-subagents setting is shown as enabled") | |||
| 63 | + | } | |||
| 64 | + | if !strings.Contains(item.hint, tt.wantHint) { | |||
| 65 | + | t.Fatalf("hint = %q; want %q", item.hint, tt.wantHint) | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) { | |||
| 72 | + | allowed := false | |||
| 73 | + | enabled := false | |||
| 74 | + | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | |||
| 75 | + | t.Cleanup(supervisor.StopAll) | |||
| 76 | + | iv := &Interactive{ | |||
| 77 | + | agent: &core.Agent{Tools: core.Registry{}}, | |||
| 78 | + | cfg: InteractiveConfig{ | |||
| 79 | + | AutoSubagentsEnabled: &enabled, | |||
| 80 | + | AutoSubagentsToolAllowed: &allowed, | |||
| 81 | + | Supervisor: supervisor, | |||
| 82 | + | }, | |||
| 83 | + | dirty: make(chan struct{}, 1), | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | iv.applyAutoSubagentsTool(true) | |||
| 87 | + | if _, ok := iv.agent.Tools["subagent_spawn"]; ok { | |||
| 88 | + | t.Fatal("subagent_spawn registered despite launch-time policy") | |||
| 89 | + | } | |||
| 90 | + | iv.applySettingToggle("auto_subagents_enabled", true) | |||
| 91 | + | iv.mu.Lock() | |||
| 92 | + | statusErr := iv.statusErr | |||
| 93 | + | value := iv.cfg.AutoSubagentsEnabled != nil && *iv.cfg.AutoSubagentsEnabled | |||
| 94 | + | iv.mu.Unlock() | |||
| 95 | + | if value { | |||
| 96 | + | t.Fatal("auto-subagents toggle enabled despite launch-time policy") | |||
| 97 | + | } | |||
| 98 | + | if !strings.Contains(statusErr, "launch-time tool policy") { | |||
| 99 | + | t.Fatalf("toggle error = %q; want launch-time policy hint", statusErr) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | type serializedAutoSubagentsClient struct { | |||
| 104 | + | started chan struct{} | |||
| 105 | + | request chan provider.Request | |||
| 106 | + | release chan struct{} | |||
| 107 | + | finished chan struct{} | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (c *serializedAutoSubagentsClient) Name() string { return "serialized-auto-subagents-test" } | |||
| 111 | + | ||||
| 112 | + | func (c *serializedAutoSubagentsClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 113 | + | c.request <- req | |||
| 114 | + | c.started <- struct{}{} | |||
| 115 | + | out := make(chan provider.Event, 1) | |||
| 116 | + | go func() { | |||
| 117 | + | defer close(out) | |||
| 118 | + | select { | |||
| 119 | + | case <-c.release: | |||
| 120 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |||
| 121 | + | case <-ctx.Done(): | |||
| 122 | + | out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()} | |||
| 123 | + | } | |||
| 124 | + | close(c.finished) | |||
| 125 | + | }() | |||
| 126 | + | return out, nil | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestAutoSubagentsPromptConfigurationIsSynchronized(t *testing.T) { | |||
| 130 | + | client := &serializedAutoSubagentsClient{ | |||
| 131 | + | started: make(chan struct{}, 1), | |||
| 132 | + | request: make(chan provider.Request, 1), | |||
| 133 | + | release: make(chan struct{}), | |||
| 134 | + | finished: make(chan struct{}), | |||
| 135 | + | } | |||
| 136 | + | agent := core.NewAgent(client, "test-model", "base system", nil) | |||
| 137 | + | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | |||
| 138 | + | t.Cleanup(supervisor.StopAll) | |||
| 139 | + | allowed := true | |||
| 140 | + | iv := NewInteractive(InteractiveConfig{ | |||
| 141 | + | Agent: agent, | |||
| 142 | + | Supervisor: supervisor, | |||
| 143 | + | AutoSubagentsToolAllowed: &allowed, | |||
| 144 | + | AutoSubagentsSystemAddendum: "delegation guidance", | |||
| 145 | + | }) | |||
| 146 | + | ||||
| 147 | + | iv.startTurn(context.Background(), "hello") | |||
| 148 | + | select { | |||
| 149 | + | case <-client.started: | |||
| 150 | + | case <-time.After(time.Second): | |||
| 151 | + | t.Fatal("prompt did not reach provider") | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | request := <-client.request | |||
| 155 | + | if request.System != "base system" { | |||
| 156 | + | t.Fatalf("provider request system = %q; want base system snapshot", request.System) | |||
| 157 | + | } | |||
| 158 | + | applied := make(chan struct{}) | |||
| 159 | + | go func() { | |||
| 160 | + | iv.applyAutoSubagentsSystemPrompt(true) | |||
| 161 | + | close(applied) | |||
| 162 | + | }() | |||
| 163 | + | select { | |||
| 164 | + | case <-applied: | |||
| 165 | + | case <-time.After(time.Second): | |||
| 166 | + | t.Fatal("system-prompt update did not apply while provider was running") | |||
| 167 | + | } | |||
| 168 | + | if system, _ := agent.PromptConfig(); !strings.Contains(system, "delegation guidance") { | |||
| 169 | + | t.Fatalf("system prompt missing applied guidance: %q", system) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | close(client.release) | |||
| 173 | + | select { | |||
| 174 | + | case <-client.finished: | |||
| 175 | + | case <-time.After(time.Second): | |||
| 176 | + | t.Fatal("prompt did not finish after provider release") | |||
| 177 | + | } | |||
| 178 | + | } | |||
R packages/agent/modes/auto_swarm_test.go → packages/agent/modes/auto_subagents_test.go +40 -40 renamed, 58% similar
| @@ -7,64 +7,64 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | 10 | + | "github.com/patriceckhart/zot/packages/agent/subagents" |
| 11 | "github.com/patriceckhart/zot/packages/core" | 11 | "github.com/patriceckhart/zot/packages/core" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | - | func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) { | 14 | + | func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) { |
| 15 | iv := &Interactive{ | 15 | iv := &Interactive{ | ||
| 16 | agent: &core.Agent{System: "base system"}, | 16 | agent: &core.Agent{System: "base system"}, | ||
| 17 | cfg: InteractiveConfig{ | 17 | cfg: InteractiveConfig{ | ||
| 18 | - | AutoSwarmSystemAddendum: "auto-swarm guidance", | 18 | + | AutoSubagentsSystemAddendum: "auto-subagents guidance", |
| 19 | - | SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]", | 19 | + | SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]", |
| 20 | }, | 20 | }, | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | - | iv.applyAutoSwarmSystemPrompt(true) | 23 | + | iv.applyAutoSubagentsSystemPrompt(true) |
| 24 | - | if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-swarm guidance") { | 24 | + | if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-subagents guidance") { |
| 25 | - | t.Fatalf("enabled system prompt missing swarm blocks: %q", iv.agent.System) | 25 | + | t.Fatalf("enabled system prompt missing subagent blocks: %q", iv.agent.System) |
| 26 | } | 26 | } | ||
| 27 | before := iv.agent.System | 27 | before := iv.agent.System | ||
| 28 | - | iv.applyAutoSwarmSystemPrompt(true) | 28 | + | iv.applyAutoSubagentsSystemPrompt(true) |
| 29 | if iv.agent.System != before { | 29 | if iv.agent.System != before { | ||
| 30 | t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System) | 30 | t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System) | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | - | iv.applyAutoSwarmSystemPrompt(false) | 33 | + | iv.applyAutoSubagentsSystemPrompt(false) |
| 34 | - | if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-swarm guidance") { | 34 | + | if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-subagents guidance") { |
| 35 | - | t.Fatalf("disabled system prompt retained swarm blocks: %q", iv.agent.System) | 35 | + | t.Fatalf("disabled system prompt retained subagent blocks: %q", iv.agent.System) |
| 36 | } | 36 | } | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | - | func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) { | 39 | + | func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) { |
| 40 | manifest := "[subagents_list]\n- reviewer\n[/subagents_list]" | 40 | manifest := "[subagents_list]\n- reviewer\n[/subagents_list]" | ||
| 41 | - | guidance := "auto-swarm guidance" | 41 | + | guidance := "auto-subagents guidance" |
| 42 | base := "base system\n\n" + manifest + "\n\n" + guidance | 42 | base := "base system\n\n" + manifest + "\n\n" + guidance | ||
| 43 | iv := &Interactive{ | 43 | iv := &Interactive{ | ||
| 44 | agent: &core.Agent{System: base}, | 44 | agent: &core.Agent{System: base}, | ||
| 45 | cfg: InteractiveConfig{ | 45 | cfg: InteractiveConfig{ | ||
| 46 | - | AutoSwarmSystemAddendum: guidance, | 46 | + | AutoSubagentsSystemAddendum: guidance, |
| 47 | - | SubagentsSystemAddendum: manifest, | 47 | + | SubagentsSystemAddendum: manifest, |
| 48 | }, | 48 | }, | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | - | iv.applyAutoSwarmSystemPrompt(true) | 51 | + | iv.applyAutoSubagentsSystemPrompt(true) |
| 52 | if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 { | 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) | 53 | t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System) | ||
| 54 | } | 54 | } | ||
| 55 | - | iv.applyAutoSwarmSystemPrompt(false) | 55 | + | iv.applyAutoSubagentsSystemPrompt(false) |
| 56 | if got := strings.TrimSpace(iv.agent.System); got != base { | 56 | if got := strings.TrimSpace(iv.agent.System); got != base { | ||
| 57 | t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base) | 57 | t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base) | ||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | - | func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { | 61 | + | func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { |
| 62 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | 62 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | ||
| 63 | - | mgr := swarm.New(swarm.Config{ | 63 | + | mgr := subagents.New(subagents.Config{ |
| 64 | Root: t.TempDir(), | 64 | Root: t.TempDir(), | ||
| 65 | RepoRoot: t.TempDir(), | 65 | RepoRoot: t.TempDir(), | ||
| 66 | - | NewRunner: func(*swarm.Agent) swarm.Runner { | 66 | + | NewRunner: func(*subagents.Agent) subagents.Runner { |
| 67 | - | return swarm.RunnerFunc(func(_ context.Context, sink swarm.Sink) error { | 67 | + | return subagents.RunnerFunc(func(_ context.Context, sink subagents.Sink) error { |
| 68 | sink.Transcript(response) | 68 | sink.Transcript(response) | ||
| 69 | return nil | 69 | return nil | ||
| 70 | }) | 70 | }) | ||
| @@ -75,20 +75,20 @@ func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T | |||||
| 75 | t.Fatal(err) | 75 | t.Fatal(err) | ||
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | - | iv := newQueuedAutoSwarmInteractive() | 78 | + | iv := newQueuedAutoSubagentsInteractive() |
| 79 | - | iv.TrackSwarmAgent(a, a.Task) | 79 | + | iv.TrackSubagentWorker(a, a.Task) |
| 80 | update := waitForQueuedPrompt(t, iv) | 80 | update := waitForQueuedPrompt(t, iv) | ||
| 81 | if !strings.Contains(update, "final response:\n"+response) { | 81 | if !strings.Contains(update, "final response:\n"+response) { | ||
| 82 | t.Fatalf("update missing complete final response: %q", update) | 82 | t.Fatalf("update missing complete final response: %q", update) | ||
| 83 | } | 83 | } | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | - | func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) { | 86 | + | func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) { |
| 87 | - | mgr := swarm.New(swarm.Config{ | 87 | + | mgr := subagents.New(subagents.Config{ |
| 88 | Root: t.TempDir(), | 88 | Root: t.TempDir(), | ||
| 89 | RepoRoot: t.TempDir(), | 89 | RepoRoot: t.TempDir(), | ||
| 90 | - | NewRunner: func(*swarm.Agent) swarm.Runner { | 90 | + | NewRunner: func(*subagents.Agent) subagents.Runner { |
| 91 | - | return swarm.RunnerFunc(func(context.Context, swarm.Sink) error { | 91 | + | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { |
| 92 | return errors.New("listener startup failed") | 92 | return errors.New("listener startup failed") | ||
| 93 | }) | 93 | }) | ||
| 94 | }, | 94 | }, | ||
| @@ -98,8 +98,8 @@ func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) { | |||||
| 98 | t.Fatal(err) | 98 | t.Fatal(err) | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | - | iv := newQueuedAutoSwarmInteractive() | 101 | + | iv := newQueuedAutoSubagentsInteractive() |
| 102 | - | iv.TrackSwarmAgent(a, a.Task) | 102 | + | iv.TrackSubagentWorker(a, a.Task) |
| 103 | 103 | ||||
| 104 | update := waitForQueuedPrompt(t, iv) | 104 | update := waitForQueuedPrompt(t, iv) | ||
| 105 | if !strings.Contains(update, "status: failed") { | 105 | if !strings.Contains(update, "status: failed") { | ||
| @@ -110,13 +110,13 @@ func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) { | |||||
| 110 | } | 110 | } | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | - | func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) { | 113 | + | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { |
| 114 | started := make(chan struct{}) | 114 | started := make(chan struct{}) | ||
| 115 | - | mgr := swarm.New(swarm.Config{ | 115 | + | mgr := subagents.New(subagents.Config{ |
| 116 | Root: t.TempDir(), | 116 | Root: t.TempDir(), | ||
| 117 | RepoRoot: t.TempDir(), | 117 | RepoRoot: t.TempDir(), | ||
| 118 | - | NewRunner: func(*swarm.Agent) swarm.Runner { | 118 | + | NewRunner: func(*subagents.Agent) subagents.Runner { |
| 119 | - | return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error { | 119 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { |
| 120 | close(started) | 120 | close(started) | ||
| 121 | <-ctx.Done() | 121 | <-ctx.Done() | ||
| 122 | return ctx.Err() | 122 | return ctx.Err() | ||
| @@ -133,11 +133,11 @@ func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) { | |||||
| 133 | a.Wait() | 133 | a.Wait() | ||
| 134 | }) | 134 | }) | ||
| 135 | 135 | ||||
| 136 | - | iv := newQueuedAutoSwarmInteractive() | 136 | + | iv := newQueuedAutoSubagentsInteractive() |
| 137 | - | entry := &swarmWatchEntry{agent: a, task: a.Task} | 137 | + | entry := &subagentWatchEntry{agent: a, task: a.Task} |
| 138 | - | iv.swarmWatch = []*swarmWatchEntry{entry} | 138 | + | iv.subagentWatch = []*subagentWatchEntry{entry} |
| 139 | - | iv.completeSwarmWatchEntry(entry, "completed", "") | 139 | + | iv.completeSupervisorWatchEntry(entry, "completed", "") |
| 140 | - | iv.completeSwarmWatchEntry(entry, "failed", "late error") | 140 | + | iv.completeSupervisorWatchEntry(entry, "failed", "late error") |
| 141 | 141 | ||||
| 142 | update := waitForQueuedPrompt(t, iv) | 142 | update := waitForQueuedPrompt(t, iv) | ||
| 143 | if !strings.Contains(update, "status: completed") { | 143 | if !strings.Contains(update, "status: completed") { | ||
| @@ -151,7 +151,7 @@ func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) { | |||||
| 151 | } | 151 | } | ||
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | - | func newQueuedAutoSwarmInteractive() *Interactive { | 154 | + | func newQueuedAutoSubagentsInteractive() *Interactive { |
| 155 | return &Interactive{ | 155 | return &Interactive{ | ||
| 156 | agent: &core.Agent{}, | 156 | agent: &core.Agent{}, | ||
| 157 | busy: true, | 157 | busy: true, | ||
| @@ -176,7 +176,7 @@ func waitForQueuedPrompt(t *testing.T, iv *Interactive) string { | |||||
| 176 | iv.mu.Unlock() | 176 | iv.mu.Unlock() | ||
| 177 | select { | 177 | select { | ||
| 178 | case <-deadline.C: | 178 | case <-deadline.C: | ||
| 179 | - | t.Fatal("timed out waiting for auto-swarm update") | 179 | + | t.Fatal("timed out waiting for auto-subagents update") |
| 180 | case <-ticker.C: | 180 | case <-ticker.C: | ||
| 181 | } | 181 | } | ||
| 182 | } | 182 | } | ||
M packages/agent/modes/dialog_text_test.go +7 -7
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/mattn/go-runewidth" | 7 | "github.com/mattn/go-runewidth" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | 9 | + | "github.com/patriceckhart/zot/packages/agent/subagents" |
| 10 | "github.com/patriceckhart/zot/packages/tui" | 10 | "github.com/patriceckhart/zot/packages/tui" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| @@ -47,10 +47,10 @@ func TestBtwDialogWrapsAssistantAndKeepsCursorAligned(t *testing.T) { | |||||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | - | func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) { | 50 | + | func TestSupervisorTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) { |
| 51 | const width = 80 | 51 | const width = 80 | ||
| 52 | long := strings.Repeat("long response text ", 8) | 52 | long := strings.Repeat("long response text ", 8) | ||
| 53 | - | rows := renderSwarmTranscriptBlocks([]string{ | 53 | + | rows := renderSupervisorTranscriptBlocks([]string{ |
| 54 | long, | 54 | long, | ||
| 55 | "stderr: " + long, | 55 | "stderr: " + long, | ||
| 56 | "error: " + long, | 56 | "error: " + long, | ||
| @@ -65,15 +65,15 @@ func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) { | |||||
| 65 | } | 65 | } | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | - | func TestSwarmDialogViewingWrapsSpawnedAgentResponse(t *testing.T) { | 68 | + | func TestSubagentsDialogViewingWrapsSpawnedAgentResponse(t *testing.T) { |
| 69 | const width = 80 | 69 | const width = 80 | ||
| 70 | response := strings.Repeat("spawned agent response ", 6) | 70 | response := strings.Repeat("spawned agent response ", 6) | ||
| 71 | - | rows := []swarm.AgentSnapshot{{ | 71 | + | rows := []subagents.AgentSnapshot{{ |
| 72 | ID: "agent-1", | 72 | ID: "agent-1", | ||
| 73 | - | Status: swarm.StatusDone, | 73 | + | Status: subagents.StatusDone, |
| 74 | Lines: []string{response}, | 74 | Lines: []string{response}, | ||
| 75 | }} | 75 | }} | ||
| 76 | - | d := newSwarmDialog() | 76 | + | d := newSubagentsDialog() |
| 77 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 77 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 78 | _ = d.Render(tui.Theme{}, width) | 78 | _ = d.Render(tui.Theme{}, width) | ||
| 79 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | 79 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | ||
M packages/agent/modes/double_escape.go +1 -1
| @@ -79,7 +79,7 @@ func (i *Interactive) sessionTreeEscapeBlocked() bool { | |||||
| 79 | i.llamaDialog != nil && i.llamaDialog.Active() || | 79 | i.llamaDialog != nil && i.llamaDialog.Active() || | ||
| 80 | i.rescueDialog != nil && i.rescueDialog.Active() || | 80 | i.rescueDialog != nil && i.rescueDialog.Active() || | ||
| 81 | i.sessionDialog != nil && i.sessionDialog.Active() || | 81 | i.sessionDialog != nil && i.sessionDialog.Active() || | ||
| 82 | - | i.swarmDialog != nil && i.swarmDialog.Active() || | 82 | + | i.subagentsDialog != nil && i.subagentsDialog.Active() || |
| 83 | i.jumpDialog != nil && i.jumpDialog.Active() || | 83 | i.jumpDialog != nil && i.jumpDialog.Active() || | ||
| 84 | i.btwDialog != nil && i.btwDialog.Active() || | 84 | i.btwDialog != nil && i.btwDialog.Active() || | ||
| 85 | i.skillsDialog != nil && i.skillsDialog.Active() || | 85 | i.skillsDialog != nil && i.skillsDialog.Active() || | ||
M packages/agent/modes/interactive.go +217 -144
| @@ -18,7 +18,6 @@ import ( | |||||
| 18 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | 18 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | ||
| 19 | "github.com/patriceckhart/zot/packages/agent/skills" | 19 | "github.com/patriceckhart/zot/packages/agent/skills" | ||
| 20 | "github.com/patriceckhart/zot/packages/agent/subagents" | 20 | "github.com/patriceckhart/zot/packages/agent/subagents" | ||
| 21 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 22 | "github.com/patriceckhart/zot/packages/agent/tools" | 21 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 23 | "github.com/patriceckhart/zot/packages/core" | 22 | "github.com/patriceckhart/zot/packages/core" | ||
| 24 | "github.com/patriceckhart/zot/packages/provider" | 23 | "github.com/patriceckhart/zot/packages/provider" | ||
| @@ -68,10 +67,14 @@ type InteractiveConfig struct { | |||||
| 68 | // title updates. nil means enabled; false disables both. | 67 | // title updates. nil means enabled; false disables both. | ||
| 69 | TerminalTitleEnabled *bool | 68 | TerminalTitleEnabled *bool | ||
| 70 | 69 | ||||
| 71 | - | // AutoSwarmEnabled mirrors the persisted config flag at startup so | 70 | + | // AutoSubagentsEnabled mirrors the persisted config flag at startup so |
| 72 | // the /settings dialog can render the current state without | 71 | // the /settings dialog can render the current state without | ||
| 73 | // re-reading config.json on every open. | 72 | // re-reading config.json on every open. | ||
| 74 | - | AutoSwarmEnabled *bool | 73 | + | AutoSubagentsEnabled *bool |
| 74 | + | ||||
| 75 | + | // AutoSubagentsToolAllowed is nil for normal sessions and false when the | |||
| 76 | + | // launch-time --no-tools/--tools policy excludes delegation. | |||
| 77 | + | AutoSubagentsToolAllowed *bool | |||
| 75 | 78 | ||||
| 76 | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | 79 | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | ||
| 77 | // nil/missing means disabled. Unsupported providers reject attempts | 80 | // nil/missing means disabled. Unsupported providers reject attempts | ||
| @@ -82,7 +85,7 @@ type InteractiveConfig struct { | |||||
| 82 | // enabled by default. | 85 | // enabled by default. | ||
| 83 | LSPEnabled *bool | 86 | LSPEnabled *bool | ||
| 84 | 87 | ||||
| 85 | - | // SubagentLSPEnabled controls whether newly spawned swarm children | 88 | + | // SubagentLSPEnabled controls whether newly spawned subagent children |
| 86 | // receive the lsp tool. nil means enabled by default. | 89 | // receive the lsp tool. nil means enabled by default. | ||
| 87 | SubagentLSPEnabled *bool | 90 | SubagentLSPEnabled *bool | ||
| 88 | 91 | ||||
| @@ -141,14 +144,14 @@ type InteractiveConfig struct { | |||||
| 141 | // ExtensionThemes returns themes bundled with loaded extensions. | 144 | // ExtensionThemes returns themes bundled with loaded extensions. | ||
| 142 | ExtensionThemes func() []tui.ThemeOption | 145 | ExtensionThemes func() []tui.ThemeOption | ||
| 143 | 146 | ||||
| 144 | - | // AutoSwarmSystemAddendum is the system-prompt block that gets | 147 | + | // AutoSubagentsSystemAddendum is the system-prompt block that gets |
| 145 | - | // appended/stripped when the user toggles auto-swarm at runtime. | 148 | + | // appended/stripped when the user toggles auto-subagents at runtime. |
| 146 | // Plumbed in from the cli so this package doesn't have to import | 149 | // Plumbed in from the cli so this package doesn't have to import | ||
| 147 | // agent (cycle). | 150 | // agent (cycle). | ||
| 148 | - | AutoSwarmSystemAddendum string | 151 | + | AutoSubagentsSystemAddendum string |
| 149 | 152 | ||||
| 150 | // SubagentsSystemAddendum is the metadata-only [subagents_list] | 153 | // SubagentsSystemAddendum is the metadata-only [subagents_list] | ||
| 151 | - | // block that is added or removed together with auto-swarm. | 154 | + | // block that is added or removed together with auto-subagents. |
| 152 | SubagentsSystemAddendum string | 155 | SubagentsSystemAddendum string | ||
| 153 | SettingsStore SettingsStore | 156 | SettingsStore SettingsStore | ||
| 154 | 157 | ||||
| @@ -315,15 +318,15 @@ type InteractiveConfig struct { | |||||
| 315 | // AFTER the built-in catalog so a built-in name always wins. | 318 | // AFTER the built-in catalog so a built-in name always wins. | ||
| 316 | Extensions *extensions.Manager | 319 | Extensions *extensions.Manager | ||
| 317 | 320 | ||||
| 318 | - | // Swarm, if non-nil, enables the /swarm slash command and the | 321 | + | // Supervisor, if non-nil, enables the /subagents slash command and the |
| 319 | - | // dashboard dialog. The cli constructs the Swarm once per | 322 | + | // dashboard dialog. The cli constructs the Supervisor once per |
| 320 | // interactive run and tears it down on exit. nil disables the | 323 | // interactive run and tears it down on exit. nil disables the | ||
| 321 | // feature entirely (used by embedders / tests that don't want | 324 | // feature entirely (used by embedders / tests that don't want | ||
| 322 | // subprocesses). | 325 | // subprocesses). | ||
| 323 | - | Swarm *swarm.Swarm | 326 | + | Supervisor *subagents.Supervisor |
| 324 | 327 | ||||
| 325 | // ResolveSubagent validates a named markdown profile for direct | 328 | // ResolveSubagent validates a named markdown profile for direct | ||
| 326 | - | // /swarm commands. Auto-swarm uses the equivalent callback on its | 329 | + | // /subagents commands. Auto-subagents uses the equivalent callback on its |
| 327 | // tool; keeping this optional preserves lightweight embedders. | 330 | // tool; keeping this optional preserves lightweight embedders. | ||
| 328 | ResolveSubagent func(name string) (*subagents.Profile, error) | 331 | ResolveSubagent func(name string) (*subagents.Profile, error) | ||
| 329 | 332 | ||||
| @@ -410,7 +413,7 @@ type extensionWidget struct { | |||||
| 410 | type SettingsStore interface { | 413 | type SettingsStore interface { | ||
| 411 | SetQuickModelShortcut(slot int, providerName, model string) error | 414 | SetQuickModelShortcut(slot int, providerName, model string) error | ||
| 412 | SetInlineImages(enabled bool) error | 415 | SetInlineImages(enabled bool) error | ||
| 413 | - | SetAutoSwarm(enabled bool) error | 416 | + | SetAutoSubagents(enabled bool) error |
| 414 | SetJailByDefault(enabled bool) error | 417 | SetJailByDefault(enabled bool) error | ||
| 415 | SetRecursiveFileSuggest(enabled bool) error | 418 | SetRecursiveFileSuggest(enabled bool) error | ||
| 416 | SetRespectGitignore(enabled bool) error | 419 | SetRespectGitignore(enabled bool) error | ||
| @@ -455,13 +458,16 @@ type Interactive struct { | |||||
| 455 | 458 | ||||
| 456 | mu sync.Mutex | 459 | mu sync.Mutex | ||
| 457 | agent *core.Agent | 460 | agent *core.Agent | ||
| 458 | - | // managedAutoSwarmAddenda records exact prompt blocks appended by | 461 | + | // agentMu serializes live registry replacement with side-dialog |
| 459 | - | // auto-swarm. Disable only removes these owned occurrences, leaving | 462 | + | // snapshots, whose copied-agent constructor reads the registry field. |
| 463 | + | agentMu sync.Mutex | |||
| 464 | + | // managedAutoSubagentsAddenda records exact prompt blocks appended by | |||
| 465 | + | // auto-subagents. Disable only removes these owned occurrences, leaving | |||
| 460 | // identical text that came from the user's base prompt untouched. | 466 | // identical text that came from the user's base prompt untouched. | ||
| 461 | - | managedAutoSwarmAddenda []string | 467 | + | managedAutoSubagentsAddenda []string |
| 462 | - | streaming strings.Builder // what's currently painted on screen | 468 | + | streaming strings.Builder // what's currently painted on screen |
| 463 | - | streamOn bool | 469 | + | streamOn bool |
| 464 | - | pendingAlert *extproto.AlertRequest | 470 | + | pendingAlert *extproto.AlertRequest |
| 465 | 471 | ||||
| 466 | // streamPending is the runes buffered after each EvTextDelta that | 472 | // streamPending is the runes buffered after each EvTextDelta that | ||
| 467 | // haven't yet been promoted into `streaming` for rendering. It | 473 | // haven't yet been promoted into `streaming` for rendering. It | ||
| @@ -572,7 +578,7 @@ type Interactive struct { | |||||
| 572 | llamaDialog *llamaDialog | 578 | llamaDialog *llamaDialog | ||
| 573 | rescueDialog *rescueDialog | 579 | rescueDialog *rescueDialog | ||
| 574 | sessionDialog *sessionDialog | 580 | sessionDialog *sessionDialog | ||
| 575 | - | swarmDialog *swarmDialog | 581 | + | subagentsDialog *subagentsDialog |
| 576 | jumpDialog *jumpDialog | 582 | jumpDialog *jumpDialog | ||
| 577 | btwDialog *btwDialog | 583 | btwDialog *btwDialog | ||
| 578 | skillsDialog *skillsDialog | 584 | skillsDialog *skillsDialog | ||
| @@ -588,14 +594,18 @@ type Interactive struct { | |||||
| 588 | extPanel *extPanelDialog | 594 | extPanel *extPanelDialog | ||
| 589 | llamaConfigured bool | 595 | llamaConfigured bool | ||
| 590 | 596 | ||||
| 591 | - | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | 597 | + | // subagentWatch tracks auto-subagents sub-agents the main agent spawned |
| 592 | - | // via swarm_spawn. Each entry holds the agent + the task text; | 598 | + | // via subagent_spawn. Each entry holds the agent + the task text; |
| 593 | // a per-entry goroutine waits on the agent's terminal state. When | 599 | // a per-entry goroutine waits on the agent's terminal state. When | ||
| 594 | // every tracked entry has finished, the watcher flushes a single | 600 | // every tracked entry has finished, the watcher flushes a single | ||
| 595 | // summary turn into the main chat (queued if the main agent is | 601 | // summary turn into the main chat (queued if the main agent is | ||
| 596 | // busy, run immediately if idle). | 602 | // busy, run immediately if idle). | ||
| 597 | - | swarmWatchMu sync.Mutex | 603 | + | subagentWatchMu sync.Mutex |
| 598 | - | swarmWatch []*swarmWatchEntry | 604 | + | subagentWatch []*subagentWatchEntry |
| 605 | + | ||||
| 606 | + | // subagentsWaitWatcherDone is an optional test hook invoked when a | |||
| 607 | + | // /subagents wait watcher exits. | |||
| 608 | + | subagentsWaitWatcherDone func() | |||
| 599 | 609 | ||||
| 600 | // pendingFork is true when the user ran /session fork: the next | 610 | // pendingFork is true when the user ran /session fork: the next | ||
| 601 | // jump-picker selection should branch off that message instead | 611 | // jump-picker selection should branch off that message instead | ||
| @@ -752,7 +762,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 752 | llamaDialog: newLlamaDialog(), | 762 | llamaDialog: newLlamaDialog(), | ||
| 753 | rescueDialog: newRescueDialog(), | 763 | rescueDialog: newRescueDialog(), | ||
| 754 | sessionDialog: newSessionDialog(), | 764 | sessionDialog: newSessionDialog(), | ||
| 755 | - | swarmDialog: newSwarmDialog(), | 765 | + | subagentsDialog: newSubagentsDialog(), |
| 756 | jumpDialog: newJumpDialog(), | 766 | jumpDialog: newJumpDialog(), | ||
| 757 | btwDialog: newBtwDialog(), | 767 | btwDialog: newBtwDialog(), | ||
| 758 | skillsDialog: newSkillsDialog(), | 768 | skillsDialog: newSkillsDialog(), | ||
| @@ -779,8 +789,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 779 | baseURL, _, err := cfg.LlamaCPPConfig() | 789 | baseURL, _, err := cfg.LlamaCPPConfig() | ||
| 780 | i.llamaConfigured = err == nil && baseURL != "" | 790 | i.llamaConfigured = err == nil && baseURL != "" | ||
| 781 | } | 791 | } | ||
| 782 | - | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | 792 | + | if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && (cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed) { |
| 783 | - | i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg) | 793 | + | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg) |
| 784 | } | 794 | } | ||
| 785 | if cfg.Agent != nil { | 795 | if cfg.Agent != nil { | ||
| 786 | i.agent = cfg.Agent | 796 | i.agent = cfg.Agent | ||
| @@ -808,8 +818,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 808 | i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages) | 818 | i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages) | ||
| 809 | i.titleGenerationStarted = i.titleRealPromptSeen | 819 | i.titleGenerationStarted = i.titleRealPromptSeen | ||
| 810 | } | 820 | } | ||
| 811 | - | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | 821 | + | if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { |
| 812 | - | i.applyAutoSwarmTool(true) | 822 | + | i.applyAutoSubagentsTool(true) |
| 813 | } | 823 | } | ||
| 814 | return i | 824 | return i | ||
| 815 | } | 825 | } | ||
| @@ -1066,7 +1076,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1066 | // blink inside dialogs that host their own editor (btw), | 1076 | // blink inside dialogs that host their own editor (btw), | ||
| 1067 | // because each frame re-emits hide-cursor + show-cursor. | 1077 | // because each frame re-emits hide-cursor + show-cursor. | ||
| 1068 | // | 1078 | // | ||
| 1069 | - | // The swarm dashboard is also animated: its rows reflect | 1079 | + | // The subagent dashboard is also animated: its rows reflect |
| 1070 | // background subprocesses whose activity / age change | 1080 | // background subprocesses whose activity / age change | ||
| 1071 | // without any user input. Without the tick redraw the | 1081 | // without any user input. Without the tick redraw the | ||
| 1072 | // dashboard freezes on the snapshot taken when the user | 1082 | // dashboard freezes on the snapshot taken when the user | ||
| @@ -1074,7 +1084,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1074 | // of its inline editors (spawn task or prompt composer) | 1084 | // of its inline editors (spawn task or prompt composer) | ||
| 1075 | // is active so the cursor blink in those editors works | 1085 | // is active so the cursor blink in those editors works | ||
| 1076 | // the same way it does inside btw. | 1086 | // the same way it does inside btw. | ||
| 1077 | - | if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() { | 1087 | + | if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() { |
| 1078 | requestRedraw() | 1088 | requestRedraw() | ||
| 1079 | } | 1089 | } | ||
| 1080 | } | 1090 | } | ||
| @@ -1541,8 +1551,8 @@ func (i *Interactive) redraw() { | |||||
| 1541 | } | 1551 | } | ||
| 1542 | i.sessionDialog.MaxRows = avail | 1552 | i.sessionDialog.MaxRows = avail | ||
| 1543 | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | 1553 | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | ||
| 1544 | - | case i.swarmDialog.Active(): | 1554 | + | case i.subagentsDialog.Active(): |
| 1545 | - | dialog = i.swarmDialog.Render(i.cfg.Theme, cols) | 1555 | + | dialog = i.subagentsDialog.Render(i.cfg.Theme, cols) |
| 1546 | case i.jumpDialog.Active(): | 1556 | case i.jumpDialog.Active(): | ||
| 1547 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | 1557 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | ||
| 1548 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | 1558 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | ||
| @@ -1692,6 +1702,7 @@ func (i *Interactive) redraw() { | |||||
| 1692 | Provider: i.cfg.Provider, | 1702 | Provider: i.cfg.Provider, | ||
| 1693 | Model: i.cfg.Model, | 1703 | Model: i.cfg.Model, | ||
| 1694 | Reasoning: i.cfg.Reasoning, | 1704 | Reasoning: i.cfg.Reasoning, | ||
| 1705 | + | FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode, | |||
| 1695 | Busy: i.busy, | 1706 | Busy: i.busy, | ||
| 1696 | BusyPrefix: statusBusyPrefix, | 1707 | BusyPrefix: statusBusyPrefix, | ||
| 1697 | CWD: i.cfg.CWD, | 1708 | CWD: i.cfg.CWD, | ||
| @@ -1765,15 +1776,15 @@ func (i *Interactive) redraw() { | |||||
| 1765 | bottom = append(bottom, "") | 1776 | bottom = append(bottom, "") | ||
| 1766 | } | 1777 | } | ||
| 1767 | bottom = append(bottom, dialog...) | 1778 | bottom = append(bottom, dialog...) | ||
| 1768 | - | // The swarm dashboard owns the bottom of the screen while it's | 1779 | + | // The subagent dashboard owns the bottom of the screen while it's |
| 1769 | // active: it has its own inline editors for spawn (`n`) and | 1780 | // active: it has its own inline editors for spawn (`n`) and | ||
| 1770 | // prompt (`p`), so the main input would be a confusing second | 1781 | // prompt (`p`), so the main input would be a confusing second | ||
| 1771 | // caret. The suggest popup, sliding-in queue, status block, and | 1782 | // caret. The suggest popup, sliding-in queue, status block, and | ||
| 1772 | // main editor are all hidden underneath it. Keystrokes still | 1783 | // main editor are all hidden underneath it. Keystrokes still | ||
| 1773 | - | // reach handleKey — it routes them to swarmDialog.HandleKey | 1784 | + | // reach handleKey — it routes them to subagentsDialog.HandleKey |
| 1774 | // before the editor ever sees them — so the only effect of this | 1785 | // before the editor ever sees them — so the only effect of this | ||
| 1775 | // branch is visual. | 1786 | // branch is visual. | ||
| 1776 | - | if !i.swarmDialog.Active() { | 1787 | + | if !i.subagentsDialog.Active() { |
| 1777 | bottom = append(bottom, suggest...) | 1788 | bottom = append(bottom, suggest...) | ||
| 1778 | bottom = append(bottom, queue...) | 1789 | bottom = append(bottom, queue...) | ||
| 1779 | lineInput := inputStyle == tui.InputStyleLines | 1790 | lineInput := inputStyle == tui.InputStyleLines | ||
| @@ -2004,8 +2015,8 @@ func (i *Interactive) redraw() { | |||||
| 2004 | cursorCol = c | 2015 | cursorCol = c | ||
| 2005 | } | 2016 | } | ||
| 2006 | } | 2017 | } | ||
| 2007 | - | if i.swarmDialog.Active() { | 2018 | + | if i.subagentsDialog.Active() { |
| 2008 | - | if r, c := i.swarmDialog.CursorPos(cols); r >= 0 { | 2019 | + | if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 { |
| 2009 | cursorRow = dialogLead + r | 2020 | cursorRow = dialogLead + r | ||
| 2010 | cursorCol = c | 2021 | cursorCol = c | ||
| 2011 | } else { | 2022 | } else { | ||
| @@ -2307,7 +2318,7 @@ func (i *Interactive) confirmChildActive() bool { | |||||
| 2307 | i.llamaDialog.Active() || | 2318 | i.llamaDialog.Active() || | ||
| 2308 | i.rescueDialog.Active() || | 2319 | i.rescueDialog.Active() || | ||
| 2309 | i.sessionDialog.Active() || | 2320 | i.sessionDialog.Active() || | ||
| 2310 | - | i.swarmDialog.Active() || | 2321 | + | i.subagentsDialog.Active() || |
| 2311 | i.jumpDialog.Active() || | 2322 | i.jumpDialog.Active() || | ||
| 2312 | i.btwDialog.Active() || | 2323 | i.btwDialog.Active() || | ||
| 2313 | i.skillsDialog.Active() || | 2324 | i.skillsDialog.Active() || | ||
| @@ -2515,13 +2526,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2515 | i.invalidate() | 2526 | i.invalidate() | ||
| 2516 | return false | 2527 | return false | ||
| 2517 | } | 2528 | } | ||
| 2518 | - | if i.swarmDialog.Active() { | 2529 | + | if i.subagentsDialog.Active() { |
| 2519 | if k.Kind == tui.KeyCtrlC { | 2530 | if k.Kind == tui.KeyCtrlC { | ||
| 2520 | - | i.swarmDialog.Close() | 2531 | + | i.subagentsDialog.Close() |
| 2521 | i.invalidate() | 2532 | i.invalidate() | ||
| 2522 | return false | 2533 | return false | ||
| 2523 | } | 2534 | } | ||
| 2524 | - | _, msg, errMsg := i.swarmDialog.HandleKey(k) | 2535 | + | _, msg, errMsg := i.subagentsDialog.HandleKey(k) |
| 2525 | if msg != "" || errMsg != "" { | 2536 | if msg != "" || errMsg != "" { | ||
| 2526 | i.mu.Lock() | 2537 | i.mu.Lock() | ||
| 2527 | i.statusOK = msg | 2538 | i.statusOK = msg | ||
| @@ -3500,10 +3511,10 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 3500 | i.agent = ag | 3511 | i.agent = ag | ||
| 3501 | i.cfg.CWD = cwd | 3512 | i.cfg.CWD = cwd | ||
| 3502 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | 3513 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | ||
| 3503 | - | if i.autoSwarmEnabled() { | 3514 | + | if i.autoSubagentsEnabledLocked() { |
| 3504 | - | i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg) | 3515 | + | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg) |
| 3505 | } else { | 3516 | } else { | ||
| 3506 | - | i.managedAutoSwarmAddenda = nil | 3517 | + | i.managedAutoSubagentsAddenda = nil |
| 3507 | } | 3518 | } | ||
| 3508 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | 3519 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | ||
| 3509 | i.view.StartupContextPaths = nil | 3520 | i.view.StartupContextPaths = nil | ||
| @@ -3847,15 +3858,14 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3847 | 3858 | ||||
| 3848 | terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) | 3859 | terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) | ||
| 3849 | terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled) | 3860 | terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled) | ||
| 3850 | - | autoSwarm := false | 3861 | + | autoSubagents := false |
| 3851 | - | if i.cfg.AutoSwarmEnabled != nil { | 3862 | + | if i.cfg.AutoSubagentsEnabled != nil { |
| 3852 | - | autoSwarm = *i.cfg.AutoSwarmEnabled | 3863 | + | autoSubagents = *i.cfg.AutoSubagentsEnabled |
| 3853 | } | 3864 | } | ||
| 3854 | - | autoSwarmDisabled := i.cfg.Swarm == nil | 3865 | + | autoSubagentsDisabled := !i.autoSubagentsAvailable() |
| 3855 | - | autoSwarmHint := "" | 3866 | + | autoSubagentsHint := i.autoSubagentsUnavailableHint() |
| 3856 | - | if autoSwarmDisabled { | 3867 | + | if autoSubagentsDisabled { |
| 3857 | - | autoSwarm = false | 3868 | + | autoSubagents = false |
| 3858 | - | autoSwarmHint = "swarm supervisor not available in this mode" | |||
| 3859 | } | 3869 | } | ||
| 3860 | 3870 | ||||
| 3861 | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | 3871 | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | ||
| @@ -3977,12 +3987,12 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3977 | value: terminalTitles, | 3987 | value: terminalTitles, | ||
| 3978 | }, | 3988 | }, | ||
| 3979 | { | 3989 | { | ||
| 3980 | - | key: "auto_swarm_enabled", | 3990 | + | key: "auto_subagents_enabled", |
| 3981 | - | label: "auto-swarm", | 3991 | + | label: "auto-subagents", |
| 3982 | - | desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool", | 3992 | + | desc: "let the agent spawn background sub-agents in parallel via the subagent_spawn tool", |
| 3983 | - | value: autoSwarm, | 3993 | + | value: autoSubagents, |
| 3984 | - | disabled: autoSwarmDisabled, | 3994 | + | disabled: autoSubagentsDisabled, |
| 3985 | - | hint: autoSwarmHint, | 3995 | + | hint: autoSubagentsHint, |
| 3986 | }, | 3996 | }, | ||
| 3987 | { | 3997 | { | ||
| 3988 | key: "fast_mode", | 3998 | key: "fast_mode", | ||
| @@ -4388,28 +4398,37 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4388 | i.statusOK = "AI terminal titles " + onOff(value) | 4398 | i.statusOK = "AI terminal titles " + onOff(value) | ||
| 4389 | i.statusErr = "" | 4399 | i.statusErr = "" | ||
| 4390 | i.mu.Unlock() | 4400 | i.mu.Unlock() | ||
| 4391 | - | case "auto_swarm_enabled": | 4401 | + | case "auto_subagents_enabled": |
| 4402 | + | if value && !i.autoSubagentsAvailable() { | |||
| 4403 | + | i.mu.Lock() | |||
| 4404 | + | i.statusOK = "" | |||
| 4405 | + | i.statusErr = "auto-subagents unavailable: " + i.autoSubagentsUnavailableHint() | |||
| 4406 | + | i.mu.Unlock() | |||
| 4407 | + | return | |||
| 4408 | + | } | |||
| 4392 | val := value | 4409 | val := value | ||
| 4393 | - | i.cfg.AutoSwarmEnabled = &val | 4410 | + | i.mu.Lock() |
| 4411 | + | i.cfg.AutoSubagentsEnabled = &val | |||
| 4412 | + | i.mu.Unlock() | |||
| 4394 | if i.cfg.SettingsStore != nil { | 4413 | if i.cfg.SettingsStore != nil { | ||
| 4395 | - | if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil { | 4414 | + | if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil { |
| 4396 | i.mu.Lock() | 4415 | i.mu.Lock() | ||
| 4397 | i.statusErr = "settings: " + err.Error() | 4416 | i.statusErr = "settings: " + err.Error() | ||
| 4398 | i.mu.Unlock() | 4417 | i.mu.Unlock() | ||
| 4399 | return | 4418 | return | ||
| 4400 | } | 4419 | } | ||
| 4401 | } | 4420 | } | ||
| 4402 | - | // Add/remove the swarm_spawn tool on the live agent so the | 4421 | + | // Add/remove the subagent_spawn tool on the live agent so the |
| 4403 | // model's tools[] list reflects the toggle on the next turn. | 4422 | // model's tools[] list reflects the toggle on the next turn. | ||
| 4404 | // Without this the tool stays advertised after a disable and | 4423 | // Without this the tool stays advertised after a disable and | ||
| 4405 | // the model keeps trying to call it. | 4424 | // the model keeps trying to call it. | ||
| 4406 | - | i.applyAutoSwarmTool(value) | 4425 | + | i.applyAutoSubagentsTool(value) |
| 4407 | // Also swap the system-prompt addendum in/out so the model | 4426 | // Also swap the system-prompt addendum in/out so the model | ||
| 4408 | // knows to use the tool proactively (or stops referencing it | 4427 | // knows to use the tool proactively (or stops referencing it | ||
| 4409 | // after a disable). | 4428 | // after a disable). | ||
| 4410 | - | i.applyAutoSwarmSystemPrompt(value) | 4429 | + | i.applyAutoSubagentsSystemPrompt(value) |
| 4411 | i.mu.Lock() | 4430 | i.mu.Lock() | ||
| 4412 | - | i.statusOK = "auto-swarm " + onOff(value) | 4431 | + | i.statusOK = "auto-subagents " + onOff(value) |
| 4413 | i.statusErr = "" | 4432 | i.statusErr = "" | ||
| 4414 | i.mu.Unlock() | 4433 | i.mu.Unlock() | ||
| 4415 | case "fast_mode": | 4434 | case "fast_mode": | ||
| @@ -4435,8 +4454,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4435 | if i.agent != nil { | 4454 | if i.agent != nil { | ||
| 4436 | i.agent.SetFastMode(value) | 4455 | i.agent.SetFastMode(value) | ||
| 4437 | } | 4456 | } | ||
| 4438 | - | if i.cfg.Swarm != nil { | 4457 | + | if i.cfg.Supervisor != nil { |
| 4439 | - | i.cfg.Swarm.SetFastMode(value) | 4458 | + | i.cfg.Supervisor.SetFastMode(value) |
| 4440 | } | 4459 | } | ||
| 4441 | i.mu.Lock() | 4460 | i.mu.Lock() | ||
| 4442 | i.statusOK = "fast mode " + onOff(value) | 4461 | i.statusOK = "fast mode " + onOff(value) | ||
| @@ -4799,7 +4818,7 @@ func buildStudyPrompt(arg, cwd string) string { | |||||
| 4799 | // keeps Tab as a literal no-op. | 4818 | // keeps Tab as a literal no-op. | ||
| 4800 | // | 4819 | // | ||
| 4801 | // Free function (not a method) so the same logic runs against the | 4820 | // Free function (not a method) so the same logic runs against the | ||
| 4802 | - | // editor instances owned by btwDialog and swarmDialog without each | 4821 | + | // editor instances owned by btwDialog and subagentsDialog without each |
| 4803 | // dialog needing its own copy. | 4822 | // dialog needing its own copy. | ||
| 4804 | func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool { | 4823 | func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool { | ||
| 4805 | if ed == nil { | 4824 | if ed == nil { | ||
| @@ -5210,8 +5229,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5210 | break | 5229 | break | ||
| 5211 | } | 5230 | } | ||
| 5212 | i.openSessionOpsDialog() | 5231 | i.openSessionOpsDialog() | ||
| 5213 | - | case "/swarm": | 5232 | + | case "/subagents": |
| 5214 | - | i.runSwarm(ctx, parts[1:]) | 5233 | + | i.runSubagents(ctx, parts[1:]) |
| 5215 | default: | 5234 | default: | ||
| 5216 | // Last-resort fallback: try the extension manager. Built-in | 5235 | // Last-resort fallback: try the extension manager. Built-in | ||
| 5217 | // cases above always win; this branch only fires for slash | 5236 | // cases above always win; this branch only fires for slash | ||
| @@ -5411,7 +5430,7 @@ func (i *Interactive) doLogout(target string) { | |||||
| 5411 | // it so prompts can't go out with the stale client, and hint at | 5430 | // it so prompts can't go out with the stale client, and hint at | ||
| 5412 | // /login. Close any LSP processes owned by its tools first. | 5431 | // /login. Close any LSP processes owned by its tools first. | ||
| 5413 | if i.agent != nil { | 5432 | if i.agent != nil { | ||
| 5414 | - | _ = tools.CloseLSPManagers(i.agent.Tools) | 5433 | + | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) |
| 5415 | } | 5434 | } | ||
| 5416 | i.agent = nil | 5435 | i.agent = nil | ||
| 5417 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | 5436 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | ||
| @@ -5621,14 +5640,29 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||||
| 5621 | // submitted as the first question, so '/btw does X work?' fires the | 5640 | // submitted as the first question, so '/btw does X work?' fires the | ||
| 5622 | // model call immediately instead of just opening an empty dialog. | 5641 | // model call immediately instead of just opening an empty dialog. | ||
| 5623 | func (i *Interactive) openBtwDialog(args []string) { | 5642 | func (i *Interactive) openBtwDialog(args []string) { | ||
| 5624 | - | if i.agent == nil { | 5643 | + | i.agentMu.Lock() |
| 5644 | + | defer i.agentMu.Unlock() | |||
| 5645 | + | i.mu.Lock() | |||
| 5646 | + | ag := i.agent | |||
| 5647 | + | system := "" | |||
| 5648 | + | if ag != nil { | |||
| 5649 | + | system, _ = ag.PromptConfig() | |||
| 5650 | + | } | |||
| 5651 | + | theme := i.cfg.Theme | |||
| 5652 | + | model := i.cfg.Model | |||
| 5653 | + | cwd := i.cfg.CWD | |||
| 5654 | + | compact := i.compactModeEnabled() | |||
| 5655 | + | flatTools := i.cfg.FlatTools | |||
| 5656 | + | lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines | |||
| 5657 | + | i.mu.Unlock() | |||
| 5658 | + | if ag == nil { | |||
| 5625 | i.mu.Lock() | 5659 | i.mu.Lock() | ||
| 5626 | i.statusErr = "not logged in. type /login first." | 5660 | i.statusErr = "not logged in. type /login first." | ||
| 5627 | i.mu.Unlock() | 5661 | i.mu.Unlock() | ||
| 5628 | return | 5662 | return | ||
| 5629 | } | 5663 | } | ||
| 5630 | seed := strings.TrimSpace(strings.Join(args, " ")) | 5664 | seed := strings.TrimSpace(strings.Join(args, " ")) | ||
| 5631 | - | i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate) | 5665 | + | i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate) |
| 5632 | i.invalidate() | 5666 | i.invalidate() | ||
| 5633 | } | 5667 | } | ||
| 5634 | 5668 | ||||
| @@ -6005,7 +6039,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||||
| 6005 | ag.SeedCost(carryCost) | 6039 | ag.SeedCost(carryCost) | ||
| 6006 | 6040 | ||||
| 6007 | if i.agent != nil && i.agent != ag { | 6041 | if i.agent != nil && i.agent != ag { | ||
| 6008 | - | _ = tools.CloseLSPManagers(i.agent.Tools) | 6042 | + | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) |
| 6009 | } | 6043 | } | ||
| 6010 | i.mu.Lock() | 6044 | i.mu.Lock() | ||
| 6011 | i.agent = ag | 6045 | i.agent = ag | ||
| @@ -6025,7 +6059,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||||
| 6025 | // dynamically-registered tools need to be reattached. The apply | 6059 | // dynamically-registered tools need to be reattached. The apply | ||
| 6026 | // helpers are no-ops when their feature is inactive, so the | 6060 | // helpers are no-ops when their feature is inactive, so the | ||
| 6027 | // cross-provider path still works on a vanilla setup. | 6061 | // cross-provider path still works on a vanilla setup. | ||
| 6028 | - | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | 6062 | + | i.applyAutoSubagentsTool(i.autoSubagentsEnabled()) |
| 6029 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | 6063 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | ||
| 6030 | if i.cfg.PersistModel != nil { | 6064 | if i.cfg.PersistModel != nil { | ||
| 6031 | i.cfg.PersistModel(p, md) | 6065 | i.cfg.PersistModel(p, md) | ||
| @@ -6057,9 +6091,9 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||||
| 6057 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | 6091 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | ||
| 6058 | i.mu.Unlock() | 6092 | i.mu.Unlock() | ||
| 6059 | if oldAgent != nil { | 6093 | if oldAgent != nil { | ||
| 6060 | - | _ = tools.CloseLSPManagers(oldAgent.Tools) | 6094 | + | _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot()) |
| 6061 | } | 6095 | } | ||
| 6062 | - | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | 6096 | + | i.applyAutoSubagentsTool(i.autoSubagentsEnabled()) |
| 6063 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | 6097 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | ||
| 6064 | // Authentication can change the provider used by the live | 6098 | // Authentication can change the provider used by the live | ||
| 6065 | // agent. Persist it on the active session just like /model. | 6099 | // agent. Persist it on the active session just like /model. | ||
| @@ -7205,95 +7239,95 @@ func (a telegramSenderAdapter) Active() bool { | |||||
| 7205 | return a.bridge != nil && a.bridge.Active() | 7239 | return a.bridge != nil && a.bridge.Active() | ||
| 7206 | } | 7240 | } | ||
| 7207 | 7241 | ||||
| 7208 | - | // swarmWatchEntry is one tracked auto-swarm sub-agent. It records the | 7242 | + | // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the |
| 7209 | // delegated turn's outcome separately from the long-lived daemon status. | 7243 | // delegated turn's outcome separately from the long-lived daemon status. | ||
| 7210 | - | type swarmWatchEntry struct { | 7244 | + | type subagentWatchEntry struct { |
| 7211 | - | agent *swarm.Agent | 7245 | + | agent *subagents.Agent |
| 7212 | task string | 7246 | task string | ||
| 7213 | done bool | 7247 | done bool | ||
| 7214 | outcome string | 7248 | outcome string | ||
| 7215 | err string | 7249 | err string | ||
| 7216 | } | 7250 | } | ||
| 7217 | 7251 | ||||
| 7218 | - | // TrackSwarmAgent is the exported entry point used by the cli to | 7252 | + | // TrackSubagentWorker is the exported entry point used by the cli to |
| 7219 | - | // hand a freshly-spawned auto-swarm agent off to the watcher. | 7253 | + | // hand a freshly-spawned auto-subagents agent off to the watcher. |
| 7220 | - | func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) { | 7254 | + | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) { |
| 7221 | - | i.trackSwarmAgent(a, task) | 7255 | + | i.trackSubagentWorker(a, task) |
| 7222 | } | 7256 | } | ||
| 7223 | 7257 | ||||
| 7224 | - | // trackSwarmAgent records a freshly-spawned auto-swarm agent and | 7258 | + | // trackSubagentWorker records a freshly-spawned auto-subagents agent and |
| 7225 | // subscribes to both ways its delegated task can finish. Successful | 7259 | // subscribes to both ways its delegated task can finish. Successful | ||
| 7226 | // long-lived agents report a prompt-level turn_end and keep listening; | 7260 | // long-lived agents report a prompt-level turn_end and keep listening; | ||
| 7227 | // startup failures and unexpected daemon exits only unblock Agent.Wait. | 7261 | // startup failures and unexpected daemon exits only unblock Agent.Wait. | ||
| 7228 | // Whichever signal arrives first completes the entry exactly once. | 7262 | // Whichever signal arrives first completes the entry exactly once. | ||
| 7229 | // | 7263 | // | ||
| 7230 | - | // Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto- | 7264 | + | // Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto- |
| 7231 | - | // swarm is enabled, so this is a no-op when the feature is off. | 7265 | + | // subagent is enabled, so this is a no-op when the feature is off. |
| 7232 | - | func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) { | 7266 | + | func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) { |
| 7233 | if i == nil || a == nil { | 7267 | if i == nil || a == nil { | ||
| 7234 | return | 7268 | return | ||
| 7235 | } | 7269 | } | ||
| 7236 | - | entry := &swarmWatchEntry{agent: a, task: task} | 7270 | + | entry := &subagentWatchEntry{agent: a, task: task} |
| 7237 | - | i.swarmWatchMu.Lock() | 7271 | + | i.subagentWatchMu.Lock() |
| 7238 | - | i.swarmWatch = append(i.swarmWatch, entry) | 7272 | + | i.subagentWatch = append(i.subagentWatch, entry) |
| 7239 | - | i.swarmWatchMu.Unlock() | 7273 | + | i.subagentWatchMu.Unlock() |
| 7240 | 7274 | ||||
| 7241 | a.SetOnTurnEnd(func(step int, errMsg string) { | 7275 | a.SetOnTurnEnd(func(step int, errMsg string) { | ||
| 7242 | outcome := "completed" | 7276 | outcome := "completed" | ||
| 7243 | if errMsg != "" { | 7277 | if errMsg != "" { | ||
| 7244 | outcome = "failed" | 7278 | outcome = "failed" | ||
| 7245 | } | 7279 | } | ||
| 7246 | - | i.completeSwarmWatchEntry(entry, outcome, errMsg) | 7280 | + | i.completeSupervisorWatchEntry(entry, outcome, errMsg) |
| 7247 | }) | 7281 | }) | ||
| 7248 | go func() { | 7282 | go func() { | ||
| 7249 | a.Wait() | 7283 | a.Wait() | ||
| 7250 | snap := a.Snapshot() | 7284 | snap := a.Snapshot() | ||
| 7251 | outcome := string(snap.Status) | 7285 | outcome := string(snap.Status) | ||
| 7252 | - | if snap.Status == swarm.StatusDone { | 7286 | + | if snap.Status == subagents.StatusDone { |
| 7253 | outcome = "completed" | 7287 | outcome = "completed" | ||
| 7254 | } | 7288 | } | ||
| 7255 | - | i.completeSwarmWatchEntry(entry, outcome, snap.Err) | 7289 | + | i.completeSupervisorWatchEntry(entry, outcome, snap.Err) |
| 7256 | }() | 7290 | }() | ||
| 7257 | } | 7291 | } | ||
| 7258 | 7292 | ||||
| 7259 | - | func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) { | 7293 | + | func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) { |
| 7260 | - | i.swarmWatchMu.Lock() | 7294 | + | i.subagentWatchMu.Lock() |
| 7261 | if entry.done { | 7295 | if entry.done { | ||
| 7262 | - | i.swarmWatchMu.Unlock() | 7296 | + | i.subagentWatchMu.Unlock() |
| 7263 | return | 7297 | return | ||
| 7264 | } | 7298 | } | ||
| 7265 | entry.done = true | 7299 | entry.done = true | ||
| 7266 | entry.outcome = outcome | 7300 | entry.outcome = outcome | ||
| 7267 | entry.err = errMsg | 7301 | entry.err = errMsg | ||
| 7268 | allDone := true | 7302 | allDone := true | ||
| 7269 | - | for _, e := range i.swarmWatch { | 7303 | + | for _, e := range i.subagentWatch { |
| 7270 | if !e.done { | 7304 | if !e.done { | ||
| 7271 | allDone = false | 7305 | allDone = false | ||
| 7272 | break | 7306 | break | ||
| 7273 | } | 7307 | } | ||
| 7274 | } | 7308 | } | ||
| 7275 | - | var batch []*swarmWatchEntry | 7309 | + | var batch []*subagentWatchEntry |
| 7276 | if allDone { | 7310 | if allDone { | ||
| 7277 | - | batch = i.swarmWatch | 7311 | + | batch = i.subagentWatch |
| 7278 | - | i.swarmWatch = nil | 7312 | + | i.subagentWatch = nil |
| 7279 | } | 7313 | } | ||
| 7280 | - | i.swarmWatchMu.Unlock() | 7314 | + | i.subagentWatchMu.Unlock() |
| 7281 | if len(batch) != 0 { | 7315 | if len(batch) != 0 { | ||
| 7282 | - | i.flushSwarmSummary(batch) | 7316 | + | i.flushSupervisorSummary(batch) |
| 7283 | } | 7317 | } | ||
| 7284 | } | 7318 | } | ||
| 7285 | 7319 | ||||
| 7286 | - | // flushSwarmSummary composes a synthetic user turn describing every | 7320 | + | // flushSupervisorSummary composes a synthetic user turn describing every |
| 7287 | // sub-agent's outcome and injects it via SubmitOrQueue so the main | 7321 | // sub-agent's outcome and injects it via SubmitOrQueue so the main | ||
| 7288 | // agent picks it up at the next safe boundary. The summary is | 7322 | // agent picks it up at the next safe boundary. The summary is | ||
| 7289 | - | // phrased as a system update ("Auto-swarm finished: ...") so the | 7323 | + | // phrased as a system update ("Auto-subagents finished: ...") so the |
| 7290 | // model treats it as observed state, not as a fresh user request. | 7324 | // model treats it as observed state, not as a fresh user request. | ||
| 7291 | - | func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) { | 7325 | + | func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) { |
| 7292 | if len(batch) == 0 { | 7326 | if len(batch) == 0 { | ||
| 7293 | return | 7327 | return | ||
| 7294 | } | 7328 | } | ||
| 7295 | var sb strings.Builder | 7329 | var sb strings.Builder | ||
| 7296 | - | fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch)) | 7330 | + | fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch)) |
| 7297 | for idx, e := range batch { | 7331 | for idx, e := range batch { | ||
| 7298 | snap := e.agent.Snapshot() | 7332 | snap := e.agent.Snapshot() | ||
| 7299 | status := e.outcome | 7333 | status := e.outcome | ||
| @@ -7332,20 +7366,20 @@ func truncateForSummary(s string, n int) string { | |||||
| 7332 | return s[:n-3] + "..." | 7366 | return s[:n-3] + "..." | ||
| 7333 | } | 7367 | } | ||
| 7334 | 7368 | ||||
| 7335 | - | // autoSwarmAddenda returns the prompt blocks owned by the auto-swarm | 7369 | + | // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents |
| 7336 | // toggle, in the same order Resolve appends them to a new agent. | 7370 | // toggle, in the same order Resolve appends them to a new agent. | ||
| 7337 | - | func autoSwarmAddenda(cfg InteractiveConfig) []string { | 7371 | + | func autoSubagentsAddenda(cfg InteractiveConfig) []string { |
| 7338 | addenda := make([]string, 0, 2) | 7372 | addenda := make([]string, 0, 2) | ||
| 7339 | if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" { | 7373 | if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" { | ||
| 7340 | addenda = append(addenda, addendum) | 7374 | addenda = append(addenda, addendum) | ||
| 7341 | } | 7375 | } | ||
| 7342 | - | if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" { | 7376 | + | if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" { |
| 7343 | addenda = append(addenda, addendum) | 7377 | addenda = append(addenda, addendum) | ||
| 7344 | } | 7378 | } | ||
| 7345 | return addenda | 7379 | return addenda | ||
| 7346 | } | 7380 | } | ||
| 7347 | 7381 | ||||
| 7348 | - | func containsAutoSwarmAddendum(addenda []string, want string) bool { | 7382 | + | func containsAutoSubagentsAddendum(addenda []string, want string) bool { |
| 7349 | for _, addendum := range addenda { | 7383 | for _, addendum := range addenda { | ||
| 7350 | if addendum == want { | 7384 | if addendum == want { | ||
| 7351 | return true | 7385 | return true | ||
| @@ -7354,10 +7388,10 @@ func containsAutoSwarmAddendum(addenda []string, want string) bool { | |||||
| 7354 | return false | 7388 | return false | ||
| 7355 | } | 7389 | } | ||
| 7356 | 7390 | ||||
| 7357 | - | // removeLastAutoSwarmAddendum removes one occurrence of a block known to | 7391 | + | // removeLastAutoSubagentsAddendum removes one occurrence of a block known to |
| 7358 | - | // have been appended by auto-swarm. Resolve appends owned blocks at the | 7392 | + | // have been appended by auto-subagents. Resolve appends owned blocks at the |
| 7359 | // end, so removing the last occurrence preserves an identical base block. | 7393 | // end, so removing the last occurrence preserves an identical base block. | ||
| 7360 | - | func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) { | 7394 | + | func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) { |
| 7361 | idx := strings.LastIndex(system, addendum) | 7395 | idx := strings.LastIndex(system, addendum) | ||
| 7362 | if idx < 0 { | 7396 | if idx < 0 { | ||
| 7363 | return system, false | 7397 | return system, false | ||
| @@ -7365,75 +7399,110 @@ func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) { | |||||
| 7365 | return system[:idx] + system[idx+len(addendum):], true | 7399 | return system[:idx] + system[idx+len(addendum):], true | ||
| 7366 | } | 7400 | } | ||
| 7367 | 7401 | ||||
| 7368 | - | // applyAutoSwarmSystemPrompt appends (active=true) or strips | 7402 | + | // applyAutoSubagentsSystemPrompt appends (active=true) or strips |
| 7369 | - | // (active=false) the auto-swarm prompt blocks on the running agent. | 7403 | + | // (active=false) the auto-subagents prompt blocks on the running agent. |
| 7370 | // The profile manifest and delegation guidance are managed together so | 7404 | // The profile manifest and delegation guidance are managed together so | ||
| 7371 | - | // toggling auto-swarm never leaves the model with names but no tool. | 7405 | + | // toggling auto-subagents never leaves the model with names but no tool. |
| 7372 | - | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | 7406 | + | func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { |
| 7407 | + | // i.mu is sufficient here: this path updates the existing agent's system | |||
| 7408 | + | // prompt and managed addenda in place; it does not replace the agent or | |||
| 7409 | + | // its tool registry, so agentMu is not required. | |||
| 7410 | + | i.mu.Lock() | |||
| 7411 | + | defer i.mu.Unlock() | |||
| 7373 | if i.agent == nil { | 7412 | if i.agent == nil { | ||
| 7374 | return | 7413 | return | ||
| 7375 | } | 7414 | } | ||
| 7376 | - | addenda := autoSwarmAddenda(i.cfg) | 7415 | + | addenda := autoSubagentsAddenda(i.cfg) |
| 7377 | if active { | 7416 | if active { | ||
| 7378 | - | sys := i.agent.System | 7417 | + | sys, _ := i.agent.PromptConfig() |
| 7379 | for _, addendum := range addenda { | 7418 | for _, addendum := range addenda { | ||
| 7380 | - | if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) { | 7419 | + | if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) { |
| 7381 | continue | 7420 | continue | ||
| 7382 | } | 7421 | } | ||
| 7383 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | 7422 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | ||
| 7384 | sys += "\n\n" | 7423 | sys += "\n\n" | ||
| 7385 | } | 7424 | } | ||
| 7386 | sys += addendum | 7425 | sys += addendum | ||
| 7387 | - | i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum) | 7426 | + | i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum) |
| 7388 | } | 7427 | } | ||
| 7389 | - | i.agent.System = sys | 7428 | + | i.agent.SetSystemPrompt(sys) |
| 7390 | return | 7429 | return | ||
| 7391 | } | 7430 | } | ||
| 7392 | 7431 | ||||
| 7393 | - | sys := i.agent.System | 7432 | + | sys, _ := i.agent.PromptConfig() |
| 7394 | changed := false | 7433 | changed := false | ||
| 7395 | - | for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- { | 7434 | + | for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- { |
| 7396 | var removed bool | 7435 | var removed bool | ||
| 7397 | - | sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx]) | 7436 | + | sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx]) |
| 7398 | changed = changed || removed | 7437 | changed = changed || removed | ||
| 7399 | } | 7438 | } | ||
| 7400 | - | i.managedAutoSwarmAddenda = nil | 7439 | + | i.managedAutoSubagentsAddenda = nil |
| 7401 | if changed { | 7440 | if changed { | ||
| 7402 | - | i.agent.System = strings.TrimRight(sys, "\n") + "\n" | 7441 | + | i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n") |
| 7403 | } | 7442 | } | ||
| 7404 | } | 7443 | } | ||
| 7405 | 7444 | ||||
| 7406 | - | // applyAutoSwarmTool registers (active=true) or removes (active=false) | 7445 | + | // applyAutoSubagentsTool registers (active=true) or removes (active=false) |
| 7407 | - | // the swarm_spawn tool on the running agent so the model only sees it | 7446 | + | // the subagent_spawn tool on the running agent so the model only sees it |
| 7408 | - | // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools' | 7447 | + | // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools' |
| 7409 | // snapshot+mutate pattern so extension tools and /reload-ext additions | 7448 | // snapshot+mutate pattern so extension tools and /reload-ext additions | ||
| 7410 | // survive a toggle. | 7449 | // survive a toggle. | ||
| 7411 | - | func (i *Interactive) autoSwarmEnabled() bool { | 7450 | + | func (i *Interactive) autoSubagentsAvailable() bool { |
| 7412 | - | return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled | 7451 | + | return i.cfg.Supervisor != nil && (i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed) |
| 7452 | + | } | |||
| 7453 | + | ||||
| 7454 | + | func (i *Interactive) autoSubagentsUnavailableHint() string { | |||
| 7455 | + | var hints []string | |||
| 7456 | + | if i.cfg.Supervisor == nil { | |||
| 7457 | + | hints = append(hints, "subagent supervisor not available in this mode") | |||
| 7458 | + | } | |||
| 7459 | + | if !i.autoSubagentsToolAllowed() { | |||
| 7460 | + | hints = append(hints, "launch-time tool policy excludes subagent_spawn") | |||
| 7461 | + | } | |||
| 7462 | + | return strings.Join(hints, "; ") | |||
| 7413 | } | 7463 | } | ||
| 7414 | 7464 | ||||
| 7415 | - | func (i *Interactive) applyAutoSwarmTool(active bool) { | 7465 | + | func (i *Interactive) autoSubagentsToolAllowed() bool { |
| 7466 | + | return i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed | |||
| 7467 | + | } | |||
| 7468 | + | ||||
| 7469 | + | func (i *Interactive) autoSubagentsEnabled() bool { | |||
| 7470 | + | i.mu.Lock() | |||
| 7471 | + | defer i.mu.Unlock() | |||
| 7472 | + | return i.autoSubagentsEnabledLocked() | |||
| 7473 | + | } | |||
| 7474 | + | ||||
| 7475 | + | func (i *Interactive) autoSubagentsEnabledLocked() bool { | |||
| 7476 | + | return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable() | |||
| 7477 | + | } | |||
| 7478 | + | ||||
| 7479 | + | func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||
| 7480 | + | i.agentMu.Lock() | |||
| 7481 | + | defer i.agentMu.Unlock() | |||
| 7482 | + | i.mu.Lock() | |||
| 7483 | + | defer i.mu.Unlock() | |||
| 7416 | if i.agent == nil { | 7484 | if i.agent == nil { | ||
| 7417 | return | 7485 | return | ||
| 7418 | } | 7486 | } | ||
| 7419 | - | current := i.agent.Tools | 7487 | + | current := i.agent.ToolsSnapshot() |
| 7420 | next := core.Registry{} | 7488 | next := core.Registry{} | ||
| 7421 | for name, t := range current { | 7489 | for name, t := range current { | ||
| 7422 | - | if name == "swarm_spawn" { | 7490 | + | if name == "subagent_spawn" { |
| 7423 | continue | 7491 | continue | ||
| 7424 | } | 7492 | } | ||
| 7425 | next[name] = t | 7493 | next[name] = t | ||
| 7426 | } | 7494 | } | ||
| 7427 | - | if active && i.cfg.Swarm != nil { | 7495 | + | if active && i.autoSubagentsAvailable() { |
| 7428 | - | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | 7496 | + | canonical := &tools.SubagentSpawnTool{ |
| 7429 | - | Swarm: i.cfg.Swarm, | 7497 | + | Supervisor: i.cfg.Supervisor, |
| 7430 | - | Enabled: func() bool { return true }, | 7498 | + | Enabled: func() bool { return i.autoSubagentsEnabled() }, |
| 7431 | DefaultModel: func() string { return i.cfg.Model }, | 7499 | DefaultModel: func() string { return i.cfg.Model }, | ||
| 7432 | DefaultProvider: func() string { return i.cfg.Provider }, | 7500 | DefaultProvider: func() string { return i.cfg.Provider }, | ||
| 7433 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | 7501 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | ||
| 7434 | ResolveSubagent: i.cfg.ResolveSubagent, | 7502 | ResolveSubagent: i.cfg.ResolveSubagent, | ||
| 7435 | - | OnSpawned: i.trackSwarmAgent, | 7503 | + | OnSpawned: i.trackSubagentWorker, |
| 7436 | } | 7504 | } | ||
| 7505 | + | next[canonical.Name()] = canonical | |||
| 7437 | } | 7506 | } | ||
| 7438 | i.agent.SetTools(next) | 7507 | i.agent.SetTools(next) | ||
| 7439 | } | 7508 | } | ||
| @@ -7446,10 +7515,14 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||||
| 7446 | // later /telegram disconnect (we only add or strip the two telegram | 7515 | // later /telegram disconnect (we only add or strip the two telegram | ||
| 7447 | // entries, never the rest). | 7516 | // entries, never the rest). | ||
| 7448 | func (i *Interactive) applyTelegramTools(active bool) { | 7517 | func (i *Interactive) applyTelegramTools(active bool) { | ||
| 7518 | + | i.agentMu.Lock() | |||
| 7519 | + | defer i.agentMu.Unlock() | |||
| 7520 | + | i.mu.Lock() | |||
| 7521 | + | defer i.mu.Unlock() | |||
| 7449 | if i.agent == nil { | 7522 | if i.agent == nil { | ||
| 7450 | return | 7523 | return | ||
| 7451 | } | 7524 | } | ||
| 7452 | - | current := i.agent.Tools | 7525 | + | current := i.agent.ToolsSnapshot() |
| 7453 | next := core.Registry{} | 7526 | next := core.Registry{} | ||
| 7454 | for name, t := range current { | 7527 | for name, t := range current { | ||
| 7455 | if name == "telegram_send_image" || name == "telegram_send_file" { | 7528 | if name == "telegram_send_image" || name == "telegram_send_file" { | ||
M packages/agent/modes/slash_suggest.go +1 -1
| @@ -62,7 +62,7 @@ var slashCatalog = []slashCommand{ | |||||
| 62 | {Name: "/jail", Desc: "confine tools to the current directory"}, | 62 | {Name: "/jail", Desc: "confine tools to the current directory"}, | ||
| 63 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | 63 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | ||
| 64 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | 64 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | ||
| 65 | - | {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, | 65 | + | {Name: "/subagents", Desc: "supervise background subagents with lifecycle, results, and optional worktrees"}, |
| 66 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | 66 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | ||
| 67 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | 67 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | ||
| 68 | {Name: "/settings", Desc: "open settings"}, | 68 | {Name: "/settings", Desc: "open settings"}, | ||
M packages/agent/modes/slash_suggest_test.go +3 -3
| @@ -37,10 +37,10 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) { | |||||
| 37 | } | 37 | } | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | - | func TestSlashSuggesterHasSwarm(t *testing.T) { | 40 | + | func TestSlashSuggesterHasSupervisor(t *testing.T) { |
| 41 | s := newSlashSuggester() | 41 | s := newSlashSuggester() | ||
| 42 | - | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | 42 | + | if got := commandNames(s.matches("/su")); !contains(got, "/subagents") { |
| 43 | - | t.Fatalf("/swarm missing from suggestions, got %v", got) | 43 | + | t.Fatalf("/subagents missing from suggestions, got %v", got) |
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
R packages/agent/modes/swarm_dialog.go → packages/agent/modes/subagents_dialog.go +97 -94 renamed, 88% similar
| @@ -6,13 +6,13 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | 9 | + | "github.com/patriceckhart/zot/packages/agent/subagents" |
| 10 | "github.com/patriceckhart/zot/packages/tui" | 10 | "github.com/patriceckhart/zot/packages/tui" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | // swarmDialog is the dashboard shown by /swarm (no argument) and | 13 | + | // subagentsDialog is the dashboard shown by /subagents (no argument) and |
| 14 | - | // /swarm list. It's a read-only window onto a *swarm.Swarm; the | 14 | + | // /subagents list. It's a read-only window onto a *subagents.Supervisor; the |
| 15 | - | // caller passes a snapshotter so the dialog never holds the swarm | 15 | + | // caller passes a snapshotter so the dialog never holds the subagent |
| 16 | // pointer or its lock. | 16 | // pointer or its lock. | ||
| 17 | // | 17 | // | ||
| 18 | // Keys (list view): | 18 | // Keys (list view): | ||
| @@ -31,16 +31,16 @@ import ( | |||||
| 31 | // p send a follow-up prompt to this agent (inline editor) | 31 | // p send a follow-up prompt to this agent (inline editor) | ||
| 32 | // ↑/↓/PgUp/PgDn/Home/End scroll | 32 | // ↑/↓/PgUp/PgDn/Home/End scroll | ||
| 33 | // esc / q return to the list | 33 | // esc / q return to the list | ||
| 34 | - | type swarmDialog struct { | 34 | + | type subagentsDialog struct { |
| 35 | active bool | 35 | active bool | ||
| 36 | compactMode bool | 36 | compactMode bool | ||
| 37 | lineInput bool | 37 | lineInput bool | ||
| 38 | - | snapshot func() []swarm.AgentSnapshot | 38 | + | snapshot func() []subagents.AgentSnapshot |
| 39 | stop func(id string) error | 39 | stop func(id string) error | ||
| 40 | remove func(id string) error | 40 | remove func(id string) error | ||
| 41 | // spawn accepts an optional model + provider override (empty | 41 | // spawn accepts an optional model + provider override (empty | ||
| 42 | // strings mean "let the child resolve its own default"). The cli | 42 | // strings mean "let the child resolve its own default"). The cli | ||
| 43 | - | // adapter forwards these to swarm.Swarm.SpawnReq. | 43 | + | // adapter forwards these to subagents.Supervisor.SpawnReq. |
| 44 | spawn func(task, model, provider string) error | 44 | spawn func(task, model, provider string) error | ||
| 45 | // send delivers a follow-up user turn to a running agent's inbox. | 45 | // send delivers a follow-up user turn to a running agent's inbox. | ||
| 46 | // Wired by Open(); when nil the inline 'p' shortcut is disabled. | 46 | // Wired by Open(); when nil the inline 'p' shortcut is disabled. | ||
| @@ -50,7 +50,7 @@ type swarmDialog struct { | |||||
| 50 | // disabled. | 50 | // disabled. | ||
| 51 | resume func(id string) error | 51 | resume func(id string) error | ||
| 52 | 52 | ||||
| 53 | - | rows []swarm.AgentSnapshot | 53 | + | rows []subagents.AgentSnapshot |
| 54 | cursor int | 54 | cursor int | ||
| 55 | viewing bool // when true, show full transcript of the selected row | 55 | viewing bool // when true, show full transcript of the selected row | ||
| 56 | 56 | ||||
| @@ -63,7 +63,7 @@ type swarmDialog struct { | |||||
| 63 | transcriptSpin *spinner | 63 | transcriptSpin *spinner | ||
| 64 | 64 | ||||
| 65 | // spawning toggles the inline "enter task" editor. While true the | 65 | // spawning toggles the inline "enter task" editor. While true the | ||
| 66 | - | // dialog captures keys into a real tui.Editor so swarm-new has the | 66 | + | // dialog captures keys into a real tui.Editor so subagent-new has the |
| 67 | // same editing, paste, drag-drop file chips, and visual style as | 67 | // same editing, paste, drag-drop file chips, and visual style as | ||
| 68 | // the default input and /btw input. | 68 | // the default input and /btw input. | ||
| 69 | spawning bool | 69 | spawning bool | ||
| @@ -74,7 +74,7 @@ type swarmDialog struct { | |||||
| 74 | // pendingModel / pendingProvider are the model + provider every | 74 | // pendingModel / pendingProvider are the model + provider every | ||
| 75 | // freshly-spawned agent will be pinned to. They're seeded from | 75 | // freshly-spawned agent will be pinned to. They're seeded from | ||
| 76 | // the host's currently-active model (via SetCurrentModel) at the | 76 | // the host's currently-active model (via SetCurrentModel) at the | ||
| 77 | - | // moment the user opens the dashboard, so swarm agents inherit | 77 | + | // moment the user opens the dashboard, so subagent agents inherit |
| 78 | // whatever the user last picked via /model. Empty means "don't | 78 | // whatever the user last picked via /model. Empty means "don't | ||
| 79 | // pin a model; let the child resolve its own default". | 79 | // pin a model; let the child resolve its own default". | ||
| 80 | pendingModel string | 80 | pendingModel string | ||
| @@ -98,7 +98,7 @@ type swarmDialog struct { | |||||
| 98 | 98 | ||||
| 99 | // prompting toggles the inline "send prompt to selected agent" | 99 | // prompting toggles the inline "send prompt to selected agent" | ||
| 100 | // editor. Distinct from `spawning` so we don't accidentally | 100 | // editor. Distinct from `spawning` so we don't accidentally | ||
| 101 | - | // route a follow-up into Swarm.Spawn. The editor and its | 101 | + | // route a follow-up into Supervisor.Spawn. The editor and its |
| 102 | // file-suggest live in their own fields so opening the prompt | 102 | // file-suggest live in their own fields so opening the prompt | ||
| 103 | // editor while a spawn editor is half-typed doesn't clobber it | 103 | // editor while a spawn editor is half-typed doesn't clobber it | ||
| 104 | // (the dialog enforces one-at-a-time, but the separate state | 104 | // (the dialog enforces one-at-a-time, but the separate state | ||
| @@ -113,30 +113,30 @@ type swarmDialog struct { | |||||
| 113 | promptTargetID string | 113 | promptTargetID string | ||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | - | func newSwarmDialog() *swarmDialog { return &swarmDialog{} } | 116 | + | func newSubagentsDialog() *subagentsDialog { return &subagentsDialog{} } |
| 117 | 117 | ||||
| 118 | - | // SetCurrentModel pins the model + provider every fresh spawn will | 118 | + | func (d *subagentsDialog) SetCompactMode(enabled bool) { |
| 119 | - | // inherit. The host wires this to the same Model / Provider the rest | |||
| 120 | - | // of zot is currently using, so agents started from the dashboard | |||
| 121 | - | // run on whatever the user last picked via /model. Pass empty | |||
| 122 | - | // strings to clear the override (the child then resolves its own | |||
| 123 | - | // default the same way a bare `zot` invocation does). | |||
| 124 | - | func (d *swarmDialog) SetCompactMode(enabled bool) { | |||
| 125 | d.compactMode = enabled | 119 | d.compactMode = enabled | ||
| 126 | } | 120 | } | ||
| 127 | 121 | ||||
| 128 | - | func (d *swarmDialog) SetLineInput(enabled bool) { | 122 | + | func (d *subagentsDialog) SetLineInput(enabled bool) { |
| 129 | d.lineInput = enabled | 123 | d.lineInput = enabled | ||
| 130 | } | 124 | } | ||
| 131 | 125 | ||||
| 132 | - | func (d *swarmDialog) editorPrompt(th tui.Theme) string { | 126 | + | func (d *subagentsDialog) editorPrompt(th tui.Theme) string { |
| 133 | if d.lineInput { | 127 | if d.lineInput { | ||
| 134 | return "" | 128 | return "" | ||
| 135 | } | 129 | } | ||
| 136 | return th.AccentBar(th.Accent) | 130 | return th.AccentBar(th.Accent) | ||
| 137 | } | 131 | } | ||
| 138 | 132 | ||||
| 139 | - | func (d *swarmDialog) SetCurrentModel(model, providerID string) { | 133 | + | // SetCurrentModel pins the model + provider every fresh spawn will |
| 134 | + | // inherit. The host wires this to the same Model / Provider the rest | |||
| 135 | + | // of zot is currently using, so agents started from the dashboard | |||
| 136 | + | // run on whatever the user last picked via /model. Pass empty | |||
| 137 | + | // strings to clear the override (the child then resolves its own | |||
| 138 | + | // default the same way a bare `zot` invocation does). | |||
| 139 | + | func (d *subagentsDialog) SetCurrentModel(model, providerID string) { | |||
| 140 | d.pendingModel = model | 140 | d.pendingModel = model | ||
| 141 | d.pendingProvider = providerID | 141 | d.pendingProvider = providerID | ||
| 142 | } | 142 | } | ||
| @@ -146,16 +146,16 @@ func (d *swarmDialog) SetCurrentModel(model, providerID string) { | |||||
| 146 | // the user invokes /model inside the spawn editor so the picker can | 146 | // the user invokes /model inside the spawn editor so the picker can | ||
| 147 | // filter out unauthenticated providers, matching the global /model | 147 | // filter out unauthenticated providers, matching the global /model | ||
| 148 | // behaviour. | 148 | // behaviour. | ||
| 149 | - | func (d *swarmDialog) SetLoggedInProviders(provs []string) { | 149 | + | func (d *subagentsDialog) SetLoggedInProviders(provs []string) { |
| 150 | d.modelPickerLoggedIn = provs | 150 | d.modelPickerLoggedIn = provs | ||
| 151 | } | 151 | } | ||
| 152 | 152 | ||||
| 153 | - | // Open binds the dialog to a swarm via the provided callbacks. The | 153 | + | // Open binds the dialog to a subagent via the provided callbacks. The |
| 154 | // dialog refreshes the snapshot every Render so the dashboard reads | 154 | // dialog refreshes the snapshot every Render so the dashboard reads | ||
| 155 | // live state without needing a goroutine. spawn / send are optional; | 155 | // live state without needing a goroutine. spawn / send are optional; | ||
| 156 | // when nil the corresponding inline shortcut ('n' / 'p') is disabled. | 156 | // when nil the corresponding inline shortcut ('n' / 'p') is disabled. | ||
| 157 | - | func (d *swarmDialog) Open( | 157 | + | func (d *subagentsDialog) Open( |
| 158 | - | snapshot func() []swarm.AgentSnapshot, | 158 | + | snapshot func() []subagents.AgentSnapshot, |
| 159 | stop func(id string) error, | 159 | stop func(id string) error, | ||
| 160 | remove func(id string) error, | 160 | remove func(id string) error, | ||
| 161 | spawn func(task, model, provider string) error, | 161 | spawn func(task, model, provider string) error, | ||
| @@ -194,7 +194,7 @@ func (d *swarmDialog) Open( | |||||
| 194 | // CursorPos returns the row/col for the terminal cursor while an | 194 | // CursorPos returns the row/col for the terminal cursor while an | ||
| 195 | // inline editor (spawn or prompt) is active so the caret blinks in | 195 | // inline editor (spawn or prompt) is active so the caret blinks in | ||
| 196 | // the right spot. Returns -1, -1 when no input is being captured. | 196 | // the right spot. Returns -1, -1 when no input is being captured. | ||
| 197 | - | func (d *swarmDialog) CursorPos(width int) (row, col int) { | 197 | + | func (d *subagentsDialog) CursorPos(width int) (row, col int) { |
| 198 | if !d.Active() { | 198 | if !d.Active() { | ||
| 199 | return -1, -1 | 199 | return -1, -1 | ||
| 200 | } | 200 | } | ||
| @@ -264,7 +264,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||||
| 264 | // belongs on when the always-on editor sits at the bottom of the | 264 | // belongs on when the always-on editor sits at the bottom of the | ||
| 265 | // transcript view. Mirrors renderTranscript's row layout so the | 265 | // transcript view. Mirrors renderTranscript's row layout so the | ||
| 266 | // terminal cursor stays glued to the editor as the body grows. | 266 | // terminal cursor stays glued to the editor as the body grows. | ||
| 267 | - | func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int { | 267 | + | func (d *subagentsDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int { |
| 268 | a := d.selected() | 268 | a := d.selected() | ||
| 269 | if a == nil { | 269 | if a == nil { | ||
| 270 | return -1 | 270 | return -1 | ||
| @@ -299,7 +299,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||||
| 299 | // Full body, no scroll window — the host's outer scrollback | 299 | // Full body, no scroll window — the host's outer scrollback | ||
| 300 | // owns overflow now. Cursor row math just counts every | 300 | // owns overflow now. Cursor row math just counts every | ||
| 301 | // rendered line. | 301 | // rendered line. | ||
| 302 | - | body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode) | 302 | + | body := renderSupervisorTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode) |
| 303 | row += len(body) | 303 | row += len(body) | ||
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| @@ -311,8 +311,8 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||||
| 311 | return row + editorRowOffset | 311 | return row + editorRowOffset | ||
| 312 | } | 312 | } | ||
| 313 | 313 | ||||
| 314 | - | func (d *swarmDialog) Active() bool { return d != nil && d.active } | 314 | + | func (d *subagentsDialog) Active() bool { return d != nil && d.active } |
| 315 | - | func (d *swarmDialog) Close() { d.active = false } | 315 | + | func (d *subagentsDialog) Close() { d.active = false } |
| 316 | 316 | ||||
| 317 | // NeedsTickRefresh reports whether the host's animation tick should | 317 | // NeedsTickRefresh reports whether the host's animation tick should | ||
| 318 | // repaint the screen while this dialog is open. True when the | 318 | // repaint the screen while this dialog is open. True when the | ||
| @@ -327,7 +327,7 @@ func (d *swarmDialog) Close() { d.active = false } | |||||
| 327 | // tick refresh. The cursor blink still works because the tick rate | 327 | // tick refresh. The cursor blink still works because the tick rate | ||
| 328 | // is well below the terminal's blink rate, and we need the redraws | 328 | // is well below the terminal's blink rate, and we need the redraws | ||
| 329 | // for streaming output. /btw makes the same trade-off. | 329 | // for streaming output. /btw makes the same trade-off. | ||
| 330 | - | func (d *swarmDialog) NeedsTickRefresh() bool { | 330 | + | func (d *subagentsDialog) NeedsTickRefresh() bool { |
| 331 | if d == nil || !d.active { | 331 | if d == nil || !d.active { | ||
| 332 | return false | 332 | return false | ||
| 333 | } | 333 | } | ||
| @@ -345,12 +345,12 @@ func (d *swarmDialog) NeedsTickRefresh() bool { | |||||
| 345 | } | 345 | } | ||
| 346 | 346 | ||||
| 347 | // OpenViewing opens the dashboard and jumps straight to the | 347 | // OpenViewing opens the dashboard and jumps straight to the | ||
| 348 | - | // transcript view for the agent with id agentID. Used by /swarm logs | 348 | + | // transcript view for the agent with id agentID. Used by /subagents logs |
| 349 | // <id>. If no such agent is found the dashboard opens normally so | 349 | // <id>. If no such agent is found the dashboard opens normally so | ||
| 350 | // the user can see why (and pick from the list). | 350 | // the user can see why (and pick from the list). | ||
| 351 | - | func (d *swarmDialog) OpenViewing( | 351 | + | func (d *subagentsDialog) OpenViewing( |
| 352 | agentID string, | 352 | agentID string, | ||
| 353 | - | snapshot func() []swarm.AgentSnapshot, | 353 | + | snapshot func() []subagents.AgentSnapshot, |
| 354 | stop func(id string) error, | 354 | stop func(id string) error, | ||
| 355 | remove func(id string) error, | 355 | remove func(id string) error, | ||
| 356 | spawn func(task, model, provider string) error, | 356 | spawn func(task, model, provider string) error, | ||
| @@ -365,9 +365,9 @@ func (d *swarmDialog) OpenViewing( | |||||
| 365 | d.cursor = i | 365 | d.cursor = i | ||
| 366 | d.viewing = true | 366 | d.viewing = true | ||
| 367 | // Auto-open the inline editor for running agents so | 367 | // Auto-open the inline editor for running agents so | ||
| 368 | - | // /swarm logs <id> drops the user straight into a | 368 | + | // /subagents logs <id> drops the user straight into a |
| 369 | // /btw-style send-ready prompt. | 369 | // /btw-style send-ready prompt. | ||
| 370 | - | if r.Status == swarm.StatusRunning && d.send != nil { | 370 | + | if r.Status == subagents.StatusRunning && d.send != nil { |
| 371 | d.openPromptEditor(tui.Theme{}, r.ID) | 371 | d.openPromptEditor(tui.Theme{}, r.ID) | ||
| 372 | } | 372 | } | ||
| 373 | return true | 373 | return true | ||
| @@ -386,8 +386,8 @@ func (d *swarmDialog) OpenViewing( | |||||
| 386 | // so the user has context: pressing ↑/↓ they can still walk through | 386 | // so the user has context: pressing ↑/↓ they can still walk through | ||
| 387 | // running agents, and the R key is a no-op there (Resume rejects | 387 | // running agents, and the R key is a no-op there (Resume rejects | ||
| 388 | // non-terminal agents with a clear error). | 388 | // non-terminal agents with a clear error). | ||
| 389 | - | func (d *swarmDialog) OpenForResume( | 389 | + | func (d *subagentsDialog) OpenForResume( |
| 390 | - | snapshot func() []swarm.AgentSnapshot, | 390 | + | snapshot func() []subagents.AgentSnapshot, |
| 391 | stop func(id string) error, | 391 | stop func(id string) error, | ||
| 392 | remove func(id string) error, | 392 | remove func(id string) error, | ||
| 393 | spawn func(task, model, provider string) error, | 393 | spawn func(task, model, provider string) error, | ||
| @@ -410,14 +410,14 @@ func (d *swarmDialog) OpenForResume( | |||||
| 410 | } | 410 | } | ||
| 411 | 411 | ||||
| 412 | // isResumable reports whether a Status is eligible for Resume. Mirror | 412 | // isResumable reports whether a Status is eligible for Resume. Mirror | ||
| 413 | - | // of the precondition inside Swarm.Resume; centralised so the dialog | 413 | + | // of the precondition inside Supervisor.Resume; centralised so the dialog |
| 414 | // and the slash handler agree on what "resumable" means. | 414 | // and the slash handler agree on what "resumable" means. | ||
| 415 | - | func isResumable(s swarm.Status) bool { | 415 | + | func isResumable(s subagents.Status) bool { |
| 416 | switch s { | 416 | switch s { | ||
| 417 | - | case swarm.StatusDetached, | 417 | + | case subagents.StatusDetached, |
| 418 | - | swarm.StatusDone, | 418 | + | subagents.StatusDone, |
| 419 | - | swarm.StatusFailed, | 419 | + | subagents.StatusFailed, |
| 420 | - | swarm.StatusKilled: | 420 | + | subagents.StatusKilled: |
| 421 | return true | 421 | return true | ||
| 422 | } | 422 | } | ||
| 423 | return false | 423 | return false | ||
| @@ -426,8 +426,8 @@ func isResumable(s swarm.Status) bool { | |||||
| 426 | // promptDisabledHint returns the status-bar message shown when the | 426 | // promptDisabledHint returns the status-bar message shown when the | ||
| 427 | // user presses 'p' on an agent that can't receive input. We branch on | 427 | // user presses 'p' on an agent that can't receive input. We branch on | ||
| 428 | // status so the hint points at the right next step: resume for the | 428 | // status so the hint points at the right next step: resume for the | ||
| 429 | - | // states Swarm.Resume accepts; nothing useful otherwise. | 429 | + | // states Supervisor.Resume accepts; nothing useful otherwise. |
| 430 | - | func promptDisabledHint(s swarm.Status) string { | 430 | + | func promptDisabledHint(s subagents.Status) string { |
| 431 | if isResumable(s) { | 431 | if isResumable(s) { | ||
| 432 | return "prompt: agent is " + string(s) + "; press R to resume first" | 432 | return "prompt: agent is " + string(s) + "; press R to resume first" | ||
| 433 | } | 433 | } | ||
| @@ -438,24 +438,24 @@ func promptDisabledHint(s swarm.Status) string { | |||||
| 438 | // Kill only makes sense on running / pending agents; on detached and | 438 | // Kill only makes sense on running / pending agents; on detached and | ||
| 439 | // terminal ones it's a no-op and the user usually wants 'r' (remove) | 439 | // terminal ones it's a no-op and the user usually wants 'r' (remove) | ||
| 440 | // to clear out the agent's state instead. | 440 | // to clear out the agent's state instead. | ||
| 441 | - | func killDisabledHint(s swarm.Status) string { | 441 | + | func killDisabledHint(s subagents.Status) string { |
| 442 | return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" | 442 | return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" | ||
| 443 | } | 443 | } | ||
| 444 | 444 | ||||
| 445 | // friendlySendErr turns a raw send error into a status-bar message | 445 | // friendlySendErr turns a raw send error into a status-bar message | ||
| 446 | // the user can act on. The most common failure mode — dialling an | 446 | // the user can act on. The most common failure mode — dialling an | ||
| 447 | // agent whose previous subprocess exited — surfaces as | 447 | // agent whose previous subprocess exited — surfaces as | ||
| 448 | - | // swarm.ErrNotReady; we rewrite it to mention resume instead of | 448 | + | // subagents.ErrNotReady; we rewrite it to mention resume instead of |
| 449 | // leaking the unix-socket path. Other errors fall through verbatim | 449 | // leaking the unix-socket path. Other errors fall through verbatim | ||
| 450 | // because they're rare and almost always indicate a real bug. | 450 | // because they're rare and almost always indicate a real bug. | ||
| 451 | func friendlySendErr(id string, err error) string { | 451 | func friendlySendErr(id string, err error) string { | ||
| 452 | - | if errors.Is(err, swarm.ErrNotReady) { | 452 | + | if errors.Is(err, subagents.ErrNotReady) { |
| 453 | return "send: agent " + id + " isn't accepting input (press R to resume)" | 453 | return "send: agent " + id + " isn't accepting input (press R to resume)" | ||
| 454 | } | 454 | } | ||
| 455 | return "send: " + err.Error() | 455 | return "send: " + err.Error() | ||
| 456 | } | 456 | } | ||
| 457 | 457 | ||||
| 458 | - | func (d *swarmDialog) refresh() { | 458 | + | func (d *subagentsDialog) refresh() { |
| 459 | if d.snapshot == nil { | 459 | if d.snapshot == nil { | ||
| 460 | d.rows = nil | 460 | d.rows = nil | ||
| 461 | return | 461 | return | ||
| @@ -472,7 +472,7 @@ func (d *swarmDialog) refresh() { | |||||
| 472 | // HandleKey advances state. Returns (closed, statusMsg, statusErr). | 472 | // HandleKey advances state. Returns (closed, statusMsg, statusErr). | ||
| 473 | // statusMsg / statusErr surface in the host's status bar so the user | 473 | // statusMsg / statusErr surface in the host's status bar so the user | ||
| 474 | // gets feedback on kill/remove without writing to the chat. | 474 | // gets feedback on kill/remove without writing to the chat. | ||
| 475 | - | func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | 475 | + | func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { |
| 476 | if !d.Active() { | 476 | if !d.Active() { | ||
| 477 | return false, "", "" | 477 | return false, "", "" | ||
| 478 | } | 478 | } | ||
| @@ -519,7 +519,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | |||||
| 519 | // can start typing immediately. Skipped for non-running | 519 | // can start typing immediately. Skipped for non-running | ||
| 520 | // agents; their inbox isn't listening, so an editor | 520 | // agents; their inbox isn't listening, so an editor | ||
| 521 | // would just frustrate them with bounced sends. | 521 | // would just frustrate them with bounced sends. | ||
| 522 | - | if a := d.selected(); a != nil && a.Status == swarm.StatusRunning && d.send != nil { | 522 | + | if a := d.selected(); a != nil && a.Status == subagents.StatusRunning && d.send != nil { |
| 523 | d.openPromptEditor(tui.Theme{}, a.ID) | 523 | d.openPromptEditor(tui.Theme{}, a.ID) | ||
| 524 | } | 524 | } | ||
| 525 | } | 525 | } | ||
| @@ -534,7 +534,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | |||||
| 534 | // agent will run against was already captured via | 534 | // agent will run against was already captured via | ||
| 535 | // SetCurrentModel from the host's /model selection; | 535 | // SetCurrentModel from the host's /model selection; | ||
| 536 | // the user changes models the normal way (/model) | 536 | // the user changes models the normal way (/model) | ||
| 537 | - | // before opening /swarm. | 537 | + | // before opening /subagents. |
| 538 | d.openSpawnEditor(tui.Theme{}) | 538 | d.openSpawnEditor(tui.Theme{}) | ||
| 539 | } | 539 | } | ||
| 540 | case 'p': | 540 | case 'p': | ||
| @@ -544,7 +544,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | |||||
| 544 | // failed / killed agent fails with a long ECONNREFUSED | 544 | // failed / killed agent fails with a long ECONNREFUSED | ||
| 545 | // or ENOENT trail that's not actionable. Tell the user | 545 | // or ENOENT trail that's not actionable. Tell the user | ||
| 546 | // what to do instead. | 546 | // what to do instead. | ||
| 547 | - | if a.Status != swarm.StatusRunning { | 547 | + | if a.Status != subagents.StatusRunning { |
| 548 | return false, "", promptDisabledHint(a.Status) | 548 | return false, "", promptDisabledHint(a.Status) | ||
| 549 | } | 549 | } | ||
| 550 | d.openPromptEditor(tui.Theme{}, a.ID) | 550 | d.openPromptEditor(tui.Theme{}, a.ID) | ||
| @@ -565,10 +565,10 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | |||||
| 565 | if a := d.selected(); a != nil && d.stop != nil { | 565 | if a := d.selected(); a != nil && d.stop != nil { | ||
| 566 | // Only running / pending agents have something to | 566 | // Only running / pending agents have something to | ||
| 567 | // cancel. Detached and already-terminal agents would | 567 | // cancel. Detached and already-terminal agents would | ||
| 568 | - | // be silent no-ops at the Swarm layer; surface a hint | 568 | + | // be silent no-ops at the Supervisor layer; surface a hint |
| 569 | // so the user knows kill did nothing and what to do | 569 | // so the user knows kill did nothing and what to do | ||
| 570 | // instead (remove via 'r'). | 570 | // instead (remove via 'r'). | ||
| 571 | - | if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending { | 571 | + | if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending { |
| 572 | return false, "", killDisabledHint(a.Status) | 572 | return false, "", killDisabledHint(a.Status) | ||
| 573 | } | 573 | } | ||
| 574 | if err := d.stop(a.ID); err != nil { | 574 | if err := d.stop(a.ID); err != nil { | ||
| @@ -603,14 +603,14 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | |||||
| 603 | // suggest, arrow keys for caret movement, etc.). Submits stay in | 603 | // suggest, arrow keys for caret movement, etc.). Submits stay in | ||
| 604 | // the view and clear the editor for the next message instead of | 604 | // the view and clear the editor for the next message instead of | ||
| 605 | // closing it — see handlePromptKey for the always-on behaviour. | 605 | // closing it — see handlePromptKey for the always-on behaviour. | ||
| 606 | - | func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) { | 606 | + | func (d *subagentsDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) { |
| 607 | a := d.selected() | 607 | a := d.selected() | ||
| 608 | 608 | ||||
| 609 | // Lazy-open the editor on first key into the transcript view, so | 609 | // Lazy-open the editor on first key into the transcript view, so | ||
| 610 | // re-entering an agent after switching away picks up cleanly. | 610 | // re-entering an agent after switching away picks up cleanly. | ||
| 611 | // Skipped for non-running agents (their inbox isn't listening; | 611 | // Skipped for non-running agents (their inbox isn't listening; | ||
| 612 | // sending would just bounce). | 612 | // sending would just bounce). | ||
| 613 | - | if a != nil && a.Status == swarm.StatusRunning && d.send != nil && !d.prompting { | 613 | + | if a != nil && a.Status == subagents.StatusRunning && d.send != nil && !d.prompting { |
| 614 | d.openPromptEditor(tui.Theme{}, a.ID) | 614 | d.openPromptEditor(tui.Theme{}, a.ID) | ||
| 615 | } | 615 | } | ||
| 616 | 616 | ||||
| @@ -634,7 +634,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri | |||||
| 634 | // Everything else — typed runes, Enter, Backspace, arrow keys | 634 | // Everything else — typed runes, Enter, Backspace, arrow keys | ||
| 635 | // for caret movement, @-file picker — is editor input. If the | 635 | // for caret movement, @-file picker — is editor input. If the | ||
| 636 | // agent isn't running there's no live editor; surface a hint. | 636 | // agent isn't running there's no live editor; surface a hint. | ||
| 637 | - | if a == nil || a.Status != swarm.StatusRunning || d.send == nil { | 637 | + | if a == nil || a.Status != subagents.StatusRunning || d.send == nil { |
| 638 | if k.Kind == tui.KeyRune && a != nil { | 638 | if k.Kind == tui.KeyRune && a != nil { | ||
| 639 | return false, "", promptDisabledHint(a.Status) | 639 | return false, "", promptDisabledHint(a.Status) | ||
| 640 | } | 640 | } | ||
| @@ -644,10 +644,10 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri | |||||
| 644 | } | 644 | } | ||
| 645 | 645 | ||||
| 646 | // openSpawnEditor enters the inline new-agent prompt. It deliberately | 646 | // openSpawnEditor enters the inline new-agent prompt. It deliberately | ||
| 647 | - | // uses tui.Editor (not a one-line string) so swarm-new supports the same | 647 | + | // uses tui.Editor (not a one-line string) so subagent-new supports the same |
| 648 | // editing model as the normal input: multiline paste collapse, drag-drop | 648 | // editing model as the normal input: multiline paste collapse, drag-drop | ||
| 649 | // file/dir chips, cursor movement, and the same accent-bar prompt style. | 649 | // file/dir chips, cursor movement, and the same accent-bar prompt style. | ||
| 650 | - | func (d *swarmDialog) openSpawnEditor(th tui.Theme) { | 650 | + | func (d *subagentsDialog) openSpawnEditor(th tui.Theme) { |
| 651 | d.spawning = true | 651 | d.spawning = true | ||
| 652 | d.newTaskEd = tui.NewEditor(d.editorPrompt(th)) | 652 | d.newTaskEd = tui.NewEditor(d.editorPrompt(th)) | ||
| 653 | d.fileSuggest = newFileSuggester() | 653 | d.fileSuggest = newFileSuggester() | ||
| @@ -662,7 +662,7 @@ func (d *swarmDialog) openSpawnEditor(th tui.Theme) { | |||||
| 662 | // submits "/model" from inside the spawn editor; on select we | 662 | // submits "/model" from inside the spawn editor; on select we | ||
| 663 | // capture the new pendingModel/Provider and reopen the spawn | 663 | // capture the new pendingModel/Provider and reopen the spawn | ||
| 664 | // editor with the previously-typed task buffer restored. | 664 | // editor with the previously-typed task buffer restored. | ||
| 665 | - | func (d *swarmDialog) openModelPicker() { | 665 | + | func (d *subagentsDialog) openModelPicker() { |
| 666 | d.spawning = false | 666 | d.spawning = false | ||
| 667 | d.newTaskEd = nil | 667 | d.newTaskEd = nil | ||
| 668 | d.fileSuggest = nil | 668 | d.fileSuggest = nil | ||
| @@ -677,7 +677,7 @@ func (d *swarmDialog) openModelPicker() { | |||||
| 677 | // pendingModel + pendingProvider; Esc leaves them unchanged (the | 677 | // pendingModel + pendingProvider; Esc leaves them unchanged (the | ||
| 678 | // user gets to keep whatever was inherited before they invoked | 678 | // user gets to keep whatever was inherited before they invoked | ||
| 679 | // /model). | 679 | // /model). | ||
| 680 | - | func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) { | 680 | + | func (d *subagentsDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) { |
| 681 | if d.modelPicker == nil { | 681 | if d.modelPicker == nil { | ||
| 682 | d.openModelPicker() | 682 | d.openModelPicker() | ||
| 683 | } | 683 | } | ||
| @@ -738,7 +738,7 @@ func stripSlashModelLine(buf string) string { | |||||
| 738 | // openPromptEditor enters the inline "send a follow-up prompt" editor | 738 | // openPromptEditor enters the inline "send a follow-up prompt" editor | ||
| 739 | // for the agent with id targetID. Mirrors openSpawnEditor so the | 739 | // for the agent with id targetID. Mirrors openSpawnEditor so the | ||
| 740 | // editing experience (paste, @-picker, drop chips) is identical. | 740 | // editing experience (paste, @-picker, drop chips) is identical. | ||
| 741 | - | func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) { | 741 | + | func (d *subagentsDialog) openPromptEditor(th tui.Theme, targetID string) { |
| 742 | d.prompting = true | 742 | d.prompting = true | ||
| 743 | d.promptEd = tui.NewEditor(d.editorPrompt(th)) | 743 | d.promptEd = tui.NewEditor(d.editorPrompt(th)) | ||
| 744 | d.promptSuggest = newFileSuggester() | 744 | d.promptSuggest = newFileSuggester() | ||
| @@ -751,7 +751,7 @@ func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) { | |||||
| 751 | // with the captured target id instead of d.spawn. Esc cancels and | 751 | // with the captured target id instead of d.spawn. Esc cancels and | ||
| 752 | // returns the user to whichever view (list or transcript) was active | 752 | // returns the user to whichever view (list or transcript) was active | ||
| 753 | // when 'p' was pressed. | 753 | // when 'p' was pressed. | ||
| 754 | - | func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) { | 754 | + | func (d *subagentsDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) { |
| 755 | if d.promptEd == nil { | 755 | if d.promptEd == nil { | ||
| 756 | d.openPromptEditor(tui.Theme{}, d.promptTargetID) | 756 | d.openPromptEditor(tui.Theme{}, d.promptTargetID) | ||
| 757 | } | 757 | } | ||
| @@ -868,7 +868,7 @@ func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg strin | |||||
| 868 | 868 | ||||
| 869 | // closePromptEditor resets all prompt-editor state. Called on Esc and | 869 | // closePromptEditor resets all prompt-editor state. Called on Esc and | ||
| 870 | // after submit so a subsequent 'p' starts from a clean slate. | 870 | // after submit so a subsequent 'p' starts from a clean slate. | ||
| 871 | - | func (d *swarmDialog) closePromptEditor() { | 871 | + | func (d *subagentsDialog) closePromptEditor() { |
| 872 | d.prompting = false | 872 | d.prompting = false | ||
| 873 | d.promptEd = nil | 873 | d.promptEd = nil | ||
| 874 | d.promptSuggest = nil | 874 | d.promptSuggest = nil | ||
| @@ -878,7 +878,7 @@ func (d *swarmDialog) closePromptEditor() { | |||||
| 878 | // handleSpawnKey owns the keystrokes while the inline new-agent editor | 878 | // handleSpawnKey owns the keystrokes while the inline new-agent editor | ||
| 879 | // is open. It mirrors the normal input's @ file-picker path and submit | 879 | // is open. It mirrors the normal input's @ file-picker path and submit | ||
| 880 | // expansion, then asks the host to spawn. | 880 | // expansion, then asks the host to spawn. | ||
| 881 | - | func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) { | 881 | + | func (d *subagentsDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) { |
| 882 | if d.newTaskEd == nil { | 882 | if d.newTaskEd == nil { | ||
| 883 | d.openSpawnEditor(tui.Theme{}) | 883 | d.openSpawnEditor(tui.Theme{}) | ||
| 884 | } | 884 | } | ||
| @@ -986,7 +986,7 @@ func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string | |||||
| 986 | return false, "", "" | 986 | return false, "", "" | ||
| 987 | } | 987 | } | ||
| 988 | 988 | ||||
| 989 | - | func (d *swarmDialog) selected() *swarm.AgentSnapshot { | 989 | + | func (d *subagentsDialog) selected() *subagents.AgentSnapshot { |
| 990 | if d.cursor < 0 || d.cursor >= len(d.rows) { | 990 | if d.cursor < 0 || d.cursor >= len(d.rows) { | ||
| 991 | return nil | 991 | return nil | ||
| 992 | } | 992 | } | ||
| @@ -996,7 +996,7 @@ func (d *swarmDialog) selected() *swarm.AgentSnapshot { | |||||
| 996 | 996 | ||||
| 997 | // Render returns the dialog lines. Re-reads the snapshot each call so | 997 | // Render returns the dialog lines. Re-reads the snapshot each call so | ||
| 998 | // the dashboard updates as agents make progress. | 998 | // the dashboard updates as agents make progress. | ||
| 999 | - | func (d *swarmDialog) Render(th tui.Theme, width int) []string { | 999 | + | func (d *subagentsDialog) Render(th tui.Theme, width int) []string { |
| 1000 | if !d.Active() { | 1000 | if !d.Active() { | ||
| 1001 | return nil | 1001 | return nil | ||
| 1002 | } | 1002 | } | ||
| @@ -1006,7 +1006,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |||||
| 1006 | return d.renderTranscript(th, width) | 1006 | return d.renderTranscript(th, width) | ||
| 1007 | } | 1007 | } | ||
| 1008 | 1008 | ||||
| 1009 | - | out := []string{frameHeader(th, "swarm (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)} | 1009 | + | out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)} |
| 1010 | if d.prompting { | 1010 | if d.prompting { | ||
| 1011 | return d.renderPromptEditor(th, width, out) | 1011 | return d.renderPromptEditor(th, width, out) | ||
| 1012 | } | 1012 | } | ||
| @@ -1061,17 +1061,17 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |||||
| 1061 | return out | 1061 | return out | ||
| 1062 | } | 1062 | } | ||
| 1063 | if len(d.rows) == 0 { | 1063 | if len(d.rows) == 0 { | ||
| 1064 | - | out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /swarm new <task>)")) | 1064 | + | out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /subagents new <task>)")) |
| 1065 | out = append(out, frameRule(th, width)) | 1065 | out = append(out, frameRule(th, width)) | ||
| 1066 | return out | 1066 | return out | ||
| 1067 | } | 1067 | } | ||
| 1068 | 1068 | ||||
| 1069 | // Column header for readability. | 1069 | // Column header for readability. | ||
| 1070 | - | header := fmt.Sprintf(" %-9s %-26s %-8s %s", "STATUS", "ID", "AGE", "ACTIVITY") | 1070 | + | header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY") |
| 1071 | out = append(out, th.FG256(th.Muted, header)) | 1071 | out = append(out, th.FG256(th.Muted, header)) | ||
| 1072 | 1072 | ||||
| 1073 | for i, r := range d.rows { | 1073 | for i, r := range d.rows { | ||
| 1074 | - | row := formatSwarmRow(r, width-2) | 1074 | + | row := formatSupervisorRow(r, width-2) |
| 1075 | if i == d.cursor { | 1075 | if i == d.cursor { | ||
| 1076 | out = append(out, th.PadHighlight(" "+row, width)) | 1076 | out = append(out, th.PadHighlight(" "+row, width)) | ||
| 1077 | } else { | 1077 | } else { | ||
| @@ -1082,14 +1082,14 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |||||
| 1082 | return out | 1082 | return out | ||
| 1083 | } | 1083 | } | ||
| 1084 | 1084 | ||||
| 1085 | - | func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | 1085 | + | func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string { |
| 1086 | a := d.selected() | 1086 | a := d.selected() | ||
| 1087 | if a == nil { | 1087 | if a == nil { | ||
| 1088 | d.viewing = false | 1088 | d.viewing = false | ||
| 1089 | return d.Render(th, width) | 1089 | return d.Render(th, width) | ||
| 1090 | } | 1090 | } | ||
| 1091 | header := []string{ | 1091 | header := []string{ | ||
| 1092 | - | frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width), | 1092 | + | frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width), |
| 1093 | " " + th.FG256(th.Muted, "task: "+a.Task), | 1093 | " " + th.FG256(th.Muted, "task: "+a.Task), | ||
| 1094 | " " + th.FG256(th.Muted, "dir: "+a.Dir), | 1094 | " " + th.FG256(th.Muted, "dir: "+a.Dir), | ||
| 1095 | " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), | 1095 | " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), | ||
| @@ -1141,7 +1141,7 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |||||
| 1141 | // That gives the user the chat UX they expect (transcript grows | 1141 | // That gives the user the chat UX they expect (transcript grows | ||
| 1142 | // with the window, arrow keys belong to the editor, no "↑ N | 1142 | // with the window, arrow keys belong to the editor, no "↑ N | ||
| 1143 | // more above" cliff). | 1143 | // more above" cliff). | ||
| 1144 | - | body := renderSwarmTranscriptBlocks(raw, th, width, d.compactMode) | 1144 | + | body := renderSupervisorTranscriptBlocks(raw, th, width, d.compactMode) |
| 1145 | 1145 | ||||
| 1146 | out := header | 1146 | out := header | ||
| 1147 | out = append(out, body...) | 1147 | out = append(out, body...) | ||
| @@ -1161,11 +1161,11 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |||||
| 1161 | // row, the editor itself, another breathing row, and a single muted | 1161 | // row, the editor itself, another breathing row, and a single muted | ||
| 1162 | // hint. For non-running agents we render a short "can't send while | 1162 | // hint. For non-running agents we render a short "can't send while | ||
| 1163 | // X" note instead so the user knows why typing is disabled. | 1163 | // X" note instead so the user knows why typing is disabled. | ||
| 1164 | - | func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *swarm.AgentSnapshot) []string { | 1164 | + | func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *subagents.AgentSnapshot) []string { |
| 1165 | if a == nil { | 1165 | if a == nil { | ||
| 1166 | return out | 1166 | return out | ||
| 1167 | } | 1167 | } | ||
| 1168 | - | if a.Status != swarm.StatusRunning || d.send == nil { | 1168 | + | if a.Status != subagents.StatusRunning || d.send == nil { |
| 1169 | out = append(out, "") | 1169 | out = append(out, "") | ||
| 1170 | out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status))) | 1170 | out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status))) | ||
| 1171 | return out | 1171 | return out | ||
| @@ -1227,11 +1227,11 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i | |||||
| 1227 | // suppress the spinner so transient / unfamiliar activity strings | 1227 | // suppress the spinner so transient / unfamiliar activity strings | ||
| 1228 | // still animate. Detached / done / failed / killed never animate — | 1228 | // still animate. Detached / done / failed / killed never animate — | ||
| 1229 | // they aren't actively producing output. | 1229 | // they aren't actively producing output. | ||
| 1230 | - | func agentIsBusy(a *swarm.AgentSnapshot) bool { | 1230 | + | func agentIsBusy(a *subagents.AgentSnapshot) bool { |
| 1231 | if a == nil { | 1231 | if a == nil { | ||
| 1232 | return false | 1232 | return false | ||
| 1233 | } | 1233 | } | ||
| 1234 | - | if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending { | 1234 | + | if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending { |
| 1235 | return false | 1235 | return false | ||
| 1236 | } | 1236 | } | ||
| 1237 | switch strings.TrimSpace(a.Activity) { | 1237 | switch strings.TrimSpace(a.Activity) { | ||
| @@ -1241,7 +1241,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool { | |||||
| 1241 | return true | 1241 | return true | ||
| 1242 | } | 1242 | } | ||
| 1243 | 1243 | ||||
| 1244 | - | // renderSwarmTranscriptBlocks converts the agent's flat transcript | 1244 | + | // renderSupervisorTranscriptBlocks converts the agent's flat transcript |
| 1245 | // log into a slice of display rows styled like the main chat: user | 1245 | // log into a slice of display rows styled like the main chat: user | ||
| 1246 | // echoes in bubble rows, assistant prose through tui.RenderMarkdown, | 1246 | // echoes in bubble rows, assistant prose through tui.RenderMarkdown, | ||
| 1247 | // stderr / error chatter tinted. Each output row is fully indented | 1247 | // stderr / error chatter tinted. Each output row is fully indented | ||
| @@ -1252,7 +1252,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool { | |||||
| 1252 | // whole block together — essential for markdown, which needs the | 1252 | // whole block together — essential for markdown, which needs the | ||
| 1253 | // full message body in one piece to detect fenced code spans, | 1253 | // full message body in one piece to detect fenced code spans, | ||
| 1254 | // bullet lists, and so on. | 1254 | // bullet lists, and so on. | ||
| 1255 | - | func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string { | 1255 | + | func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string { |
| 1256 | type blockKind int | 1256 | type blockKind int | ||
| 1257 | const ( | 1257 | const ( | ||
| 1258 | kindAssistant blockKind = iota | 1258 | kindAssistant blockKind = iota | ||
| @@ -1342,7 +1342,7 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac | |||||
| 1342 | // the pre-built header (frame header for list view, or the agent | 1342 | // the pre-built header (frame header for list view, or the agent | ||
| 1343 | // metadata block for transcript view); we append onto it and close | 1343 | // metadata block for transcript view); we append onto it and close | ||
| 1344 | // with the standard frame rule. | 1344 | // with the standard frame rule. | ||
| 1345 | - | func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string { | 1345 | + | func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string { |
| 1346 | if d.promptEd == nil { | 1346 | if d.promptEd == nil { | ||
| 1347 | d.openPromptEditor(th, d.promptTargetID) | 1347 | d.openPromptEditor(th, d.promptTargetID) | ||
| 1348 | } | 1348 | } | ||
| @@ -1375,17 +1375,20 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) | |||||
| 1375 | return out | 1375 | return out | ||
| 1376 | } | 1376 | } | ||
| 1377 | 1377 | ||||
| 1378 | - | // formatSwarmRow is the one-line summary shown per agent. | 1378 | + | // formatSupervisorRow is the one-line summary shown per agent. |
| 1379 | // | 1379 | // | ||
| 1380 | // Layout (fixed-width columns, then free-form activity): | 1380 | // Layout (fixed-width columns, then free-form activity): | ||
| 1381 | // | 1381 | // | ||
| 1382 | // STATUS ID AGE ACTIVITY | 1382 | // STATUS ID AGE ACTIVITY | ||
| 1383 | // ● run fix-login-12345 3m editing main.go | 1383 | // ● run fix-login-12345 3m editing main.go | ||
| 1384 | // ✓ done write-tests-67890 1h done | 1384 | // ✓ done write-tests-67890 1h done | ||
| 1385 | - | func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | 1385 | + | func formatSupervisorRow(r subagents.AgentSnapshot, maxWidth int) string { |
| 1386 | status := statusLabel(r.Status) | 1386 | status := statusLabel(r.Status) | ||
| 1387 | + | if r.ProcessState != "" && r.TurnState != "" { | |||
| 1388 | + | status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18) | |||
| 1389 | + | } | |||
| 1387 | age := formatAge(r.Started) | 1390 | age := formatAge(r.Started) | ||
| 1388 | - | left := fmt.Sprintf("%-9s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) | 1391 | + | left := fmt.Sprintf("%-18s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) |
| 1389 | room := maxWidth - len([]rune(left)) | 1392 | room := maxWidth - len([]rune(left)) | ||
| 1390 | if room < 10 { | 1393 | if room < 10 { | ||
| 1391 | room = 10 | 1394 | room = 10 | ||
| @@ -1411,19 +1414,19 @@ func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | |||||
| 1411 | 1414 | ||||
| 1412 | // statusLabel returns a short, padded status badge. Kept stable across | 1415 | // statusLabel returns a short, padded status badge. Kept stable across | ||
| 1413 | // runs (no colours embedded) so tests can match it deterministically. | 1416 | // runs (no colours embedded) so tests can match it deterministically. | ||
| 1414 | - | func statusLabel(s swarm.Status) string { | 1417 | + | func statusLabel(s subagents.Status) string { |
| 1415 | switch s { | 1418 | switch s { | ||
| 1416 | - | case swarm.StatusPending: | 1419 | + | case subagents.StatusPending: |
| 1417 | return "● pend" | 1420 | return "● pend" | ||
| 1418 | - | case swarm.StatusRunning: | 1421 | + | case subagents.StatusRunning: |
| 1419 | return "● run" | 1422 | return "● run" | ||
| 1420 | - | case swarm.StatusDone: | 1423 | + | case subagents.StatusDone: |
| 1421 | return "✓ done" | 1424 | return "✓ done" | ||
| 1422 | - | case swarm.StatusFailed: | 1425 | + | case subagents.StatusFailed: |
| 1423 | return "✗ fail" | 1426 | return "✗ fail" | ||
| 1424 | - | case swarm.StatusKilled: | 1427 | + | case subagents.StatusKilled: |
| 1425 | return "■ kill" | 1428 | return "■ kill" | ||
| 1426 | - | case swarm.StatusDetached: | 1429 | + | case subagents.StatusDetached: |
| 1427 | return "○ detach" | 1430 | return "○ detach" | ||
| 1428 | } | 1431 | } | ||
| 1429 | return string(s) | 1432 | return string(s) | ||
R packages/agent/modes/swarm_dialog_test.go → packages/agent/modes/subagents_dialog_test.go +170 -170 renamed, 79% similar
| @@ -9,23 +9,23 @@ import ( | |||||
| 9 | "testing" | 9 | "testing" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | 12 | + | "github.com/patriceckhart/zot/packages/agent/subagents" |
| 13 | "github.com/patriceckhart/zot/packages/provider" | 13 | "github.com/patriceckhart/zot/packages/provider" | ||
| 14 | "github.com/patriceckhart/zot/packages/tui" | 14 | "github.com/patriceckhart/zot/packages/tui" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | // staticSnapshots returns a deterministic snapshot slice for the | 17 | // staticSnapshots returns a deterministic snapshot slice for the | ||
| 18 | // dialog to render. | 18 | // dialog to render. | ||
| 19 | - | func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot { | 19 | + | func staticSnapshots(rows ...subagents.AgentSnapshot) func() []subagents.AgentSnapshot { |
| 20 | - | return func() []swarm.AgentSnapshot { | 20 | + | return func() []subagents.AgentSnapshot { |
| 21 | - | out := make([]swarm.AgentSnapshot, len(rows)) | 21 | + | out := make([]subagents.AgentSnapshot, len(rows)) |
| 22 | copy(out, rows) | 22 | copy(out, rows) | ||
| 23 | return out | 23 | return out | ||
| 24 | } | 24 | } | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | - | func TestSwarmDialogEmptyState(t *testing.T) { | 27 | + | func TestSubagentsDialogEmptyState(t *testing.T) { |
| 28 | - | d := newSwarmDialog() | 28 | + | d := newSubagentsDialog() |
| 29 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | 29 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | ||
| 30 | lines := d.Render(tui.Theme{}, 80) | 30 | lines := d.Render(tui.Theme{}, 80) | ||
| 31 | joined := strings.Join(lines, "\n") | 31 | joined := strings.Join(lines, "\n") | ||
| @@ -37,13 +37,13 @@ func TestSwarmDialogEmptyState(t *testing.T) { | |||||
| 37 | } | 37 | } | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | - | func TestSwarmDialogRendersRows(t *testing.T) { | 40 | + | func TestSubagentsDialogRendersRows(t *testing.T) { |
| 41 | now := time.Now() | 41 | now := time.Now() | ||
| 42 | - | rows := []swarm.AgentSnapshot{ | 42 | + | rows := []subagents.AgentSnapshot{ |
| 43 | - | {ID: "alpha-1", Task: "fix login", Status: swarm.StatusRunning, Activity: "editing file", Started: now}, | 43 | + | {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Activity: "editing file", Started: now}, |
| 44 | - | {ID: "beta-2", Task: "write tests", Status: swarm.StatusDone, Activity: "done", Started: now}, | 44 | + | {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Activity: "done", Started: now}, |
| 45 | } | 45 | } | ||
| 46 | - | d := newSwarmDialog() | 46 | + | d := newSubagentsDialog() |
| 47 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 47 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 48 | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | 48 | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | ||
| 49 | for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} { | 49 | for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} { | ||
| @@ -53,14 +53,14 @@ func TestSwarmDialogRendersRows(t *testing.T) { | |||||
| 53 | } | 53 | } | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | - | func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) { | 56 | + | func TestSubagentsDialogStopAndRemoveCallbacks(t *testing.T) { |
| 57 | now := time.Now() | 57 | now := time.Now() | ||
| 58 | - | rows := []swarm.AgentSnapshot{ | 58 | + | rows := []subagents.AgentSnapshot{ |
| 59 | - | {ID: "alpha-1", Status: swarm.StatusRunning, Started: now}, | 59 | + | {ID: "alpha-1", Status: subagents.StatusRunning, Started: now}, |
| 60 | } | 60 | } | ||
| 61 | stops := 0 | 61 | stops := 0 | ||
| 62 | removes := 0 | 62 | removes := 0 | ||
| 63 | - | d := newSwarmDialog() | 63 | + | d := newSubagentsDialog() |
| 64 | d.Open( | 64 | d.Open( | ||
| 65 | staticSnapshots(rows...), | 65 | staticSnapshots(rows...), | ||
| 66 | func(id string) error { stops++; return nil }, | 66 | func(id string) error { stops++; return nil }, | ||
| @@ -90,12 +90,12 @@ func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) { | |||||
| 90 | } | 90 | } | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | - | func TestSwarmDialogEnterShowsTranscript(t *testing.T) { | 93 | + | func TestSubagentsDialogEnterShowsTranscript(t *testing.T) { |
| 94 | now := time.Now() | 94 | now := time.Now() | ||
| 95 | - | rows := []swarm.AgentSnapshot{ | 95 | + | rows := []subagents.AgentSnapshot{ |
| 96 | - | {ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now}, | 96 | + | {ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Tail: "line a\nline b", Started: now}, |
| 97 | } | 97 | } | ||
| 98 | - | d := newSwarmDialog() | 98 | + | d := newSubagentsDialog() |
| 99 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 99 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 100 | _ = d.Render(tui.Theme{}, 80) | 100 | _ = d.Render(tui.Theme{}, 80) | ||
| 101 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | 101 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | ||
| @@ -113,8 +113,8 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) { | |||||
| 113 | } | 113 | } | ||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | - | func TestSwarmDialogEscClosesList(t *testing.T) { | 116 | + | func TestSubagentsDialogEscClosesList(t *testing.T) { |
| 117 | - | d := newSwarmDialog() | 117 | + | d := newSubagentsDialog() |
| 118 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | 118 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | ||
| 119 | closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | 119 | closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | ||
| 120 | if !closed { | 120 | if !closed { | ||
| @@ -125,12 +125,12 @@ func TestSwarmDialogEscClosesList(t *testing.T) { | |||||
| 125 | } | 125 | } | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | // TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end: | 128 | + | // TestSubagentsDialogSpawnFlow drives the inline n-prompt end-to-end: |
| 129 | // press n, type a task, press enter, observe the spawn callback | 129 | // press n, type a task, press enter, observe the spawn callback | ||
| 130 | // fires with the right text and the prompt closes. | 130 | // fires with the right text and the prompt closes. | ||
| 131 | - | func TestSwarmDialogSpawnFlow(t *testing.T) { | 131 | + | func TestSubagentsDialogSpawnFlow(t *testing.T) { |
| 132 | var spawned string | 132 | var spawned string | ||
| 133 | - | d := newSwarmDialog() | 133 | + | d := newSubagentsDialog() |
| 134 | d.Open( | 134 | d.Open( | ||
| 135 | staticSnapshots(), | 135 | staticSnapshots(), | ||
| 136 | nil, nil, | 136 | nil, nil, | ||
| @@ -173,13 +173,13 @@ func TestSwarmDialogSpawnFlow(t *testing.T) { | |||||
| 173 | } | 173 | } | ||
| 174 | } | 174 | } | ||
| 175 | 175 | ||||
| 176 | - | func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) { | 176 | + | func TestSubagentsDialogSpawnFilePickerFlow(t *testing.T) { |
| 177 | cwd := t.TempDir() | 177 | cwd := t.TempDir() | ||
| 178 | if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil { | 178 | if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil { | ||
| 179 | t.Fatal(err) | 179 | t.Fatal(err) | ||
| 180 | } | 180 | } | ||
| 181 | var spawned string | 181 | var spawned string | ||
| 182 | - | d := newSwarmDialog() | 182 | + | d := newSubagentsDialog() |
| 183 | d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd) | 183 | d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd) | ||
| 184 | _ = d.Render(tui.Theme{}, 80) | 184 | _ = d.Render(tui.Theme{}, 80) | ||
| 185 | 185 | ||||
| @@ -200,7 +200,7 @@ func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) { | |||||
| 200 | } | 200 | } | ||
| 201 | 201 | ||||
| 202 | // A second Enter submits. The chip should expand to the absolute | 202 | // A second Enter submits. The chip should expand to the absolute | ||
| 203 | - | // path before the task reaches Swarm.Spawn. | 203 | + | // path before the task reaches Supervisor.Spawn. |
| 204 | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | 204 | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | ||
| 205 | if errMsg != "" { | 205 | if errMsg != "" { | ||
| 206 | t.Fatalf("unexpected err: %s", errMsg) | 206 | t.Fatalf("unexpected err: %s", errMsg) | ||
| @@ -214,9 +214,9 @@ func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) { | |||||
| 214 | } | 214 | } | ||
| 215 | } | 215 | } | ||
| 216 | 216 | ||||
| 217 | - | func TestSwarmDialogSpawnEscCancels(t *testing.T) { | 217 | + | func TestSubagentsDialogSpawnEscCancels(t *testing.T) { |
| 218 | called := false | 218 | called := false | ||
| 219 | - | d := newSwarmDialog() | 219 | + | d := newSubagentsDialog() |
| 220 | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "") | 220 | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "") | ||
| 221 | _ = d.Render(tui.Theme{}, 80) | 221 | _ = d.Render(tui.Theme{}, 80) | ||
| 222 | 222 | ||||
| @@ -236,8 +236,8 @@ func TestSwarmDialogSpawnEscCancels(t *testing.T) { | |||||
| 236 | } | 236 | } | ||
| 237 | } | 237 | } | ||
| 238 | 238 | ||||
| 239 | - | func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) { | 239 | + | func TestSubagentsDialogNoNWithoutSpawnCallback(t *testing.T) { |
| 240 | - | d := newSwarmDialog() | 240 | + | d := newSubagentsDialog() |
| 241 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil | 241 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil | ||
| 242 | _ = d.Render(tui.Theme{}, 80) | 242 | _ = d.Render(tui.Theme{}, 80) | ||
| 243 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | 243 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | ||
| @@ -246,23 +246,23 @@ func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) { | |||||
| 246 | } | 246 | } | ||
| 247 | } | 247 | } | ||
| 248 | 248 | ||||
| 249 | - | // TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style | 249 | + | // TestSubagentsDialogFullTranscriptRendersEverything pins the /btw-style |
| 250 | // rendering: the dialog NEVER clips the transcript into a fixed | 250 | // rendering: the dialog NEVER clips the transcript into a fixed | ||
| 251 | // window with internal scroll. Every line goes into the rendered | 251 | // window with internal scroll. Every line goes into the rendered | ||
| 252 | // output and the host's outer scrollback owns overflow. This is | 252 | // output and the host's outer scrollback owns overflow. This is | ||
| 253 | // what makes the transcript grow with the window and with the | 253 | // what makes the transcript grow with the window and with the | ||
| 254 | // content, instead of cutting off at row 16 with a "↑ N more above" | 254 | // content, instead of cutting off at row 16 with a "↑ N more above" | ||
| 255 | // indicator. | 255 | // indicator. | ||
| 256 | - | func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) { | 256 | + | func TestSubagentsDialogFullTranscriptRendersEverything(t *testing.T) { |
| 257 | lines := make([]string, 60) | 257 | lines := make([]string, 60) | ||
| 258 | for i := range lines { | 258 | for i := range lines { | ||
| 259 | lines[i] = fmt.Sprintf("line %d", i) | 259 | lines[i] = fmt.Sprintf("line %d", i) | ||
| 260 | } | 260 | } | ||
| 261 | - | rows := []swarm.AgentSnapshot{{ | 261 | + | rows := []subagents.AgentSnapshot{{ |
| 262 | - | ID: "alpha-1", Task: "x", Status: swarm.StatusDone, | 262 | + | ID: "alpha-1", Task: "x", Status: subagents.StatusDone, |
| 263 | Lines: lines, Started: time.Now(), | 263 | Lines: lines, Started: time.Now(), | ||
| 264 | }} | 264 | }} | ||
| 265 | - | d := newSwarmDialog() | 265 | + | d := newSubagentsDialog() |
| 266 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 266 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 267 | _ = d.Render(tui.Theme{}, 80) | 267 | _ = d.Render(tui.Theme{}, 80) | ||
| 268 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer | 268 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer | ||
| @@ -289,16 +289,16 @@ func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) { | |||||
| 289 | } | 289 | } | ||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| 292 | - | // TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs | 292 | + | // TestSubagentsDialogOpenViewingJumpsToAgent regression-tests /subagents logs |
| 293 | // <id>: OpenViewing should select the row whose id matches the | 293 | // <id>: OpenViewing should select the row whose id matches the | ||
| 294 | // (possibly truncated) argument and enter the transcript view | 294 | // (possibly truncated) argument and enter the transcript view | ||
| 295 | // directly without showing the list first. | 295 | // directly without showing the list first. | ||
| 296 | - | func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) { | 296 | + | func TestSubagentsDialogOpenViewingJumpsToAgent(t *testing.T) { |
| 297 | - | rows := []swarm.AgentSnapshot{ | 297 | + | rows := []subagents.AgentSnapshot{ |
| 298 | {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()}, | 298 | {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()}, | ||
| 299 | {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()}, | 299 | {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()}, | ||
| 300 | } | 300 | } | ||
| 301 | - | d := newSwarmDialog() | 301 | + | d := newSubagentsDialog() |
| 302 | ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 302 | ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 303 | if !ok { | 303 | if !ok { | ||
| 304 | t.Fatal("prefix match did not resolve") | 304 | t.Fatal("prefix match did not resolve") | ||
| @@ -312,24 +312,24 @@ func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) { | |||||
| 312 | } | 312 | } | ||
| 313 | } | 313 | } | ||
| 314 | 314 | ||||
| 315 | - | func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) { | 315 | + | func TestSubagentsDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) { |
| 316 | - | d := newSwarmDialog() | 316 | + | d := newSubagentsDialog() |
| 317 | ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "") | 317 | ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "") | ||
| 318 | if ok { | 318 | if ok { | ||
| 319 | t.Fatal("expected miss for unknown id") | 319 | t.Fatal("expected miss for unknown id") | ||
| 320 | } | 320 | } | ||
| 321 | } | 321 | } | ||
| 322 | 322 | ||||
| 323 | - | // TestSwarmDialogPromptFlowFromList drives the inline p-prompt: | 323 | + | // TestSubagentsDialogPromptFlowFromList drives the inline p-prompt: |
| 324 | // press p on the selected agent, type a message, press enter, | 324 | // press p on the selected agent, type a message, press enter, | ||
| 325 | // and assert the send callback fired with the right id+text. | 325 | // and assert the send callback fired with the right id+text. | ||
| 326 | - | func TestSwarmDialogPromptFlowFromList(t *testing.T) { | 326 | + | func TestSubagentsDialogPromptFlowFromList(t *testing.T) { |
| 327 | - | rows := []swarm.AgentSnapshot{ | 327 | + | rows := []subagents.AgentSnapshot{ |
| 328 | - | {ID: "alpha-1", Status: swarm.StatusRunning, Started: time.Now()}, | 328 | + | {ID: "alpha-1", Status: subagents.StatusRunning, Started: time.Now()}, |
| 329 | } | 329 | } | ||
| 330 | var gotID, gotText string | 330 | var gotID, gotText string | ||
| 331 | sends := 0 | 331 | sends := 0 | ||
| 332 | - | d := newSwarmDialog() | 332 | + | d := newSubagentsDialog() |
| 333 | d.Open( | 333 | d.Open( | ||
| 334 | staticSnapshots(rows...), | 334 | staticSnapshots(rows...), | ||
| 335 | nil, nil, nil, | 335 | nil, nil, nil, | ||
| @@ -371,18 +371,18 @@ func TestSwarmDialogPromptFlowFromList(t *testing.T) { | |||||
| 371 | } | 371 | } | ||
| 372 | } | 372 | } | ||
| 373 | 373 | ||||
| 374 | - | // TestSwarmDialogPromptFromTranscriptView pins the always-on editor | 374 | + | // TestSubagentsDialogPromptFromTranscriptView pins the always-on editor |
| 375 | // behaviour for the transcript view: entering an agent immediately | 375 | // behaviour for the transcript view: entering an agent immediately | ||
| 376 | // auto-opens the inline composer (no `p` needed), typing flows | 376 | // auto-opens the inline composer (no `p` needed), typing flows | ||
| 377 | // straight into it, Enter sends, and the metadata stays visible. | 377 | // straight into it, Enter sends, and the metadata stays visible. | ||
| 378 | // The transcript view is /btw-style now — you never leave the | 378 | // The transcript view is /btw-style now — you never leave the | ||
| 379 | // view to send a follow-up. | 379 | // view to send a follow-up. | ||
| 380 | - | func TestSwarmDialogPromptFromTranscriptView(t *testing.T) { | 380 | + | func TestSubagentsDialogPromptFromTranscriptView(t *testing.T) { |
| 381 | - | rows := []swarm.AgentSnapshot{ | 381 | + | rows := []subagents.AgentSnapshot{ |
| 382 | - | {ID: "beta-2", Task: "do thing", Status: swarm.StatusRunning, Started: time.Now(), Tail: "hi from beta"}, | 382 | + | {ID: "beta-2", Task: "do thing", Status: subagents.StatusRunning, Started: time.Now(), Tail: "hi from beta"}, |
| 383 | } | 383 | } | ||
| 384 | var gotID, gotText string | 384 | var gotID, gotText string | ||
| 385 | - | d := newSwarmDialog() | 385 | + | d := newSubagentsDialog() |
| 386 | d.Open( | 386 | d.Open( | ||
| 387 | staticSnapshots(rows...), | 387 | staticSnapshots(rows...), | ||
| 388 | nil, nil, nil, | 388 | nil, nil, nil, | ||
| @@ -404,7 +404,7 @@ func TestSwarmDialogPromptFromTranscriptView(t *testing.T) { | |||||
| 404 | if !strings.Contains(out, "task: do thing") { | 404 | if !strings.Contains(out, "task: do thing") { | ||
| 405 | t.Fatalf("transcript metadata not visible:\n%s", out) | 405 | t.Fatalf("transcript metadata not visible:\n%s", out) | ||
| 406 | } | 406 | } | ||
| 407 | - | if !strings.Contains(out, "swarm: beta-2") { | 407 | + | if !strings.Contains(out, "subagent: beta-2") { |
| 408 | t.Fatalf("frame header missing agent id:\n%s", out) | 408 | t.Fatalf("frame header missing agent id:\n%s", out) | ||
| 409 | } | 409 | } | ||
| 410 | 410 | ||||
| @@ -430,12 +430,12 @@ func TestSwarmDialogPromptFromTranscriptView(t *testing.T) { | |||||
| 430 | } | 430 | } | ||
| 431 | } | 431 | } | ||
| 432 | 432 | ||||
| 433 | - | // TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt | 433 | + | // TestSubagentsDialogPromptEscCancels makes sure Esc inside the prompt |
| 434 | // editor aborts without invoking the send callback. | 434 | // editor aborts without invoking the send callback. | ||
| 435 | - | func TestSwarmDialogPromptEscCancels(t *testing.T) { | 435 | + | func TestSubagentsDialogPromptEscCancels(t *testing.T) { |
| 436 | - | rows := []swarm.AgentSnapshot{{ID: "gamma-3", Status: swarm.StatusRunning, Started: time.Now()}} | 436 | + | rows := []subagents.AgentSnapshot{{ID: "gamma-3", Status: subagents.StatusRunning, Started: time.Now()}} |
| 437 | sends := 0 | 437 | sends := 0 | ||
| 438 | - | d := newSwarmDialog() | 438 | + | d := newSubagentsDialog() |
| 439 | d.Open( | 439 | d.Open( | ||
| 440 | staticSnapshots(rows...), | 440 | staticSnapshots(rows...), | ||
| 441 | nil, nil, nil, | 441 | nil, nil, nil, | ||
| @@ -457,12 +457,12 @@ func TestSwarmDialogPromptEscCancels(t *testing.T) { | |||||
| 457 | } | 457 | } | ||
| 458 | } | 458 | } | ||
| 459 | 459 | ||||
| 460 | - | // TestSwarmDialogPWithoutSendCallback verifies that pressing 'p' | 460 | + | // TestSubagentsDialogPWithoutSendCallback verifies that pressing 'p' |
| 461 | // when no send callback is wired is a silent no-op (mirrors the | 461 | // when no send callback is wired is a silent no-op (mirrors the | ||
| 462 | // existing 'n' / no-spawn-callback contract). | 462 | // existing 'n' / no-spawn-callback contract). | ||
| 463 | - | func TestSwarmDialogPWithoutSendCallback(t *testing.T) { | 463 | + | func TestSubagentsDialogPWithoutSendCallback(t *testing.T) { |
| 464 | - | rows := []swarm.AgentSnapshot{{ID: "delta-4", Status: swarm.StatusRunning, Started: time.Now()}} | 464 | + | rows := []subagents.AgentSnapshot{{ID: "delta-4", Status: subagents.StatusRunning, Started: time.Now()}} |
| 465 | - | d := newSwarmDialog() | 465 | + | d := newSubagentsDialog() |
| 466 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 466 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 467 | _ = d.Render(tui.Theme{}, 80) | 467 | _ = d.Render(tui.Theme{}, 80) | ||
| 468 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | 468 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | ||
| @@ -476,13 +476,13 @@ func TestSwarmDialogPWithoutSendCallback(t *testing.T) { | |||||
| 476 | // dialog opens with the cursor on the first detached row (skipping | 476 | // dialog opens with the cursor on the first detached row (skipping | ||
| 477 | // any leading running ones) so the user can press R immediately. | 477 | // any leading running ones) so the user can press R immediately. | ||
| 478 | func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) { | 478 | func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) { | ||
| 479 | - | rows := []swarm.AgentSnapshot{ | 479 | + | rows := []subagents.AgentSnapshot{ |
| 480 | - | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | 480 | + | {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()}, |
| 481 | - | {ID: "live-2", Status: swarm.StatusRunning, Started: time.Now()}, | 481 | + | {ID: "live-2", Status: subagents.StatusRunning, Started: time.Now()}, |
| 482 | - | {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, | 482 | + | {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()}, |
| 483 | - | {ID: "detached-2", Status: swarm.StatusKilled, Started: time.Now()}, | 483 | + | {ID: "detached-2", Status: subagents.StatusKilled, Started: time.Now()}, |
| 484 | } | 484 | } | ||
| 485 | - | d := newSwarmDialog() | 485 | + | d := newSubagentsDialog() |
| 486 | count := d.OpenForResume( | 486 | count := d.OpenForResume( | ||
| 487 | staticSnapshots(rows...), | 487 | staticSnapshots(rows...), | ||
| 488 | nil, nil, nil, nil, | 488 | nil, nil, nil, nil, | ||
| @@ -506,10 +506,10 @@ func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) { | |||||
| 506 | // slash handler is responsible for surfacing the "no resumable | 506 | // slash handler is responsible for surfacing the "no resumable | ||
| 507 | // agents" status string — the dialog itself just opens. | 507 | // agents" status string — the dialog itself just opens. | ||
| 508 | func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) { | 508 | func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) { | ||
| 509 | - | rows := []swarm.AgentSnapshot{ | 509 | + | rows := []subagents.AgentSnapshot{ |
| 510 | - | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | 510 | + | {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()}, |
| 511 | } | 511 | } | ||
| 512 | - | d := newSwarmDialog() | 512 | + | d := newSubagentsDialog() |
| 513 | count := d.OpenForResume( | 513 | count := d.OpenForResume( | ||
| 514 | staticSnapshots(rows...), | 514 | staticSnapshots(rows...), | ||
| 515 | nil, nil, nil, nil, | 515 | nil, nil, nil, nil, | ||
| @@ -528,15 +528,15 @@ func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) { | |||||
| 528 | } | 528 | } | ||
| 529 | 529 | ||||
| 530 | // TestOpenForResumeREnterTriggersResume drives the full picker UX: | 530 | // TestOpenForResumeREnterTriggersResume drives the full picker UX: | ||
| 531 | - | // /swarm resume → dialog opens parked on a resumable row → user | 531 | + | // /subagents resume → dialog opens parked on a resumable row → user |
| 532 | // presses R → the resume callback fires for that exact id. | 532 | // presses R → the resume callback fires for that exact id. | ||
| 533 | func TestOpenForResumeREnterTriggersResume(t *testing.T) { | 533 | func TestOpenForResumeREnterTriggersResume(t *testing.T) { | ||
| 534 | - | rows := []swarm.AgentSnapshot{ | 534 | + | rows := []subagents.AgentSnapshot{ |
| 535 | - | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | 535 | + | {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()}, |
| 536 | - | {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, | 536 | + | {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()}, |
| 537 | } | 537 | } | ||
| 538 | var resumed string | 538 | var resumed string | ||
| 539 | - | d := newSwarmDialog() | 539 | + | d := newSubagentsDialog() |
| 540 | d.OpenForResume( | 540 | d.OpenForResume( | ||
| 541 | staticSnapshots(rows...), | 541 | staticSnapshots(rows...), | ||
| 542 | nil, nil, nil, nil, | 542 | nil, nil, nil, nil, | ||
| @@ -561,7 +561,7 @@ func TestOpenForResumeREnterTriggersResume(t *testing.T) { | |||||
| 561 | // the way of the inline editors (which need the cursor blink). | 561 | // the way of the inline editors (which need the cursor blink). | ||
| 562 | func TestNeedsTickRefresh(t *testing.T) { | 562 | func TestNeedsTickRefresh(t *testing.T) { | ||
| 563 | // Closed. | 563 | // Closed. | ||
| 564 | - | d := newSwarmDialog() | 564 | + | d := newSubagentsDialog() |
| 565 | if d.NeedsTickRefresh() { | 565 | if d.NeedsTickRefresh() { | ||
| 566 | t.Fatal("closed dialog should not request tick refresh") | 566 | t.Fatal("closed dialog should not request tick refresh") | ||
| 567 | } | 567 | } | ||
| @@ -573,7 +573,7 @@ func TestNeedsTickRefresh(t *testing.T) { | |||||
| 573 | } | 573 | } | ||
| 574 | 574 | ||||
| 575 | // Transcript view: still background-driven; tick should refresh. | 575 | // Transcript view: still background-driven; tick should refresh. | ||
| 576 | - | rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | 576 | + | rows := []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}} |
| 577 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 577 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 578 | _ = d.Render(tui.Theme{}, 80) | 578 | _ = d.Render(tui.Theme{}, 80) | ||
| 579 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | 579 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | ||
| @@ -585,7 +585,7 @@ func TestNeedsTickRefresh(t *testing.T) { | |||||
| 585 | } | 585 | } | ||
| 586 | 586 | ||||
| 587 | // Spawn editor open: tick should NOT refresh (cursor blink). | 587 | // Spawn editor open: tick should NOT refresh (cursor blink). | ||
| 588 | - | d = newSwarmDialog() | 588 | + | d = newSubagentsDialog() |
| 589 | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") | 589 | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") | ||
| 590 | _ = d.Render(tui.Theme{}, 80) | 590 | _ = d.Render(tui.Theme{}, 80) | ||
| 591 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | 591 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | ||
| @@ -597,8 +597,8 @@ func TestNeedsTickRefresh(t *testing.T) { | |||||
| 597 | } | 597 | } | ||
| 598 | 598 | ||||
| 599 | // Prompt editor open: same suppression. | 599 | // Prompt editor open: same suppression. | ||
| 600 | - | d = newSwarmDialog() | 600 | + | d = newSubagentsDialog() |
| 601 | - | rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | 601 | + | rows = []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}} |
| 602 | d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") | 602 | d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") | ||
| 603 | _ = d.Render(tui.Theme{}, 80) | 603 | _ = d.Render(tui.Theme{}, 80) | ||
| 604 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | 604 | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | ||
| @@ -616,12 +616,12 @@ func TestNeedsTickRefresh(t *testing.T) { | |||||
| 616 | // stayed at the (now-hidden) main editor row when the user | 616 | // stayed at the (now-hidden) main editor row when the user | ||
| 617 | // pressed 'p', so the visible caret looked wrong. | 617 | // pressed 'p', so the visible caret looked wrong. | ||
| 618 | func TestCursorPosPromptEditor(t *testing.T) { | 618 | func TestCursorPosPromptEditor(t *testing.T) { | ||
| 619 | - | rows := []swarm.AgentSnapshot{ | 619 | + | rows := []subagents.AgentSnapshot{ |
| 620 | - | {ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()}, | 620 | + | {ID: "a", Task: "t", Status: subagents.StatusRunning, Started: time.Now()}, |
| 621 | } | 621 | } | ||
| 622 | 622 | ||||
| 623 | // From the list view. | 623 | // From the list view. | ||
| 624 | - | d := newSwarmDialog() | 624 | + | d := newSubagentsDialog() |
| 625 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 625 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 626 | func(string, string) error { return nil }, nil, "") | 626 | func(string, string) error { return nil }, nil, "") | ||
| 627 | _ = d.Render(tui.Theme{}, 80) | 627 | _ = d.Render(tui.Theme{}, 80) | ||
| @@ -635,7 +635,7 @@ func TestCursorPosPromptEditor(t *testing.T) { | |||||
| 635 | } | 635 | } | ||
| 636 | 636 | ||||
| 637 | // From the transcript view (deeper offset due to metadata block). | 637 | // From the transcript view (deeper offset due to metadata block). | ||
| 638 | - | d = newSwarmDialog() | 638 | + | d = newSubagentsDialog() |
| 639 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 639 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 640 | func(string, string) error { return nil }, nil, "") | 640 | func(string, string) error { return nil }, nil, "") | ||
| 641 | _ = d.Render(tui.Theme{}, 80) | 641 | _ = d.Render(tui.Theme{}, 80) | ||
| @@ -651,7 +651,7 @@ func TestCursorPosPromptEditor(t *testing.T) { | |||||
| 651 | } | 651 | } | ||
| 652 | 652 | ||||
| 653 | func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) { | 653 | func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) { | ||
| 654 | - | d := newSwarmDialog() | 654 | + | d := newSubagentsDialog() |
| 655 | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | 655 | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | ||
| 656 | d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "") | 656 | d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "") | ||
| 657 | _ = d.Render(tui.Theme{}, 80) | 657 | _ = d.Render(tui.Theme{}, 80) | ||
| @@ -670,19 +670,19 @@ func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) | |||||
| 670 | } | 670 | } | ||
| 671 | } | 671 | } | ||
| 672 | 672 | ||||
| 673 | - | // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression | 673 | + | // TestSubagentsDialogPOnNonRunningAgentShowsHint covers the regression |
| 674 | // caught by the user's screenshot: pressing 'p' on a detached agent | 674 | // caught by the user's screenshot: pressing 'p' on a detached agent | ||
| 675 | // dialled a dead unix socket and printed the raw path as an error. | 675 | // dialled a dead unix socket and printed the raw path as an error. | ||
| 676 | // We now block the prompt editor for any non-running status and | 676 | // We now block the prompt editor for any non-running status and | ||
| 677 | // surface a clear status-bar hint instead. | 677 | // surface a clear status-bar hint instead. | ||
| 678 | - | func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) { | 678 | + | func TestSubagentsDialogPOnNonRunningAgentShowsHint(t *testing.T) { |
| 679 | - | for _, st := range []swarm.Status{ | 679 | + | for _, st := range []subagents.Status{ |
| 680 | - | swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | 680 | + | subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled, |
| 681 | } { | 681 | } { | ||
| 682 | t.Run(string(st), func(t *testing.T) { | 682 | t.Run(string(st), func(t *testing.T) { | ||
| 683 | - | rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} | 683 | + | rows := []subagents.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} |
| 684 | sends := 0 | 684 | sends := 0 | ||
| 685 | - | d := newSwarmDialog() | 685 | + | d := newSubagentsDialog() |
| 686 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 686 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 687 | func(string, string) error { sends++; return nil }, nil, "") | 687 | func(string, string) error { sends++; return nil }, nil, "") | ||
| 688 | _ = d.Render(tui.Theme{}, 80) | 688 | _ = d.Render(tui.Theme{}, 80) | ||
| @@ -700,19 +700,19 @@ func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) { | |||||
| 700 | if !strings.Contains(errMsg, string(st)) { | 700 | if !strings.Contains(errMsg, string(st)) { | ||
| 701 | t.Errorf("hint = %q; should mention status %q", errMsg, st) | 701 | t.Errorf("hint = %q; should mention status %q", errMsg, st) | ||
| 702 | } | 702 | } | ||
| 703 | - | if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning { | 703 | + | if !strings.Contains(errMsg, "resume") && st != subagents.StatusPending && st != subagents.StatusRunning { |
| 704 | t.Errorf("hint = %q; should mention resume for resumable state", errMsg) | 704 | t.Errorf("hint = %q; should mention resume for resumable state", errMsg) | ||
| 705 | } | 705 | } | ||
| 706 | }) | 706 | }) | ||
| 707 | } | 707 | } | ||
| 708 | } | 708 | } | ||
| 709 | 709 | ||||
| 710 | - | // TestSwarmDialogPOnRunningAgentOpensEditor is the positive | 710 | + | // TestSubagentsDialogPOnRunningAgentOpensEditor is the positive |
| 711 | // complement of the previous test: running agents still open the | 711 | // complement of the previous test: running agents still open the | ||
| 712 | // editor normally. | 712 | // editor normally. | ||
| 713 | - | func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) { | 713 | + | func TestSubagentsDialogPOnRunningAgentOpensEditor(t *testing.T) { |
| 714 | - | rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | 714 | + | rows := []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}} |
| 715 | - | d := newSwarmDialog() | 715 | + | d := newSubagentsDialog() |
| 716 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 716 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 717 | func(string, string) error { return nil }, nil, "") | 717 | func(string, string) error { return nil }, nil, "") | ||
| 718 | _ = d.Render(tui.Theme{}, 80) | 718 | _ = d.Render(tui.Theme{}, 80) | ||
| @@ -726,11 +726,11 @@ func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) { | |||||
| 726 | // user-friendly "isn't accepting input" hint, and unknown errors | 726 | // user-friendly "isn't accepting input" hint, and unknown errors | ||
| 727 | // fall through verbatim. | 727 | // fall through verbatim. | ||
| 728 | func TestFriendlySendErrRewritesNotReady(t *testing.T) { | 728 | func TestFriendlySendErrRewritesNotReady(t *testing.T) { | ||
| 729 | - | hint := friendlySendErr("alpha-1", swarm.ErrNotReady) | 729 | + | hint := friendlySendErr("alpha-1", subagents.ErrNotReady) |
| 730 | if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") { | 730 | if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") { | ||
| 731 | t.Errorf("friendlySendErr(ErrNotReady) = %q", hint) | 731 | t.Errorf("friendlySendErr(ErrNotReady) = %q", hint) | ||
| 732 | } | 732 | } | ||
| 733 | - | if strings.Contains(hint, "swarm:") { | 733 | + | if strings.Contains(hint, "subagent:") { |
| 734 | t.Errorf("friendlySendErr leaked raw error text: %q", hint) | 734 | t.Errorf("friendlySendErr leaked raw error text: %q", hint) | ||
| 735 | } | 735 | } | ||
| 736 | 736 | ||||
| @@ -741,11 +741,11 @@ func TestFriendlySendErrRewritesNotReady(t *testing.T) { | |||||
| 741 | } | 741 | } | ||
| 742 | } | 742 | } | ||
| 743 | 743 | ||||
| 744 | - | // TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat | 744 | + | // TestRenderSupervisorTranscriptBlocks_GroupsByRole asserts the flat |
| 745 | // transcript log is regrouped into user / assistant / stderr / | 745 | // transcript log is regrouped into user / assistant / stderr / | ||
| 746 | // error blocks before rendering. Each output row should be styled | 746 | // error blocks before rendering. Each output row should be styled | ||
| 747 | // for its role rather than printed verbatim. | 747 | // for its role rather than printed verbatim. | ||
| 748 | - | func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) { | 748 | + | func TestRenderSupervisorTranscriptBlocks_GroupsByRole(t *testing.T) { |
| 749 | lines := []string{ | 749 | lines := []string{ | ||
| 750 | "user: hello", | 750 | "user: hello", | ||
| 751 | "hi there", | 751 | "hi there", | ||
| @@ -753,7 +753,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) { | |||||
| 753 | "stderr: warning thing", | 753 | "stderr: warning thing", | ||
| 754 | "error: boom", | 754 | "error: boom", | ||
| 755 | } | 755 | } | ||
| 756 | - | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false) | 756 | + | out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false) |
| 757 | joined := strings.Join(out, "\n") | 757 | joined := strings.Join(out, "\n") | ||
| 758 | 758 | ||||
| 759 | // The user echo should appear as a bubble row, which contains | 759 | // The user echo should appear as a bubble row, which contains | ||
| @@ -780,17 +780,17 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) { | |||||
| 780 | } | 780 | } | ||
| 781 | } | 781 | } | ||
| 782 | 782 | ||||
| 783 | - | // TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the | 783 | + | // TestRenderSupervisorTranscriptBlocks_MarkdownStyling proves the |
| 784 | // assistant text goes through tui.RenderMarkdown: a literal **bold** | 784 | // assistant text goes through tui.RenderMarkdown: a literal **bold** | ||
| 785 | // pair should become an ANSI bold sequence and bullet "-" should | 785 | // pair should become an ANSI bold sequence and bullet "-" should | ||
| 786 | // become "•". | 786 | // become "•". | ||
| 787 | - | func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) { | 787 | + | func TestRenderSupervisorTranscriptBlocks_MarkdownStyling(t *testing.T) { |
| 788 | lines := []string{ | 788 | lines := []string{ | ||
| 789 | "Here is a list:", | 789 | "Here is a list:", | ||
| 790 | "- **bold** item", | 790 | "- **bold** item", | ||
| 791 | "- plain item", | 791 | "- plain item", | ||
| 792 | } | 792 | } | ||
| 793 | - | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false) | 793 | + | out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false) |
| 794 | joined := strings.Join(out, "\n") | 794 | joined := strings.Join(out, "\n") | ||
| 795 | 795 | ||||
| 796 | // ANSI bold on/off. Markdown rendering uses CSI 1 / 22. | 796 | // ANSI bold on/off. Markdown rendering uses CSI 1 / 22. | ||
| @@ -807,11 +807,11 @@ func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) { | |||||
| 807 | } | 807 | } | ||
| 808 | } | 808 | } | ||
| 809 | 809 | ||||
| 810 | - | // TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines | 810 | + | // TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines |
| 811 | // guards the contract that consecutive assistant lines form one | 811 | // guards the contract that consecutive assistant lines form one | ||
| 812 | // markdown block. If we render each line in isolation, fenced code | 812 | // markdown block. If we render each line in isolation, fenced code | ||
| 813 | // (and other multi-line constructs) would never close. | 813 | // (and other multi-line constructs) would never close. | ||
| 814 | - | func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) { | 814 | + | func TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) { |
| 815 | lines := []string{ | 815 | lines := []string{ | ||
| 816 | "Some prose.", | 816 | "Some prose.", | ||
| 817 | "```go", | 817 | "```go", | ||
| @@ -821,7 +821,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) | |||||
| 821 | "```", | 821 | "```", | ||
| 822 | "Done.", | 822 | "Done.", | ||
| 823 | } | 823 | } | ||
| 824 | - | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false) | 824 | + | out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false) |
| 825 | joined := strings.Join(out, "\n") | 825 | joined := strings.Join(out, "\n") | ||
| 826 | 826 | ||||
| 827 | // The literal fence markers should be consumed by the markdown | 827 | // The literal fence markers should be consumed by the markdown | ||
| @@ -838,19 +838,19 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) | |||||
| 838 | } | 838 | } | ||
| 839 | } | 839 | } | ||
| 840 | 840 | ||||
| 841 | - | // TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt- | 841 | + | // TestSubagentsDialogKOnNonRunningAgentShowsHint mirrors the prompt- |
| 842 | // disabled test: pressing 'k' on an already-terminal or detached | 842 | // disabled test: pressing 'k' on an already-terminal or detached | ||
| 843 | - | // agent now surfaces a hint instead of routing to Swarm.Stop, which | 843 | + | // agent now surfaces a hint instead of routing to Supervisor.Stop, which |
| 844 | // is a no-op for those states (and used to segfault for detached | 844 | // is a no-op for those states (and used to segfault for detached | ||
| 845 | // agents before the cancel-nil guard). | 845 | // agents before the cancel-nil guard). | ||
| 846 | - | func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) { | 846 | + | func TestSubagentsDialogKOnNonRunningAgentShowsHint(t *testing.T) { |
| 847 | - | for _, st := range []swarm.Status{ | 847 | + | for _, st := range []subagents.Status{ |
| 848 | - | swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | 848 | + | subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled, |
| 849 | } { | 849 | } { | ||
| 850 | t.Run(string(st), func(t *testing.T) { | 850 | t.Run(string(st), func(t *testing.T) { | ||
| 851 | - | rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} | 851 | + | rows := []subagents.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} |
| 852 | stops := 0 | 852 | stops := 0 | ||
| 853 | - | d := newSwarmDialog() | 853 | + | d := newSubagentsDialog() |
| 854 | d.Open(staticSnapshots(rows...), | 854 | d.Open(staticSnapshots(rows...), | ||
| 855 | func(string) error { stops++; return nil }, | 855 | func(string) error { stops++; return nil }, | ||
| 856 | nil, nil, nil, nil, "") | 856 | nil, nil, nil, nil, "") | ||
| @@ -866,14 +866,14 @@ func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) { | |||||
| 866 | } | 866 | } | ||
| 867 | } | 867 | } | ||
| 868 | 868 | ||||
| 869 | - | // TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn | 869 | + | // TestSubagentsDialogSpawnFlow_CurrentModelInherited proves the spawn |
| 870 | // callback receives whatever model the host pinned via | 870 | // callback receives whatever model the host pinned via | ||
| 871 | // SetCurrentModel before opening the dialog. This is how /model is | 871 | // SetCurrentModel before opening the dialog. This is how /model is | ||
| 872 | - | // surfaced to swarm agents: the user picks via /model first, then | 872 | + | // surfaced to subagent agents: the user picks via /model first, then |
| 873 | - | // /swarm n, and the new agent inherits. | 873 | + | // /subagents n, and the new agent inherits. |
| 874 | - | func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) { | 874 | + | func TestSubagentsDialogSpawnFlow_CurrentModelInherited(t *testing.T) { |
| 875 | var gotTask, gotModel, gotProv string | 875 | var gotTask, gotModel, gotProv string | ||
| 876 | - | d := newSwarmDialog() | 876 | + | d := newSubagentsDialog() |
| 877 | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | 877 | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | ||
| 878 | d.Open(staticSnapshots(), nil, nil, | 878 | d.Open(staticSnapshots(), nil, nil, | ||
| 879 | func(task, model, prov string) error { | 879 | func(task, model, prov string) error { | ||
| @@ -909,14 +909,14 @@ func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) { | |||||
| 909 | } | 909 | } | ||
| 910 | } | 910 | } | ||
| 911 | 911 | ||||
| 912 | - | // TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in- | 912 | + | // TestSubagentsDialogSpawnEditor_SlashModelOpensPicker proves the in- |
| 913 | // editor /model command pops the same model dialog the global | 913 | // editor /model command pops the same model dialog the global | ||
| 914 | // /model uses, that picking a model captures it as the pending | 914 | // /model uses, that picking a model captures it as the pending | ||
| 915 | // override, and that the spawn editor reopens with any previously- | 915 | // override, and that the spawn editor reopens with any previously- | ||
| 916 | // typed task text intact so the user doesn't lose work. | 916 | // typed task text intact so the user doesn't lose work. | ||
| 917 | - | func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { | 917 | + | func TestSubagentsDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { |
| 918 | var gotTask, gotModel, gotProv string | 918 | var gotTask, gotModel, gotProv string | ||
| 919 | - | d := newSwarmDialog() | 919 | + | d := newSubagentsDialog() |
| 920 | d.SetCurrentModel("old-model", "old-prov") | 920 | d.SetCurrentModel("old-model", "old-prov") | ||
| 921 | d.Open(staticSnapshots(), nil, nil, | 921 | d.Open(staticSnapshots(), nil, nil, | ||
| 922 | func(task, model, prov string) error { | 922 | func(task, model, prov string) error { | ||
| @@ -984,12 +984,12 @@ func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { | |||||
| 984 | } | 984 | } | ||
| 985 | } | 985 | } | ||
| 986 | 986 | ||||
| 987 | - | // TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms | 987 | + | // TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft confirms |
| 988 | // pressing Esc inside the picker (no selection) reopens the spawn | 988 | // pressing Esc inside the picker (no selection) reopens the spawn | ||
| 989 | // editor with the previously-typed text intact AND leaves the | 989 | // editor with the previously-typed text intact AND leaves the | ||
| 990 | // pending model untouched, so the user can bail out cleanly. | 990 | // pending model untouched, so the user can bail out cleanly. | ||
| 991 | - | func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) { | 991 | + | func TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) { |
| 992 | - | d := newSwarmDialog() | 992 | + | d := newSubagentsDialog() |
| 993 | d.SetCurrentModel("keep-me", "keep-prov") | 993 | d.SetCurrentModel("keep-me", "keep-prov") | ||
| 994 | d.Open(staticSnapshots(), nil, nil, | 994 | d.Open(staticSnapshots(), nil, nil, | ||
| 995 | func(string, string, string) error { return nil }, | 995 | func(string, string, string) error { return nil }, | ||
| @@ -1041,13 +1041,13 @@ func TestStripSlashModelLine(t *testing.T) { | |||||
| 1041 | } | 1041 | } | ||
| 1042 | } | 1042 | } | ||
| 1043 | 1043 | ||||
| 1044 | - | // TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that | 1044 | + | // TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault confirms that |
| 1045 | // when the host doesn't pin a model (SetCurrentModel never called or | 1045 | // when the host doesn't pin a model (SetCurrentModel never called or | ||
| 1046 | // called with empty strings), the spawn callback receives empty | 1046 | // called with empty strings), the spawn callback receives empty | ||
| 1047 | // model/provider and the child subprocess resolves its own default. | 1047 | // model/provider and the child subprocess resolves its own default. | ||
| 1048 | - | func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) { | 1048 | + | func TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) { |
| 1049 | var gotModel, gotProv string | 1049 | var gotModel, gotProv string | ||
| 1050 | - | d := newSwarmDialog() | 1050 | + | d := newSubagentsDialog() |
| 1051 | d.Open(staticSnapshots(), nil, nil, | 1051 | d.Open(staticSnapshots(), nil, nil, | ||
| 1052 | func(_, model, prov string) error { | 1052 | func(_, model, prov string) error { | ||
| 1053 | gotModel, gotProv = model, prov | 1053 | gotModel, gotProv = model, prov | ||
| @@ -1066,20 +1066,20 @@ func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) { | |||||
| 1066 | } | 1066 | } | ||
| 1067 | } | 1067 | } | ||
| 1068 | 1068 | ||||
| 1069 | - | // TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style | 1069 | + | // TestSupervisorTranscriptGrowsWithNewMessages confirms the /btw-style |
| 1070 | // "the transcript grows with the content" property: when the agent | 1070 | // "the transcript grows with the content" property: when the agent | ||
| 1071 | // reply lands in the snapshot, the next render includes it in full, | 1071 | // reply lands in the snapshot, the next render includes it in full, | ||
| 1072 | // alongside everything that was already there. Replaces the old | 1072 | // alongside everything that was already there. Replaces the old | ||
| 1073 | // followTail/scroll tests that pinned a viewport mechanic we no | 1073 | // followTail/scroll tests that pinned a viewport mechanic we no | ||
| 1074 | // longer have. | 1074 | // longer have. | ||
| 1075 | - | func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) { | 1075 | + | func TestSupervisorTranscriptGrowsWithNewMessages(t *testing.T) { |
| 1076 | - | cur := []swarm.AgentSnapshot{{ | 1076 | + | cur := []subagents.AgentSnapshot{{ |
| 1077 | - | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | 1077 | + | ID: "agent-1", Task: "x", Status: subagents.StatusRunning, |
| 1078 | Lines: []string{"old line 1", "old line 2"}, Started: time.Now(), | 1078 | Lines: []string{"old line 1", "old line 2"}, Started: time.Now(), | ||
| 1079 | }} | 1079 | }} | ||
| 1080 | - | snap := func() []swarm.AgentSnapshot { return cur } | 1080 | + | snap := func() []subagents.AgentSnapshot { return cur } |
| 1081 | 1081 | ||||
| 1082 | - | d := newSwarmDialog() | 1082 | + | d := newSubagentsDialog() |
| 1083 | d.Open(snap, nil, nil, nil, | 1083 | d.Open(snap, nil, nil, nil, | ||
| 1084 | func(string, string) error { return nil }, | 1084 | func(string, string) error { return nil }, | ||
| 1085 | nil, "") | 1085 | nil, "") | ||
| @@ -1093,8 +1093,8 @@ func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) { | |||||
| 1093 | 1093 | ||||
| 1094 | // Agent appends a fresh reply: the very next render must show | 1094 | // Agent appends a fresh reply: the very next render must show | ||
| 1095 | // both the new content AND keep the old content (no clipping). | 1095 | // both the new content AND keep the old content (no clipping). | ||
| 1096 | - | cur = []swarm.AgentSnapshot{{ | 1096 | + | cur = []subagents.AgentSnapshot{{ |
| 1097 | - | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | 1097 | + | ID: "agent-1", Task: "x", Status: subagents.StatusRunning, |
| 1098 | Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"}, | 1098 | Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"}, | ||
| 1099 | Started: cur[0].Started, | 1099 | Started: cur[0].Started, | ||
| 1100 | }} | 1100 | }} | ||
| @@ -1106,19 +1106,19 @@ func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) { | |||||
| 1106 | } | 1106 | } | ||
| 1107 | } | 1107 | } | ||
| 1108 | 1108 | ||||
| 1109 | - | // TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow | 1109 | + | // TestSupervisorTranscriptBtwStyleMultipleSends pins the /btw-style flow |
| 1110 | // for the transcript view: after one Enter-submit the editor stays | 1110 | // for the transcript view: after one Enter-submit the editor stays | ||
| 1111 | // mounted and clears, and a second message can be composed and sent | 1111 | // mounted and clears, and a second message can be composed and sent | ||
| 1112 | // without any modal toggle. Regresses the previous design where | 1112 | // without any modal toggle. Regresses the previous design where | ||
| 1113 | // every send closed the prompt and forced the user to press `p` | 1113 | // every send closed the prompt and forced the user to press `p` | ||
| 1114 | // again. | 1114 | // again. | ||
| 1115 | - | func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) { | 1115 | + | func TestSupervisorTranscriptBtwStyleMultipleSends(t *testing.T) { |
| 1116 | - | rows := []swarm.AgentSnapshot{{ | 1116 | + | rows := []subagents.AgentSnapshot{{ |
| 1117 | - | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), | 1117 | + | ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(), |
| 1118 | Lines: []string{"warm-up"}, | 1118 | Lines: []string{"warm-up"}, | ||
| 1119 | }} | 1119 | }} | ||
| 1120 | var sent []string | 1120 | var sent []string | ||
| 1121 | - | d := newSwarmDialog() | 1121 | + | d := newSubagentsDialog() |
| 1122 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 1122 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 1123 | func(_, text string) error { sent = append(sent, text); return nil }, | 1123 | func(_, text string) error { sent = append(sent, text); return nil }, | ||
| 1124 | nil, "") | 1124 | nil, "") | ||
| @@ -1145,18 +1145,18 @@ func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) { | |||||
| 1145 | } | 1145 | } | ||
| 1146 | } | 1146 | } | ||
| 1147 | 1147 | ||||
| 1148 | - | // TestSwarmTranscriptEscClosesView pins the Esc semantics for the | 1148 | + | // TestSupervisorTranscriptEscClosesView pins the Esc semantics for the |
| 1149 | // /btw-style transcript view: a single Esc closes the whole view | 1149 | // /btw-style transcript view: a single Esc closes the whole view | ||
| 1150 | // (back to the dashboard list), regardless of whether the editor | 1150 | // (back to the dashboard list), regardless of whether the editor | ||
| 1151 | // is currently composing. Without this routing the editor's own | 1151 | // is currently composing. Without this routing the editor's own | ||
| 1152 | // Esc handler would just clear the modal and leave the user stuck | 1152 | // Esc handler would just clear the modal and leave the user stuck | ||
| 1153 | // inside an empty transcript view. | 1153 | // inside an empty transcript view. | ||
| 1154 | - | func TestSwarmTranscriptEscClosesView(t *testing.T) { | 1154 | + | func TestSupervisorTranscriptEscClosesView(t *testing.T) { |
| 1155 | - | rows := []swarm.AgentSnapshot{{ | 1155 | + | rows := []subagents.AgentSnapshot{{ |
| 1156 | - | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), | 1156 | + | ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(), |
| 1157 | Lines: []string{"hi"}, | 1157 | Lines: []string{"hi"}, | ||
| 1158 | }} | 1158 | }} | ||
| 1159 | - | d := newSwarmDialog() | 1159 | + | d := newSubagentsDialog() |
| 1160 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 1160 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 1161 | func(string, string) error { return nil }, | 1161 | func(string, string) error { return nil }, | ||
| 1162 | nil, "") | 1162 | nil, "") | ||
| @@ -1175,17 +1175,17 @@ func TestSwarmTranscriptEscClosesView(t *testing.T) { | |||||
| 1175 | } | 1175 | } | ||
| 1176 | } | 1176 | } | ||
| 1177 | 1177 | ||||
| 1178 | - | // TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net: | 1178 | + | // TestSupervisorTranscriptNonRunningAgentNoEditor pins the safety net: |
| 1179 | // transcripts for done / failed / killed agents must NOT auto-open | 1179 | // transcripts for done / failed / killed agents must NOT auto-open | ||
| 1180 | // an editor (the agent's inbox isn't listening, so a send would | 1180 | // an editor (the agent's inbox isn't listening, so a send would | ||
| 1181 | // just bounce). The view shows the transcript plus a hint about | 1181 | // just bounce). The view shows the transcript plus a hint about | ||
| 1182 | // resuming instead. | 1182 | // resuming instead. | ||
| 1183 | - | func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) { | 1183 | + | func TestSupervisorTranscriptNonRunningAgentNoEditor(t *testing.T) { |
| 1184 | - | rows := []swarm.AgentSnapshot{{ | 1184 | + | rows := []subagents.AgentSnapshot{{ |
| 1185 | - | ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(), | 1185 | + | ID: "agent-1", Task: "x", Status: subagents.StatusDone, Started: time.Now(), |
| 1186 | Lines: []string{"all done"}, | 1186 | Lines: []string{"all done"}, | ||
| 1187 | }} | 1187 | }} | ||
| 1188 | - | d := newSwarmDialog() | 1188 | + | d := newSubagentsDialog() |
| 1189 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 1189 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 1190 | func(string, string) error { return nil }, | 1190 | func(string, string) error { return nil }, | ||
| 1191 | nil, "") | 1191 | nil, "") | ||
| @@ -1200,19 +1200,19 @@ func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) { | |||||
| 1200 | } | 1200 | } | ||
| 1201 | } | 1201 | } | ||
| 1202 | 1202 | ||||
| 1203 | - | // TestSwarmTranscriptTickRefreshWhileEditing pins the streaming | 1203 | + | // TestSupervisorTranscriptTickRefreshWhileEditing pins the streaming |
| 1204 | // fix: with the always-on editor in transcript view, the host's | 1204 | // fix: with the always-on editor in transcript view, the host's | ||
| 1205 | // animation tick MUST still call us so streaming agent output | 1205 | // animation tick MUST still call us so streaming agent output | ||
| 1206 | // renders. Without this, sending a message and watching the reply | 1206 | // renders. Without this, sending a message and watching the reply | ||
| 1207 | // land silently failed because the prompting=true branch was | 1207 | // land silently failed because the prompting=true branch was | ||
| 1208 | // suppressing redraws (like the modal editors do). | 1208 | // suppressing redraws (like the modal editors do). | ||
| 1209 | - | func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) { | 1209 | + | func TestSupervisorTranscriptTickRefreshWhileEditing(t *testing.T) { |
| 1210 | - | rows := []swarm.AgentSnapshot{{ | 1210 | + | rows := []subagents.AgentSnapshot{{ |
| 1211 | - | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | 1211 | + | ID: "agent-1", Task: "x", Status: subagents.StatusRunning, |
| 1212 | Activity: "thinking", Started: time.Now(), | 1212 | Activity: "thinking", Started: time.Now(), | ||
| 1213 | Lines: []string{"hello"}, | 1213 | Lines: []string{"hello"}, | ||
| 1214 | }} | 1214 | }} | ||
| 1215 | - | d := newSwarmDialog() | 1215 | + | d := newSubagentsDialog() |
| 1216 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 1216 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 1217 | func(string, string) error { return nil }, | 1217 | func(string, string) error { return nil }, | ||
| 1218 | nil, "") | 1218 | nil, "") | ||
| @@ -1227,15 +1227,15 @@ func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) { | |||||
| 1227 | } | 1227 | } | ||
| 1228 | } | 1228 | } | ||
| 1229 | 1229 | ||||
| 1230 | - | // TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse: | 1230 | + | // TestSupervisorTranscriptTickRefreshModalSuppressed pins the inverse: |
| 1231 | // the list-view `p` modal (prompting without viewing) still | 1231 | // the list-view `p` modal (prompting without viewing) still | ||
| 1232 | // suppresses tick like the other modal editors, so the cursor | 1232 | // suppresses tick like the other modal editors, so the cursor | ||
| 1233 | // blink works cleanly while composing a one-off prompt. | 1233 | // blink works cleanly while composing a one-off prompt. | ||
| 1234 | - | func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) { | 1234 | + | func TestSupervisorTranscriptTickRefreshModalSuppressed(t *testing.T) { |
| 1235 | - | rows := []swarm.AgentSnapshot{{ | 1235 | + | rows := []subagents.AgentSnapshot{{ |
| 1236 | - | ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(), | 1236 | + | ID: "agent-1", Status: subagents.StatusRunning, Started: time.Now(), |
| 1237 | }} | 1237 | }} | ||
| 1238 | - | d := newSwarmDialog() | 1238 | + | d := newSubagentsDialog() |
| 1239 | d.Open(staticSnapshots(rows...), nil, nil, nil, | 1239 | d.Open(staticSnapshots(rows...), nil, nil, nil, | ||
| 1240 | func(string, string) error { return nil }, | 1240 | func(string, string) error { return nil }, | ||
| 1241 | nil, "") | 1241 | nil, "") | ||
| @@ -1250,18 +1250,18 @@ func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) { | |||||
| 1250 | } | 1250 | } | ||
| 1251 | } | 1251 | } | ||
| 1252 | 1252 | ||||
| 1253 | - | // TestSwarmTranscriptBusySpinnerRenders proves the inline spinner | 1253 | + | // TestSupervisorTranscriptBusySpinnerRenders proves the inline spinner |
| 1254 | // appears above the always-on editor while the agent is busy and | 1254 | // appears above the always-on editor while the agent is busy and | ||
| 1255 | // disappears when it goes idle. This is the /btw-style "something | 1255 | // disappears when it goes idle. This is the /btw-style "something | ||
| 1256 | // is happening" feedback the user was missing. | 1256 | // is happening" feedback the user was missing. | ||
| 1257 | - | func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) { | 1257 | + | func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) { |
| 1258 | - | cur := []swarm.AgentSnapshot{{ | 1258 | + | cur := []subagents.AgentSnapshot{{ |
| 1259 | - | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | 1259 | + | ID: "agent-1", Task: "x", Status: subagents.StatusRunning, |
| 1260 | Activity: "thinking", Started: time.Now(), | 1260 | Activity: "thinking", Started: time.Now(), | ||
| 1261 | Lines: []string{"hello"}, | 1261 | Lines: []string{"hello"}, | ||
| 1262 | }} | 1262 | }} | ||
| 1263 | - | snap := func() []swarm.AgentSnapshot { return cur } | 1263 | + | snap := func() []subagents.AgentSnapshot { return cur } |
| 1264 | - | d := newSwarmDialog() | 1264 | + | d := newSubagentsDialog() |
| 1265 | d.Open(snap, nil, nil, nil, | 1265 | d.Open(snap, nil, nil, nil, | ||
| 1266 | func(string, string) error { return nil }, | 1266 | func(string, string) error { return nil }, | ||
| 1267 | nil, "") | 1267 | nil, "") | ||
| @@ -1289,9 +1289,9 @@ func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) { | |||||
| 1289 | } | 1289 | } | ||
| 1290 | 1290 | ||||
| 1291 | func TestStatusLabelCoversAll(t *testing.T) { | 1291 | func TestStatusLabelCoversAll(t *testing.T) { | ||
| 1292 | - | for _, s := range []swarm.Status{ | 1292 | + | for _, s := range []subagents.Status{ |
| 1293 | - | swarm.StatusPending, swarm.StatusRunning, | 1293 | + | subagents.StatusPending, subagents.StatusRunning, |
| 1294 | - | swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | 1294 | + | subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled, |
| 1295 | } { | 1295 | } { | ||
| 1296 | if statusLabel(s) == "" { | 1296 | if statusLabel(s) == "" { | ||
| 1297 | t.Fatalf("no label for %s", s) | 1297 | t.Fatalf("no label for %s", s) | ||
A packages/agent/modes/subagents_slash.go +468 -0
| @@ -0,0 +1,468 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // runSubagents dispatches /subagents subcommands. Layout: | |||
| 14 | + | // | |||
| 15 | + | // /subagents -> open the dashboard | |||
| 16 | + | // /subagents list -> open the dashboard | |||
| 17 | + | // /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...> | |||
| 18 | + | // -> spawn an agent with optional profile/model overrides | |||
| 19 | + | // /subagents kill <id> -> stop a running agent | |||
| 20 | + | // /subagents cancel <id> -> cancel the active turn | |||
| 21 | + | // /subagents remove <id> -> tear down a terminated agent | |||
| 22 | + | // /subagents logs <id> -> open the scrollable transcript view | |||
| 23 | + | // /subagents result|inspect <id> -> show the completed result | |||
| 24 | + | // /subagents wait <id> -> wait for the agent to finish | |||
| 25 | + | // /subagents send <id> <text...> -> send a follow-up user turn to <id> | |||
| 26 | + | // /subagents resume [id] -> resume an agent (omit id to pick from a list) | |||
| 27 | + | // /subagents restart-task|restart <id> -> restart the original task | |||
| 28 | + | // /subagents attach <id> -> (planned) drop into the agent's TUI | |||
| 29 | + | // | |||
| 30 | + | // When cfg.Supervisor is nil the command tells the user the feature is | |||
| 31 | + | // disabled instead of pretending to work. | |||
| 32 | + | func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 33 | + | if i.cfg.Supervisor == nil { | |||
| 34 | + | i.mu.Lock() | |||
| 35 | + | i.statusErr = "subagent is disabled in this build" | |||
| 36 | + | i.statusOK = "" | |||
| 37 | + | i.mu.Unlock() | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | sub := "" | |||
| 42 | + | rest := "" | |||
| 43 | + | if len(args) > 0 { | |||
| 44 | + | sub = strings.ToLower(args[0]) | |||
| 45 | + | // Guard the args[1:] reslice: when only the subcommand was | |||
| 46 | + | // typed (e.g. bare "/subagents new"), args has length 1 and the | |||
| 47 | + | // naive args[1:] is fine, but when args is empty (bare | |||
| 48 | + | // "/subagents") the reslice is [1:0] and panics. The len>0 branch | |||
| 49 | + | // here keeps both cases safe. | |||
| 50 | + | if len(args) > 1 { | |||
| 51 | + | rest = strings.TrimSpace(strings.Join(args[1:], " ")) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // spawnAdapter, sendAdapter, resumeAdapter wrap the Supervisor methods | |||
| 56 | + | // in the signatures the dialog expects. Defined once here so the | |||
| 57 | + | // three Open()-shaped entry points (list, logs/view-jump, resume) | |||
| 58 | + | // feed the dialog identical callbacks. | |||
| 59 | + | spawnAdapter := func(task, model, provider string) error { | |||
| 60 | + | _, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{ | |||
| 61 | + | Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning, | |||
| 62 | + | }) | |||
| 63 | + | return err | |||
| 64 | + | } | |||
| 65 | + | sendAdapter := func(id, text string) error { | |||
| 66 | + | return i.cfg.Supervisor.SendUserTurn(id, text) | |||
| 67 | + | } | |||
| 68 | + | resumeAdapter := func(id string) error { | |||
| 69 | + | _, err := i.cfg.Supervisor.ResumeSession(ctx, id) | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Pin every fresh spawn to whatever the host's /model selection | |||
| 74 | + | // is right now. This is captured at /subagents time — if the user | |||
| 75 | + | // wants a different model for the next subagent agent, they pick it | |||
| 76 | + | // via /model first (globally), or, while inside the spawn | |||
| 77 | + | // editor, by typing /model on its own line to pop the picker. | |||
| 78 | + | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | |||
| 79 | + | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | |||
| 80 | + | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |||
| 81 | + | if i.cfg.LoggedInProviders != nil { | |||
| 82 | + | i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | switch sub { | |||
| 86 | + | case "", "list", "ls", "ps": | |||
| 87 | + | i.subagentsDialog.Open( | |||
| 88 | + | i.cfg.Supervisor.SnapshotAll, | |||
| 89 | + | i.cfg.Supervisor.Stop, | |||
| 90 | + | i.cfg.Supervisor.Remove, | |||
| 91 | + | spawnAdapter, | |||
| 92 | + | sendAdapter, | |||
| 93 | + | resumeAdapter, | |||
| 94 | + | i.cfg.CWD, | |||
| 95 | + | ) | |||
| 96 | + | case "new", "spawn": | |||
| 97 | + | if rest == "" { | |||
| 98 | + | i.subagentsStatus("", "/subagents new <task>: missing task") | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | // Permit profile/model/provider/reasoning flags before the task | |||
| 102 | + | // so scripts can pin a child without going through the dialog. | |||
| 103 | + | // Anything that isn't a recognised flag terminates parsing and | |||
| 104 | + | // the rest becomes the task; only leading flags are consumed. | |||
| 105 | + | model, provider, reasoning, subagent, task := parseSpawnFlags(rest) | |||
| 106 | + | var profileTools []string | |||
| 107 | + | var fastModeOverride *bool | |||
| 108 | + | if task == "" { | |||
| 109 | + | i.subagentsStatus("", "/subagents new: missing task (after any spawn flags)") | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | if subagent != "" { | |||
| 113 | + | if i.cfg.ResolveSubagent == nil { | |||
| 114 | + | i.subagentsStatus("", "spawn: named subagent profiles are unavailable") | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | profile, err := i.cfg.ResolveSubagent(subagent) | |||
| 118 | + | if err != nil { | |||
| 119 | + | i.subagentsStatus("", "spawn: "+err.Error()) | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | if profile == nil { | |||
| 123 | + | i.subagentsStatus("", "spawn: unknown subagent profile "+subagent) | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | subagent = profile.Name | |||
| 127 | + | profileTools = append([]string(nil), profile.Tools...) | |||
| 128 | + | fastModeOverride = profile.FastMode | |||
| 129 | + | profileProvider, profileModel := profile.ModelSelection() | |||
| 130 | + | if model == "" { | |||
| 131 | + | model = profileModel | |||
| 132 | + | } | |||
| 133 | + | if provider == "" { | |||
| 134 | + | provider = profileProvider | |||
| 135 | + | } | |||
| 136 | + | if reasoning == "" { | |||
| 137 | + | reasoning = profile.Thinking | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | if model == "" { | |||
| 141 | + | model = i.cfg.Model | |||
| 142 | + | } | |||
| 143 | + | if provider == "" { | |||
| 144 | + | provider = i.cfg.Provider | |||
| 145 | + | } | |||
| 146 | + | if reasoning == "" { | |||
| 147 | + | reasoning = i.cfg.Reasoning | |||
| 148 | + | } | |||
| 149 | + | if reasoning != "" { | |||
| 150 | + | normalizedReasoning, err := normalizeSpawnReasoning(reasoning) | |||
| 151 | + | if err != nil { | |||
| 152 | + | i.subagentsStatus("", "spawn: "+err.Error()) | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | reasoning = normalizedReasoning | |||
| 156 | + | } | |||
| 157 | + | a, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{ | |||
| 158 | + | Task: task, Model: model, Provider: provider, Reasoning: reasoning, | |||
| 159 | + | FastMode: fastModeOverride, Subagent: subagent, | |||
| 160 | + | Tools: profileTools, | |||
| 161 | + | }) | |||
| 162 | + | if err != nil { | |||
| 163 | + | i.subagentsStatus("", "spawn: "+err.Error()) | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | status := "spawned " + a.ID | |||
| 167 | + | if subagent != "" { | |||
| 168 | + | status += " (agent " + subagent + ")" | |||
| 169 | + | } else if model != "" { | |||
| 170 | + | status += " (model " + model + ")" | |||
| 171 | + | } | |||
| 172 | + | i.subagentsStatus(status, "") | |||
| 173 | + | case "kill", "stop": | |||
| 174 | + | if rest == "" { | |||
| 175 | + | i.subagentsStatus("", "/subagents kill <id>: missing id") | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | if err := i.cfg.Supervisor.Stop(rest); err != nil { | |||
| 179 | + | i.subagentsStatus("", "kill: "+err.Error()) | |||
| 180 | + | return | |||
| 181 | + | } | |||
| 182 | + | i.subagentsStatus("stopped "+rest, "") | |||
| 183 | + | case "cancel": | |||
| 184 | + | if rest == "" { | |||
| 185 | + | i.subagentsStatus("", "/subagents cancel <id>: missing id") | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 188 | + | if err := i.cfg.Supervisor.CancelTurn(rest); err != nil { | |||
| 189 | + | i.subagentsStatus("", "cancel: "+err.Error()) | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | i.subagentsStatus("canceling turn "+rest, "") | |||
| 193 | + | case "remove", "rm": | |||
| 194 | + | if rest == "" { | |||
| 195 | + | i.subagentsStatus("", "/subagents remove <id>: missing id") | |||
| 196 | + | return | |||
| 197 | + | } | |||
| 198 | + | if err := i.cfg.Supervisor.Remove(rest); err != nil { | |||
| 199 | + | i.subagentsStatus("", "remove: "+err.Error()) | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | i.subagentsStatus("removed "+rest, "") | |||
| 203 | + | case "logs", "log", "view": | |||
| 204 | + | if rest == "" { | |||
| 205 | + | i.subagentsStatus("", "/subagents logs <id>: missing id") | |||
| 206 | + | return | |||
| 207 | + | } | |||
| 208 | + | ok := i.subagentsDialog.OpenViewing( | |||
| 209 | + | rest, | |||
| 210 | + | i.cfg.Supervisor.SnapshotAll, | |||
| 211 | + | i.cfg.Supervisor.Stop, | |||
| 212 | + | i.cfg.Supervisor.Remove, | |||
| 213 | + | spawnAdapter, | |||
| 214 | + | sendAdapter, | |||
| 215 | + | resumeAdapter, | |||
| 216 | + | i.cfg.CWD, | |||
| 217 | + | ) | |||
| 218 | + | if !ok { | |||
| 219 | + | i.subagentsStatus("", "/subagents logs: no agent matching "+rest) | |||
| 220 | + | } | |||
| 221 | + | case "result", "inspect": | |||
| 222 | + | if rest == "" { | |||
| 223 | + | i.subagentsStatus("", "/subagents result <id>: missing id") | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | result, err := i.cfg.Supervisor.ReadResult(rest) | |||
| 227 | + | if err != nil { | |||
| 228 | + | i.subagentsStatus("", "result: "+err.Error()) | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | status := fmt.Sprintf("%s result %s", result.Status, i.cfg.Supervisor.ResultReference(rest)) | |||
| 232 | + | if result.Summary != "" { | |||
| 233 | + | status += ": " + truncateStatus(result.Summary, 160) | |||
| 234 | + | } | |||
| 235 | + | i.subagentsStatus(status, "") | |||
| 236 | + | case "wait": | |||
| 237 | + | if rest == "" { | |||
| 238 | + | i.subagentsStatus("", "/subagents wait <id>: missing id") | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | a := i.cfg.Supervisor.Get(rest) | |||
| 242 | + | if a == nil { | |||
| 243 | + | i.subagentsStatus("", "wait: no such agent "+rest) | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | waitWatcherDone := i.subagentsWaitWatcherDone | |||
| 247 | + | go func() { | |||
| 248 | + | defer func() { | |||
| 249 | + | if waitWatcherDone != nil { | |||
| 250 | + | waitWatcherDone() | |||
| 251 | + | } | |||
| 252 | + | }() | |||
| 253 | + | if err := a.WaitContext(ctx); err != nil { | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | // If cancellation became observable at the same time as | |||
| 257 | + | // completion, do not report a completion that the caller | |||
| 258 | + | // no longer owns. | |||
| 259 | + | if ctx.Err() == nil { | |||
| 260 | + | i.subagentsStatus("completed "+a.ID, "") | |||
| 261 | + | } | |||
| 262 | + | }() | |||
| 263 | + | i.subagentsStatus("waiting for "+a.ID, "") | |||
| 264 | + | case "resume-session", "resume", "reattach", "reopen": | |||
| 265 | + | if rest == "" { | |||
| 266 | + | count := i.subagentsDialog.OpenForResume( | |||
| 267 | + | i.cfg.Supervisor.SnapshotAll, | |||
| 268 | + | i.cfg.Supervisor.Stop, | |||
| 269 | + | i.cfg.Supervisor.Remove, | |||
| 270 | + | spawnAdapter, | |||
| 271 | + | sendAdapter, | |||
| 272 | + | resumeAdapter, | |||
| 273 | + | i.cfg.CWD, | |||
| 274 | + | ) | |||
| 275 | + | switch count { | |||
| 276 | + | case 0: | |||
| 277 | + | i.subagentsStatus("", "/subagents resume-session: no resumable agents") | |||
| 278 | + | case 1: | |||
| 279 | + | i.subagentsStatus("1 resumable agent, press R to resume", "") | |||
| 280 | + | default: | |||
| 281 | + | i.subagentsStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "") | |||
| 282 | + | } | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | a, err := i.cfg.Supervisor.ResumeSession(ctx, rest) | |||
| 286 | + | if err != nil { | |||
| 287 | + | i.subagentsStatus("", "resume-session: "+err.Error()) | |||
| 288 | + | return | |||
| 289 | + | } | |||
| 290 | + | i.subagentsStatus("resumed "+a.ID, "") | |||
| 291 | + | case "restart-task", "restart": | |||
| 292 | + | if rest == "" { | |||
| 293 | + | i.subagentsStatus("", "/subagents restart-task <id>: missing id") | |||
| 294 | + | return | |||
| 295 | + | } | |||
| 296 | + | a, err := i.cfg.Supervisor.RestartTask(ctx, rest) | |||
| 297 | + | if err != nil { | |||
| 298 | + | i.subagentsStatus("", "restart-task: "+err.Error()) | |||
| 299 | + | return | |||
| 300 | + | } | |||
| 301 | + | i.subagentsStatus("restarted task "+a.ID, "") | |||
| 302 | + | case "send", "prompt", "msg": | |||
| 303 | + | // /subagents send <id> <text...> is the non-interactive | |||
| 304 | + | // counterpart of pressing 'p' in the dashboard. We split the | |||
| 305 | + | // joined `rest` ourselves rather than reusing the dispatcher's | |||
| 306 | + | // already-fielded args[] because the text may contain spaces | |||
| 307 | + | // the user expects to be preserved verbatim. | |||
| 308 | + | id, text := splitIDAndRest(rest) | |||
| 309 | + | if id == "" { | |||
| 310 | + | i.subagentsStatus("", "/subagents send <id> <text>: missing id") | |||
| 311 | + | return | |||
| 312 | + | } | |||
| 313 | + | if text == "" { | |||
| 314 | + | i.subagentsStatus("", "/subagents send <id> <text>: missing text") | |||
| 315 | + | return | |||
| 316 | + | } | |||
| 317 | + | if err := i.cfg.Supervisor.SendUserTurn(id, text); err != nil { | |||
| 318 | + | i.subagentsStatus("", friendlySendErr(id, err)) | |||
| 319 | + | return | |||
| 320 | + | } | |||
| 321 | + | i.subagentsStatus("sent to "+id, "") | |||
| 322 | + | case "attach": | |||
| 323 | + | // PTY-reparenting is a significant chunk of work I haven't | |||
| 324 | + | // landed yet (see the design sketch). Recognise the name so | |||
| 325 | + | // /subagents attach doesn't fall through to the generic "unknown | |||
| 326 | + | // subcommand" path — that error message is misleading because | |||
| 327 | + | // it makes attach sound like a typo instead of a planned | |||
| 328 | + | // feature. Point the user at /subagents logs in the meantime. | |||
| 329 | + | i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.") | |||
| 330 | + | default: | |||
| 331 | + | i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)") | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | // normalizeSpawnReasoning validates and canonicalizes the reasoning levels | |||
| 336 | + | // accepted by ParseArgs before a child process is launched. | |||
| 337 | + | func normalizeSpawnReasoning(value string) (string, error) { | |||
| 338 | + | raw := strings.ToLower(strings.TrimSpace(value)) | |||
| 339 | + | switch raw { | |||
| 340 | + | case "", "off": | |||
| 341 | + | return raw, nil | |||
| 342 | + | case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max": | |||
| 343 | + | return provider.NormalizeReasoning(raw), nil | |||
| 344 | + | default: | |||
| 345 | + | return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max") | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // parseSpawnFlags consumes leading profile/model/provider/reasoning flags | |||
| 350 | + | // using both separate-value and equals forms. Flags after the task remain | |||
| 351 | + | // part of the task text. | |||
| 352 | + | func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) { | |||
| 353 | + | fields := strings.Fields(s) | |||
| 354 | + | i := 0 | |||
| 355 | + | for i < len(fields) { | |||
| 356 | + | f := fields[i] | |||
| 357 | + | switch { | |||
| 358 | + | case f == "--agent": | |||
| 359 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 360 | + | subagent = fields[i+1] | |||
| 361 | + | i += 2 | |||
| 362 | + | } else { | |||
| 363 | + | i++ | |||
| 364 | + | } | |||
| 365 | + | continue | |||
| 366 | + | case strings.HasPrefix(f, "--agent="): | |||
| 367 | + | subagent = strings.TrimPrefix(f, "--agent=") | |||
| 368 | + | i++ | |||
| 369 | + | continue | |||
| 370 | + | case f == "--model": | |||
| 371 | + | // Consume the flag even when no value follows so a | |||
| 372 | + | // dangling "--model" doesn't leak into the task. The | |||
| 373 | + | // caller surfaces "missing task" instead. | |||
| 374 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 375 | + | model = fields[i+1] | |||
| 376 | + | i += 2 | |||
| 377 | + | } else { | |||
| 378 | + | i++ | |||
| 379 | + | } | |||
| 380 | + | continue | |||
| 381 | + | case strings.HasPrefix(f, "--model="): | |||
| 382 | + | model = strings.TrimPrefix(f, "--model=") | |||
| 383 | + | i++ | |||
| 384 | + | continue | |||
| 385 | + | case f == "--provider": | |||
| 386 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 387 | + | provider = fields[i+1] | |||
| 388 | + | i += 2 | |||
| 389 | + | } else { | |||
| 390 | + | i++ | |||
| 391 | + | } | |||
| 392 | + | continue | |||
| 393 | + | case strings.HasPrefix(f, "--provider="): | |||
| 394 | + | provider = strings.TrimPrefix(f, "--provider=") | |||
| 395 | + | i++ | |||
| 396 | + | continue | |||
| 397 | + | case f == "--reasoning" || f == "--thinking": | |||
| 398 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 399 | + | reasoning = fields[i+1] | |||
| 400 | + | i += 2 | |||
| 401 | + | } else { | |||
| 402 | + | i++ | |||
| 403 | + | } | |||
| 404 | + | continue | |||
| 405 | + | case strings.HasPrefix(f, "--reasoning="): | |||
| 406 | + | reasoning = strings.TrimPrefix(f, "--reasoning=") | |||
| 407 | + | i++ | |||
| 408 | + | continue | |||
| 409 | + | case strings.HasPrefix(f, "--thinking="): | |||
| 410 | + | reasoning = strings.TrimPrefix(f, "--thinking=") | |||
| 411 | + | i++ | |||
| 412 | + | continue | |||
| 413 | + | } | |||
| 414 | + | break | |||
| 415 | + | } | |||
| 416 | + | task = strings.TrimSpace(strings.Join(fields[i:], " ")) | |||
| 417 | + | return | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | // splitIDAndRest splits "<id> <text...>" into (id, text). The text | |||
| 421 | + | // half preserves all whitespace after the first token so the agent | |||
| 422 | + | // receives the user's prompt verbatim (modulo a single trim of the | |||
| 423 | + | // boundary space). Returns ("", "") when s is empty so the caller | |||
| 424 | + | // can surface a missing-id error. | |||
| 425 | + | func splitIDAndRest(s string) (id, text string) { | |||
| 426 | + | s = strings.TrimLeft(s, " \t") | |||
| 427 | + | if s == "" { | |||
| 428 | + | return "", "" | |||
| 429 | + | } | |||
| 430 | + | cut := strings.IndexAny(s, " \t") | |||
| 431 | + | if cut < 0 { | |||
| 432 | + | return s, "" | |||
| 433 | + | } | |||
| 434 | + | return s[:cut], strings.TrimLeft(s[cut+1:], " \t") | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | // firstWord returns the first whitespace-separated token of s, or | |||
| 438 | + | // "<id>" when s is empty. Used to keep the "/subagents attach" hint | |||
| 439 | + | // readable even when the user typed no argument. | |||
| 440 | + | func firstWord(s string) string { | |||
| 441 | + | s = strings.TrimSpace(s) | |||
| 442 | + | if s == "" { | |||
| 443 | + | return "<id>" | |||
| 444 | + | } | |||
| 445 | + | if i := strings.IndexAny(s, " \t"); i >= 0 { | |||
| 446 | + | return s[:i] | |||
| 447 | + | } | |||
| 448 | + | return s | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | func truncateStatus(value string, max int) string { | |||
| 452 | + | value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ") | |||
| 453 | + | if len([]rune(value)) <= max { | |||
| 454 | + | return value | |||
| 455 | + | } | |||
| 456 | + | if max <= 3 { | |||
| 457 | + | return string([]rune(value)[:max]) | |||
| 458 | + | } | |||
| 459 | + | return string([]rune(value)[:max-3]) + "..." | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func (i *Interactive) subagentsStatus(ok, errMsg string) { | |||
| 463 | + | i.mu.Lock() | |||
| 464 | + | i.statusOK = ok | |||
| 465 | + | i.statusErr = errMsg | |||
| 466 | + | i.mu.Unlock() | |||
| 467 | + | i.invalidate() | |||
| 468 | + | } | |||
A packages/agent/modes/subagents_slash_test.go +401 -0
| @@ -0,0 +1,401 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "runtime" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newInteractiveForSupervisorTest builds the minimal Interactive scaffolding | |||
| 14 | + | // runSubagents needs. It does NOT call NewInteractive (which would pull in | |||
| 15 | + | // the whole TUI); the runSubagents method only touches cfg.Supervisor, the | |||
| 16 | + | // status mutex, and the subagent dialog, so we hand-build those. | |||
| 17 | + | func newInteractiveForSupervisorTest(t *testing.T) (*Interactive, *subagents.Supervisor) { | |||
| 18 | + | t.Helper() | |||
| 19 | + | root := t.TempDir() | |||
| 20 | + | f := subagents.New(subagents.Config{ | |||
| 21 | + | Root: root, | |||
| 22 | + | RepoRoot: root, | |||
| 23 | + | NewRunner: func(a *subagents.Agent) subagents.Runner { | |||
| 24 | + | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |||
| 25 | + | <-ctx.Done() | |||
| 26 | + | return ctx.Err() | |||
| 27 | + | }) | |||
| 28 | + | }, | |||
| 29 | + | }) | |||
| 30 | + | iv := &Interactive{ | |||
| 31 | + | subagentsDialog: newSubagentsDialog(), | |||
| 32 | + | dirty: make(chan struct{}, 1), | |||
| 33 | + | } | |||
| 34 | + | iv.cfg.Supervisor = f | |||
| 35 | + | return iv, f | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // TestRunSupervisorBareDoesNotPanic regression-tests the slice-out-of-range | |||
| 39 | + | // panic that hit when /subagents was typed with no subcommand: runSubagents | |||
| 40 | + | // did args[1:] without checking len(args), which panics as [1:0]. | |||
| 41 | + | func TestRunSupervisorBareDoesNotPanic(t *testing.T) { | |||
| 42 | + | iv, _ := newInteractiveForSupervisorTest(t) | |||
| 43 | + | defer iv.cfg.Supervisor.StopAll() | |||
| 44 | + | ||||
| 45 | + | // Bare /subagents: parts[1:] from the dispatcher is an empty slice. | |||
| 46 | + | iv.runSubagents(context.Background(), nil) | |||
| 47 | + | ||||
| 48 | + | if !iv.subagentsDialog.Active() { | |||
| 49 | + | t.Fatal("bare /subagents should open the dashboard") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) { | |||
| 54 | + | iv, _ := newInteractiveForSupervisorTest(t) | |||
| 55 | + | defer iv.cfg.Supervisor.StopAll() | |||
| 56 | + | ||||
| 57 | + | // Each row is the slice that the dispatcher hands to runSubagents — | |||
| 58 | + | // i.e. parts[1:] where parts was strings.Fields of the slash | |||
| 59 | + | // command. Mixing zero-arg and arg'd forms exercises both | |||
| 60 | + | // branches of the reslice guard. | |||
| 61 | + | cases := [][]string{ | |||
| 62 | + | {"list"}, | |||
| 63 | + | {"new"}, | |||
| 64 | + | {"new", "fix", "the", "thing"}, | |||
| 65 | + | {"kill"}, | |||
| 66 | + | {"kill", "no-such-id"}, | |||
| 67 | + | {"remove"}, | |||
| 68 | + | {"remove", "no-such-id"}, | |||
| 69 | + | {"send"}, | |||
| 70 | + | {"send", "no-such-id"}, | |||
| 71 | + | {"send", "no-such-id", "hello", "world"}, | |||
| 72 | + | {"bogus"}, | |||
| 73 | + | } | |||
| 74 | + | for _, args := range cases { | |||
| 75 | + | func() { | |||
| 76 | + | defer func() { | |||
| 77 | + | if r := recover(); r != nil { | |||
| 78 | + | t.Fatalf("runSubagents(%v) panicked: %v", args, r) | |||
| 79 | + | } | |||
| 80 | + | }() | |||
| 81 | + | iv.runSubagents(context.Background(), args) | |||
| 82 | + | }() | |||
| 83 | + | } | |||
| 84 | + | iv.mu.Lock() | |||
| 85 | + | statusErr := iv.statusErr | |||
| 86 | + | iv.mu.Unlock() | |||
| 87 | + | if !strings.Contains(statusErr, "cancel") || !strings.Contains(statusErr, "wait") { | |||
| 88 | + | t.Fatalf("unknown-command hint = %q; want cancel and wait", statusErr) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestRunSupervisorRejectsNamedProfileWithoutResolver(t *testing.T) { | |||
| 93 | + | iv, f := newInteractiveForSupervisorTest(t) | |||
| 94 | + | defer f.StopAll() | |||
| 95 | + | ||||
| 96 | + | iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"}) | |||
| 97 | + | if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") { | |||
| 98 | + | t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr) | |||
| 99 | + | } | |||
| 100 | + | if got := len(f.List()); got != 0 { | |||
| 101 | + | t.Fatalf("spawned agents = %d, want 0", got) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestRunSupervisorNewSpawnsAgent(t *testing.T) { | |||
| 106 | + | iv, f := newInteractiveForSupervisorTest(t) | |||
| 107 | + | defer f.StopAll() | |||
| 108 | + | ||||
| 109 | + | iv.runSubagents(context.Background(), []string{"new", "do", "stuff"}) | |||
| 110 | + | agents := f.List() | |||
| 111 | + | if len(agents) != 1 { | |||
| 112 | + | t.Fatalf("want 1 agent; got %d", len(agents)) | |||
| 113 | + | } | |||
| 114 | + | if agents[0].Task != "do stuff" { | |||
| 115 | + | t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff") | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestRunSupervisorNamedProfileAppliesFastModeRestriction(t *testing.T) { | |||
| 120 | + | root := t.TempDir() | |||
| 121 | + | profileFastMode := false | |||
| 122 | + | f := subagents.New(subagents.Config{ | |||
| 123 | + | Root: root, | |||
| 124 | + | RepoRoot: root, | |||
| 125 | + | FastMode: true, | |||
| 126 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 127 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |||
| 128 | + | <-ctx.Done() | |||
| 129 | + | return ctx.Err() | |||
| 130 | + | }) | |||
| 131 | + | }, | |||
| 132 | + | }) | |||
| 133 | + | defer f.StopAll() | |||
| 134 | + | iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)} | |||
| 135 | + | iv.cfg.Supervisor = f | |||
| 136 | + | iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) { | |||
| 137 | + | if name != "reviewer" { | |||
| 138 | + | return nil, nil | |||
| 139 | + | } | |||
| 140 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"}) | |||
| 144 | + | agents := f.List() | |||
| 145 | + | if len(agents) != 1 || agents[0].FastMode { | |||
| 146 | + | t.Fatalf("agent fast mode = %#v, want disabled by profile", agents) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // TestRunSupervisorSendDeliversToAgentInbox spins up a real agent with a | |||
| 151 | + | // fake Runner whose only job is to forward inbox lines to a channel, | |||
| 152 | + | // then asserts the /subagents send <id> <text...> path routes through | |||
| 153 | + | // Supervisor.SendUserTurn and lands at the agent verbatim. | |||
| 154 | + | func TestRunSupervisorSendDeliversToAgentInbox(t *testing.T) { | |||
| 155 | + | if runtime.GOOS == "windows" { | |||
| 156 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 157 | + | } | |||
| 158 | + | root := t.TempDir() | |||
| 159 | + | recv := make(chan string, 4) | |||
| 160 | + | ready := make(chan error, 1) | |||
| 161 | + | f := subagents.New(subagents.Config{ | |||
| 162 | + | Root: root, | |||
| 163 | + | RepoRoot: root, | |||
| 164 | + | NewRunner: func(a *subagents.Agent) subagents.Runner { | |||
| 165 | + | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |||
| 166 | + | // Stand up a real Listener on the agent's inbox path so | |||
| 167 | + | // SendUserTurn (which dials a unix socket) actually has | |||
| 168 | + | // something to talk to. The runner-test stubs do the | |||
| 169 | + | // same; this is the minimum to exercise the wire. | |||
| 170 | + | ln, err := subagents.Listen(a.InboxPath) | |||
| 171 | + | ready <- err | |||
| 172 | + | if err != nil { | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | defer ln.Close() | |||
| 176 | + | for { | |||
| 177 | + | select { | |||
| 178 | + | case <-ctx.Done(): | |||
| 179 | + | return ctx.Err() | |||
| 180 | + | case line, ok := <-ln.Lines(): | |||
| 181 | + | if !ok { | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | recv <- line | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | }) | |||
| 188 | + | }, | |||
| 189 | + | }) | |||
| 190 | + | defer f.StopAll() | |||
| 191 | + | iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)} | |||
| 192 | + | iv.cfg.Supervisor = f | |||
| 193 | + | ||||
| 194 | + | a, err := f.Spawn(context.Background(), "do thing") | |||
| 195 | + | if err != nil { | |||
| 196 | + | t.Fatalf("spawn: %v", err) | |||
| 197 | + | } | |||
| 198 | + | select { | |||
| 199 | + | case err := <-ready: | |||
| 200 | + | if err != nil { | |||
| 201 | + | t.Fatalf("listen: %v", err) | |||
| 202 | + | } | |||
| 203 | + | case <-time.After(5 * time.Second): | |||
| 204 | + | t.Fatal("timed out waiting for agent inbox listener") | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Run /subagents send <id> <text...>. The dispatcher would have | |||
| 208 | + | // already strings.Fields-ed the input; mirror that here. | |||
| 209 | + | iv.runSubagents(context.Background(), []string{"send", a.ID, "please", "continue"}) | |||
| 210 | + | ||||
| 211 | + | select { | |||
| 212 | + | case msg := <-recv: | |||
| 213 | + | command, parseErr := subagents.ParseCommand(msg) | |||
| 214 | + | if parseErr != nil { | |||
| 215 | + | t.Fatalf("agent received invalid command %q: %v", msg, parseErr) | |||
| 216 | + | } | |||
| 217 | + | if command.Type != subagents.CommandTurnStart { | |||
| 218 | + | t.Fatalf("agent received command %q; want %q", command.Type, subagents.CommandTurnStart) | |||
| 219 | + | } | |||
| 220 | + | var payload subagents.TurnStartPayload | |||
| 221 | + | if err := command.DecodePayload(&payload); err != nil { | |||
| 222 | + | t.Fatalf("decode turn.start payload: %v", err) | |||
| 223 | + | } | |||
| 224 | + | if payload.Prompt != "please continue" { | |||
| 225 | + | t.Fatalf("agent received prompt %q; want %q", payload.Prompt, "please continue") | |||
| 226 | + | } | |||
| 227 | + | case <-time.After(2 * time.Second): | |||
| 228 | + | t.Fatal("timed out waiting for agent to receive the prompt") | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | if iv.statusErr != "" { | |||
| 232 | + | t.Fatalf("status err set: %q", iv.statusErr) | |||
| 233 | + | } | |||
| 234 | + | if iv.statusOK == "" || iv.statusOK[:7] != "sent to" { | |||
| 235 | + | t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func TestRunSupervisorWaitHonorsRunContext(t *testing.T) { | |||
| 240 | + | iv, f := newInteractiveForSupervisorTest(t) | |||
| 241 | + | defer f.StopAll() | |||
| 242 | + | ||||
| 243 | + | a, err := f.Spawn(context.Background(), "long-running task") | |||
| 244 | + | if err != nil { | |||
| 245 | + | t.Fatal(err) | |||
| 246 | + | } | |||
| 247 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 248 | + | watcherDone := make(chan struct{}) | |||
| 249 | + | iv.subagentsWaitWatcherDone = func() { close(watcherDone) } | |||
| 250 | + | iv.runSubagents(ctx, []string{"wait", a.ID}) | |||
| 251 | + | iv.mu.Lock() | |||
| 252 | + | status := iv.statusOK | |||
| 253 | + | iv.mu.Unlock() | |||
| 254 | + | if status != "waiting for "+a.ID { | |||
| 255 | + | t.Fatalf("initial wait status = %q", status) | |||
| 256 | + | } | |||
| 257 | + | cancel() | |||
| 258 | + | ||||
| 259 | + | // The wait watcher must stop observing the run context and must not | |||
| 260 | + | // later overwrite the status with a false completion after cleanup. | |||
| 261 | + | deadline := time.NewTimer(time.Second) | |||
| 262 | + | defer deadline.Stop() | |||
| 263 | + | select { | |||
| 264 | + | case <-watcherDone: | |||
| 265 | + | case <-deadline.C: | |||
| 266 | + | t.Fatal("timed out waiting for wait watcher to exit") | |||
| 267 | + | } | |||
| 268 | + | iv.mu.Lock() | |||
| 269 | + | status = iv.statusOK | |||
| 270 | + | iv.mu.Unlock() | |||
| 271 | + | if strings.Contains(status, "completed ") { | |||
| 272 | + | t.Fatalf("wait reported completion after cancellation: %q", status) | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) { | |||
| 277 | + | root := t.TempDir() | |||
| 278 | + | f := subagents.New(subagents.Config{ | |||
| 279 | + | Root: root, | |||
| 280 | + | RepoRoot: root, | |||
| 281 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 282 | + | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil }) | |||
| 283 | + | }, | |||
| 284 | + | }) | |||
| 285 | + | defer f.StopAll() | |||
| 286 | + | iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)} | |||
| 287 | + | iv.cfg.Supervisor = f | |||
| 288 | + | ||||
| 289 | + | a, err := f.Spawn(context.Background(), "quick task") | |||
| 290 | + | if err != nil { | |||
| 291 | + | t.Fatal(err) | |||
| 292 | + | } | |||
| 293 | + | iv.runSubagents(context.Background(), []string{"wait", a.ID}) | |||
| 294 | + | deadline := time.After(time.Second) | |||
| 295 | + | for { | |||
| 296 | + | iv.mu.Lock() | |||
| 297 | + | status := iv.statusOK | |||
| 298 | + | iv.mu.Unlock() | |||
| 299 | + | if status == "completed "+a.ID { | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | select { | |||
| 303 | + | case <-deadline: | |||
| 304 | + | t.Fatalf("wait did not report completion; status = %q", status) | |||
| 305 | + | case <-time.After(time.Millisecond): | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func TestNormalizeSpawnReasoning(t *testing.T) { | |||
| 311 | + | cases := []struct { | |||
| 312 | + | in, want string | |||
| 313 | + | ok bool | |||
| 314 | + | }{ | |||
| 315 | + | {in: "OFF", want: "off", ok: true}, | |||
| 316 | + | {in: "minimal", want: "minimum", ok: true}, | |||
| 317 | + | {in: "maximum", want: "xhigh", ok: true}, | |||
| 318 | + | {in: "MAX", want: "max", ok: true}, | |||
| 319 | + | {in: "bogus", ok: false}, | |||
| 320 | + | } | |||
| 321 | + | for _, tc := range cases { | |||
| 322 | + | got, err := normalizeSpawnReasoning(tc.in) | |||
| 323 | + | if tc.ok { | |||
| 324 | + | if err != nil || got != tc.want { | |||
| 325 | + | t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want) | |||
| 326 | + | } | |||
| 327 | + | continue | |||
| 328 | + | } | |||
| 329 | + | if err == nil { | |||
| 330 | + | t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got) | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func TestParseSpawnFlags(t *testing.T) { | |||
| 336 | + | cases := []struct { | |||
| 337 | + | in string | |||
| 338 | + | wantModel, wantProv string | |||
| 339 | + | wantReasoning, wantSubagent string | |||
| 340 | + | wantTask string | |||
| 341 | + | }{ | |||
| 342 | + | {"do x", "", "", "", "", "do x"}, | |||
| 343 | + | {"--model claude do x", "claude", "", "", "", "do x"}, | |||
| 344 | + | {"--model=claude do x", "claude", "", "", "", "do x"}, | |||
| 345 | + | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |||
| 346 | + | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |||
| 347 | + | {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"}, | |||
| 348 | + | {"--agent --reasoning high review auth", "", "", "high", "", "review auth"}, | |||
| 349 | + | {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"}, | |||
| 350 | + | {"--thinking=max do x", "", "", "max", "", "do x"}, | |||
| 351 | + | // Only LEADING flags are consumed. | |||
| 352 | + | {"do --model x", "", "", "", "", "do --model x"}, | |||
| 353 | + | // Missing value: --model with no follow-up token leaves model empty | |||
| 354 | + | // and the next field starts the task. | |||
| 355 | + | {"--model", "", "", "", "", ""}, | |||
| 356 | + | } | |||
| 357 | + | for _, c := range cases { | |||
| 358 | + | m, p, r, a, task := parseSpawnFlags(c.in) | |||
| 359 | + | if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask { | |||
| 360 | + | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)", | |||
| 361 | + | c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask) | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestSplitIDAndRest(t *testing.T) { | |||
| 367 | + | cases := []struct { | |||
| 368 | + | in string | |||
| 369 | + | wantID string | |||
| 370 | + | wantText string | |||
| 371 | + | }{ | |||
| 372 | + | {"", "", ""}, | |||
| 373 | + | {" ", "", ""}, | |||
| 374 | + | {"alpha", "alpha", ""}, | |||
| 375 | + | {"alpha hello world", "alpha", "hello world"}, | |||
| 376 | + | {" alpha hello world ", "alpha", "hello world "}, | |||
| 377 | + | {"alpha\thi", "alpha", "hi"}, | |||
| 378 | + | } | |||
| 379 | + | for _, c := range cases { | |||
| 380 | + | gotID, gotText := splitIDAndRest(c.in) | |||
| 381 | + | if gotID != c.wantID || gotText != c.wantText { | |||
| 382 | + | t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func TestRunSupervisorWithoutSupervisorIsNoop(t *testing.T) { | |||
| 388 | + | iv := &Interactive{ | |||
| 389 | + | subagentsDialog: newSubagentsDialog(), | |||
| 390 | + | dirty: make(chan struct{}, 1), | |||
| 391 | + | } | |||
| 392 | + | // cfg.Supervisor stays nil. The command should set a status err and | |||
| 393 | + | // otherwise be inert. | |||
| 394 | + | iv.runSubagents(context.Background(), nil) | |||
| 395 | + | if iv.subagentsDialog.Active() { | |||
| 396 | + | t.Fatal("dialog opened despite no subagent") | |||
| 397 | + | } | |||
| 398 | + | if iv.statusErr == "" { | |||
| 399 | + | t.Fatal("expected a status error when subagent is nil") | |||
| 400 | + | } | |||
| 401 | + | } | |||
D packages/agent/modes/swarm_slash.go +0 -384
| @@ -1,384 +0,0 @@ | |||||
| 1 | - | package modes | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "fmt" | |||
| 6 | - | "strings" | |||
| 7 | - | ||||
| 8 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 9 | - | "github.com/patriceckhart/zot/packages/provider" | |||
| 10 | - | "github.com/patriceckhart/zot/packages/tui" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | // runSwarm dispatches /swarm subcommands. Layout: | |||
| 14 | - | // | |||
| 15 | - | // /swarm -> open the dashboard | |||
| 16 | - | // /swarm list -> open the dashboard | |||
| 17 | - | // /swarm new [--agent A] [--model M] [--provider P] [--reasoning L] <task...> | |||
| 18 | - | // -> spawn an agent with optional profile/model overrides | |||
| 19 | - | // /swarm kill <id> -> stop a running agent | |||
| 20 | - | // /swarm remove <id> -> tear down a terminated agent | |||
| 21 | - | // /swarm logs <id> -> open the scrollable transcript view | |||
| 22 | - | // /swarm send <id> <text...> -> send a follow-up user turn to <id> | |||
| 23 | - | // /swarm resume [id] -> resume an agent (omit id to pick from a list) | |||
| 24 | - | // /swarm attach <id> -> (planned) drop into the agent's TUI | |||
| 25 | - | // | |||
| 26 | - | // When cfg.Swarm is nil the command tells the user the feature is | |||
| 27 | - | // disabled instead of pretending to work. | |||
| 28 | - | func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 29 | - | if i.cfg.Swarm == nil { | |||
| 30 | - | i.mu.Lock() | |||
| 31 | - | i.statusErr = "swarm is disabled in this build" | |||
| 32 | - | i.statusOK = "" | |||
| 33 | - | i.mu.Unlock() | |||
| 34 | - | return | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | sub := "" | |||
| 38 | - | rest := "" | |||
| 39 | - | if len(args) > 0 { | |||
| 40 | - | sub = strings.ToLower(args[0]) | |||
| 41 | - | // Guard the args[1:] reslice: when only the subcommand was | |||
| 42 | - | // typed (e.g. bare "/swarm new"), args has length 1 and the | |||
| 43 | - | // naive args[1:] is fine, but when args is empty (bare | |||
| 44 | - | // "/swarm") the reslice is [1:0] and panics. The len>0 branch | |||
| 45 | - | // here keeps both cases safe. | |||
| 46 | - | if len(args) > 1 { | |||
| 47 | - | rest = strings.TrimSpace(strings.Join(args[1:], " ")) | |||
| 48 | - | } | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | // spawnAdapter, sendAdapter, resumeAdapter wrap the Swarm methods | |||
| 52 | - | // in the signatures the dialog expects. Defined once here so the | |||
| 53 | - | // three Open()-shaped entry points (list, logs/view-jump, resume) | |||
| 54 | - | // feed the dialog identical callbacks. | |||
| 55 | - | spawnAdapter := func(task, model, provider string) error { | |||
| 56 | - | _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |||
| 57 | - | Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning, | |||
| 58 | - | }) | |||
| 59 | - | return err | |||
| 60 | - | } | |||
| 61 | - | sendAdapter := func(id, text string) error { | |||
| 62 | - | return i.cfg.Swarm.SendUserTurn(id, text) | |||
| 63 | - | } | |||
| 64 | - | resumeAdapter := func(id string) error { | |||
| 65 | - | _, err := i.cfg.Swarm.Resume(ctx, id) | |||
| 66 | - | return err | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | // Pin every fresh spawn to whatever the host's /model selection | |||
| 70 | - | // is right now. This is captured at /swarm time — if the user | |||
| 71 | - | // wants a different model for the next swarm agent, they pick it | |||
| 72 | - | // via /model first (globally), or, while inside the spawn | |||
| 73 | - | // editor, by typing /model on its own line to pop the picker. | |||
| 74 | - | i.swarmDialog.SetCompactMode(i.compactModeEnabled()) | |||
| 75 | - | i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | |||
| 76 | - | i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |||
| 77 | - | if i.cfg.LoggedInProviders != nil { | |||
| 78 | - | i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | switch sub { | |||
| 82 | - | case "", "list", "ls", "ps": | |||
| 83 | - | i.swarmDialog.Open( | |||
| 84 | - | i.cfg.Swarm.SnapshotAll, | |||
| 85 | - | i.cfg.Swarm.Stop, | |||
| 86 | - | i.cfg.Swarm.Remove, | |||
| 87 | - | spawnAdapter, | |||
| 88 | - | sendAdapter, | |||
| 89 | - | resumeAdapter, | |||
| 90 | - | i.cfg.CWD, | |||
| 91 | - | ) | |||
| 92 | - | case "new", "spawn": | |||
| 93 | - | if rest == "" { | |||
| 94 | - | i.swarmStatus("", "/swarm new <task>: missing task") | |||
| 95 | - | return | |||
| 96 | - | } | |||
| 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) | |||
| 102 | - | var fastModeOverride *bool | |||
| 103 | - | if task == "" { | |||
| 104 | - | i.swarmStatus("", "/swarm new: missing task (after any spawn flags)") | |||
| 105 | - | return | |||
| 106 | - | } | |||
| 107 | - | if subagent != "" { | |||
| 108 | - | if i.cfg.ResolveSubagent == nil { | |||
| 109 | - | i.swarmStatus("", "spawn: named subagent profiles are unavailable") | |||
| 110 | - | return | |||
| 111 | - | } | |||
| 112 | - | profile, err := i.cfg.ResolveSubagent(subagent) | |||
| 113 | - | if err != nil { | |||
| 114 | - | i.swarmStatus("", "spawn: "+err.Error()) | |||
| 115 | - | return | |||
| 116 | - | } | |||
| 117 | - | if profile == nil { | |||
| 118 | - | i.swarmStatus("", "spawn: unknown subagent profile "+subagent) | |||
| 119 | - | return | |||
| 120 | - | } | |||
| 121 | - | subagent = profile.Name | |||
| 122 | - | fastModeOverride = profile.FastMode | |||
| 123 | - | if model == "" && provider == "" { | |||
| 124 | - | profileProvider, profileModel := profile.ModelSelection() | |||
| 125 | - | if profileProvider != "" && profileModel != "" { | |||
| 126 | - | provider, model = profileProvider, profileModel | |||
| 127 | - | } | |||
| 128 | - | } | |||
| 129 | - | if reasoning == "" { | |||
| 130 | - | reasoning = profile.Thinking | |||
| 131 | - | } | |||
| 132 | - | } | |||
| 133 | - | if reasoning == "" { | |||
| 134 | - | reasoning = i.cfg.Reasoning | |||
| 135 | - | } | |||
| 136 | - | if reasoning != "" { | |||
| 137 | - | normalizedReasoning, err := normalizeSpawnReasoning(reasoning) | |||
| 138 | - | if err != nil { | |||
| 139 | - | i.swarmStatus("", "spawn: "+err.Error()) | |||
| 140 | - | return | |||
| 141 | - | } | |||
| 142 | - | reasoning = normalizedReasoning | |||
| 143 | - | } | |||
| 144 | - | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |||
| 145 | - | Task: task, Model: model, Provider: provider, Reasoning: reasoning, | |||
| 146 | - | FastMode: fastModeOverride, Subagent: subagent, | |||
| 147 | - | }) | |||
| 148 | - | if err != nil { | |||
| 149 | - | i.swarmStatus("", "spawn: "+err.Error()) | |||
| 150 | - | return | |||
| 151 | - | } | |||
| 152 | - | status := "spawned " + a.ID | |||
| 153 | - | if subagent != "" { | |||
| 154 | - | status += " (agent " + subagent + ")" | |||
| 155 | - | } else if model != "" { | |||
| 156 | - | status += " (model " + model + ")" | |||
| 157 | - | } | |||
| 158 | - | i.swarmStatus(status, "") | |||
| 159 | - | case "kill", "stop": | |||
| 160 | - | if rest == "" { | |||
| 161 | - | i.swarmStatus("", "/swarm kill <id>: missing id") | |||
| 162 | - | return | |||
| 163 | - | } | |||
| 164 | - | if err := i.cfg.Swarm.Stop(rest); err != nil { | |||
| 165 | - | i.swarmStatus("", "kill: "+err.Error()) | |||
| 166 | - | return | |||
| 167 | - | } | |||
| 168 | - | i.swarmStatus("stopped "+rest, "") | |||
| 169 | - | case "remove", "rm": | |||
| 170 | - | if rest == "" { | |||
| 171 | - | i.swarmStatus("", "/swarm remove <id>: missing id") | |||
| 172 | - | return | |||
| 173 | - | } | |||
| 174 | - | if err := i.cfg.Swarm.Remove(rest); err != nil { | |||
| 175 | - | i.swarmStatus("", "remove: "+err.Error()) | |||
| 176 | - | return | |||
| 177 | - | } | |||
| 178 | - | i.swarmStatus("removed "+rest, "") | |||
| 179 | - | case "logs", "log", "view": | |||
| 180 | - | if rest == "" { | |||
| 181 | - | i.swarmStatus("", "/swarm logs <id>: missing id") | |||
| 182 | - | return | |||
| 183 | - | } | |||
| 184 | - | ok := i.swarmDialog.OpenViewing( | |||
| 185 | - | rest, | |||
| 186 | - | i.cfg.Swarm.SnapshotAll, | |||
| 187 | - | i.cfg.Swarm.Stop, | |||
| 188 | - | i.cfg.Swarm.Remove, | |||
| 189 | - | spawnAdapter, | |||
| 190 | - | sendAdapter, | |||
| 191 | - | resumeAdapter, | |||
| 192 | - | i.cfg.CWD, | |||
| 193 | - | ) | |||
| 194 | - | if !ok { | |||
| 195 | - | i.swarmStatus("", "/swarm logs: no agent matching "+rest) | |||
| 196 | - | } | |||
| 197 | - | case "resume", "reattach", "reopen": | |||
| 198 | - | if rest == "" { | |||
| 199 | - | // No id given: open the dashboard with the cursor | |||
| 200 | - | // pre-positioned on the first resumable agent, and | |||
| 201 | - | // tell the user how many there are so they know what | |||
| 202 | - | // to expect. Pressing R confirms; ↑/↓ to pick a | |||
| 203 | - | // different row first. | |||
| 204 | - | count := i.swarmDialog.OpenForResume( | |||
| 205 | - | i.cfg.Swarm.SnapshotAll, | |||
| 206 | - | i.cfg.Swarm.Stop, | |||
| 207 | - | i.cfg.Swarm.Remove, | |||
| 208 | - | spawnAdapter, | |||
| 209 | - | sendAdapter, | |||
| 210 | - | resumeAdapter, | |||
| 211 | - | i.cfg.CWD, | |||
| 212 | - | ) | |||
| 213 | - | switch count { | |||
| 214 | - | case 0: | |||
| 215 | - | i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)") | |||
| 216 | - | case 1: | |||
| 217 | - | i.swarmStatus("1 resumable agent, press R to resume", "") | |||
| 218 | - | default: | |||
| 219 | - | i.swarmStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "") | |||
| 220 | - | } | |||
| 221 | - | return | |||
| 222 | - | } | |||
| 223 | - | a, err := i.cfg.Swarm.Resume(ctx, rest) | |||
| 224 | - | if err != nil { | |||
| 225 | - | i.swarmStatus("", "resume: "+err.Error()) | |||
| 226 | - | return | |||
| 227 | - | } | |||
| 228 | - | i.swarmStatus("resumed "+a.ID, "") | |||
| 229 | - | case "send", "prompt", "msg": | |||
| 230 | - | // /swarm send <id> <text...> is the non-interactive | |||
| 231 | - | // counterpart of pressing 'p' in the dashboard. We split the | |||
| 232 | - | // joined `rest` ourselves rather than reusing the dispatcher's | |||
| 233 | - | // already-fielded args[] because the text may contain spaces | |||
| 234 | - | // the user expects to be preserved verbatim. | |||
| 235 | - | id, text := splitIDAndRest(rest) | |||
| 236 | - | if id == "" { | |||
| 237 | - | i.swarmStatus("", "/swarm send <id> <text>: missing id") | |||
| 238 | - | return | |||
| 239 | - | } | |||
| 240 | - | if text == "" { | |||
| 241 | - | i.swarmStatus("", "/swarm send <id> <text>: missing text") | |||
| 242 | - | return | |||
| 243 | - | } | |||
| 244 | - | if err := i.cfg.Swarm.SendUserTurn(id, text); err != nil { | |||
| 245 | - | i.swarmStatus("", friendlySendErr(id, err)) | |||
| 246 | - | return | |||
| 247 | - | } | |||
| 248 | - | i.swarmStatus("sent to "+id, "") | |||
| 249 | - | case "attach": | |||
| 250 | - | // PTY-reparenting is a significant chunk of work I haven't | |||
| 251 | - | // landed yet (see the design sketch). Recognise the name so | |||
| 252 | - | // /swarm attach doesn't fall through to the generic "unknown | |||
| 253 | - | // subcommand" path — that error message is misleading because | |||
| 254 | - | // it makes attach sound like a typo instead of a planned | |||
| 255 | - | // feature. Point the user at /swarm logs in the meantime. | |||
| 256 | - | i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.") | |||
| 257 | - | default: | |||
| 258 | - | i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)") | |||
| 259 | - | } | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | // normalizeSpawnReasoning validates and canonicalizes the reasoning levels | |||
| 263 | - | // accepted by ParseArgs before a child process is launched. | |||
| 264 | - | func normalizeSpawnReasoning(value string) (string, error) { | |||
| 265 | - | raw := strings.ToLower(strings.TrimSpace(value)) | |||
| 266 | - | switch raw { | |||
| 267 | - | case "", "off": | |||
| 268 | - | return raw, nil | |||
| 269 | - | case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max": | |||
| 270 | - | return provider.NormalizeReasoning(raw), nil | |||
| 271 | - | default: | |||
| 272 | - | return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max") | |||
| 273 | - | } | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | // parseSpawnFlags consumes leading profile/model/provider/reasoning flags | |||
| 277 | - | // using both separate-value and equals forms. Flags after the task remain | |||
| 278 | - | // part of the task text. | |||
| 279 | - | func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) { | |||
| 280 | - | fields := strings.Fields(s) | |||
| 281 | - | i := 0 | |||
| 282 | - | for i < len(fields) { | |||
| 283 | - | f := fields[i] | |||
| 284 | - | switch { | |||
| 285 | - | case f == "--agent": | |||
| 286 | - | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 287 | - | subagent = fields[i+1] | |||
| 288 | - | i += 2 | |||
| 289 | - | } else { | |||
| 290 | - | i++ | |||
| 291 | - | } | |||
| 292 | - | continue | |||
| 293 | - | case strings.HasPrefix(f, "--agent="): | |||
| 294 | - | subagent = strings.TrimPrefix(f, "--agent=") | |||
| 295 | - | i++ | |||
| 296 | - | continue | |||
| 297 | - | case f == "--model": | |||
| 298 | - | // Consume the flag even when no value follows so a | |||
| 299 | - | // dangling "--model" doesn't leak into the task. The | |||
| 300 | - | // caller surfaces "missing task" instead. | |||
| 301 | - | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 302 | - | model = fields[i+1] | |||
| 303 | - | i += 2 | |||
| 304 | - | } else { | |||
| 305 | - | i++ | |||
| 306 | - | } | |||
| 307 | - | continue | |||
| 308 | - | case strings.HasPrefix(f, "--model="): | |||
| 309 | - | model = strings.TrimPrefix(f, "--model=") | |||
| 310 | - | i++ | |||
| 311 | - | continue | |||
| 312 | - | case f == "--provider": | |||
| 313 | - | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 314 | - | provider = fields[i+1] | |||
| 315 | - | i += 2 | |||
| 316 | - | } else { | |||
| 317 | - | i++ | |||
| 318 | - | } | |||
| 319 | - | continue | |||
| 320 | - | case strings.HasPrefix(f, "--provider="): | |||
| 321 | - | provider = strings.TrimPrefix(f, "--provider=") | |||
| 322 | - | i++ | |||
| 323 | - | continue | |||
| 324 | - | case f == "--reasoning" || f == "--thinking": | |||
| 325 | - | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |||
| 326 | - | reasoning = fields[i+1] | |||
| 327 | - | i += 2 | |||
| 328 | - | } else { | |||
| 329 | - | i++ | |||
| 330 | - | } | |||
| 331 | - | continue | |||
| 332 | - | case strings.HasPrefix(f, "--reasoning="): | |||
| 333 | - | reasoning = strings.TrimPrefix(f, "--reasoning=") | |||
| 334 | - | i++ | |||
| 335 | - | continue | |||
| 336 | - | case strings.HasPrefix(f, "--thinking="): | |||
| 337 | - | reasoning = strings.TrimPrefix(f, "--thinking=") | |||
| 338 | - | i++ | |||
| 339 | - | continue | |||
| 340 | - | } | |||
| 341 | - | break | |||
| 342 | - | } | |||
| 343 | - | task = strings.TrimSpace(strings.Join(fields[i:], " ")) | |||
| 344 | - | return | |||
| 345 | - | } | |||
| 346 | - | ||||
| 347 | - | // splitIDAndRest splits "<id> <text...>" into (id, text). The text | |||
| 348 | - | // half preserves all whitespace after the first token so the agent | |||
| 349 | - | // receives the user's prompt verbatim (modulo a single trim of the | |||
| 350 | - | // boundary space). Returns ("", "") when s is empty so the caller | |||
| 351 | - | // can surface a missing-id error. | |||
| 352 | - | func splitIDAndRest(s string) (id, text string) { | |||
| 353 | - | s = strings.TrimLeft(s, " \t") | |||
| 354 | - | if s == "" { | |||
| 355 | - | return "", "" | |||
| 356 | - | } | |||
| 357 | - | cut := strings.IndexAny(s, " \t") | |||
| 358 | - | if cut < 0 { | |||
| 359 | - | return s, "" | |||
| 360 | - | } | |||
| 361 | - | return s[:cut], strings.TrimLeft(s[cut+1:], " \t") | |||
| 362 | - | } | |||
| 363 | - | ||||
| 364 | - | // firstWord returns the first whitespace-separated token of s, or | |||
| 365 | - | // "<id>" when s is empty. Used to keep the "/swarm attach" hint | |||
| 366 | - | // readable even when the user typed no argument. | |||
| 367 | - | func firstWord(s string) string { | |||
| 368 | - | s = strings.TrimSpace(s) | |||
| 369 | - | if s == "" { | |||
| 370 | - | return "<id>" | |||
| 371 | - | } | |||
| 372 | - | if i := strings.IndexAny(s, " \t"); i >= 0 { | |||
| 373 | - | return s[:i] | |||
| 374 | - | } | |||
| 375 | - | return s | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | func (i *Interactive) swarmStatus(ok, errMsg string) { | |||
| 379 | - | i.mu.Lock() | |||
| 380 | - | i.statusOK = ok | |||
| 381 | - | i.statusErr = errMsg | |||
| 382 | - | i.mu.Unlock() | |||
| 383 | - | i.invalidate() | |||
| 384 | - | } | |||
D packages/agent/modes/swarm_slash_test.go +0 -315
| @@ -1,315 +0,0 @@ | |||||
| 1 | - | package modes | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "runtime" | |||
| 6 | - | "strings" | |||
| 7 | - | "testing" | |||
| 8 | - | "time" | |||
| 9 | - | ||||
| 10 | - | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 11 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // newInteractiveForSwarmTest builds the minimal Interactive scaffolding | |||
| 15 | - | // runSwarm needs. It does NOT call NewInteractive (which would pull in | |||
| 16 | - | // the whole TUI); the runSwarm method only touches cfg.Swarm, the | |||
| 17 | - | // status mutex, and the swarm dialog, so we hand-build those. | |||
| 18 | - | func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) { | |||
| 19 | - | t.Helper() | |||
| 20 | - | root := t.TempDir() | |||
| 21 | - | f := swarm.New(swarm.Config{ | |||
| 22 | - | Root: root, | |||
| 23 | - | RepoRoot: root, | |||
| 24 | - | NewRunner: func(a *swarm.Agent) swarm.Runner { | |||
| 25 | - | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |||
| 26 | - | <-ctx.Done() | |||
| 27 | - | return ctx.Err() | |||
| 28 | - | }) | |||
| 29 | - | }, | |||
| 30 | - | }) | |||
| 31 | - | iv := &Interactive{ | |||
| 32 | - | swarmDialog: newSwarmDialog(), | |||
| 33 | - | dirty: make(chan struct{}, 1), | |||
| 34 | - | } | |||
| 35 | - | iv.cfg.Swarm = f | |||
| 36 | - | return iv, f | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | // TestRunSwarmBareDoesNotPanic regression-tests the slice-out-of-range | |||
| 40 | - | // panic that hit when /swarm was typed with no subcommand: runSwarm | |||
| 41 | - | // did args[1:] without checking len(args), which panics as [1:0]. | |||
| 42 | - | func TestRunSwarmBareDoesNotPanic(t *testing.T) { | |||
| 43 | - | iv, _ := newInteractiveForSwarmTest(t) | |||
| 44 | - | defer iv.cfg.Swarm.StopAll() | |||
| 45 | - | ||||
| 46 | - | // Bare /swarm: parts[1:] from the dispatcher is an empty slice. | |||
| 47 | - | iv.runSwarm(context.Background(), nil) | |||
| 48 | - | ||||
| 49 | - | if !iv.swarmDialog.Active() { | |||
| 50 | - | t.Fatal("bare /swarm should open the dashboard") | |||
| 51 | - | } | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) { | |||
| 55 | - | iv, _ := newInteractiveForSwarmTest(t) | |||
| 56 | - | defer iv.cfg.Swarm.StopAll() | |||
| 57 | - | ||||
| 58 | - | // Each row is the slice that the dispatcher hands to runSwarm — | |||
| 59 | - | // i.e. parts[1:] where parts was strings.Fields of the slash | |||
| 60 | - | // command. Mixing zero-arg and arg'd forms exercises both | |||
| 61 | - | // branches of the reslice guard. | |||
| 62 | - | cases := [][]string{ | |||
| 63 | - | {"list"}, | |||
| 64 | - | {"new"}, | |||
| 65 | - | {"new", "fix", "the", "thing"}, | |||
| 66 | - | {"kill"}, | |||
| 67 | - | {"kill", "no-such-id"}, | |||
| 68 | - | {"remove"}, | |||
| 69 | - | {"remove", "no-such-id"}, | |||
| 70 | - | {"send"}, | |||
| 71 | - | {"send", "no-such-id"}, | |||
| 72 | - | {"send", "no-such-id", "hello", "world"}, | |||
| 73 | - | {"bogus"}, | |||
| 74 | - | } | |||
| 75 | - | for _, args := range cases { | |||
| 76 | - | func() { | |||
| 77 | - | defer func() { | |||
| 78 | - | if r := recover(); r != nil { | |||
| 79 | - | t.Fatalf("runSwarm(%v) panicked: %v", args, r) | |||
| 80 | - | } | |||
| 81 | - | }() | |||
| 82 | - | iv.runSwarm(context.Background(), args) | |||
| 83 | - | }() | |||
| 84 | - | } | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | func TestRunSwarmRejectsNamedProfileWithoutResolver(t *testing.T) { | |||
| 88 | - | iv, f := newInteractiveForSwarmTest(t) | |||
| 89 | - | defer f.StopAll() | |||
| 90 | - | ||||
| 91 | - | iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"}) | |||
| 92 | - | if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") { | |||
| 93 | - | t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr) | |||
| 94 | - | } | |||
| 95 | - | if got := len(f.List()); got != 0 { | |||
| 96 | - | t.Fatalf("spawned agents = %d, want 0", got) | |||
| 97 | - | } | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | func TestRunSwarmNewSpawnsAgent(t *testing.T) { | |||
| 101 | - | iv, f := newInteractiveForSwarmTest(t) | |||
| 102 | - | defer f.StopAll() | |||
| 103 | - | ||||
| 104 | - | iv.runSwarm(context.Background(), []string{"new", "do", "stuff"}) | |||
| 105 | - | agents := f.List() | |||
| 106 | - | if len(agents) != 1 { | |||
| 107 | - | t.Fatalf("want 1 agent; got %d", len(agents)) | |||
| 108 | - | } | |||
| 109 | - | if agents[0].Task != "do stuff" { | |||
| 110 | - | t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff") | |||
| 111 | - | } | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | func TestRunSwarmNamedProfileAppliesFastModeRestriction(t *testing.T) { | |||
| 115 | - | root := t.TempDir() | |||
| 116 | - | profileFastMode := false | |||
| 117 | - | f := swarm.New(swarm.Config{ | |||
| 118 | - | Root: root, | |||
| 119 | - | RepoRoot: root, | |||
| 120 | - | FastMode: true, | |||
| 121 | - | NewRunner: func(*swarm.Agent) swarm.Runner { | |||
| 122 | - | return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error { | |||
| 123 | - | <-ctx.Done() | |||
| 124 | - | return ctx.Err() | |||
| 125 | - | }) | |||
| 126 | - | }, | |||
| 127 | - | }) | |||
| 128 | - | defer f.StopAll() | |||
| 129 | - | iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)} | |||
| 130 | - | iv.cfg.Swarm = f | |||
| 131 | - | iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) { | |||
| 132 | - | if name != "reviewer" { | |||
| 133 | - | return nil, nil | |||
| 134 | - | } | |||
| 135 | - | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"}) | |||
| 139 | - | agents := f.List() | |||
| 140 | - | if len(agents) != 1 || agents[0].FastMode { | |||
| 141 | - | t.Fatalf("agent fast mode = %#v, want disabled by profile", agents) | |||
| 142 | - | } | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | // TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a | |||
| 146 | - | // fake Runner whose only job is to forward inbox lines to a channel, | |||
| 147 | - | // then asserts the /swarm send <id> <text...> path routes through | |||
| 148 | - | // Swarm.SendUserTurn and lands at the agent verbatim. | |||
| 149 | - | func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||
| 150 | - | if runtime.GOOS == "windows" { | |||
| 151 | - | t.Skip("swarm inbox transport uses Unix-domain sockets") | |||
| 152 | - | } | |||
| 153 | - | root := t.TempDir() | |||
| 154 | - | recv := make(chan string, 4) | |||
| 155 | - | ready := make(chan error, 1) | |||
| 156 | - | f := swarm.New(swarm.Config{ | |||
| 157 | - | Root: root, | |||
| 158 | - | RepoRoot: root, | |||
| 159 | - | NewRunner: func(a *swarm.Agent) swarm.Runner { | |||
| 160 | - | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |||
| 161 | - | // Stand up a real Listener on the agent's inbox path so | |||
| 162 | - | // SendUserTurn (which dials a unix socket) actually has | |||
| 163 | - | // something to talk to. The runner-test stubs do the | |||
| 164 | - | // same; this is the minimum to exercise the wire. | |||
| 165 | - | ln, err := swarm.Listen(a.InboxPath) | |||
| 166 | - | ready <- err | |||
| 167 | - | if err != nil { | |||
| 168 | - | return err | |||
| 169 | - | } | |||
| 170 | - | defer ln.Close() | |||
| 171 | - | for { | |||
| 172 | - | select { | |||
| 173 | - | case <-ctx.Done(): | |||
| 174 | - | return ctx.Err() | |||
| 175 | - | case line, ok := <-ln.Lines(): | |||
| 176 | - | if !ok { | |||
| 177 | - | return nil | |||
| 178 | - | } | |||
| 179 | - | recv <- line | |||
| 180 | - | } | |||
| 181 | - | } | |||
| 182 | - | }) | |||
| 183 | - | }, | |||
| 184 | - | }) | |||
| 185 | - | defer f.StopAll() | |||
| 186 | - | iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)} | |||
| 187 | - | iv.cfg.Swarm = f | |||
| 188 | - | ||||
| 189 | - | a, err := f.Spawn(context.Background(), "do thing") | |||
| 190 | - | if err != nil { | |||
| 191 | - | t.Fatalf("spawn: %v", err) | |||
| 192 | - | } | |||
| 193 | - | select { | |||
| 194 | - | case err := <-ready: | |||
| 195 | - | if err != nil { | |||
| 196 | - | t.Fatalf("listen: %v", err) | |||
| 197 | - | } | |||
| 198 | - | case <-time.After(5 * time.Second): | |||
| 199 | - | t.Fatal("timed out waiting for agent inbox listener") | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | // Run /swarm send <id> <text...>. The dispatcher would have | |||
| 203 | - | // already strings.Fields-ed the input; mirror that here. | |||
| 204 | - | iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"}) | |||
| 205 | - | ||||
| 206 | - | select { | |||
| 207 | - | case msg := <-recv: | |||
| 208 | - | want := "user please continue" | |||
| 209 | - | if msg != want { | |||
| 210 | - | t.Fatalf("agent received %q; want %q", msg, want) | |||
| 211 | - | } | |||
| 212 | - | case <-time.After(2 * time.Second): | |||
| 213 | - | t.Fatal("timed out waiting for agent to receive the prompt") | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | if iv.statusErr != "" { | |||
| 217 | - | t.Fatalf("status err set: %q", iv.statusErr) | |||
| 218 | - | } | |||
| 219 | - | if iv.statusOK == "" || iv.statusOK[:7] != "sent to" { | |||
| 220 | - | t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK) | |||
| 221 | - | } | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | func TestNormalizeSpawnReasoning(t *testing.T) { | |||
| 225 | - | cases := []struct { | |||
| 226 | - | in, want string | |||
| 227 | - | ok bool | |||
| 228 | - | }{ | |||
| 229 | - | {in: "OFF", want: "off", ok: true}, | |||
| 230 | - | {in: "minimal", want: "minimum", ok: true}, | |||
| 231 | - | {in: "maximum", want: "xhigh", ok: true}, | |||
| 232 | - | {in: "MAX", want: "max", ok: true}, | |||
| 233 | - | {in: "bogus", ok: false}, | |||
| 234 | - | } | |||
| 235 | - | for _, tc := range cases { | |||
| 236 | - | got, err := normalizeSpawnReasoning(tc.in) | |||
| 237 | - | if tc.ok { | |||
| 238 | - | if err != nil || got != tc.want { | |||
| 239 | - | t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want) | |||
| 240 | - | } | |||
| 241 | - | continue | |||
| 242 | - | } | |||
| 243 | - | if err == nil { | |||
| 244 | - | t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got) | |||
| 245 | - | } | |||
| 246 | - | } | |||
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | func TestParseSpawnFlags(t *testing.T) { | |||
| 250 | - | cases := []struct { | |||
| 251 | - | in string | |||
| 252 | - | wantModel, wantProv string | |||
| 253 | - | wantReasoning, wantSubagent string | |||
| 254 | - | wantTask string | |||
| 255 | - | }{ | |||
| 256 | - | {"do x", "", "", "", "", "do x"}, | |||
| 257 | - | {"--model claude do x", "claude", "", "", "", "do x"}, | |||
| 258 | - | {"--model=claude do x", "claude", "", "", "", "do x"}, | |||
| 259 | - | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |||
| 260 | - | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |||
| 261 | - | {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"}, | |||
| 262 | - | {"--agent --reasoning high review auth", "", "", "high", "", "review auth"}, | |||
| 263 | - | {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"}, | |||
| 264 | - | {"--thinking=max do x", "", "", "max", "", "do x"}, | |||
| 265 | - | // Only LEADING flags are consumed. | |||
| 266 | - | {"do --model x", "", "", "", "", "do --model x"}, | |||
| 267 | - | // Missing value: --model with no follow-up token leaves model empty | |||
| 268 | - | // and the next field starts the task. | |||
| 269 | - | {"--model", "", "", "", "", ""}, | |||
| 270 | - | } | |||
| 271 | - | for _, c := range cases { | |||
| 272 | - | m, p, r, a, task := parseSpawnFlags(c.in) | |||
| 273 | - | if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask { | |||
| 274 | - | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)", | |||
| 275 | - | c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask) | |||
| 276 | - | } | |||
| 277 | - | } | |||
| 278 | - | } | |||
| 279 | - | ||||
| 280 | - | func TestSplitIDAndRest(t *testing.T) { | |||
| 281 | - | cases := []struct { | |||
| 282 | - | in string | |||
| 283 | - | wantID string | |||
| 284 | - | wantText string | |||
| 285 | - | }{ | |||
| 286 | - | {"", "", ""}, | |||
| 287 | - | {" ", "", ""}, | |||
| 288 | - | {"alpha", "alpha", ""}, | |||
| 289 | - | {"alpha hello world", "alpha", "hello world"}, | |||
| 290 | - | {" alpha hello world ", "alpha", "hello world "}, | |||
| 291 | - | {"alpha\thi", "alpha", "hi"}, | |||
| 292 | - | } | |||
| 293 | - | for _, c := range cases { | |||
| 294 | - | gotID, gotText := splitIDAndRest(c.in) | |||
| 295 | - | if gotID != c.wantID || gotText != c.wantText { | |||
| 296 | - | t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText) | |||
| 297 | - | } | |||
| 298 | - | } | |||
| 299 | - | } | |||
| 300 | - | ||||
| 301 | - | func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) { | |||
| 302 | - | iv := &Interactive{ | |||
| 303 | - | swarmDialog: newSwarmDialog(), | |||
| 304 | - | dirty: make(chan struct{}, 1), | |||
| 305 | - | } | |||
| 306 | - | // cfg.Swarm stays nil. The command should set a status err and | |||
| 307 | - | // otherwise be inert. | |||
| 308 | - | iv.runSwarm(context.Background(), nil) | |||
| 309 | - | if iv.swarmDialog.Active() { | |||
| 310 | - | t.Fatal("dialog opened despite no swarm") | |||
| 311 | - | } | |||
| 312 | - | if iv.statusErr == "" { | |||
| 313 | - | t.Fatal("expected a status error when swarm is nil") | |||
| 314 | - | } | |||
| 315 | - | } | |||
M packages/agent/settings_store.go +14 -12
| @@ -59,12 +59,12 @@ func (configSettingsStore) SetTerminalTitleEnabled(enabled bool) error { | |||||
| 59 | return SaveConfig(cfg) | 59 | return SaveConfig(cfg) | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | - | func (configSettingsStore) SetAutoSwarm(enabled bool) error { | 62 | + | func (configSettingsStore) SetAutoSubagents(enabled bool) error { |
| 63 | cfg, err := LoadConfig() | 63 | cfg, err := LoadConfig() | ||
| 64 | if err != nil { | 64 | if err != nil { | ||
| 65 | return err | 65 | return err | ||
| 66 | } | 66 | } | ||
| 67 | - | cfg.AutoSwarmEnabled = &enabled | 67 | + | cfg.AutoSubagentsEnabled = &enabled |
| 68 | return SaveConfig(cfg) | 68 | return SaveConfig(cfg) | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
| @@ -212,24 +212,26 @@ func (configSettingsStore) SetTheme(name string) error { | |||||
| 212 | return SaveConfig(cfg) | 212 | return SaveConfig(cfg) | ||
| 213 | } | 213 | } | ||
| 214 | 214 | ||||
| 215 | - | // AutoSwarmEnabled reads the current auto-swarm flag from config. | 215 | + | // AutoSubagentsEnabled reads the current auto-subagents flag from config. |
| 216 | - | // Used by the swarm_spawn tool at call time to gate execution. | 216 | + | // The build/CLI startup path uses it when initially injecting |
| 217 | - | func AutoSwarmEnabled() bool { | 217 | + | // subagent_spawn into the tool registry; interactive mode mirrors the setting |
| 218 | + | // into the live tool registry when its settings toggle changes. | |||
| 219 | + | func AutoSubagentsEnabled() bool { | |||
| 218 | cfg, err := LoadConfig() | 220 | cfg, err := LoadConfig() | ||
| 219 | if err != nil { | 221 | if err != nil { | ||
| 220 | return false | 222 | return false | ||
| 221 | } | 223 | } | ||
| 222 | - | return cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled | 224 | + | return cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled |
| 223 | } | 225 | } | ||
| 224 | 226 | ||||
| 225 | - | // AutoSwarmSystemAddendum is appended to the system prompt when | 227 | + | // AutoSubagentsSystemAddendum is appended to the system prompt when |
| 226 | - | // auto-swarm is enabled, so the model knows it may delegate to | 228 | + | // auto-subagents is enabled, so the model knows it may delegate to |
| 227 | // background sub-agents without the user having to mention the tool | 229 | // background sub-agents without the user having to mention the tool | ||
| 228 | // by name. Kept short so it doesn't bloat the cached prompt prefix. | 230 | // by name. Kept short so it doesn't bloat the cached prompt prefix. | ||
| 229 | - | 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. | 231 | + | const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You have a subagent_spawn tool that starts background sub-agents in separate long-lived processes. |
| 230 | 232 | ||||
| 231 | - | 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. | 233 | + | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding (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. When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to summarize the outcome and decide any follow-up. |
| 232 | 234 | ||||
| 233 | - | 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. | 235 | + | Do NOT use subagent_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. Child workers cannot recursively spawn more sub-agents in v1. |
| 234 | 236 | ||||
| 235 | - | When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-swarm update] message recapping each agent's status, task, and transcript tail. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing the agents by id.` | 237 | + | When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message containing each agent's id, status, task, optional error, and final response (or a tail when the response is long). Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.` |
A packages/agent/subagent_worker.go +541 -0
| @@ -0,0 +1,541 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "time" | |||
| 14 | + | "unicode/utf8" | |||
| 15 | + | ||||
| 16 | + | "github.com/patriceckhart/zot/packages/agent/modes" | |||
| 17 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 18 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 19 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // runSubagentWorkerMode is the daemon-mode entry point used by every | |||
| 23 | + | // subagent-spawned zot subprocess. It's intentionally close in shape to | |||
| 24 | + | // runJSONMode but with two key differences: | |||
| 25 | + | // | |||
| 26 | + | // - Lifetime: the process stays alive across many user turns. The | |||
| 27 | + | // initial positional task (if any) is the first turn; subsequent | |||
| 28 | + | // turns arrive through the inbox unix socket at args.SubagentWorker. | |||
| 29 | + | // | |||
| 30 | + | // - Output: every emitted JSON line is also mirrored verbatim into | |||
| 31 | + | // events.jsonl (see ZOT_SUBAGENT_EVENT_LOG) so a separate zot | |||
| 32 | + | // process can /subagents open this agent and replay its full history | |||
| 33 | + | // even after the parent that spawned us is long gone. | |||
| 34 | + | // | |||
| 35 | + | // The runner in packages/agent/subagents/runner.go is the only caller in | |||
| 36 | + | // production; tests use the stubchild binary under | |||
| 37 | + | // packages/agent/subagents/testdata/cmd/stubchild instead of the real model | |||
| 38 | + | // loop. | |||
| 39 | + | func runSubagentWorkerMode(ctx context.Context, args Args, version string) error { | |||
| 40 | + | if args.SubagentWorker == "" { | |||
| 41 | + | return fmt.Errorf("--subagent-worker requires a socket path") | |||
| 42 | + | } | |||
| 43 | + | if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_STDIN") == "1" { | |||
| 44 | + | var inherited subagents.Credential | |||
| 45 | + | dec := json.NewDecoder(io.LimitReader(os.Stdin, 1<<20)) | |||
| 46 | + | if err := dec.Decode(&inherited); err != nil { | |||
| 47 | + | return fmt.Errorf("read inherited subagent credential: %w", err) | |||
| 48 | + | } | |||
| 49 | + | if inherited.Value == "" || inherited.Method == "" { | |||
| 50 | + | return fmt.Errorf("read inherited subagent credential: missing value or method") | |||
| 51 | + | } | |||
| 52 | + | args.inheritedCredential = inherited.Value | |||
| 53 | + | args.inheritedAuthMethod = inherited.Method | |||
| 54 | + | args.inheritedAccountID = inherited.AccountID | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | r, err := Resolve(args, true) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |||
| 62 | + | defer stopExt() | |||
| 63 | + | ||||
| 64 | + | ag := r.NewAgent() | |||
| 65 | + | initialAg := ag | |||
| 66 | + | defer func() { | |||
| 67 | + | closeAgentLSP(ag) | |||
| 68 | + | if ag != initialAg { | |||
| 69 | + | closeAgentLSP(initialAg) | |||
| 70 | + | } | |||
| 71 | + | }() | |||
| 72 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |||
| 73 | + | sess, err := openOrCreateSession(args, r, ag, version) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | if sess != nil { | |||
| 78 | + | var providerName, model string | |||
| 79 | + | sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | r.Provider, r.Model = providerName, model | |||
| 84 | + | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |||
| 85 | + | defer sess.Close() | |||
| 86 | + | } | |||
| 87 | + | announceSession(extMgr, sess) | |||
| 88 | + | ||||
| 89 | + | // Open the inbox listener BEFORE emitting agent_ready so the | |||
| 90 | + | // supervisor can dial through on the very first send. The | |||
| 91 | + | // subagent supervisor's Inbox retries dialing for a | |||
| 92 | + | // short window, but emitting ready first and then listening | |||
| 93 | + | // would still race in tight loops. | |||
| 94 | + | ln, err := subagents.Listen(args.SubagentWorker) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return fmt.Errorf("subagent-worker listen: %w", err) | |||
| 97 | + | } | |||
| 98 | + | defer ln.Close() | |||
| 99 | + | ||||
| 100 | + | // Event log is owned by the supervisor's runner via stdout, but | |||
| 101 | + | // the daemon also writes a redundant copy here when the runner's | |||
| 102 | + | // pipe is closed (e.g. parent zot exited but the agent is still | |||
| 103 | + | // running headless). The env var is set by the runner; if it's | |||
| 104 | + | // empty we silently skip the second mirror. | |||
| 105 | + | var logMirror *subagents.EventLog | |||
| 106 | + | if path := os.Getenv("ZOT_SUBAGENT_EVENT_LOG"); path != "" { | |||
| 107 | + | logMirror, _ = subagents.OpenEventLog(path) | |||
| 108 | + | } | |||
| 109 | + | if logMirror != nil { | |||
| 110 | + | defer logMirror.Close() | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | em := newSubagentEmitter(os.Stdout, logMirror) | |||
| 114 | + | em.setProtocolIdentity(os.Getenv("ZOT_SUBAGENT_AGENT_ID")) | |||
| 115 | + | em.emit("agent.ready", map[string]any{ | |||
| 116 | + | "version": version, | |||
| 117 | + | "cwd": r.CWD, | |||
| 118 | + | "model": r.Model, | |||
| 119 | + | }) | |||
| 120 | + | ||||
| 121 | + | // Heartbeats make a live-but-detached child distinguishable from a | |||
| 122 | + | // failed turn after supervisor reconnect. The parent still treats | |||
| 123 | + | // unknown event types as forward-compatible. | |||
| 124 | + | heartbeatDone := make(chan struct{}) | |||
| 125 | + | defer close(heartbeatDone) | |||
| 126 | + | go func() { | |||
| 127 | + | interval := 10 * time.Second | |||
| 128 | + | if raw := os.Getenv("ZOT_SUBAGENT_HEARTBEAT_INTERVAL"); raw != "" { | |||
| 129 | + | if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 { | |||
| 130 | + | interval = parsed | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ticker := time.NewTicker(interval) | |||
| 134 | + | defer ticker.Stop() | |||
| 135 | + | for { | |||
| 136 | + | select { | |||
| 137 | + | case <-heartbeatDone: | |||
| 138 | + | return | |||
| 139 | + | case <-ticker.C: | |||
| 140 | + | em.emit("agent.heartbeat", map[string]any{"activity": "alive"}) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | }() | |||
| 144 | + | ||||
| 145 | + | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | |||
| 146 | + | // an in-flight turn without tearing down the whole daemon. | |||
| 147 | + | var ( | |||
| 148 | + | mu sync.Mutex | |||
| 149 | + | cancelFn context.CancelFunc | |||
| 150 | + | busyTurn bool | |||
| 151 | + | turnNo = initialTurnNumber(os.Getenv("ZOT_SUBAGENT_EVENT_LOG")) | |||
| 152 | + | turnWG sync.WaitGroup | |||
| 153 | + | closing bool | |||
| 154 | + | shutdown = make(chan struct{}) | |||
| 155 | + | shutdownOnce sync.Once | |||
| 156 | + | ) | |||
| 157 | + | maxOutputBytes, maxOutputLines := workerOutputLimits() | |||
| 158 | + | ||||
| 159 | + | runOne := func(prompt string) { | |||
| 160 | + | mu.Lock() | |||
| 161 | + | // launchTurn admits the goroutine to turnWG before it starts so | |||
| 162 | + | // shutdown can join it. A shutdown can win the scheduling race | |||
| 163 | + | // after that admission but before this goroutine acquires mu; in | |||
| 164 | + | // that case do not start a new Prompt after waitForActiveTurn has | |||
| 165 | + | // declared the worker closing. | |||
| 166 | + | if closing { | |||
| 167 | + | mu.Unlock() | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | if args.SubagentMaxTurns > 0 && turnNo >= args.SubagentMaxTurns { | |||
| 171 | + | // Consume a unique rejected attempt so repeated prompts after | |||
| 172 | + | // the limit cannot reuse the same turn id in the event log. | |||
| 173 | + | turnNo++ | |||
| 174 | + | step := turnNo | |||
| 175 | + | mu.Unlock() | |||
| 176 | + | turnID := fmt.Sprintf("turn-%d", step) | |||
| 177 | + | errPayload := map[string]any{"code": "max_turns", "message": "maximum subagent turns reached"} | |||
| 178 | + | em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload}) | |||
| 179 | + | em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload}) | |||
| 180 | + | em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]}) | |||
| 181 | + | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | if busyTurn { | |||
| 185 | + | // Drop concurrent turns rather than queuing. The | |||
| 186 | + | // supervisor protocol assumes one outstanding turn per | |||
| 187 | + | // agent; if a user really wants to interrupt and start | |||
| 188 | + | // another, they should send "cancel" first. | |||
| 189 | + | mu.Unlock() | |||
| 190 | + | em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"}) | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | busyTurn = true | |||
| 194 | + | turnNo++ | |||
| 195 | + | step := turnNo | |||
| 196 | + | c, cancel := context.WithCancel(ctx) | |||
| 197 | + | cancelFn = cancel | |||
| 198 | + | mu.Unlock() | |||
| 199 | + | ||||
| 200 | + | turnID := fmt.Sprintf("turn-%d", step) | |||
| 201 | + | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | |||
| 202 | + | ||||
| 203 | + | sink := func(ev core.AgentEvent) { | |||
| 204 | + | em.emit(ev.Type(), modes.EventToJSON(ev)) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | start := len(ag.Messages()) | |||
| 208 | + | err := ag.Prompt(c, prompt, nil, sink) | |||
| 209 | + | WriteNewTranscript(ag, sess, start) | |||
| 210 | + | ||||
| 211 | + | output := finalAssistantText(ag.Messages()[start:]) | |||
| 212 | + | resultStatus := "succeeded" | |||
| 213 | + | if err != nil { | |||
| 214 | + | if errors.Is(err, context.Canceled) { | |||
| 215 | + | resultStatus = "canceled" | |||
| 216 | + | } else { | |||
| 217 | + | resultStatus = "failed" | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | resultPayload := map[string]any{ | |||
| 221 | + | "status": resultStatus, | |||
| 222 | + | "turn_id": turnID, | |||
| 223 | + | "summary": boundedResultSummary(output), | |||
| 224 | + | "output": boundedResultOutput(output, maxOutputBytes, maxOutputLines), | |||
| 225 | + | "error": resultErrorPayload(err), | |||
| 226 | + | } | |||
| 227 | + | em.emit("turn.result", resultPayload) | |||
| 228 | + | if err != nil { | |||
| 229 | + | em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err)}) | |||
| 230 | + | } | |||
| 231 | + | em.emit("turn_end", map[string]any{ | |||
| 232 | + | "step": step, | |||
| 233 | + | "turn_id": turnID, | |||
| 234 | + | "error": errString(err), | |||
| 235 | + | }) | |||
| 236 | + | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |||
| 237 | + | ||||
| 238 | + | cancel() | |||
| 239 | + | mu.Lock() | |||
| 240 | + | busyTurn = false | |||
| 241 | + | cancelFn = nil | |||
| 242 | + | mu.Unlock() | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | launchTurn := func(prompt string) { | |||
| 246 | + | mu.Lock() | |||
| 247 | + | if closing { | |||
| 248 | + | mu.Unlock() | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | // Register the goroutine before launching it. Shutdown can then | |||
| 252 | + | // close the gate and wait for every turn that was admitted, even | |||
| 253 | + | // if runOne has not acquired mu and set busyTurn yet. | |||
| 254 | + | turnWG.Add(1) | |||
| 255 | + | mu.Unlock() | |||
| 256 | + | go func() { | |||
| 257 | + | defer turnWG.Done() | |||
| 258 | + | runOne(prompt) | |||
| 259 | + | }() | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | waitForActiveTurn := func(cancel bool) { | |||
| 263 | + | mu.Lock() | |||
| 264 | + | closing = true | |||
| 265 | + | activeCancel := cancelFn | |||
| 266 | + | mu.Unlock() | |||
| 267 | + | if cancel && activeCancel != nil { | |||
| 268 | + | activeCancel() | |||
| 269 | + | } | |||
| 270 | + | turnWG.Wait() | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | requestShutdown := func() { | |||
| 274 | + | mu.Lock() | |||
| 275 | + | closing = true | |||
| 276 | + | mu.Unlock() | |||
| 277 | + | shutdownOnce.Do(func() { close(shutdown) }) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // Initial task: run before processing the inbox so the agent | |||
| 281 | + | // "starts working" the moment it boots, matching what users | |||
| 282 | + | // expect from `/subagents new <task>`. | |||
| 283 | + | if args.Prompt != "" { | |||
| 284 | + | launchTurn(args.Prompt) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // Inbox loop: one supervisor message at a time. We don't spawn | |||
| 288 | + | // a goroutine per turn because runOne already serialises them | |||
| 289 | + | // via the busyTurn flag; doing the dispatch on the main | |||
| 290 | + | // goroutine keeps the daemon's lifecycle easy to follow. | |||
| 291 | + | for { | |||
| 292 | + | select { | |||
| 293 | + | case <-ctx.Done(): | |||
| 294 | + | waitForActiveTurn(true) | |||
| 295 | + | em.emit("agent.exited", map[string]any{"reason": "cancelled"}) | |||
| 296 | + | return ctx.Err() | |||
| 297 | + | case <-shutdown: | |||
| 298 | + | // Shutdown is an explicit supervisor stop. Cancel an active | |||
| 299 | + | // turn before joining it so a detached worker cannot remain | |||
| 300 | + | // alive indefinitely while the host waits for its socket to | |||
| 301 | + | // disappear. | |||
| 302 | + | waitForActiveTurn(true) | |||
| 303 | + | em.emit("agent.exited", map[string]any{"reason": "shutdown"}) | |||
| 304 | + | return nil | |||
| 305 | + | case msg, ok := <-ln.Lines(): | |||
| 306 | + | if !ok { | |||
| 307 | + | waitForActiveTurn(true) | |||
| 308 | + | em.emit("agent.exited", map[string]any{"reason": "inbox-closed"}) | |||
| 309 | + | return nil | |||
| 310 | + | } | |||
| 311 | + | command, parseErr := subagents.ParseCommand(msg) | |||
| 312 | + | if parseErr != nil { | |||
| 313 | + | em.emit("error", map[string]any{"message": "invalid supervisor command"}) | |||
| 314 | + | continue | |||
| 315 | + | } | |||
| 316 | + | switch command.Type { | |||
| 317 | + | case subagents.CommandAgentShutdown: | |||
| 318 | + | requestShutdown() | |||
| 319 | + | case subagents.CommandTurnCancel: | |||
| 320 | + | mu.Lock() | |||
| 321 | + | if cancelFn != nil { | |||
| 322 | + | cancelFn() | |||
| 323 | + | } | |||
| 324 | + | mu.Unlock() | |||
| 325 | + | case subagents.CommandAgentPing: | |||
| 326 | + | em.emit("agent.heartbeat", map[string]any{"activity": "alive"}) | |||
| 327 | + | case subagents.CommandTurnStart: | |||
| 328 | + | var payload subagents.TurnStartPayload | |||
| 329 | + | if err := command.DecodePayload(&payload); err != nil { | |||
| 330 | + | em.emit("error", map[string]any{"message": "invalid turn.start payload"}) | |||
| 331 | + | continue | |||
| 332 | + | } | |||
| 333 | + | launchTurn(payload.Prompt) | |||
| 334 | + | default: | |||
| 335 | + | // Unknown commands are rejected rather than interpreted as | |||
| 336 | + | // child-originated control requests. | |||
| 337 | + | em.emit("error", map[string]any{"message": "unknown supervisor command"}) | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // subagentEmitter serialises events to stdout and (optionally) to a | |||
| 344 | + | // durable log file. Concurrent goroutines call emit so we have to | |||
| 345 | + | // hold a mutex around the encoder. | |||
| 346 | + | type subagentEmitter struct { | |||
| 347 | + | mu sync.Mutex | |||
| 348 | + | w *os.File | |||
| 349 | + | mirror *subagents.EventLog | |||
| 350 | + | agentID string | |||
| 351 | + | ||||
| 352 | + | // orphan flips true the first time a stdout write fails (broken | |||
| 353 | + | // pipe — the supervisor died). Until then the mirror stays | |||
| 354 | + | // dormant: the supervisor is the canonical writer to events.jsonl | |||
| 355 | + | // (it parses our stdout and Append()s each event itself). Writing | |||
| 356 | + | // from both sides used to land every event in the log twice, | |||
| 357 | + | // which showed up as a fully-duplicated transcript the next time | |||
| 358 | + | // the agent was reloaded — the exact "why is everything doubled" | |||
| 359 | + | // bug. Once orphaned the mirror takes over so the events still | |||
| 360 | + | // land on disk for the next reload. | |||
| 361 | + | orphan bool | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func newSubagentEmitter(w *os.File, mirror *subagents.EventLog) *subagentEmitter { | |||
| 365 | + | return &subagentEmitter{w: w, mirror: mirror} | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func (e *subagentEmitter) setProtocolIdentity(agentID string) { | |||
| 369 | + | e.mu.Lock() | |||
| 370 | + | e.agentID = agentID | |||
| 371 | + | e.mu.Unlock() | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func (e *subagentEmitter) emit(typ string, data map[string]any) { | |||
| 375 | + | if data == nil { | |||
| 376 | + | data = map[string]any{} | |||
| 377 | + | } | |||
| 378 | + | payload := make(map[string]any, len(data)) | |||
| 379 | + | for key, value := range data { | |||
| 380 | + | payload[key] = value | |||
| 381 | + | } | |||
| 382 | + | turnID, _ := payload["turn_id"].(string) | |||
| 383 | + | ||||
| 384 | + | e.mu.Lock() | |||
| 385 | + | defer e.mu.Unlock() | |||
| 386 | + | ||||
| 387 | + | wireType := canonicalWorkerEvent(typ) | |||
| 388 | + | envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload) | |||
| 389 | + | line, err := subagents.MarshalJSONL(envelope) | |||
| 390 | + | ||||
| 391 | + | if !e.orphan && err == nil { | |||
| 392 | + | if _, werr := e.w.Write(line); werr != nil { | |||
| 393 | + | // Supervisor's stdout pipe is gone (parent zot exited but we | |||
| 394 | + | // kept running). Switch to mirror-only mode and preserve this | |||
| 395 | + | // event in the durable log. | |||
| 396 | + | e.orphan = true | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | if e.orphan && e.mirror != nil { | |||
| 401 | + | _ = e.mirror.Append(subagents.NewEvent(wireType, payload)) | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func canonicalWorkerEvent(typ string) string { | |||
| 406 | + | switch typ { | |||
| 407 | + | case "agent_ready": | |||
| 408 | + | return subagents.EventAgentReady | |||
| 409 | + | case "agent_stopped": | |||
| 410 | + | return subagents.EventAgentExited | |||
| 411 | + | case "turn_start": | |||
| 412 | + | return subagents.EventTurnStarted | |||
| 413 | + | case "turn_progress": | |||
| 414 | + | return subagents.EventTurnProgress | |||
| 415 | + | case "tool_call": | |||
| 416 | + | return subagents.EventToolStarted | |||
| 417 | + | case "tool_result": | |||
| 418 | + | return subagents.EventToolFinished | |||
| 419 | + | case "text_delta": | |||
| 420 | + | return subagents.EventMessageDelta | |||
| 421 | + | default: | |||
| 422 | + | return typ | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func finalAssistantText(messages []provider.Message) string { | |||
| 427 | + | var out strings.Builder | |||
| 428 | + | for _, message := range messages { | |||
| 429 | + | if message.Role != provider.RoleAssistant { | |||
| 430 | + | continue | |||
| 431 | + | } | |||
| 432 | + | for _, block := range message.Content { | |||
| 433 | + | if text, ok := block.(provider.TextBlock); ok { | |||
| 434 | + | if out.Len() > 0 { | |||
| 435 | + | out.WriteByte('\n') | |||
| 436 | + | } | |||
| 437 | + | out.WriteString(text.Text) | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | return out.String() | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | func firstLine(text string) string { | |||
| 445 | + | return strings.Split(strings.TrimSpace(text), "\n")[0] | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func boundedResultSummary(text string) string { | |||
| 449 | + | return boundedResultOutput(firstLine(text), 4*1024, 1) | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func workerOutputLimits() (maxBytes, maxLines int) { | |||
| 453 | + | maxBytes, maxLines = 500_000, 5_000 | |||
| 454 | + | if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZOT_SUBAGENT_MAX_OUTPUT_BYTES"))); err == nil && value > 0 { | |||
| 455 | + | maxBytes = value | |||
| 456 | + | } | |||
| 457 | + | if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZOT_SUBAGENT_MAX_OUTPUT_LINES"))); err == nil && value > 0 { | |||
| 458 | + | maxLines = value | |||
| 459 | + | } | |||
| 460 | + | return maxBytes, maxLines | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | func boundedResultOutput(text string, maxBytes, maxLines int) string { | |||
| 464 | + | const marker = "...[output truncated]" | |||
| 465 | + | lines := strings.Split(text, "\n") | |||
| 466 | + | if maxLines > 0 && len(lines) > maxLines { | |||
| 467 | + | if maxLines == 1 { | |||
| 468 | + | text = marker | |||
| 469 | + | } else { | |||
| 470 | + | text = strings.Join(append(lines[:maxLines-1], marker), "\n") | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | if maxBytes > 0 && len([]byte(text)) > maxBytes { | |||
| 474 | + | if maxBytes <= len(marker) { | |||
| 475 | + | return truncateOutputUTF8(text, maxBytes) | |||
| 476 | + | } | |||
| 477 | + | return truncateOutputUTF8(text, maxBytes-len(marker)) + marker | |||
| 478 | + | } | |||
| 479 | + | return text | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func truncateOutputUTF8(value string, maxBytes int) string { | |||
| 483 | + | if maxBytes <= 0 { | |||
| 484 | + | return "" | |||
| 485 | + | } | |||
| 486 | + | data := []byte(value) | |||
| 487 | + | if len(data) <= maxBytes { | |||
| 488 | + | return value | |||
| 489 | + | } | |||
| 490 | + | data = data[:maxBytes] | |||
| 491 | + | for len(data) > 0 && !utf8.Valid(data) { | |||
| 492 | + | data = data[:len(data)-1] | |||
| 493 | + | } | |||
| 494 | + | return string(data) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func resultErrorPayload(err error) map[string]any { | |||
| 498 | + | if err == nil { | |||
| 499 | + | return nil | |||
| 500 | + | } | |||
| 501 | + | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func errString(err error) string { | |||
| 505 | + | if err == nil { | |||
| 506 | + | return "" | |||
| 507 | + | } | |||
| 508 | + | return err.Error() | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | func initialTurnNumber(path string) int { | |||
| 512 | + | if strings.TrimSpace(path) == "" { | |||
| 513 | + | return 0 | |||
| 514 | + | } | |||
| 515 | + | events, err := subagents.ReadEventLog(path) | |||
| 516 | + | if err != nil { | |||
| 517 | + | return 0 | |||
| 518 | + | } | |||
| 519 | + | maxStep := 0 | |||
| 520 | + | for _, ev := range events { | |||
| 521 | + | if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep { | |||
| 522 | + | maxStep = int(step) | |||
| 523 | + | } | |||
| 524 | + | if strings.HasPrefix(ev.TurnID, "turn-") { | |||
| 525 | + | if n, parseErr := strconv.Atoi(strings.TrimPrefix(ev.TurnID, "turn-")); parseErr == nil && n > maxStep { | |||
| 526 | + | maxStep = n | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | } | |||
| 530 | + | return maxStep | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | func truncateForLog(s string, n int) string { | |||
| 534 | + | if len(s) <= n { | |||
| 535 | + | return s | |||
| 536 | + | } | |||
| 537 | + | if n <= 3 { | |||
| 538 | + | return strings.Repeat(".", n) | |||
| 539 | + | } | |||
| 540 | + | return s[:n-3] + "..." | |||
| 541 | + | } | |||
A packages/agent/subagent_worker_test.go +188 -0
| @@ -0,0 +1,188 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | |||
| 16 | + | // the effective output caps propagated by the supervisor and retains safe | |||
| 17 | + | // defaults for standalone workers. | |||
| 18 | + | func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) { | |||
| 19 | + | t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_BYTES", "123") | |||
| 20 | + | t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_LINES", "7") | |||
| 21 | + | bytesLimit, linesLimit := workerOutputLimits() | |||
| 22 | + | if bytesLimit != 123 || linesLimit != 7 { | |||
| 23 | + | t.Fatalf("worker output limits = (%d, %d), want (123, 7)", bytesLimit, linesLimit) | |||
| 24 | + | } | |||
| 25 | + | t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_BYTES", "invalid") | |||
| 26 | + | t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_LINES", "0") | |||
| 27 | + | bytesLimit, linesLimit = workerOutputLimits() | |||
| 28 | + | if bytesLimit != 500_000 || linesLimit != 5_000 { | |||
| 29 | + | t.Fatalf("invalid worker output limits = (%d, %d), want defaults", bytesLimit, linesLimit) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | |||
| 34 | + | // "everything is doubled after reopening a subagent agent" bug. | |||
| 35 | + | // | |||
| 36 | + | // Symptom: events.jsonl held two copies of every event because the | |||
| 37 | + | // child mirrored each event to disk AND the supervisor parsed the | |||
| 38 | + | // child's stdout and appended each event to disk too. On next zot | |||
| 39 | + | // launch the replay produced two transcript lines per real one. | |||
| 40 | + | // | |||
| 41 | + | // Fix invariant: while stdout writes succeed (i.e. the supervisor is | |||
| 42 | + | // alive on the other end of the pipe), the child's mirror writes | |||
| 43 | + | // NOTHING. Only when a stdout write fails (broken pipe → orphan) | |||
| 44 | + | // does the mirror take over so events still get persisted. | |||
| 45 | + | func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |||
| 46 | + | // Real *os.File for the emitter's stdout-equivalent so the | |||
| 47 | + | // emitter's write() path (which expects *os.File) actually runs. | |||
| 48 | + | stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo") | |||
| 49 | + | stdoutFile, err := os.Create(stdoutPath) | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Fatalf("create stdout file: %v", err) | |||
| 52 | + | } | |||
| 53 | + | defer stdoutFile.Close() | |||
| 54 | + | ||||
| 55 | + | // Mirror writes go to a separate events.jsonl that we can read | |||
| 56 | + | // at the end to assert how many events the mirror emitted. | |||
| 57 | + | mirrorPath := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 58 | + | mirror, err := subagents.OpenEventLog(mirrorPath) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatalf("open mirror: %v", err) | |||
| 61 | + | } | |||
| 62 | + | defer mirror.Close() | |||
| 63 | + | ||||
| 64 | + | em := newSubagentEmitter(stdoutFile, mirror) | |||
| 65 | + | ||||
| 66 | + | // Healthy stdout: emit three events. Mirror must stay empty. | |||
| 67 | + | em.emit("turn_start", map[string]any{"step": 1}) | |||
| 68 | + | em.emit("assistant_message", map[string]any{"text": "hi"}) | |||
| 69 | + | em.emit("turn_end", map[string]any{"step": 1}) | |||
| 70 | + | ||||
| 71 | + | got, err := subagents.ReadEventLog(mirrorPath) | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatalf("read mirror: %v", err) | |||
| 74 | + | } | |||
| 75 | + | if len(got) != 0 { | |||
| 76 | + | t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v", | |||
| 77 | + | len(got), got) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Simulate supervisor death: close stdout so the next Write | |||
| 81 | + | // returns EBADF / broken pipe. The emitter must flip into | |||
| 82 | + | // orphan mode and start writing through the mirror. | |||
| 83 | + | if err := stdoutFile.Close(); err != nil { | |||
| 84 | + | t.Fatalf("close stdout: %v", err) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | em.emit("assistant_message", map[string]any{"text": "after orphan"}) | |||
| 88 | + | em.emit("turn_end", map[string]any{"step": 2}) | |||
| 89 | + | ||||
| 90 | + | got, err = subagents.ReadEventLog(mirrorPath) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatalf("read mirror post-orphan: %v", err) | |||
| 93 | + | } | |||
| 94 | + | if len(got) < 2 { | |||
| 95 | + | t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got)) | |||
| 96 | + | } | |||
| 97 | + | if got[len(got)-1].Type != "turn_end" { | |||
| 98 | + | t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the | |||
| 103 | + | // large-result wire duplication bug. | |||
| 104 | + | func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | |||
| 105 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 106 | + | file, err := os.Create(path) | |||
| 107 | + | if err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | defer file.Close() | |||
| 111 | + | ||||
| 112 | + | em := newSubagentEmitter(file, nil) | |||
| 113 | + | em.setProtocolIdentity("agent-1") | |||
| 114 | + | em.emit("turn.result", map[string]any{ | |||
| 115 | + | "status": "succeeded", | |||
| 116 | + | "turn_id": "turn-1", | |||
| 117 | + | "output": strings.Repeat("x", 500_000), | |||
| 118 | + | }) | |||
| 119 | + | if err := file.Close(); err != nil { | |||
| 120 | + | t.Fatal(err) | |||
| 121 | + | } | |||
| 122 | + | wire, err := os.ReadFile(path) | |||
| 123 | + | if err != nil { | |||
| 124 | + | t.Fatal(err) | |||
| 125 | + | } | |||
| 126 | + | if len(wire) >= 900_000 { | |||
| 127 | + | t.Fatalf("large result appears duplicated on wire: %d bytes", len(wire)) | |||
| 128 | + | } | |||
| 129 | + | var object map[string]any | |||
| 130 | + | if err := json.Unmarshal(bytes.TrimSpace(wire), &object); err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | if _, exists := object["output"]; exists { | |||
| 134 | + | t.Fatal("large output was flattened a second time") | |||
| 135 | + | } | |||
| 136 | + | payload, ok := object["payload"].(map[string]any) | |||
| 137 | + | if !ok || len(payload["output"].(string)) != 500_000 { | |||
| 138 | + | t.Fatal("canonical payload lost the complete bounded output") | |||
| 139 | + | } | |||
| 140 | + | events, err := subagents.ReadEventLog(path) | |||
| 141 | + | if err != nil || len(events) != 1 { | |||
| 142 | + | t.Fatalf("supervisor event parser could not recover the large result: events=%d err=%v", len(events), err) | |||
| 143 | + | } | |||
| 144 | + | if output, ok := events[0].Data["output"].(string); !ok || len(output) != 500_000 { | |||
| 145 | + | t.Fatal("supervisor parser lost the large result payload") | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the | |||
| 150 | + | // wire-format contract: each emitted event lands on stdout as one | |||
| 151 | + | // JSON object per line with type+time at top level alongside the | |||
| 152 | + | // data fields. The supervisor's runner parses this exact shape. | |||
| 153 | + | func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||
| 154 | + | // Pipe so we can read what the emitter wrote. | |||
| 155 | + | r, w, err := os.Pipe() | |||
| 156 | + | if err != nil { | |||
| 157 | + | t.Fatalf("pipe: %v", err) | |||
| 158 | + | } | |||
| 159 | + | defer r.Close() | |||
| 160 | + | ||||
| 161 | + | em := newSubagentEmitter(w, nil) | |||
| 162 | + | em.emit("turn_start", map[string]any{"step": 1}) | |||
| 163 | + | _ = w.Close() | |||
| 164 | + | ||||
| 165 | + | body, err := io.ReadAll(r) | |||
| 166 | + | if err != nil { | |||
| 167 | + | t.Fatalf("read pipe: %v", err) | |||
| 168 | + | } | |||
| 169 | + | // One trailing newline => one event line. | |||
| 170 | + | lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n")) | |||
| 171 | + | if len(lines) != 1 { | |||
| 172 | + | t.Fatalf("expected 1 event line, got %d: %q", len(lines), body) | |||
| 173 | + | } | |||
| 174 | + | var object map[string]any | |||
| 175 | + | if err := json.Unmarshal(lines[0], &object); err != nil { | |||
| 176 | + | t.Fatalf("not valid json: %v\n%s", err, lines[0]) | |||
| 177 | + | } | |||
| 178 | + | if object["type"] != "turn.started" { | |||
| 179 | + | t.Errorf("type field missing or wrong: %v", object["type"]) | |||
| 180 | + | } | |||
| 181 | + | if _, ok := object["timestamp"].(string); !ok { | |||
| 182 | + | t.Errorf("timestamp field missing: %v", object["timestamp"]) | |||
| 183 | + | } | |||
| 184 | + | payload, ok := object["payload"].(map[string]any) | |||
| 185 | + | if !ok || payload["step"] != float64(1) { | |||
| 186 | + | t.Errorf("payload step field missing or wrong: %v", object["payload"]) | |||
| 187 | + | } | |||
| 188 | + | } | |||
A packages/agent/subagents/agent.go +481 -0
| @@ -0,0 +1,481 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Agent is one supervised task. Public fields are immutable after | |||
| 13 | + | // Spawn; mutable state (status, activity, transcript) lives behind | |||
| 14 | + | // the embedded mutex. | |||
| 15 | + | type turnEndNotice struct { | |||
| 16 | + | step int | |||
| 17 | + | errMsg string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type Agent struct { | |||
| 21 | + | ID string | |||
| 22 | + | Task string | |||
| 23 | + | Dir string // shared checkout or isolated worktree used by the child. | |||
| 24 | + | Started time.Time | |||
| 25 | + | ||||
| 26 | + | // ParentID and RootSessionID identify the supervisor/session that owns | |||
| 27 | + | // this worker. Empty ParentID means the main supervisor created it; | |||
| 28 | + | // child-originated spawning is rejected in v1. | |||
| 29 | + | ParentID string | |||
| 30 | + | BatchID string | |||
| 31 | + | RootSessionID string | |||
| 32 | + | OriginalTask string | |||
| 33 | + | RepositoryRoot string | |||
| 34 | + | WorkspaceMode WorkspaceMode | |||
| 35 | + | WorkspacePath string | |||
| 36 | + | WorkspaceBase string | |||
| 37 | + | WorkspaceCapture CaptureMode | |||
| 38 | + | MaxTurns int | |||
| 39 | + | // Timeout is the effective per-agent lifetime, persisted so a resumed | |||
| 40 | + | // worker keeps the timeout selected at its spawn boundary. | |||
| 41 | + | Timeout time.Duration | |||
| 42 | + | HeartbeatInterval time.Duration | |||
| 43 | + | Tools []string | |||
| 44 | + | ||||
| 45 | + | // Model and Provider, when non-empty, override the child | |||
| 46 | + | // subprocess's model resolution. BaseURL and InsecureTLS carry | |||
| 47 | + | // the effective provider connection settings from the supervisor. | |||
| 48 | + | // Empty values inherit whatever the child resolves on its own from | |||
| 49 | + | // config / env / flags. All four settings are persisted in meta.json | |||
| 50 | + | // so Resume keeps using the same provider configuration across zot | |||
| 51 | + | // restarts. | |||
| 52 | + | Model string | |||
| 53 | + | Provider string | |||
| 54 | + | BaseURL string | |||
| 55 | + | InsecureTLS bool | |||
| 56 | + | Reasoning string | |||
| 57 | + | FastMode bool | |||
| 58 | + | Subagent string | |||
| 59 | + | ||||
| 60 | + | // SessionID, when non-empty, scopes the agent to a particular | |||
| 61 | + | // host zot session: the dashboard only surfaces agents whose | |||
| 62 | + | // SessionID matches the active session. Empty means "unscoped" and | |||
| 63 | + | // applies to agents spawned without a session context such as in tests. Set at | |||
| 64 | + | // Spawn time from Supervisor.activeSession and persisted in | |||
| 65 | + | // meta.json so the scope survives restarts. | |||
| 66 | + | SessionID string | |||
| 67 | + | ||||
| 68 | + | // Resuming is true when this Agent struct was built by Resume | |||
| 69 | + | // rather than Spawn. The runner consults it to decide whether | |||
| 70 | + | // to pass the original Task as a positional argv to the child: | |||
| 71 | + | // on first Spawn we want the child to run the task immediately; | |||
| 72 | + | // on Resume the task was already run last time and replaying it | |||
| 73 | + | // would produce a second turn that collides ("agent busy; send | |||
| 74 | + | // 'cancel' first") with whatever the user types next. Not | |||
| 75 | + | // persisted — every Resume sets it explicitly. | |||
| 76 | + | Resuming bool | |||
| 77 | + | ||||
| 78 | + | // InboxPath is the unix socket the child agent listens on for | |||
| 79 | + | // follow-up prompts and control messages. The supervisor opens | |||
| 80 | + | // an Inbox at this path; the child opens a Listener. | |||
| 81 | + | InboxPath string | |||
| 82 | + | ||||
| 83 | + | // Attempt increments whenever the supervisor starts a process for this | |||
| 84 | + | // agent. It is persisted so recovery can distinguish a resumed worker | |||
| 85 | + | // from a first launch. | |||
| 86 | + | Attempt int | |||
| 87 | + | ||||
| 88 | + | // ProcessPID identifies the current worker when known. It is persisted | |||
| 89 | + | // so a reloaded supervisor can force-kill a detached worker after a | |||
| 90 | + | // graceful shutdown deadline. | |||
| 91 | + | ProcessPID int | |||
| 92 | + | ||||
| 93 | + | // EventLogPath is the durable JSONL event log for this agent. | |||
| 94 | + | // The runner appends every well-formed event from the child | |||
| 95 | + | // (plus lifecycle events of its own) here. /subagents open in any | |||
| 96 | + | // zot process reads from this file to replay the full history. | |||
| 97 | + | EventLogPath string | |||
| 98 | + | ||||
| 99 | + | // SessionPath is the child's persistent session file. Surfaced | |||
| 100 | + | // so the dashboard / /subagents open can resume the agent later. | |||
| 101 | + | SessionPath string | |||
| 102 | + | ||||
| 103 | + | // inbox is the supervisor-side socket handle. Populated by | |||
| 104 | + | // Supervisor.Spawn so callers do not need to manage the socket directly. | |||
| 105 | + | inbox *Inbox | |||
| 106 | + | ||||
| 107 | + | mu sync.Mutex | |||
| 108 | + | status Status | |||
| 109 | + | activity string | |||
| 110 | + | transcript []string | |||
| 111 | + | lastAssistant string | |||
| 112 | + | finished time.Time | |||
| 113 | + | lastErr error | |||
| 114 | + | ||||
| 115 | + | lifecycleMu sync.Mutex | |||
| 116 | + | processState ProcessState | |||
| 117 | + | turnState TurnState | |||
| 118 | + | currentTurnID string | |||
| 119 | + | updatedAt time.Time | |||
| 120 | + | lastActivity time.Time | |||
| 121 | + | result *TurnResult | |||
| 122 | + | resultRef string | |||
| 123 | + | patchRef string | |||
| 124 | + | changedFiles []string | |||
| 125 | + | outputBytes int | |||
| 126 | + | outputLines int | |||
| 127 | + | outputTruncated bool | |||
| 128 | + | maxOutputBytes int | |||
| 129 | + | maxOutputLines int | |||
| 130 | + | persistFn func(*Agent) | |||
| 131 | + | stateDir string | |||
| 132 | + | workspaceCleanup func() error | |||
| 133 | + | workspaceCapture func() (WorkspaceCapture, error) | |||
| 134 | + | ||||
| 135 | + | // OnTurnEnd, if set, fires once per prompt-level turn_end event | |||
| 136 | + | // emitted by the subagent daemon wrapper. Provider/tool-loop | |||
| 137 | + | // turn_end events (for example stop=tool_use) are ignored by the | |||
| 138 | + | // runner because they do not mean the sub-agent task finished. | |||
| 139 | + | // Used by auto-subagents watchers to detect that a sub-agent's first | |||
| 140 | + | // (or n-th) task has finished without waiting for the long-lived | |||
| 141 | + | // daemon itself to exit — sub-agents keep running on the inbox | |||
| 142 | + | // even after the initial task completes, so Wait() never unblocks | |||
| 143 | + | // for them. | |||
| 144 | + | OnTurnEnd func(step int, errMsg string) | |||
| 145 | + | pendingTurnEnds []turnEndNotice | |||
| 146 | + | ||||
| 147 | + | ctx context.Context | |||
| 148 | + | cancel context.CancelFunc | |||
| 149 | + | runner Runner | |||
| 150 | + | lease *agentLease | |||
| 151 | + | ||||
| 152 | + | // launchState is an admission gate for the scheduler's goroutine: | |||
| 153 | + | // 0=not admitted, 1=runner admitted, 2=stopped before admission. It | |||
| 154 | + | // prevents an immediate Stop from starting a runner after cancellation. | |||
| 155 | + | launchState int32 | |||
| 156 | + | ||||
| 157 | + | // done closes when the run goroutine finalises the agent's | |||
| 158 | + | // status (done / failed / killed). Wait blocks on this so | |||
| 159 | + | // callers don't have to poll. | |||
| 160 | + | done chan struct{} | |||
| 161 | + | turnResults chan *TurnResult | |||
| 162 | + | doneOnce sync.Once | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Inbox exposes the supervisor-side socket handle. Returns nil for | |||
| 166 | + | // agents without inboxes (e.g. tests using a custom Runner that | |||
| 167 | + | // doesn't speak the daemon protocol). | |||
| 168 | + | func (a *Agent) Inbox() *Inbox { return a.inbox } | |||
| 169 | + | ||||
| 170 | + | // Status returns the current high-level status. Cheap; safe from any goroutine. | |||
| 171 | + | func (a *Agent) Status() Status { | |||
| 172 | + | a.mu.Lock() | |||
| 173 | + | defer a.mu.Unlock() | |||
| 174 | + | return a.status | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Activity returns the current one-line activity string. | |||
| 178 | + | func (a *Agent) Activity() string { | |||
| 179 | + | a.mu.Lock() | |||
| 180 | + | defer a.mu.Unlock() | |||
| 181 | + | return a.activity | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // ProcessState returns the child process lifecycle state. | |||
| 185 | + | func (a *Agent) ProcessState() ProcessState { | |||
| 186 | + | a.lifecycleMu.Lock() | |||
| 187 | + | defer a.lifecycleMu.Unlock() | |||
| 188 | + | return a.processState | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // TurnState returns the current delegated-turn state. | |||
| 192 | + | func (a *Agent) TurnState() TurnState { | |||
| 193 | + | a.lifecycleMu.Lock() | |||
| 194 | + | defer a.lifecycleMu.Unlock() | |||
| 195 | + | return a.turnState | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // CurrentTurnID returns the stable id of the current or most recent turn. | |||
| 199 | + | func (a *Agent) CurrentTurnID() string { | |||
| 200 | + | a.lifecycleMu.Lock() | |||
| 201 | + | defer a.lifecycleMu.Unlock() | |||
| 202 | + | return a.currentTurnID | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // AttemptValue returns the current process attempt number. | |||
| 206 | + | func (a *Agent) AttemptValue() int { | |||
| 207 | + | a.lifecycleMu.Lock() | |||
| 208 | + | defer a.lifecycleMu.Unlock() | |||
| 209 | + | return a.Attempt | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // ProcessPIDValue returns the current worker pid, when known. | |||
| 213 | + | func (a *Agent) ProcessPIDValue() int { | |||
| 214 | + | a.lifecycleMu.Lock() | |||
| 215 | + | defer a.lifecycleMu.Unlock() | |||
| 216 | + | return a.ProcessPID | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // LastActivity returns the last heartbeat or meaningful child event time. | |||
| 220 | + | func (a *Agent) LastActivity() time.Time { | |||
| 221 | + | a.lifecycleMu.Lock() | |||
| 222 | + | defer a.lifecycleMu.Unlock() | |||
| 223 | + | return a.lastActivity | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // Result returns a copy of the most recent structured turn result. | |||
| 227 | + | func (a *Agent) Result() *TurnResult { | |||
| 228 | + | a.lifecycleMu.Lock() | |||
| 229 | + | defer a.lifecycleMu.Unlock() | |||
| 230 | + | return cloneTurnResult(a.result) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func (a *Agent) setProcessState(state ProcessState) { | |||
| 234 | + | a.lifecycleMu.Lock() | |||
| 235 | + | a.processState = state | |||
| 236 | + | a.updatedAt = time.Now() | |||
| 237 | + | a.lifecycleMu.Unlock() | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func (a *Agent) incrementAttempt() int { | |||
| 241 | + | a.lifecycleMu.Lock() | |||
| 242 | + | a.Attempt++ | |||
| 243 | + | a.updatedAt = time.Now() | |||
| 244 | + | attempt := a.Attempt | |||
| 245 | + | a.lifecycleMu.Unlock() | |||
| 246 | + | return attempt | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func (a *Agent) setProcessPID(pid int) { | |||
| 250 | + | a.lifecycleMu.Lock() | |||
| 251 | + | a.ProcessPID = pid | |||
| 252 | + | a.updatedAt = time.Now() | |||
| 253 | + | a.lifecycleMu.Unlock() | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func (a *Agent) releaseLease() error { | |||
| 257 | + | a.lifecycleMu.Lock() | |||
| 258 | + | lease := a.lease | |||
| 259 | + | a.lease = nil | |||
| 260 | + | a.lifecycleMu.Unlock() | |||
| 261 | + | if lease == nil { | |||
| 262 | + | return nil | |||
| 263 | + | } | |||
| 264 | + | return lease.Close() | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (a *Agent) setTurnState(state TurnState, turnID string) { | |||
| 268 | + | a.lifecycleMu.Lock() | |||
| 269 | + | a.turnState = state | |||
| 270 | + | if turnID != "" { | |||
| 271 | + | a.currentTurnID = turnID | |||
| 272 | + | } | |||
| 273 | + | a.updatedAt = time.Now() | |||
| 274 | + | a.lifecycleMu.Unlock() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func (a *Agent) markActivity(now time.Time) { | |||
| 278 | + | if now.IsZero() { | |||
| 279 | + | now = time.Now() | |||
| 280 | + | } | |||
| 281 | + | a.lifecycleMu.Lock() | |||
| 282 | + | a.lastActivity = now | |||
| 283 | + | a.updatedAt = now | |||
| 284 | + | a.lifecycleMu.Unlock() | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (a *Agent) setResult(result *TurnResult) { | |||
| 288 | + | a.lifecycleMu.Lock() | |||
| 289 | + | a.result = cloneTurnResult(result) | |||
| 290 | + | a.updatedAt = time.Now() | |||
| 291 | + | a.lifecycleMu.Unlock() | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // Transcript returns a copy of the running transcript. | |||
| 295 | + | func (a *Agent) Transcript() []string { | |||
| 296 | + | a.mu.Lock() | |||
| 297 | + | defer a.mu.Unlock() | |||
| 298 | + | out := make([]string, len(a.transcript)) | |||
| 299 | + | copy(out, a.transcript) | |||
| 300 | + | return out | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // Err returns the runner's terminal error, if any. | |||
| 304 | + | func (a *Agent) Err() error { | |||
| 305 | + | a.mu.Lock() | |||
| 306 | + | defer a.mu.Unlock() | |||
| 307 | + | return a.lastErr | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // Wait blocks until the agent reaches a terminal state. Used by tests | |||
| 311 | + | // and by /subagents wait <id>. | |||
| 312 | + | func (a *Agent) Wait() { <-a.done } | |||
| 313 | + | ||||
| 314 | + | // WaitContext waits for the agent to finish or for ctx cancellation. | |||
| 315 | + | func (a *Agent) WaitContext(ctx context.Context) error { | |||
| 316 | + | if ctx == nil { | |||
| 317 | + | ctx = context.Background() | |||
| 318 | + | } | |||
| 319 | + | select { | |||
| 320 | + | case <-a.done: | |||
| 321 | + | return nil | |||
| 322 | + | case <-ctx.Done(): | |||
| 323 | + | return ctx.Err() | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) { | |||
| 328 | + | if ctx == nil { | |||
| 329 | + | ctx = context.Background() | |||
| 330 | + | } | |||
| 331 | + | select { | |||
| 332 | + | case result := <-a.turnResults: | |||
| 333 | + | if result == nil { | |||
| 334 | + | return nil, fmt.Errorf("subagents: nil turn result for %s", a.ID) | |||
| 335 | + | } | |||
| 336 | + | return cloneTurnResult(result), nil | |||
| 337 | + | case <-a.done: | |||
| 338 | + | if result := a.Result(); result != nil { | |||
| 339 | + | return result, nil | |||
| 340 | + | } | |||
| 341 | + | return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID) | |||
| 342 | + | case <-ctx.Done(): | |||
| 343 | + | return nil, ctx.Err() | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func (a *Agent) closeDone() { a.doneOnce.Do(func() { close(a.done) }) } | |||
| 348 | + | ||||
| 349 | + | func (a *Agent) stateDirectory(defaultRoot string) string { | |||
| 350 | + | if a.stateDir != "" { | |||
| 351 | + | return a.stateDir | |||
| 352 | + | } | |||
| 353 | + | return filepath.Join(defaultRoot, "agents", a.ID) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // SetOnTurnEnd installs (or clears, with nil) the callback fired | |||
| 357 | + | // from the runner when the child daemon emits a prompt-level | |||
| 358 | + | // turn_end event with a step field. Safe to call from any goroutine: | |||
| 359 | + | // the runner reads the callback under the same mutex. | |||
| 360 | + | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | |||
| 361 | + | a.mu.Lock() | |||
| 362 | + | a.OnTurnEnd = fn | |||
| 363 | + | pending := append([]turnEndNotice(nil), a.pendingTurnEnds...) | |||
| 364 | + | a.pendingTurnEnds = nil | |||
| 365 | + | a.mu.Unlock() | |||
| 366 | + | if fn != nil { | |||
| 367 | + | for _, notice := range pending { | |||
| 368 | + | fn(notice.step, notice.errMsg) | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func (a *Agent) setStatus(s Status) { | |||
| 374 | + | a.mu.Lock() | |||
| 375 | + | a.status = s | |||
| 376 | + | a.mu.Unlock() | |||
| 377 | + | if s == StatusRunning { | |||
| 378 | + | a.setProcessState(ProcessAlive) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func (a *Agent) setActivity(msg string) { | |||
| 383 | + | a.mu.Lock() | |||
| 384 | + | a.activity = strings.TrimSpace(msg) | |||
| 385 | + | a.mu.Unlock() | |||
| 386 | + | a.markActivity(time.Now()) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func (a *Agent) appendTranscript(chunk string) { | |||
| 390 | + | a.appendTranscriptLocked(chunk, "", false) | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func (a *Agent) appendUserMessage(text string) { | |||
| 394 | + | a.appendTranscriptLocked(text, "user: ", false) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | func (a *Agent) appendAssistantMessage(text string) { | |||
| 398 | + | a.appendTranscriptLocked(text, "", true) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) { | |||
| 402 | + | message := chunk | |||
| 403 | + | chunk = strings.TrimRight(chunk, "\n") | |||
| 404 | + | if chunk == "" { | |||
| 405 | + | return | |||
| 406 | + | } | |||
| 407 | + | a.mu.Lock() | |||
| 408 | + | for _, line := range strings.Split(chunk, "\n") { | |||
| 409 | + | line = linePrefix + line | |||
| 410 | + | a.transcript = append(a.transcript, line) | |||
| 411 | + | a.outputBytes += len(line) + 1 | |||
| 412 | + | a.outputLines++ | |||
| 413 | + | } | |||
| 414 | + | if assistant { | |||
| 415 | + | a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines) | |||
| 416 | + | } | |||
| 417 | + | // Bound the inline dashboard copy. The durable event log and session | |||
| 418 | + | // remain available through the logical history reference. | |||
| 419 | + | lineCap := a.maxOutputLines | |||
| 420 | + | if lineCap <= 0 { | |||
| 421 | + | lineCap = 2_000 | |||
| 422 | + | } | |||
| 423 | + | byteCap := a.maxOutputBytes | |||
| 424 | + | if byteCap <= 0 { | |||
| 425 | + | byteCap = 500_000 | |||
| 426 | + | } | |||
| 427 | + | for a.outputLines > lineCap { | |||
| 428 | + | line := a.transcript[0] | |||
| 429 | + | a.transcript = a.transcript[1:] | |||
| 430 | + | a.outputBytes -= len(line) + 1 | |||
| 431 | + | a.outputLines-- | |||
| 432 | + | a.outputTruncated = true | |||
| 433 | + | } | |||
| 434 | + | for a.outputLines > 0 && a.outputBytes > byteCap { | |||
| 435 | + | line := a.transcript[0] | |||
| 436 | + | a.transcript = a.transcript[1:] | |||
| 437 | + | a.outputBytes -= len(line) + 1 | |||
| 438 | + | a.outputLines-- | |||
| 439 | + | a.outputTruncated = true | |||
| 440 | + | } | |||
| 441 | + | if a.outputLines == 0 { | |||
| 442 | + | a.outputBytes = 0 | |||
| 443 | + | } | |||
| 444 | + | a.mu.Unlock() | |||
| 445 | + | a.markActivity(time.Now()) | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // newAgentID returns a short, mostly-collision-free identifier of the | |||
| 449 | + | // form "<slug>-<nano>". The slug is derived from the task text so | |||
| 450 | + | // dashboards stay readable; the nano suffix guarantees uniqueness | |||
| 451 | + | // even when two agents are spawned in the same millisecond. | |||
| 452 | + | func newAgentID(task string, now time.Time) string { | |||
| 453 | + | slug := taskSlug(task) | |||
| 454 | + | return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000) | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | func taskSlug(task string) string { | |||
| 458 | + | task = strings.ToLower(task) | |||
| 459 | + | var b strings.Builder | |||
| 460 | + | dash := false | |||
| 461 | + | for _, r := range task { | |||
| 462 | + | switch { | |||
| 463 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |||
| 464 | + | b.WriteRune(r) | |||
| 465 | + | dash = false | |||
| 466 | + | default: | |||
| 467 | + | if !dash && b.Len() > 0 { | |||
| 468 | + | b.WriteByte('-') | |||
| 469 | + | dash = true | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | if b.Len() >= 24 { | |||
| 473 | + | break | |||
| 474 | + | } | |||
| 475 | + | } | |||
| 476 | + | out := strings.Trim(b.String(), "-") | |||
| 477 | + | if out == "" { | |||
| 478 | + | out = "agent" | |||
| 479 | + | } | |||
| 480 | + | return out | |||
| 481 | + | } | |||
A packages/agent/subagents/agent_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) { | |||
| 6 | + | a := &Agent{maxOutputBytes: 6, maxOutputLines: 10} | |||
| 7 | + | if got := a.AttemptValue(); got != 0 { | |||
| 8 | + | t.Fatalf("initial attempt = %d, want 0", got) | |||
| 9 | + | } | |||
| 10 | + | if got := a.incrementAttempt(); got != 1 || a.AttemptValue() != 1 { | |||
| 11 | + | t.Fatalf("attempt after increment = %d, value = %d; want 1", got, a.AttemptValue()) | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | a.appendTranscript("a\nbb") | |||
| 15 | + | if got := a.outputBytes; got != 5 { | |||
| 16 | + | t.Fatalf("output bytes after append = %d, want 5", got) | |||
| 17 | + | } | |||
| 18 | + | if got := a.outputLines; got != 2 { | |||
| 19 | + | t.Fatalf("output lines after append = %d, want 2", got) | |||
| 20 | + | } | |||
| 21 | + | a.appendTranscript("ccc") | |||
| 22 | + | if got, want := a.Transcript(), []string{"ccc"}; len(got) != 1 || got[0] != want[0] { | |||
| 23 | + | t.Fatalf("transcript after byte eviction = %q, want %q", got, want) | |||
| 24 | + | } | |||
| 25 | + | if a.outputBytes != 4 || a.outputLines != 1 { | |||
| 26 | + | t.Fatalf("accounting after byte eviction = (%d bytes, %d lines), want (4, 1)", a.outputBytes, a.outputLines) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | a.maxOutputBytes = 1 | |||
| 30 | + | a.appendTranscript("z") | |||
| 31 | + | if got := a.Transcript(); len(got) != 0 { | |||
| 32 | + | t.Fatalf("transcript after evicting every line = %q, want empty", got) | |||
| 33 | + | } | |||
| 34 | + | if a.outputBytes != 0 || a.outputLines != 0 { | |||
| 35 | + | t.Fatalf("empty accounting = (%d bytes, %d lines), want (0, 0)", a.outputBytes, a.outputLines) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestAgentTranscriptLineCapAccounting(t *testing.T) { | |||
| 40 | + | a := &Agent{maxOutputBytes: 100, maxOutputLines: 2} | |||
| 41 | + | a.appendTranscript("one\ntwo\nthree") | |||
| 42 | + | ||||
| 43 | + | if got, want := a.Transcript(), []string{"two", "three"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { | |||
| 44 | + | t.Fatalf("transcript = %q, want %q", got, want) | |||
| 45 | + | } | |||
| 46 | + | if a.outputBytes != len("two")+1+len("three")+1 || a.outputLines != 2 { | |||
| 47 | + | t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines) | |||
| 48 | + | } | |||
| 49 | + | } | |||
A packages/agent/subagents/batch.go +429 -0
| @@ -0,0 +1,429 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "time" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // BatchRequest starts independent child tasks under one aggregate id. Batch | |||
| 17 | + | // scheduling is deliberately shallow; dependency graphs belong in workflows. | |||
| 18 | + | type BatchRequest struct { | |||
| 19 | + | Context string | |||
| 20 | + | Tasks []string | |||
| 21 | + | MaxConcurrent int | |||
| 22 | + | Spawn SpawnRequest | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | type BatchStatus string | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | BatchQueued BatchStatus = "queued" | |||
| 29 | + | BatchRunning BatchStatus = "running" | |||
| 30 | + | BatchSucceeded BatchStatus = "succeeded" | |||
| 31 | + | BatchFailed BatchStatus = "failed" | |||
| 32 | + | BatchCanceled BatchStatus = "canceled" | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | type BatchResult struct { | |||
| 36 | + | Version int `json:"version"` | |||
| 37 | + | BatchID string `json:"batch_id"` | |||
| 38 | + | TaskCount int `json:"task_count,omitempty"` | |||
| 39 | + | Status BatchStatus `json:"status"` | |||
| 40 | + | ChildIDs []string `json:"child_ids"` | |||
| 41 | + | Results map[string]*TurnResult `json:"results,omitempty"` | |||
| 42 | + | Error string `json:"error,omitempty"` | |||
| 43 | + | CreatedAt time.Time `json:"created_at"` | |||
| 44 | + | UpdatedAt time.Time `json:"updated_at"` | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | const maxBatchFileBytes = 32 * 1024 * 1024 | |||
| 48 | + | ||||
| 49 | + | type Batch struct { | |||
| 50 | + | ID string | |||
| 51 | + | Context string | |||
| 52 | + | ChildIDs []string | |||
| 53 | + | MaxConcurrent int | |||
| 54 | + | TaskCount int | |||
| 55 | + | CreatedAt time.Time | |||
| 56 | + | ||||
| 57 | + | mu sync.Mutex | |||
| 58 | + | status BatchStatus | |||
| 59 | + | result *BatchResult | |||
| 60 | + | done chan struct{} | |||
| 61 | + | once sync.Once | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (b *Batch) Status() BatchStatus { | |||
| 65 | + | b.mu.Lock() | |||
| 66 | + | defer b.mu.Unlock() | |||
| 67 | + | return b.status | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (b *Batch) Result() *BatchResult { | |||
| 71 | + | b.mu.Lock() | |||
| 72 | + | defer b.mu.Unlock() | |||
| 73 | + | return cloneBatchResult(b.result) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (b *Batch) Wait() { <-b.done } | |||
| 77 | + | func (b *Batch) closeDone() { b.once.Do(func() { close(b.done) }) } | |||
| 78 | + | ||||
| 79 | + | func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) { | |||
| 80 | + | if len(req.Tasks) == 0 { | |||
| 81 | + | return nil, fmt.Errorf("subagents: batch requires at least one task") | |||
| 82 | + | } | |||
| 83 | + | f.mu.Lock() | |||
| 84 | + | remaining := f.cfg.Policy.MaxTotalSpawned - f.totalSpawned | |||
| 85 | + | f.mu.Unlock() | |||
| 86 | + | if len(req.Tasks) > remaining { | |||
| 87 | + | return nil, fmt.Errorf("subagents: batch needs %d spawn budget entries, only %d remain", len(req.Tasks), remaining) | |||
| 88 | + | } | |||
| 89 | + | if ctx == nil { | |||
| 90 | + | ctx = context.Background() | |||
| 91 | + | } | |||
| 92 | + | now := f.cfg.Now() | |||
| 93 | + | baseID := newAgentID("batch "+now.Format("150405.000"), now) | |||
| 94 | + | f.mu.Lock() | |||
| 95 | + | if f.batches == nil { | |||
| 96 | + | f.batches = make(map[string]*Batch) | |||
| 97 | + | } | |||
| 98 | + | batchID := baseID | |||
| 99 | + | for suffix := 2; ; suffix++ { | |||
| 100 | + | if _, exists := f.batches[batchID]; !exists { | |||
| 101 | + | break | |||
| 102 | + | } | |||
| 103 | + | batchID = fmt.Sprintf("%s-%d", baseID, suffix) | |||
| 104 | + | } | |||
| 105 | + | batch := &Batch{ | |||
| 106 | + | ID: batchID, | |||
| 107 | + | Context: strings.TrimSpace(req.Context), | |||
| 108 | + | MaxConcurrent: req.MaxConcurrent, | |||
| 109 | + | TaskCount: len(req.Tasks), | |||
| 110 | + | CreatedAt: now, | |||
| 111 | + | status: BatchQueued, | |||
| 112 | + | result: &BatchResult{ | |||
| 113 | + | Version: ProtocolVersion, BatchID: batchID, TaskCount: len(req.Tasks), Status: BatchQueued, | |||
| 114 | + | CreatedAt: now, UpdatedAt: now, | |||
| 115 | + | }, | |||
| 116 | + | done: make(chan struct{}), | |||
| 117 | + | } | |||
| 118 | + | f.batches[batch.ID] = batch | |||
| 119 | + | f.mu.Unlock() | |||
| 120 | + | // Write a queued manifest before admitting children so a crash during | |||
| 121 | + | // batch admission still leaves an aggregate id to reload and reconcile. | |||
| 122 | + | f.persistBatch(batch) | |||
| 123 | + | ||||
| 124 | + | for _, task := range req.Tasks { | |||
| 125 | + | spawnReq := req.Spawn | |||
| 126 | + | spawnReq.Task = task | |||
| 127 | + | spawnReq.ParentID = batch.ID | |||
| 128 | + | spawnReq.BatchID = batch.ID | |||
| 129 | + | a, err := f.SpawnReq(ctx, spawnReq) | |||
| 130 | + | if err != nil { | |||
| 131 | + | childIDs := append([]string(nil), batch.ChildIDs...) | |||
| 132 | + | for _, childID := range childIDs { | |||
| 133 | + | _ = f.Stop(childID) | |||
| 134 | + | } | |||
| 135 | + | for _, childID := range childIDs { | |||
| 136 | + | if child := f.Get(childID); child != nil { | |||
| 137 | + | child.Wait() | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | batch.mu.Lock() | |||
| 141 | + | batch.status = BatchFailed | |||
| 142 | + | batch.result = &BatchResult{ | |||
| 143 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchFailed, | |||
| 144 | + | ChildIDs: childIDs, Error: err.Error(), CreatedAt: now, UpdatedAt: f.cfg.Now(), | |||
| 145 | + | } | |||
| 146 | + | batch.mu.Unlock() | |||
| 147 | + | f.persistBatch(batch) | |||
| 148 | + | batch.closeDone() | |||
| 149 | + | return batch, err | |||
| 150 | + | } | |||
| 151 | + | batch.ChildIDs = append(batch.ChildIDs, a.ID) | |||
| 152 | + | batch.mu.Lock() | |||
| 153 | + | batch.result = &BatchResult{ | |||
| 154 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchQueued, | |||
| 155 | + | ChildIDs: append([]string(nil), batch.ChildIDs...), CreatedAt: batch.CreatedAt, | |||
| 156 | + | UpdatedAt: f.cfg.Now(), | |||
| 157 | + | } | |||
| 158 | + | batch.mu.Unlock() | |||
| 159 | + | f.persistBatch(batch) | |||
| 160 | + | } | |||
| 161 | + | batch.mu.Lock() | |||
| 162 | + | batch.status = BatchRunning | |||
| 163 | + | batch.mu.Unlock() | |||
| 164 | + | f.persistBatch(batch) | |||
| 165 | + | go f.collectBatch(batch) | |||
| 166 | + | return batch, nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (f *Supervisor) collectBatch(batch *Batch) { | |||
| 170 | + | if batch.TaskCount > 0 && len(batch.ChildIDs) < batch.TaskCount { | |||
| 171 | + | batch.mu.Lock() | |||
| 172 | + | batch.status = BatchFailed | |||
| 173 | + | batch.result = &BatchResult{ | |||
| 174 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, | |||
| 175 | + | Status: BatchFailed, ChildIDs: append([]string(nil), batch.ChildIDs...), | |||
| 176 | + | Error: "batch admission was incomplete", CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(), | |||
| 177 | + | } | |||
| 178 | + | batch.mu.Unlock() | |||
| 179 | + | f.persistBatch(batch) | |||
| 180 | + | batch.closeDone() | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | results := make(map[string]*TurnResult, len(batch.ChildIDs)) | |||
| 184 | + | status := BatchSucceeded | |||
| 185 | + | var firstErr string | |||
| 186 | + | for _, id := range batch.ChildIDs { | |||
| 187 | + | a := f.Get(id) | |||
| 188 | + | if a == nil { | |||
| 189 | + | status = BatchFailed | |||
| 190 | + | if firstErr == "" { | |||
| 191 | + | firstErr = "missing child " + id | |||
| 192 | + | } | |||
| 193 | + | continue | |||
| 194 | + | } | |||
| 195 | + | result, err := f.waitForBatchResult(a) | |||
| 196 | + | if err == nil { | |||
| 197 | + | results[id] = result | |||
| 198 | + | switch result.Status { | |||
| 199 | + | case ResultFailed: | |||
| 200 | + | if status == BatchSucceeded { | |||
| 201 | + | status = BatchFailed | |||
| 202 | + | if result.Error != nil { | |||
| 203 | + | firstErr = result.Error.Message | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | case ResultCanceled: | |||
| 207 | + | if status == BatchSucceeded { | |||
| 208 | + | status = BatchCanceled | |||
| 209 | + | firstErr = "one or more batch children were canceled" | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } else { | |||
| 213 | + | status = BatchFailed | |||
| 214 | + | if firstErr == "" { | |||
| 215 | + | firstErr = err.Error() | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | batch.mu.Lock() | |||
| 220 | + | batch.status = status | |||
| 221 | + | batch.result = &BatchResult{ | |||
| 222 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: status, | |||
| 223 | + | ChildIDs: append([]string(nil), batch.ChildIDs...), Results: results, | |||
| 224 | + | Error: firstErr, CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(), | |||
| 225 | + | } | |||
| 226 | + | batch.mu.Unlock() | |||
| 227 | + | f.persistBatch(batch) | |||
| 228 | + | batch.closeDone() | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) { | |||
| 232 | + | if a == nil { | |||
| 233 | + | return nil, errors.New("subagents: missing batch child") | |||
| 234 | + | } | |||
| 235 | + | if result := a.Result(); result != nil { | |||
| 236 | + | return result, nil | |||
| 237 | + | } | |||
| 238 | + | if a.Status() != StatusDetached { | |||
| 239 | + | return a.waitForTurnResult(context.Background()) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // A reloaded worker has a closed supervisor-side done channel even when | |||
| 243 | + | // its orphan process is still alive. Reconcile durable result.json/events | |||
| 244 | + | // before treating that closed channel as a failed child. | |||
| 245 | + | readDurable := func() *TurnResult { | |||
| 246 | + | if result, err := readTurnResult(a.stateDirectory(f.cfg.Root)); err == nil && validateTurnResultAgent(result, a.ID) == nil { | |||
| 247 | + | result = result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines) | |||
| 248 | + | a.setResult(result) | |||
| 249 | + | return result | |||
| 250 | + | } | |||
| 251 | + | events, err := ReadEventLog(a.EventLogPath) | |||
| 252 | + | if err != nil { | |||
| 253 | + | return nil | |||
| 254 | + | } | |||
| 255 | + | for _, event := range events { | |||
| 256 | + | if event.Type != EventTurnResult && event.Type != "turn_result" { | |||
| 257 | + | continue | |||
| 258 | + | } | |||
| 259 | + | result, decodeErr := decodeTurnResultEvent(event, a.ID, f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines) | |||
| 260 | + | if decodeErr != nil { | |||
| 261 | + | continue | |||
| 262 | + | } | |||
| 263 | + | a.setResult(result) | |||
| 264 | + | return result | |||
| 265 | + | } | |||
| 266 | + | return nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | if result := readDurable(); result != nil { | |||
| 270 | + | return result, nil | |||
| 271 | + | } | |||
| 272 | + | if !inboxLive(a.InboxPath) { | |||
| 273 | + | return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | interval := 100 * time.Millisecond | |||
| 277 | + | deadline := time.NewTimer(f.cfg.Policy.DefaultTimeout) | |||
| 278 | + | defer deadline.Stop() | |||
| 279 | + | ticker := time.NewTicker(interval) | |||
| 280 | + | defer ticker.Stop() | |||
| 281 | + | for { | |||
| 282 | + | select { | |||
| 283 | + | case <-ticker.C: | |||
| 284 | + | if result := readDurable(); result != nil { | |||
| 285 | + | return result, nil | |||
| 286 | + | } | |||
| 287 | + | if !inboxLive(a.InboxPath) { | |||
| 288 | + | return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID) | |||
| 289 | + | } | |||
| 290 | + | case <-deadline.C: | |||
| 291 | + | return nil, fmt.Errorf("subagents: agent %s batch result timed out", a.ID) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func (f *Supervisor) WaitBatch(id string) (*BatchResult, error) { | |||
| 297 | + | f.mu.Lock() | |||
| 298 | + | batch := f.batches[id] | |||
| 299 | + | f.mu.Unlock() | |||
| 300 | + | if batch == nil { | |||
| 301 | + | return nil, fmt.Errorf("subagents: no such batch %q", id) | |||
| 302 | + | } | |||
| 303 | + | batch.Wait() | |||
| 304 | + | return batch.Result(), nil | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func (f *Supervisor) GetBatch(id string) *Batch { | |||
| 308 | + | f.mu.Lock() | |||
| 309 | + | defer f.mu.Unlock() | |||
| 310 | + | return f.batches[id] | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func cloneBatchResult(result *BatchResult) *BatchResult { | |||
| 314 | + | if result == nil { | |||
| 315 | + | return nil | |||
| 316 | + | } | |||
| 317 | + | copy := *result | |||
| 318 | + | copy.ChildIDs = append([]string(nil), result.ChildIDs...) | |||
| 319 | + | if result.Results != nil { | |||
| 320 | + | copy.Results = make(map[string]*TurnResult, len(result.Results)) | |||
| 321 | + | for id, child := range result.Results { | |||
| 322 | + | copy.Results[id] = cloneTurnResult(child) | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | return © | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func (f *Supervisor) reloadBatches(root string) []error { | |||
| 329 | + | entries, err := os.ReadDir(filepath.Join(root, "batches")) | |||
| 330 | + | if err != nil { | |||
| 331 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 332 | + | return nil | |||
| 333 | + | } | |||
| 334 | + | return []error{fmt.Errorf("subagents reload batches %s: %w", root, err)} | |||
| 335 | + | } | |||
| 336 | + | var errs []error | |||
| 337 | + | for _, entry := range entries { | |||
| 338 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { | |||
| 339 | + | continue | |||
| 340 | + | } | |||
| 341 | + | fileID := strings.TrimSuffix(entry.Name(), ".json") | |||
| 342 | + | if !safeAgentID(fileID) { | |||
| 343 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: unsafe batch id", entry.Name())) | |||
| 344 | + | continue | |||
| 345 | + | } | |||
| 346 | + | path := filepath.Join(root, "batches", entry.Name()) | |||
| 347 | + | file, openErr := os.Open(path) | |||
| 348 | + | if openErr != nil { | |||
| 349 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, openErr)) | |||
| 350 | + | continue | |||
| 351 | + | } | |||
| 352 | + | data, readErr := io.ReadAll(io.LimitReader(file, maxBatchFileBytes+1)) | |||
| 353 | + | _ = file.Close() | |||
| 354 | + | if readErr != nil { | |||
| 355 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, readErr)) | |||
| 356 | + | continue | |||
| 357 | + | } | |||
| 358 | + | if len(data) > maxBatchFileBytes { | |||
| 359 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: exceeds %d bytes", path, maxBatchFileBytes)) | |||
| 360 | + | continue | |||
| 361 | + | } | |||
| 362 | + | var result BatchResult | |||
| 363 | + | if unmarshalErr := json.Unmarshal(data, &result); unmarshalErr != nil { | |||
| 364 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, unmarshalErr)) | |||
| 365 | + | continue | |||
| 366 | + | } | |||
| 367 | + | if result.Version == 0 { | |||
| 368 | + | result.Version = ProtocolVersion | |||
| 369 | + | } | |||
| 370 | + | if result.Version != ProtocolVersion || result.BatchID != fileID || !safeAgentID(result.BatchID) || result.TaskCount < 0 || len(result.ChildIDs) > result.TaskCount { | |||
| 371 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: invalid result metadata", path)) | |||
| 372 | + | continue | |||
| 373 | + | } | |||
| 374 | + | batch := &Batch{ | |||
| 375 | + | ID: result.BatchID, | |||
| 376 | + | ChildIDs: append([]string(nil), result.ChildIDs...), | |||
| 377 | + | TaskCount: result.TaskCount, | |||
| 378 | + | CreatedAt: result.CreatedAt, | |||
| 379 | + | status: result.Status, | |||
| 380 | + | result: cloneBatchResult(&result), | |||
| 381 | + | done: make(chan struct{}), | |||
| 382 | + | } | |||
| 383 | + | if batch.CreatedAt.IsZero() { | |||
| 384 | + | batch.CreatedAt = result.UpdatedAt | |||
| 385 | + | } | |||
| 386 | + | f.mu.Lock() | |||
| 387 | + | _, exists := f.batches[batch.ID] | |||
| 388 | + | if !exists { | |||
| 389 | + | f.batches[batch.ID] = batch | |||
| 390 | + | } | |||
| 391 | + | f.mu.Unlock() | |||
| 392 | + | if exists { | |||
| 393 | + | continue | |||
| 394 | + | } | |||
| 395 | + | switch batch.status { | |||
| 396 | + | case BatchSucceeded, BatchFailed, BatchCanceled: | |||
| 397 | + | batch.closeDone() | |||
| 398 | + | default: | |||
| 399 | + | go f.collectBatch(batch) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | return errs | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func (f *Supervisor) persistBatch(batch *Batch) { | |||
| 406 | + | if batch == nil || !safeAgentID(batch.ID) { | |||
| 407 | + | return | |||
| 408 | + | } | |||
| 409 | + | result := batch.Result() | |||
| 410 | + | if result == nil { | |||
| 411 | + | return | |||
| 412 | + | } | |||
| 413 | + | data, err := json.MarshalIndent(result, "", " ") | |||
| 414 | + | if err != nil || len(data) > maxBatchFileBytes { | |||
| 415 | + | return | |||
| 416 | + | } | |||
| 417 | + | dir := filepath.Join(f.cfg.Root, "batches") | |||
| 418 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 419 | + | return | |||
| 420 | + | } | |||
| 421 | + | path := filepath.Join(dir, batch.ID+".json") | |||
| 422 | + | tmp := path + ".tmp" | |||
| 423 | + | if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { | |||
| 424 | + | return | |||
| 425 | + | } | |||
| 426 | + | if err := os.Rename(tmp, path); err != nil { | |||
| 427 | + | _ = os.Remove(tmp) | |||
| 428 | + | } | |||
| 429 | + | } | |||
A packages/agent/subagents/batch_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestBatchIDsRemainUniqueWithFixedClock(t *testing.T) { | |||
| 13 | + | now := time.Unix(123, 0) | |||
| 14 | + | f := New(Config{ | |||
| 15 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now }, | |||
| 16 | + | Policy: SubagentPolicy{MaxConcurrent: 2, MaxTotalSpawned: 4}, | |||
| 17 | + | NewRunner: func(*Agent) Runner { | |||
| 18 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 19 | + | }, | |||
| 20 | + | }) | |||
| 21 | + | t.Cleanup(f.StopAll) | |||
| 22 | + | first, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}}) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | second, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"two"}}) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | first.Wait() | |||
| 31 | + | second.Wait() | |||
| 32 | + | if first.ID == second.ID { | |||
| 33 | + | t.Fatalf("duplicate batch ids: %q", first.ID) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestBatchReloadsDurableResult(t *testing.T) { | |||
| 38 | + | root := t.TempDir() | |||
| 39 | + | newSupervisor := func() *Supervisor { | |||
| 40 | + | return New(Config{ | |||
| 41 | + | Root: root, RepoRoot: root, | |||
| 42 | + | NewRunner: func(*Agent) Runner { | |||
| 43 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 44 | + | }, | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | first := newSupervisor() | |||
| 48 | + | t.Cleanup(first.StopAll) | |||
| 49 | + | batch, err := first.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}}) | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | want, err := first.WaitBatch(batch.ID) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | if want.Status != BatchSucceeded { | |||
| 58 | + | t.Fatalf("first batch status = %s", want.Status) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | second := newSupervisor() | |||
| 62 | + | t.Cleanup(second.StopAll) | |||
| 63 | + | loaded, errs := second.Reload() | |||
| 64 | + | if len(errs) != 0 { | |||
| 65 | + | t.Fatalf("reload errors: %v", errs) | |||
| 66 | + | } | |||
| 67 | + | if loaded != 2 { | |||
| 68 | + | t.Fatalf("reload loaded %d agents, want 2", loaded) | |||
| 69 | + | } | |||
| 70 | + | got, err := second.WaitBatch(batch.ID) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | if got.Status != BatchSucceeded || len(got.Results) != 2 { | |||
| 75 | + | t.Fatalf("reloaded batch = %#v", got) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestBatchReloadRejectsUnsafeIDs(t *testing.T) { | |||
| 80 | + | root := t.TempDir() | |||
| 81 | + | batchDir := filepath.Join(root, "batches") | |||
| 82 | + | if err := os.MkdirAll(batchDir, 0o700); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | data, err := json.Marshal(BatchResult{ | |||
| 86 | + | Version: ProtocolVersion, BatchID: "../escape", TaskCount: 0, Status: BatchRunning, | |||
| 87 | + | }) | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | if err := os.WriteFile(filepath.Join(batchDir, "queued.json"), data, 0o600); err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | f := New(Config{Root: root, RepoRoot: root}) | |||
| 95 | + | if _, errs := f.Reload(); len(errs) == 0 { | |||
| 96 | + | t.Fatal("unsafe batch metadata was accepted") | |||
| 97 | + | } | |||
| 98 | + | if _, err := os.Stat(filepath.Join(root, "escape.json")); !os.IsNotExist(err) { | |||
| 99 | + | t.Fatalf("unsafe batch path was written: %v", err) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestBatchSpawnWaitAndCollect(t *testing.T) { | |||
| 104 | + | root := t.TempDir() | |||
| 105 | + | f := New(Config{ | |||
| 106 | + | Root: root, RepoRoot: root, | |||
| 107 | + | Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, MaxTotalSpawned: 4, DefaultTimeout: 0, IdleTimeout: 0}, | |||
| 108 | + | NewRunner: func(*Agent) Runner { | |||
| 109 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 110 | + | }, | |||
| 111 | + | }) | |||
| 112 | + | t.Cleanup(f.StopAll) | |||
| 113 | + | batch, err := f.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}, MaxConcurrent: 1}) | |||
| 114 | + | if err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | result, err := f.WaitBatch(batch.ID) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | if result.Status != BatchSucceeded || len(result.ChildIDs) != 2 || len(result.Results) != 2 { | |||
| 122 | + | t.Fatalf("batch result = %#v", result) | |||
| 123 | + | } | |||
| 124 | + | if batch.Status() != BatchSucceeded { | |||
| 125 | + | t.Fatalf("batch status = %s", batch.Status()) | |||
| 126 | + | } | |||
| 127 | + | } | |||
A packages/agent/subagents/event.go +441 -0
| @@ -0,0 +1,441 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Event is one structured datum in an agent's durable event log. | |||
| 16 | + | // | |||
| 17 | + | // The shape is intentionally a thin map+timestamp wrapper rather | |||
| 18 | + | // than a strict struct so the subagent package doesn't have to track | |||
| 19 | + | // every field added to core.AgentEvent. The child writes | |||
| 20 | + | // `modes.EventToJSON(ev)` plus a few subagent-internal types | |||
| 21 | + | // (lifecycle, user_input echo, error). The supervisor doesn't | |||
| 22 | + | // interpret most fields; it just keeps them for replay and lifts | |||
| 23 | + | // a few well-known ones into AgentSnapshot (Activity, Status, | |||
| 24 | + | // Tail) for the dashboard. | |||
| 25 | + | // | |||
| 26 | + | // The on-disk file (events.jsonl) is append-only, one event per | |||
| 27 | + | // line, newline-terminated JSON. Reading is forward-only; readers | |||
| 28 | + | // stat the file size and read from their last offset on every | |||
| 29 | + | // poll. | |||
| 30 | + | type Event struct { | |||
| 31 | + | Time time.Time `json:"time"` | |||
| 32 | + | Type string `json:"type"` | |||
| 33 | + | Version int `json:"version,omitempty"` | |||
| 34 | + | MessageID string `json:"message_id,omitempty"` | |||
| 35 | + | AgentID string `json:"agent_id,omitempty"` | |||
| 36 | + | TurnID string `json:"turn_id,omitempty"` | |||
| 37 | + | Data map[string]any `json:"-"` | |||
| 38 | + | Raw map[string]any `json:"-"` // original canonical event for replay | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // MarshalJSON writes the canonical versioned event shape used by the | |||
| 42 | + | // worker protocol and durable event logs. | |||
| 43 | + | func (e Event) MarshalJSON() ([]byte, error) { | |||
| 44 | + | version := e.Version | |||
| 45 | + | if version == 0 { | |||
| 46 | + | version = ProtocolVersion | |||
| 47 | + | } | |||
| 48 | + | when := e.Time | |||
| 49 | + | if when.IsZero() { | |||
| 50 | + | when = time.Now().UTC() | |||
| 51 | + | } | |||
| 52 | + | payload := e.Data | |||
| 53 | + | if payload == nil { | |||
| 54 | + | payload = map[string]any{} | |||
| 55 | + | } | |||
| 56 | + | out := map[string]any{ | |||
| 57 | + | "version": version, | |||
| 58 | + | "type": e.Type, | |||
| 59 | + | "timestamp": when, | |||
| 60 | + | "payload": payload, | |||
| 61 | + | } | |||
| 62 | + | if e.MessageID != "" { | |||
| 63 | + | out["message_id"] = e.MessageID | |||
| 64 | + | } | |||
| 65 | + | if e.AgentID != "" { | |||
| 66 | + | out["agent_id"] = e.AgentID | |||
| 67 | + | } | |||
| 68 | + | if e.TurnID != "" { | |||
| 69 | + | out["turn_id"] = e.TurnID | |||
| 70 | + | } | |||
| 71 | + | return json.Marshal(out) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // UnmarshalJSON accepts the canonical versioned event shape and retains the | |||
| 75 | + | // decoded payload for dashboard replay. | |||
| 76 | + | func (e *Event) UnmarshalJSON(b []byte) error { | |||
| 77 | + | var wire struct { | |||
| 78 | + | Version int `json:"version"` | |||
| 79 | + | Type string `json:"type"` | |||
| 80 | + | MessageID string `json:"message_id,omitempty"` | |||
| 81 | + | AgentID string `json:"agent_id,omitempty"` | |||
| 82 | + | TurnID string `json:"turn_id,omitempty"` | |||
| 83 | + | Timestamp time.Time `json:"timestamp"` | |||
| 84 | + | Payload map[string]any `json:"payload"` | |||
| 85 | + | } | |||
| 86 | + | if err := json.Unmarshal(b, &wire); err != nil { | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | if wire.Version != ProtocolVersion { | |||
| 90 | + | return fmt.Errorf("subagent event: unsupported version %d", wire.Version) | |||
| 91 | + | } | |||
| 92 | + | if wire.Type == "" { | |||
| 93 | + | return errors.New("subagent event: missing type") | |||
| 94 | + | } | |||
| 95 | + | if wire.Timestamp.IsZero() { | |||
| 96 | + | return errors.New("subagent event: missing timestamp") | |||
| 97 | + | } | |||
| 98 | + | if wire.Payload == nil { | |||
| 99 | + | wire.Payload = map[string]any{} | |||
| 100 | + | } | |||
| 101 | + | e.Raw = map[string]any{} | |||
| 102 | + | if err := json.Unmarshal(b, &e.Raw); err != nil { | |||
| 103 | + | return err | |||
| 104 | + | } | |||
| 105 | + | e.Time = wire.Timestamp | |||
| 106 | + | e.Type = wire.Type | |||
| 107 | + | e.Version = wire.Version | |||
| 108 | + | e.MessageID = wire.MessageID | |||
| 109 | + | e.AgentID = wire.AgentID | |||
| 110 | + | e.TurnID = wire.TurnID | |||
| 111 | + | e.Data = wire.Payload | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | const ( | |||
| 116 | + | // Event log lines are produced by a child process and may contain model | |||
| 117 | + | // payloads. Bound both per-line decoding and replay memory so a corrupt or | |||
| 118 | + | // hostile log cannot force an unbounded allocation during Reload. | |||
| 119 | + | maxEventLogLineBytes = 2 * 1024 * 1024 | |||
| 120 | + | maxEventLogEvents = 100_000 | |||
| 121 | + | ) | |||
| 122 | + | ||||
| 123 | + | // EventLog is an append-only writer for events.jsonl. Safe for | |||
| 124 | + | // concurrent writers (the child only has one writer, but tests | |||
| 125 | + | // occasionally fan out). | |||
| 126 | + | type EventLog struct { | |||
| 127 | + | path string | |||
| 128 | + | mu sync.Mutex | |||
| 129 | + | f *os.File | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // OpenEventLog opens (or creates) the events.jsonl file at path. | |||
| 133 | + | // Parent directories are created as needed. | |||
| 134 | + | func OpenEventLog(path string) (*EventLog, error) { | |||
| 135 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 136 | + | return nil, fmt.Errorf("event log dir: %w", err) | |||
| 137 | + | } | |||
| 138 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return nil, fmt.Errorf("event log open: %w", err) | |||
| 141 | + | } | |||
| 142 | + | _ = f.Chmod(0o600) | |||
| 143 | + | return &EventLog{path: path, f: f}, nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Path returns the absolute log path. | |||
| 147 | + | func (l *EventLog) Path() string { return l.path } | |||
| 148 | + | ||||
| 149 | + | // Append writes one event. The encoding is `<json>\n`. Concurrent | |||
| 150 | + | // callers are serialised; small enough events never need partial | |||
| 151 | + | // writes since unix guarantees atomicity for writes ≤ PIPE_BUF on | |||
| 152 | + | // regular files, and we hold a per-process mutex on top. | |||
| 153 | + | func (l *EventLog) Append(ev Event) error { | |||
| 154 | + | l.mu.Lock() | |||
| 155 | + | defer l.mu.Unlock() | |||
| 156 | + | if l.f == nil { | |||
| 157 | + | return errors.New("event log: closed") | |||
| 158 | + | } | |||
| 159 | + | if ev.Time.IsZero() { | |||
| 160 | + | ev.Time = time.Now().UTC() | |||
| 161 | + | } | |||
| 162 | + | b, err := json.Marshal(ev) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 166 | + | if len(b) > maxEventLogLineBytes { | |||
| 167 | + | return fmt.Errorf("event log: event exceeds %d bytes", maxEventLogLineBytes) | |||
| 168 | + | } | |||
| 169 | + | b = append(b, '\n') | |||
| 170 | + | n, err := l.f.Write(b) | |||
| 171 | + | if err == nil && n != len(b) { | |||
| 172 | + | return io.ErrShortWrite | |||
| 173 | + | } | |||
| 174 | + | return err | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Close flushes and closes the underlying file. | |||
| 178 | + | func (l *EventLog) Close() error { | |||
| 179 | + | l.mu.Lock() | |||
| 180 | + | defer l.mu.Unlock() | |||
| 181 | + | if l.f == nil { | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | err := l.f.Close() | |||
| 185 | + | l.f = nil | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // NewEvent is a convenience constructor that fills the timestamp. | |||
| 190 | + | func NewEvent(typ string, data map[string]any) Event { | |||
| 191 | + | if data == nil { | |||
| 192 | + | data = map[string]any{} | |||
| 193 | + | } | |||
| 194 | + | return Event{Time: time.Now().UTC(), Type: typ, Version: ProtocolVersion, Data: data} | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // ReadEventLog parses every event currently in the file. Used by | |||
| 198 | + | // the dashboard on first open and by tests; live tailing uses | |||
| 199 | + | // FollowEventLog below. | |||
| 200 | + | func ReadEventLog(path string) ([]Event, error) { | |||
| 201 | + | f, err := os.Open(path) | |||
| 202 | + | if err != nil { | |||
| 203 | + | if os.IsNotExist(err) { | |||
| 204 | + | return nil, nil | |||
| 205 | + | } | |||
| 206 | + | return nil, err | |||
| 207 | + | } | |||
| 208 | + | defer f.Close() | |||
| 209 | + | return readEvents(f) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | type eventRing struct { | |||
| 213 | + | events []Event | |||
| 214 | + | next int | |||
| 215 | + | count int | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func newEventRing() eventRing { | |||
| 219 | + | return eventRing{events: make([]Event, 0)} | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func (r *eventRing) append(ev Event) { | |||
| 223 | + | if r.count < maxEventLogEvents { | |||
| 224 | + | r.events = append(r.events, ev) | |||
| 225 | + | r.count++ | |||
| 226 | + | if r.count == maxEventLogEvents { | |||
| 227 | + | r.next = 0 | |||
| 228 | + | } else { | |||
| 229 | + | r.next = r.count | |||
| 230 | + | } | |||
| 231 | + | return | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | r.events[r.next] = ev | |||
| 235 | + | r.next = (r.next + 1) % len(r.events) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (r *eventRing) last() (Event, bool) { | |||
| 239 | + | if r.count == 0 { | |||
| 240 | + | return Event{}, false | |||
| 241 | + | } | |||
| 242 | + | i := r.next - 1 | |||
| 243 | + | if i < 0 { | |||
| 244 | + | i += len(r.events) | |||
| 245 | + | } | |||
| 246 | + | return r.events[i], true | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func (r *eventRing) isLikelyDoubleWrite(ev Event) bool { | |||
| 250 | + | previous, ok := r.last() | |||
| 251 | + | return ok && isLikelyDoubleWrite(ev, previous) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (r *eventRing) materialize() []Event { | |||
| 255 | + | out := make([]Event, r.count) | |||
| 256 | + | if r.count == 0 { | |||
| 257 | + | return out | |||
| 258 | + | } | |||
| 259 | + | start := r.next - r.count | |||
| 260 | + | if start < 0 { | |||
| 261 | + | start += len(r.events) | |||
| 262 | + | } | |||
| 263 | + | n := copy(out, r.events[start:]) | |||
| 264 | + | copy(out[n:], r.events[:r.count-n]) | |||
| 265 | + | return out | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func readEvents(r io.Reader) ([]Event, error) { | |||
| 269 | + | br := bufio.NewReader(r) | |||
| 270 | + | ring := newEventRing() | |||
| 271 | + | for { | |||
| 272 | + | line, err, truncated := readBoundedLine(br, maxEventLogLineBytes) | |||
| 273 | + | if len(line) > 0 && !truncated { | |||
| 274 | + | var ev Event | |||
| 275 | + | if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) { | |||
| 276 | + | ring.append(ev) | |||
| 277 | + | } | |||
| 278 | + | // Malformed and oversized lines are skipped silently; the | |||
| 279 | + | // dashboard renders only well-formed events and the child is | |||
| 280 | + | // the only writer. | |||
| 281 | + | } | |||
| 282 | + | if err != nil { | |||
| 283 | + | if errors.Is(err, io.EOF) { | |||
| 284 | + | return ring.materialize(), nil | |||
| 285 | + | } | |||
| 286 | + | return ring.materialize(), err | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // readFollowerEvents parses only newline-terminated records from a live log. | |||
| 292 | + | // A follower must not advance past an incomplete final line: another process | |||
| 293 | + | // may be in the middle of appending that record, and skipping its start would | |||
| 294 | + | // make the completed event invisible on the next poll. | |||
| 295 | + | func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) { | |||
| 296 | + | br := bufio.NewReader(r) | |||
| 297 | + | ring := newEventRing() | |||
| 298 | + | committed := offset | |||
| 299 | + | for { | |||
| 300 | + | line, err, truncated, consumed, complete := readBoundedLineStats(br, maxEventLogLineBytes) | |||
| 301 | + | if !complete { | |||
| 302 | + | break | |||
| 303 | + | } | |||
| 304 | + | committed += consumed | |||
| 305 | + | if len(line) > 0 && !truncated { | |||
| 306 | + | var ev Event | |||
| 307 | + | if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) { | |||
| 308 | + | ring.append(ev) | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | if err != nil { | |||
| 312 | + | break | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | return ring.materialize(), committed | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate | |||
| 319 | + | // of previous — same type and identical Data payload, with timestamps | |||
| 320 | + | // within a small window (the supervisor and the child's mirror used to | |||
| 321 | + | // BOTH write each event to disk, landing the same content twice in quick | |||
| 322 | + | // succession in events.jsonl). The historical behaviour was fixed at write | |||
| 323 | + | // time (the child's mirror is now dormant unless the supervisor is gone), | |||
| 324 | + | // but on-disk files from before the fix are still polluted, so we dedupe | |||
| 325 | + | // defensively at read time too. | |||
| 326 | + | // | |||
| 327 | + | // We deliberately bound by time (250ms) so two genuinely identical adjacent | |||
| 328 | + | // events that happen seconds apart — e.g. an agent that runs the same tool | |||
| 329 | + | // twice in a row — still both render. | |||
| 330 | + | func isLikelyDoubleWrite(ev, previous Event) bool { | |||
| 331 | + | if previous.Type != ev.Type { | |||
| 332 | + | return false | |||
| 333 | + | } | |||
| 334 | + | if !ev.Time.IsZero() && !previous.Time.IsZero() { | |||
| 335 | + | dt := ev.Time.Sub(previous.Time) | |||
| 336 | + | if dt < 0 { | |||
| 337 | + | dt = -dt | |||
| 338 | + | } | |||
| 339 | + | if dt > 250*time.Millisecond { | |||
| 340 | + | return false | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | return sameEventData(previous.Data, ev.Data) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // sameEventData deep-compares two event payloads. Cheap because the | |||
| 347 | + | // payloads are small map[string]any trees built from JSON, and only | |||
| 348 | + | // called for adjacent same-type pairs. | |||
| 349 | + | func sameEventData(a, b map[string]any) bool { | |||
| 350 | + | if len(a) != len(b) { | |||
| 351 | + | return false | |||
| 352 | + | } | |||
| 353 | + | ab, _ := json.Marshal(a) | |||
| 354 | + | bb, _ := json.Marshal(b) | |||
| 355 | + | return string(ab) == string(bb) | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // EventFollower polls an events.jsonl file and emits new events as | |||
| 359 | + | // they're appended. The supervisor uses one per agent so dashboard | |||
| 360 | + | // snapshots stay current without reparsing the whole file on every | |||
| 361 | + | // frame. Close stops the goroutine; the channel returned by Events | |||
| 362 | + | // is closed when Close is called or the file is removed. | |||
| 363 | + | type EventFollower struct { | |||
| 364 | + | path string | |||
| 365 | + | interval time.Duration | |||
| 366 | + | out chan Event | |||
| 367 | + | done chan struct{} | |||
| 368 | + | closed chan struct{} | |||
| 369 | + | once sync.Once | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // FollowEventLog starts polling path every interval. interval ≤ 0 | |||
| 373 | + | // defaults to 50ms which is invisible to the user and cheap on | |||
| 374 | + | // disk (one stat + one short read per agent per tick). | |||
| 375 | + | func FollowEventLog(path string, interval time.Duration) *EventFollower { | |||
| 376 | + | if interval <= 0 { | |||
| 377 | + | interval = 50 * time.Millisecond | |||
| 378 | + | } | |||
| 379 | + | f := &EventFollower{ | |||
| 380 | + | path: path, | |||
| 381 | + | interval: interval, | |||
| 382 | + | out: make(chan Event, 256), | |||
| 383 | + | done: make(chan struct{}), | |||
| 384 | + | closed: make(chan struct{}), | |||
| 385 | + | } | |||
| 386 | + | go f.loop() | |||
| 387 | + | return f | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // Events returns the receive end of the event stream. | |||
| 391 | + | func (f *EventFollower) Events() <-chan Event { return f.out } | |||
| 392 | + | ||||
| 393 | + | // Close stops polling and closes the events channel. It waits for | |||
| 394 | + | // the polling goroutine to exit so tests on Windows can remove the | |||
| 395 | + | // temp directory without racing an open file handle. | |||
| 396 | + | func (f *EventFollower) Close() { | |||
| 397 | + | f.once.Do(func() { | |||
| 398 | + | close(f.done) | |||
| 399 | + | <-f.closed | |||
| 400 | + | }) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func (f *EventFollower) loop() { | |||
| 404 | + | defer close(f.closed) | |||
| 405 | + | defer close(f.out) | |||
| 406 | + | var offset int64 | |||
| 407 | + | tick := time.NewTicker(f.interval) | |||
| 408 | + | defer tick.Stop() | |||
| 409 | + | for { | |||
| 410 | + | select { | |||
| 411 | + | case <-f.done: | |||
| 412 | + | return | |||
| 413 | + | case <-tick.C: | |||
| 414 | + | } | |||
| 415 | + | fi, err := os.Stat(f.path) | |||
| 416 | + | if err != nil { | |||
| 417 | + | continue | |||
| 418 | + | } | |||
| 419 | + | if fi.Size() <= offset { | |||
| 420 | + | continue | |||
| 421 | + | } | |||
| 422 | + | fh, err := os.Open(f.path) | |||
| 423 | + | if err != nil { | |||
| 424 | + | continue | |||
| 425 | + | } | |||
| 426 | + | if _, err := fh.Seek(offset, io.SeekStart); err != nil { | |||
| 427 | + | _ = fh.Close() | |||
| 428 | + | continue | |||
| 429 | + | } | |||
| 430 | + | evs, newOffset := readFollowerEvents(fh, offset) | |||
| 431 | + | _ = fh.Close() | |||
| 432 | + | offset = newOffset | |||
| 433 | + | for _, ev := range evs { | |||
| 434 | + | select { | |||
| 435 | + | case f.out <- ev: | |||
| 436 | + | case <-f.done: | |||
| 437 | + | return | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | } | |||
R packages/agent/swarm/event_test.go → packages/agent/subagents/event_test.go +146 -2 renamed, 60% similar
| @@ -1,14 +1,19 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 4 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 5 | "testing" | 10 | "testing" | ||
| 6 | "time" | 11 | "time" | ||
| 7 | ) | 12 | ) | ||
| 8 | 13 | ||||
| 9 | // TestEventLogAppendAndRead is the simplest possible invariant: | 14 | // TestEventLogAppendAndRead is the simplest possible invariant: | ||
| 10 | // what we write goes back through ReadEventLog exactly, including | 15 | // what we write goes back through ReadEventLog exactly, including | ||
| 11 | - | // the flat field shape and the timestamp. | 16 | + | // the payload fields and the timestamp. |
| 12 | func TestEventLogAppendAndRead(t *testing.T) { | 17 | func TestEventLogAppendAndRead(t *testing.T) { | ||
| 13 | path := filepath.Join(t.TempDir(), "events.jsonl") | 18 | path := filepath.Join(t.TempDir(), "events.jsonl") | ||
| 14 | log, err := OpenEventLog(path) | 19 | log, err := OpenEventLog(path) | ||
| @@ -43,11 +48,118 @@ func TestEventLogAppendAndRead(t *testing.T) { | |||||
| 43 | } | 48 | } | ||
| 44 | } | 49 | } | ||
| 45 | 50 | ||||
| 51 | + | func TestEventLogAppendUsesUTCForZeroTimestamp(t *testing.T) { | |||
| 52 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 53 | + | log, err := OpenEventLog(path) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | if err := log.Append(Event{Type: "zero_time", Version: ProtocolVersion, Data: map[string]any{}}); err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | if err := log.Close(); err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | got, err := ReadEventLog(path) | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | if len(got) != 1 { | |||
| 69 | + | t.Fatalf("got %d events, want 1", len(got)) | |||
| 70 | + | } | |||
| 71 | + | if got[0].Time.Location() != time.UTC { | |||
| 72 | + | t.Fatalf("zero timestamp location = %v, want UTC", got[0].Time.Location()) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 46 | // TestReadEventLogIgnoresGarbage documents that one malformed line | 76 | // TestReadEventLogIgnoresGarbage documents that one malformed line | ||
| 47 | // doesn't break the whole replay. The runner's responsibility is to | 77 | // doesn't break the whole replay. The runner's responsibility is to | ||
| 48 | // only write well-formed events, but a partial crash could leave | 78 | // only write well-formed events, but a partial crash could leave | ||
| 49 | // a half-written line; we don't want that to prevent the dashboard | 79 | // a half-written line; we don't want that to prevent the dashboard | ||
| 50 | // from showing the rest. | 80 | // from showing the rest. | ||
| 81 | + | func TestReadVersionedEventMergesPayloadFields(t *testing.T) { | |||
| 82 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 83 | + | wire := `{"version":1,"type":"turn.progress","message_id":"m1","agent_id":"a1","turn_id":"t1","timestamp":"2026-08-04T12:00:00Z","payload":{"text":"still working","future":{"step":2}}}` + "\n" | |||
| 84 | + | if err := os.WriteFile(path, []byte(wire), 0o600); err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | got, err := ReadEventLog(path) | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | if len(got) != 1 || got[0].Data["text"] != "still working" { | |||
| 92 | + | t.Fatalf("versioned payload was not replayed: %+v", got) | |||
| 93 | + | } | |||
| 94 | + | if future, ok := got[0].Data["future"].(map[string]any); !ok || future["step"] != float64(2) { | |||
| 95 | + | t.Fatalf("future payload field was lost: %+v", got[0].Data) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestReadEventLogSkipsOversizedLines(t *testing.T) { | |||
| 100 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 101 | + | oversized := fmt.Sprintf("%s\n", strings.Repeat("x", maxEventLogLineBytes+100)) | |||
| 102 | + | valid := NewEvent("valid", map[string]any{"ok": true}) | |||
| 103 | + | data, err := json.Marshal(valid) | |||
| 104 | + | if err != nil { | |||
| 105 | + | t.Fatal(err) | |||
| 106 | + | } | |||
| 107 | + | if err := os.WriteFile(path, append([]byte(oversized), append(data, '\n')...), 0o600); err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | got, err := ReadEventLog(path) | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | if len(got) != 1 || got[0].Type != "valid" { | |||
| 115 | + | t.Fatalf("oversized line affected replay: %+v", got) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestEventReadersRetainLatestEventsInOrder(t *testing.T) { | |||
| 120 | + | total := maxEventLogEvents + 2 | |||
| 121 | + | var input bytes.Buffer | |||
| 122 | + | for i := 0; i < total; i++ { | |||
| 123 | + | ev := Event{ | |||
| 124 | + | Time: time.Unix(int64(i), 0).UTC(), | |||
| 125 | + | Type: "indexed", | |||
| 126 | + | Version: ProtocolVersion, | |||
| 127 | + | Data: map[string]any{"index": i}, | |||
| 128 | + | } | |||
| 129 | + | line, err := json.Marshal(ev) | |||
| 130 | + | if err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | input.Write(line) | |||
| 134 | + | input.WriteByte('\n') | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | check := func(name string, got []Event) { | |||
| 138 | + | t.Helper() | |||
| 139 | + | if len(got) != maxEventLogEvents { | |||
| 140 | + | t.Fatalf("%s retained %d events, want %d", name, len(got), maxEventLogEvents) | |||
| 141 | + | } | |||
| 142 | + | for i, ev := range got { | |||
| 143 | + | index, ok := ev.Data["index"].(float64) | |||
| 144 | + | if !ok || int(index) != i+2 { | |||
| 145 | + | t.Fatalf("%s event %d index = %v, want %d", name, i, ev.Data["index"], i+2) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | got, err := readEvents(bytes.NewReader(input.Bytes())) | |||
| 151 | + | if err != nil { | |||
| 152 | + | t.Fatal(err) | |||
| 153 | + | } | |||
| 154 | + | check("readEvents", got) | |||
| 155 | + | ||||
| 156 | + | got, offset := readFollowerEvents(bytes.NewReader(input.Bytes()), 0) | |||
| 157 | + | check("readFollowerEvents", got) | |||
| 158 | + | if offset != int64(input.Len()) { | |||
| 159 | + | t.Fatalf("follower offset = %d, want %d", offset, input.Len()) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 51 | func TestReadEventLogIgnoresGarbage(t *testing.T) { | 163 | func TestReadEventLogIgnoresGarbage(t *testing.T) { | ||
| 52 | path := filepath.Join(t.TempDir(), "events.jsonl") | 164 | path := filepath.Join(t.TempDir(), "events.jsonl") | ||
| 53 | log, err := OpenEventLog(path) | 165 | log, err := OpenEventLog(path) | ||
| @@ -202,6 +314,38 @@ func TestFollowerEmitsNewEvents(t *testing.T) { | |||||
| 202 | } | 314 | } | ||
| 203 | } | 315 | } | ||
| 204 | 316 | ||||
| 317 | + | func TestFollowerDoesNotSkipPartialFinalEvent(t *testing.T) { | |||
| 318 | + | seed := NewEvent("preexisting", nil) | |||
| 319 | + | seedBytes, err := json.Marshal(seed) | |||
| 320 | + | if err != nil { | |||
| 321 | + | t.Fatal(err) | |||
| 322 | + | } | |||
| 323 | + | seedBytes = append(seedBytes, '\n') | |||
| 324 | + | partial := NewEvent("partial", map[string]any{"ok": true}) | |||
| 325 | + | partialBytes, err := json.Marshal(partial) | |||
| 326 | + | if err != nil { | |||
| 327 | + | t.Fatal(err) | |||
| 328 | + | } | |||
| 329 | + | mid := len(partialBytes) / 2 | |||
| 330 | + | incomplete := append(append([]byte(nil), seedBytes...), partialBytes[:mid]...) | |||
| 331 | + | events, offset := readFollowerEvents(bytes.NewReader(incomplete), 0) | |||
| 332 | + | if len(events) != 1 || events[0].Type != "preexisting" { | |||
| 333 | + | t.Fatalf("complete prefix = %+v", events) | |||
| 334 | + | } | |||
| 335 | + | if offset != int64(len(seedBytes)) { | |||
| 336 | + | t.Fatalf("offset = %d, want complete prefix %d", offset, len(seedBytes)) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | complete := append(append([]byte(nil), seedBytes...), append(partialBytes, '\n')...) | |||
| 340 | + | events, finalOffset := readFollowerEvents(bytes.NewReader(complete[offset:]), offset) | |||
| 341 | + | if len(events) != 1 || events[0].Type != "partial" { | |||
| 342 | + | t.Fatalf("completed event = %+v", events) | |||
| 343 | + | } | |||
| 344 | + | if finalOffset != int64(len(complete)) { | |||
| 345 | + | t.Fatalf("final offset = %d, want %d", finalOffset, len(complete)) | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | ||||
| 205 | func drain(ch <-chan Event) { | 349 | func drain(ch <-chan Event) { | ||
| 206 | for { | 350 | for { | ||
| 207 | select { | 351 | select { | ||
R packages/agent/swarm/fast_mode_test.go → packages/agent/subagents/fast_mode_test.go +10 -10 renamed, 88% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestSwarmFastModePropagatesToChild(t *testing.T) { | 9 | + | func TestSupervisorFastModePropagatesToChild(t *testing.T) { |
| 10 | root := t.TempDir() | 10 | root := t.TempDir() | ||
| 11 | f := New(Config{ | 11 | f := New(Config{ | ||
| 12 | Root: root, | 12 | Root: root, | ||
| @@ -103,9 +103,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |||||
| 103 | } | 103 | } | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | - | func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | 106 | + | func TestSupervisorReloadPreservesExplicitFastModeOff(t *testing.T) { |
| 107 | root := t.TempDir() | 107 | root := t.TempDir() | ||
| 108 | - | newSwarm := func(fastMode bool) *Swarm { | 108 | + | newSupervisor := func(fastMode bool) *Supervisor { |
| 109 | return New(Config{ | 109 | return New(Config{ | ||
| 110 | Root: root, | 110 | Root: root, | ||
| 111 | RepoRoot: root, | 111 | RepoRoot: root, | ||
| @@ -119,7 +119,7 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | |||||
| 119 | }) | 119 | }) | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | - | f := newSwarm(false) | 122 | + | f := newSupervisor(false) |
| 123 | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | 123 | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | ||
| 124 | if err != nil { | 124 | if err != nil { | ||
| 125 | t.Fatalf("spawn: %v", err) | 125 | t.Fatalf("spawn: %v", err) | ||
| @@ -132,7 +132,7 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | |||||
| 132 | } | 132 | } | ||
| 133 | a.Wait() | 133 | a.Wait() | ||
| 134 | 134 | ||||
| 135 | - | g := newSwarm(true) | 135 | + | g := newSupervisor(true) |
| 136 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 { | 136 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 { | ||
| 137 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | 137 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | ||
| 138 | } | 138 | } | ||
| @@ -141,15 +141,15 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | |||||
| 141 | } | 141 | } | ||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | - | func TestSwarmAgentArgsOmitFastModeWhenUnset(t *testing.T) { | 144 | + | func TestSubagentWorkerArgsOmitFastModeWhenUnset(t *testing.T) { |
| 145 | - | args := swarmAgentArgs(swarmAgentArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"}) | 145 | + | args := subagentWorkerArgs(subagentWorkerArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"}) |
| 146 | if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") { | 146 | if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") { | ||
| 147 | t.Fatalf("child args = %v, want fast mode omitted when unset", args) | 147 | t.Fatalf("child args = %v, want fast mode omitted when unset", args) | ||
| 148 | } | 148 | } | ||
| 149 | } | 149 | } | ||
| 150 | 150 | ||||
| 151 | - | func TestSwarmAgentArgsExplicitlyDisablesFastMode(t *testing.T) { | 151 | + | func TestSubagentWorkerArgsExplicitlyDisablesFastMode(t *testing.T) { |
| 152 | - | args := swarmAgentArgs(swarmAgentArgsOpts{ | 152 | + | args := subagentWorkerArgs(subagentWorkerArgsOpts{ |
| 153 | Exe: "/zot", | 153 | Exe: "/zot", | ||
| 154 | Dir: "/wt", | 154 | Dir: "/wt", | ||
| 155 | SessionPath: "/s", | 155 | SessionPath: "/s", | ||
R packages/agent/swarm/inbox.go → packages/agent/subagents/inbox.go +126 -74 renamed, 57% similar
| @@ -1,10 +1,9 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | - | "io" | |||
| 8 | "net" | 7 | "net" | ||
| 9 | "os" | 8 | "os" | ||
| 10 | "path/filepath" | 9 | "path/filepath" | ||
| @@ -13,28 +12,16 @@ import ( | |||||
| 13 | "time" | 12 | "time" | ||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | - | // Inbox is the supervisor-side handle on a per-agent unix socket | 15 | + | // Inbox is the supervisor-side handle on a per-agent unix socket. |
| 17 | - | // that the parent zot uses to send follow-up prompts (and other | 16 | + | // The wire format is newline-delimited, versioned JSON commands. The child |
| 18 | - | // control lines) to a running swarm agent. | 17 | + | // listens on the same socket via Listener (below); the parent never reads |
| 19 | - | // | 18 | + | // from it. |
| 20 | - | // Protocol on the wire: one UTF-8 message per line, newline- | |||
| 21 | - | // terminated. Three message kinds: | |||
| 22 | - | // | |||
| 23 | - | // user <text>... — append <text> as the next user turn | |||
| 24 | - | // cancel — cancel the agent's in-flight turn | |||
| 25 | - | // shutdown — graceful exit; child will write a final | |||
| 26 | - | // EvTurnEnd-equivalent JSON event and quit | |||
| 27 | - | // | |||
| 28 | - | // The child listens on the same socket via Listener (below) and | |||
| 29 | - | // translates the messages into core.Agent.Prompt calls. The | |||
| 30 | - | // parent never reads from the socket — it's a one-way channel | |||
| 31 | - | // driven by the supervisor. | |||
| 32 | // | 19 | // | ||
| 33 | // Why a socket and not a FIFO: FIFOs are POSIX-only and have | 20 | // Why a socket and not a FIFO: FIFOs are POSIX-only and have | ||
| 34 | // awkward blocking-open semantics. A unix socket gives us: | 21 | // awkward blocking-open semantics. A unix socket gives us: | ||
| 35 | // | 22 | // | ||
| 36 | // - the ability to fail fast if no child is listening yet, | 23 | // - the ability to fail fast if no child is listening yet, | ||
| 37 | - | // so SendInput returns a useful error to the TUI; | 24 | + | // so the supervisor gets a useful error to surface in the TUI; |
| 38 | // - clean shutdown semantics when the child closes the | 25 | // - clean shutdown semantics when the child closes the | ||
| 39 | // listener; | 26 | // listener; | ||
| 40 | // - works under tests with a temp dir; and | 27 | // - works under tests with a temp dir; and | ||
| @@ -48,7 +35,7 @@ type Inbox struct { | |||||
| 48 | } | 35 | } | ||
| 49 | 36 | ||||
| 50 | // NewInbox returns a handle that will dial the socket at path on | 37 | // NewInbox returns a handle that will dial the socket at path on | ||
| 51 | - | // the first SendInput call. The socket file is expected to be | 38 | + | // the first SendCommand call. The socket file is expected to be |
| 52 | // created by the child (Listener.Open below). This split lets the | 39 | // created by the child (Listener.Open below). This split lets the | ||
| 53 | // parent build the Inbox before the child has booted; sends that | 40 | // parent build the Inbox before the child has booted; sends that | ||
| 54 | // happen before the child is ready get a clear "not yet" error | 41 | // happen before the child is ready get a clear "not yet" error | ||
| @@ -59,13 +46,16 @@ func NewInbox(path string) *Inbox { return &Inbox{path: path} } | |||||
| 59 | // pass the path to the child as a flag. | 46 | // pass the path to the child as a flag. | ||
| 60 | func (b *Inbox) Path() string { return b.path } | 47 | func (b *Inbox) Path() string { return b.path } | ||
| 61 | 48 | ||||
| 62 | - | // SendInput writes msg to the inbox as one line. msg must already | 49 | + | // SendCommand sends a versioned JSONL command. |
| 63 | - | // include the leading kind ("user ", "cancel", "shutdown") — the | 50 | + | func (b *Inbox) SendCommand(command Envelope) error { |
| 64 | - | // caller chooses the shape so we don't proliferate methods. The | 51 | + | line, err := MarshalJSONL(command) |
| 65 | - | // socket is reused across calls; transient dial failures yield | 52 | + | if err != nil { |
| 66 | - | // ErrNotReady so the TUI can retry or report "agent not listening | 53 | + | return err |
| 67 | - | // yet" rather than crashing. | 54 | + | } |
| 68 | - | func (b *Inbox) SendInput(msg string) error { | 55 | + | return b.sendBytes(line) |
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (b *Inbox) sendBytes(data []byte) error { | |||
| 69 | b.mu.Lock() | 59 | b.mu.Lock() | ||
| 70 | defer b.mu.Unlock() | 60 | defer b.mu.Unlock() | ||
| 71 | if b.conn == nil { | 61 | if b.conn == nil { | ||
| @@ -75,7 +65,7 @@ func (b *Inbox) SendInput(msg string) error { | |||||
| 75 | } | 65 | } | ||
| 76 | b.conn = c | 66 | b.conn = c | ||
| 77 | } | 67 | } | ||
| 78 | - | if _, err := io.WriteString(b.conn, msg+"\n"); err != nil { | 68 | + | if _, err := b.conn.Write(data); err != nil { |
| 79 | // Drop the connection so the next call redials. The | 69 | // Drop the connection so the next call redials. The | ||
| 80 | // previous error is more informative than the redial's | 70 | // previous error is more informative than the redial's | ||
| 81 | // would be, so surface this one. | 71 | // would be, so surface this one. | ||
| @@ -100,10 +90,10 @@ func (b *Inbox) Close() error { | |||||
| 100 | return nil | 90 | return nil | ||
| 101 | } | 91 | } | ||
| 102 | 92 | ||||
| 103 | - | // ErrNotReady is returned by SendInput when the child hasn't yet | 93 | + | // ErrNotReady is returned when the child hasn't yet |
| 104 | // opened its listener. Callers can retry after a short backoff; | 94 | // opened its listener. Callers can retry after a short backoff; | ||
| 105 | // the TUI surfaces it as "agent <id> not listening yet". | 95 | // the TUI surfaces it as "agent <id> not listening yet". | ||
| 106 | - | var ErrNotReady = errors.New("swarm: agent inbox not ready") | 96 | + | var ErrNotReady = errors.New("subagent: agent inbox not ready") |
| 107 | 97 | ||||
| 108 | // dialUnix wraps net.DialTimeout with a small retry window so the | 98 | // dialUnix wraps net.DialTimeout with a small retry window so the | ||
| 109 | // happy path "child just started, supervisor sends right away" | 99 | // happy path "child just started, supervisor sends right away" | ||
| @@ -113,7 +103,7 @@ var ErrNotReady = errors.New("swarm: agent inbox not ready") | |||||
| 113 | // one is listening": either the socket file doesn't exist (child | 103 | // one is listening": either the socket file doesn't exist (child | ||
| 114 | // hasn't booted) or it does exist but the dial was refused (the | 104 | // hasn't booted) or it does exist but the dial was refused (the | ||
| 115 | // previous child exited and left a stale inode). Both are | 105 | // previous child exited and left a stale inode). Both are | ||
| 116 | - | // recoverable by Swarm.Resume, and both should surface as the same | 106 | + | // recoverable by Supervisor.Resume, and both should surface as the same |
| 117 | // user-facing hint rather than as a path-leaking error string. | 107 | // user-facing hint rather than as a path-leaking error string. | ||
| 118 | func dialUnix(path string, timeout time.Duration) (net.Conn, error) { | 108 | func dialUnix(path string, timeout time.Duration) (net.Conn, error) { | ||
| 119 | deadline := time.Now().Add(timeout) | 109 | deadline := time.Now().Add(timeout) | ||
| @@ -135,7 +125,7 @@ func dialUnix(path string, timeout time.Duration) (net.Conn, error) { | |||||
| 135 | if isNoListenerErr(lastErr) { | 125 | if isNoListenerErr(lastErr) { | ||
| 136 | return nil, ErrNotReady | 126 | return nil, ErrNotReady | ||
| 137 | } | 127 | } | ||
| 138 | - | return nil, fmt.Errorf("swarm: dial %s: %w", path, lastErr) | 128 | + | return nil, fmt.Errorf("subagent: dial %s: %w", path, lastErr) |
| 139 | } | 129 | } | ||
| 140 | 130 | ||||
| 141 | // isNoListenerErr reports whether err means "the socket file exists | 131 | // isNoListenerErr reports whether err means "the socket file exists | ||
| @@ -147,7 +137,31 @@ func isNoListenerErr(err error) bool { | |||||
| 147 | return errors.Is(err, syscall.ECONNREFUSED) | 137 | return errors.Is(err, syscall.ECONNREFUSED) | ||
| 148 | } | 138 | } | ||
| 149 | 139 | ||||
| 150 | - | // Listener is the child-side end of the inbox. The swarm-agent | 140 | + | // inboxLive reports whether a process currently accepts connections on path. |
| 141 | + | // A Unix socket inode can survive its owner, so os.Stat alone cannot | |||
| 142 | + | // distinguish a live worker from a stale path. Callers use this probe before | |||
| 143 | + | // resume/remove operations to avoid racing a detached worker's session and | |||
| 144 | + | // workspace ownership. | |||
| 145 | + | const inboxProbeLine = "__zot_subagent_probe__" | |||
| 146 | + | ||||
| 147 | + | func inboxLive(path string) bool { | |||
| 148 | + | if path == "" { | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 151 | + | conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return false | |||
| 154 | + | } | |||
| 155 | + | // Listener treats this first line as a health probe and does not | |||
| 156 | + | // replace the current supervisor connection. A bare connect would be | |||
| 157 | + | // enough to detect liveness, but sending an explicit probe lets the | |||
| 158 | + | // listener distinguish this side-effect-free check from a takeover. | |||
| 159 | + | _, writeErr := conn.Write([]byte(inboxProbeLine + "\n")) | |||
| 160 | + | _ = conn.Close() | |||
| 161 | + | return writeErr == nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Listener is the child-side end of the inbox. The subagent-worker | |||
| 151 | // daemon mode constructs one with Listen, then iterates Lines | 165 | // daemon mode constructs one with Listen, then iterates Lines | ||
| 152 | // to receive supervisor messages. Designed so a single goroutine | 166 | // to receive supervisor messages. Designed so a single goroutine | ||
| 153 | // in the child can drive prompting without juggling raw net code. | 167 | // in the child can drive prompting without juggling raw net code. | ||
| @@ -158,35 +172,53 @@ type Listener struct { | |||||
| 158 | // only expects one supervisor (the parent zot), so newer | 172 | // only expects one supervisor (the parent zot), so newer | ||
| 159 | // connections preempt older ones — the previous parent | 173 | // connections preempt older ones — the previous parent | ||
| 160 | // presumably crashed. | 174 | // presumably crashed. | ||
| 161 | - | mu sync.Mutex | 175 | + | mu sync.Mutex |
| 162 | - | active net.Conn | 176 | + | active net.Conn |
| 163 | - | out chan string | 177 | + | conns map[net.Conn]struct{} |
| 164 | - | done chan struct{} | 178 | + | out chan string |
| 165 | - | wg sync.WaitGroup | 179 | + | done chan struct{} |
| 180 | + | wg sync.WaitGroup | |||
| 181 | + | closed bool | |||
| 182 | + | closeOnce sync.Once | |||
| 166 | } | 183 | } | ||
| 167 | 184 | ||||
| 168 | // Listen creates the socket at path and starts accepting. The | 185 | // Listen creates the socket at path and starts accepting. The | ||
| 169 | // caller must call Close to remove the socket file on shutdown. | 186 | // caller must call Close to remove the socket file on shutdown. | ||
| 170 | - | // Returns the listener even if a stale socket exists from a | 187 | + | // A live endpoint is preserved and rejected; only a refused probe |
| 171 | - | // previous run: we unlink first, mirroring how most unix daemons | 188 | + | // is treated as a stale socket eligible for removal. |
| 172 | - | // behave. | |||
| 173 | func Listen(path string) (*Listener, error) { | 189 | func Listen(path string) (*Listener, error) { | ||
| 174 | - | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | 190 | + | dir := filepath.Dir(path) |
| 191 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 175 | return nil, fmt.Errorf("inbox dir: %w", err) | 192 | return nil, fmt.Errorf("inbox dir: %w", err) | ||
| 176 | } | 193 | } | ||
| 177 | - | // Best-effort cleanup of a stale socket. If the parent | 194 | + | if err := os.Chmod(dir, 0o700); err != nil { |
| 178 | - | // process is still alive and using it, Listen will fail | 195 | + | return nil, fmt.Errorf("inbox dir permissions: %w", err) |
| 179 | - | // below and the caller surfaces that as a real conflict. | 196 | + | } |
| 180 | - | _ = os.Remove(path) | 197 | + | // Probe an existing endpoint before removing it. Unlinking an active |
| 198 | + | // Unix socket does not stop its listener, so blindly removing the path | |||
| 199 | + | // could let Resume start a second worker on the same session and event log. | |||
| 200 | + | if _, statErr := os.Stat(path); statErr == nil { | |||
| 201 | + | if inboxLive(path) { | |||
| 202 | + | return nil, fmt.Errorf("subagent inbox already has a live listener") | |||
| 203 | + | } | |||
| 204 | + | // A refused probe means the socket inode is stale. Remove only that | |||
| 205 | + | // endpoint; a live listener was handled above without unlinking it. | |||
| 206 | + | _ = os.Remove(path) | |||
| 207 | + | } | |||
| 181 | ln, err := net.Listen("unix", path) | 208 | ln, err := net.Listen("unix", path) | ||
| 182 | if err != nil { | 209 | if err != nil { | ||
| 183 | return nil, fmt.Errorf("inbox listen: %w", err) | 210 | return nil, fmt.Errorf("inbox listen: %w", err) | ||
| 184 | } | 211 | } | ||
| 212 | + | // Unix sockets inherit the process umask but may otherwise be | |||
| 213 | + | // world-writable. The inbox carries prompts and control commands, | |||
| 214 | + | // so fail closed for other local users. | |||
| 215 | + | _ = os.Chmod(path, 0o600) | |||
| 185 | l := &Listener{ | 216 | l := &Listener{ | ||
| 186 | - | path: path, | 217 | + | path: path, |
| 187 | - | ln: ln, | 218 | + | ln: ln, |
| 188 | - | out: make(chan string, 16), | 219 | + | conns: make(map[net.Conn]struct{}), |
| 189 | - | done: make(chan struct{}), | 220 | + | out: make(chan string, 16), |
| 221 | + | done: make(chan struct{}), | |||
| 190 | } | 222 | } | ||
| 191 | go l.acceptLoop() | 223 | go l.acceptLoop() | ||
| 192 | return l, nil | 224 | return l, nil | ||
| @@ -199,19 +231,17 @@ func (l *Listener) Lines() <-chan string { return l.out } | |||||
| 199 | // Close stops accepting, drops the active connection, removes | 231 | // Close stops accepting, drops the active connection, removes | ||
| 200 | // the socket file, and closes Lines. Idempotent. | 232 | // the socket file, and closes Lines. Idempotent. | ||
| 201 | func (l *Listener) Close() error { | 233 | func (l *Listener) Close() error { | ||
| 202 | - | select { | 234 | + | l.closeOnce.Do(func() { |
| 203 | - | case <-l.done: | 235 | + | l.mu.Lock() |
| 204 | - | return nil | 236 | + | l.closed = true |
| 205 | - | default: | |||
| 206 | close(l.done) | 237 | close(l.done) | ||
| 207 | - | } | 238 | + | for conn := range l.conns { |
| 208 | - | l.mu.Lock() | 239 | + | _ = conn.Close() |
| 209 | - | if l.active != nil { | 240 | + | } |
| 210 | - | _ = l.active.Close() | 241 | + | l.mu.Unlock() |
| 211 | - | } | 242 | + | _ = l.ln.Close() |
| 212 | - | l.mu.Unlock() | 243 | + | _ = os.Remove(l.path) |
| 213 | - | _ = l.ln.Close() | 244 | + | }) |
| 214 | - | _ = os.Remove(l.path) | |||
| 215 | return nil | 245 | return nil | ||
| 216 | } | 246 | } | ||
| 217 | 247 | ||||
| @@ -233,12 +263,14 @@ func (l *Listener) acceptLoop() { | |||||
| 233 | } | 263 | } | ||
| 234 | } | 264 | } | ||
| 235 | l.mu.Lock() | 265 | l.mu.Lock() | ||
| 236 | - | if l.active != nil { | 266 | + | if l.closed { |
| 237 | - | _ = l.active.Close() | 267 | + | l.mu.Unlock() |
| 268 | + | _ = c.Close() | |||
| 269 | + | return | |||
| 238 | } | 270 | } | ||
| 239 | - | l.active = c | 271 | + | l.conns[c] = struct{}{} |
| 240 | - | l.mu.Unlock() | |||
| 241 | l.wg.Add(1) | 272 | l.wg.Add(1) | ||
| 273 | + | l.mu.Unlock() | |||
| 242 | go func() { | 274 | go func() { | ||
| 243 | defer l.wg.Done() | 275 | defer l.wg.Done() | ||
| 244 | l.readLoop(c) | 276 | l.readLoop(c) | ||
| @@ -247,24 +279,44 @@ func (l *Listener) acceptLoop() { | |||||
| 247 | } | 279 | } | ||
| 248 | 280 | ||||
| 249 | func (l *Listener) readLoop(c net.Conn) { | 281 | func (l *Listener) readLoop(c net.Conn) { | ||
| 282 | + | defer func() { | |||
| 283 | + | _ = c.Close() | |||
| 284 | + | l.mu.Lock() | |||
| 285 | + | delete(l.conns, c) | |||
| 286 | + | if l.active == c { | |||
| 287 | + | l.active = nil | |||
| 288 | + | } | |||
| 289 | + | l.mu.Unlock() | |||
| 290 | + | }() | |||
| 291 | + | ||||
| 250 | br := bufio.NewReader(c) | 292 | br := bufio.NewReader(c) | ||
| 293 | + | firstLine := true | |||
| 251 | for { | 294 | for { | ||
| 252 | line, err := br.ReadString('\n') | 295 | line, err := br.ReadString('\n') | ||
| 253 | if line != "" { | 296 | if line != "" { | ||
| 297 | + | message := trimRightNL(line) | |||
| 298 | + | if firstLine { | |||
| 299 | + | firstLine = false | |||
| 300 | + | if message == inboxProbeLine { | |||
| 301 | + | return | |||
| 302 | + | } | |||
| 303 | + | // The first real command claims ownership. Health probes do | |||
| 304 | + | // not preempt the current supervisor, while a replacement | |||
| 305 | + | // supervisor still gets the historical takeover behavior. | |||
| 306 | + | l.mu.Lock() | |||
| 307 | + | if l.active != nil && l.active != c { | |||
| 308 | + | _ = l.active.Close() | |||
| 309 | + | } | |||
| 310 | + | l.active = c | |||
| 311 | + | l.mu.Unlock() | |||
| 312 | + | } | |||
| 254 | select { | 313 | select { | ||
| 255 | - | case l.out <- trimRightNL(line): | 314 | + | case l.out <- message: |
| 256 | case <-l.done: | 315 | case <-l.done: | ||
| 257 | - | _ = c.Close() | |||
| 258 | return | 316 | return | ||
| 259 | } | 317 | } | ||
| 260 | } | 318 | } | ||
| 261 | if err != nil { | 319 | if err != nil { | ||
| 262 | - | _ = c.Close() | |||
| 263 | - | l.mu.Lock() | |||
| 264 | - | if l.active == c { | |||
| 265 | - | l.active = nil | |||
| 266 | - | } | |||
| 267 | - | l.mu.Unlock() | |||
| 268 | return | 320 | return | ||
| 269 | } | 321 | } | ||
| 270 | } | 322 | } | ||
R packages/agent/swarm/inbox_test.go → packages/agent/subagents/inbox_test.go +33 -19 renamed, 79% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| @@ -20,7 +20,7 @@ import ( | |||||
| 20 | func shortSocketDir(t *testing.T) string { | 20 | func shortSocketDir(t *testing.T) string { | ||
| 21 | t.Helper() | 21 | t.Helper() | ||
| 22 | if runtime.GOOS == "windows" { | 22 | if runtime.GOOS == "windows" { | ||
| 23 | - | t.Skip("swarm inbox transport uses Unix-domain sockets; Windows support will use a named-pipe backend") | 23 | + | t.Skip("subagent inbox transport uses Unix-domain sockets; Windows support will use a named-pipe backend") |
| 24 | } | 24 | } | ||
| 25 | dir, err := os.MkdirTemp("/tmp", "zot-in-") | 25 | dir, err := os.MkdirTemp("/tmp", "zot-in-") | ||
| 26 | if err != nil { | 26 | if err != nil { | ||
| @@ -41,26 +41,40 @@ func TestInboxRoundTrip(t *testing.T) { | |||||
| 41 | } | 41 | } | ||
| 42 | defer ln.Close() | 42 | defer ln.Close() | ||
| 43 | 43 | ||||
| 44 | - | got := collectFor(t, ln.Lines(), 3, time.Second) | 44 | + | got := collectFor(t, ln.Lines(), 4, time.Second) |
| 45 | inbox := NewInbox(path) | 45 | inbox := NewInbox(path) | ||
| 46 | defer inbox.Close() | 46 | defer inbox.Close() | ||
| 47 | 47 | ||||
| 48 | want := []string{"user hello", "user world", "cancel"} | 48 | want := []string{"user hello", "user world", "cancel"} | ||
| 49 | for _, msg := range want { | 49 | for _, msg := range want { | ||
| 50 | - | if err := inbox.SendInput(msg); err != nil { | 50 | + | if err := inbox.sendBytes([]byte(msg + "\n")); err != nil { |
| 51 | - | t.Fatalf("SendInput(%q): %v", msg, err) | 51 | + | t.Fatalf("sendBytes(%q): %v", msg, err) |
| 52 | } | 52 | } | ||
| 53 | } | 53 | } | ||
| 54 | + | if err := inbox.SendCommand(NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "round trip"})); err != nil { | |||
| 55 | + | t.Fatalf("SendCommand: %v", err) | |||
| 56 | + | } | |||
| 54 | 57 | ||||
| 55 | gotLines := <-got | 58 | gotLines := <-got | ||
| 56 | - | if len(gotLines) != len(want) { | 59 | + | if len(gotLines) != len(want)+1 { |
| 57 | - | t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines) | 60 | + | t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want)+1, gotLines) |
| 58 | } | 61 | } | ||
| 59 | for i, w := range want { | 62 | for i, w := range want { | ||
| 60 | if gotLines[i] != w { | 63 | if gotLines[i] != w { | ||
| 61 | t.Errorf("line %d = %q; want %q", i, gotLines[i], w) | 64 | t.Errorf("line %d = %q; want %q", i, gotLines[i], w) | ||
| 62 | } | 65 | } | ||
| 63 | } | 66 | } | ||
| 67 | + | parsed, err := ParseCommand(gotLines[len(want)]) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatalf("ParseCommand: %v", err) | |||
| 70 | + | } | |||
| 71 | + | var payload TurnStartPayload | |||
| 72 | + | if err := parsed.DecodePayload(&payload); err != nil { | |||
| 73 | + | t.Fatalf("DecodePayload: %v", err) | |||
| 74 | + | } | |||
| 75 | + | if payload.Prompt != "round trip" { | |||
| 76 | + | t.Fatalf("decoded prompt = %q, want %q", payload.Prompt, "round trip") | |||
| 77 | + | } | |||
| 64 | } | 78 | } | ||
| 65 | 79 | ||||
| 66 | // TestInboxNotReady regression-tests the explicit "child hasn't | 80 | // TestInboxNotReady regression-tests the explicit "child hasn't | ||
| @@ -70,19 +84,19 @@ func TestInboxNotReady(t *testing.T) { | |||||
| 70 | path := filepath.Join(shortSocketDir(t), "in.sock") | 84 | path := filepath.Join(shortSocketDir(t), "in.sock") | ||
| 71 | inbox := NewInbox(path) | 85 | inbox := NewInbox(path) | ||
| 72 | defer inbox.Close() | 86 | defer inbox.Close() | ||
| 73 | - | err := inbox.SendInput("user hi") | 87 | + | err := inbox.sendBytes([]byte("user hi\n")) |
| 74 | if err == nil { | 88 | if err == nil { | ||
| 75 | - | t.Fatal("SendInput on missing socket: want error") | 89 | + | t.Fatal("sendBytes on missing socket: want error") |
| 76 | } | 90 | } | ||
| 77 | if !errors.Is(err, ErrNotReady) { | 91 | if !errors.Is(err, ErrNotReady) { | ||
| 78 | - | t.Fatalf("SendInput err = %v; want ErrNotReady", err) | 92 | + | t.Fatalf("sendBytes err = %v; want ErrNotReady", err) |
| 79 | } | 93 | } | ||
| 80 | } | 94 | } | ||
| 81 | 95 | ||||
| 82 | // TestIsNoListenerErrMatchesECONNREFUSED documents the contract | 96 | // TestIsNoListenerErrMatchesECONNREFUSED documents the contract | ||
| 83 | // that the user's screenshot regression depends on: any dial error | 97 | // that the user's screenshot regression depends on: any dial error | ||
| 84 | // whose root cause is "the kernel refused the connect because nobody | 98 | // whose root cause is "the kernel refused the connect because nobody | ||
| 85 | - | // is listening" must fold into ErrNotReady at the SendInput layer, | 99 | + | // is listening" must fold into ErrNotReady at the sendBytes layer, |
| 86 | // so the TUI shows "agent not accepting input (press R to resume)" | 100 | // so the TUI shows "agent not accepting input (press R to resume)" | ||
| 87 | // instead of leaking the unix-socket path. We pattern-match via | 101 | // instead of leaking the unix-socket path. We pattern-match via | ||
| 88 | // errors.Is on syscall.ECONNREFUSED rather than string-matching the | 102 | // errors.Is on syscall.ECONNREFUSED rather than string-matching the | ||
| @@ -104,7 +118,7 @@ func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) { | |||||
| 104 | } | 118 | } | ||
| 105 | 119 | ||||
| 106 | // TestInboxNewSupervisorPreemptsOld documents that only one | 120 | // TestInboxNewSupervisorPreemptsOld documents that only one | ||
| 107 | - | // supervisor connection at a time is honoured. The swarm design | 121 | + | // supervisor connection at a time is honoured. The subagent design |
| 108 | // assumes a single parent zot owns each agent; if a second | 122 | // assumes a single parent zot owns each agent; if a second | ||
| 109 | // parent dials, the listener boots the first one. The first | 123 | // parent dials, the listener boots the first one. The first | ||
| 110 | // supervisor's already-delivered messages still land in the | 124 | // supervisor's already-delivered messages still land in the | ||
| @@ -119,7 +133,7 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) { | |||||
| 119 | defer ln.Close() | 133 | defer ln.Close() | ||
| 120 | 134 | ||||
| 121 | first := NewInbox(path) | 135 | first := NewInbox(path) | ||
| 122 | - | if err := first.SendInput("user one"); err != nil { | 136 | + | if err := first.sendBytes([]byte("user one\n")); err != nil { |
| 123 | t.Fatalf("first send: %v", err) | 137 | t.Fatalf("first send: %v", err) | ||
| 124 | } | 138 | } | ||
| 125 | // Wait for the listener to absorb "user one" before dialing the | 139 | // Wait for the listener to absorb "user one" before dialing the | ||
| @@ -130,7 +144,7 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) { | |||||
| 130 | 144 | ||||
| 131 | second := NewInbox(path) | 145 | second := NewInbox(path) | ||
| 132 | defer second.Close() | 146 | defer second.Close() | ||
| 133 | - | if err := second.SendInput("user two"); err != nil { | 147 | + | if err := second.sendBytes([]byte("user two\n")); err != nil { |
| 134 | t.Fatalf("second send: %v", err) | 148 | t.Fatalf("second send: %v", err) | ||
| 135 | } | 149 | } | ||
| 136 | 150 | ||||
| @@ -140,17 +154,17 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) { | |||||
| 140 | } | 154 | } | ||
| 141 | 155 | ||||
| 142 | // The original supervisor's connection was preempted by the | 156 | // The original supervisor's connection was preempted by the | ||
| 143 | - | // listener accepting the second dial. The next SendInput on the | 157 | + | // listener accepting the second dial. The next sendBytes on the |
| 144 | // first handle sees a broken pipe and drops the cached connection; | 158 | // first handle sees a broken pipe and drops the cached connection; | ||
| 145 | // the call after that redials successfully. Both behaviours | 159 | // the call after that redials successfully. Both behaviours | ||
| 146 | // matter: the broken-pipe error must surface (so a real | 160 | // matter: the broken-pipe error must surface (so a real | ||
| 147 | // supervisor knows it lost ownership) AND the next call must | 161 | // supervisor knows it lost ownership) AND the next call must | ||
| 148 | // recover so a flaky reconnect doesn't permanently wedge the | 162 | // recover so a flaky reconnect doesn't permanently wedge the | ||
| 149 | // inbox. | 163 | // inbox. | ||
| 150 | - | if err := first.SendInput("user three"); err == nil { | 164 | + | if err := first.sendBytes([]byte("user three\n")); err == nil { |
| 151 | t.Fatal("expected broken-pipe error on stale connection") | 165 | t.Fatal("expected broken-pipe error on stale connection") | ||
| 152 | } | 166 | } | ||
| 153 | - | if err := first.SendInput("user four"); err != nil { | 167 | + | if err := first.sendBytes([]byte("user four\n")); err != nil { |
| 154 | t.Fatalf("first redial after eviction: %v", err) | 168 | t.Fatalf("first redial after eviction: %v", err) | ||
| 155 | } | 169 | } | ||
| 156 | } | 170 | } | ||
| @@ -197,7 +211,7 @@ func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <- | |||||
| 197 | return out | 211 | return out | ||
| 198 | } | 212 | } | ||
| 199 | 213 | ||||
| 200 | - | // TestInboxConcurrentSends serialises the SendInput call so two | 214 | + | // TestInboxConcurrentSends serialises the sendBytes call so two |
| 201 | // goroutines writing at once still produce two whole lines, never | 215 | // goroutines writing at once still produce two whole lines, never | ||
| 202 | // a tearing of bytes between them. | 216 | // a tearing of bytes between them. | ||
| 203 | func TestInboxConcurrentSends(t *testing.T) { | 217 | func TestInboxConcurrentSends(t *testing.T) { | ||
| @@ -216,7 +230,7 @@ func TestInboxConcurrentSends(t *testing.T) { | |||||
| 216 | for i := 0; i < n; i++ { | 230 | for i := 0; i < n; i++ { | ||
| 217 | go func(i int) { | 231 | go func(i int) { | ||
| 218 | defer wg.Done() | 232 | defer wg.Done() | ||
| 219 | - | if err := inbox.SendInput("user msg"); err != nil { | 233 | + | if err := inbox.sendBytes([]byte("user msg\n")); err != nil { |
| 220 | t.Errorf("send %d: %v", i, err) | 234 | t.Errorf("send %d: %v", i, err) | ||
| 221 | } | 235 | } | ||
| 222 | }(i) | 236 | }(i) | ||
A packages/agent/subagents/lease.go +30 -0
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ErrAgentLeaseHeld means another supervisor currently owns an agent's | |||
| 11 | + | // durable state. The lease is advisory; socket probing remains the fence for | |||
| 12 | + | // workers that are still live. | |||
| 13 | + | var ErrAgentLeaseHeld = errors.New("subagents: agent state is owned by another supervisor") | |||
| 14 | + | ||||
| 15 | + | type agentLease struct { | |||
| 16 | + | file *os.File | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func openAgentLeaseFile(stateDir string) (*os.File, error) { | |||
| 20 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 21 | + | return nil, fmt.Errorf("subagents lease directory: %w", err) | |||
| 22 | + | } | |||
| 23 | + | path := filepath.Join(stateDir, "owner.lock") | |||
| 24 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) | |||
| 25 | + | if err != nil { | |||
| 26 | + | return nil, fmt.Errorf("subagents lease open: %w", err) | |||
| 27 | + | } | |||
| 28 | + | _ = file.Chmod(0o600) | |||
| 29 | + | return file, nil | |||
| 30 | + | } | |||
A packages/agent/subagents/lease_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestAgentLeaseIsExclusive(t *testing.T) { | |||
| 9 | + | stateDir := t.TempDir() | |||
| 10 | + | first, err := acquireAgentLease(stateDir) | |||
| 11 | + | if err != nil { | |||
| 12 | + | t.Fatal(err) | |||
| 13 | + | } | |||
| 14 | + | defer first.Close() | |||
| 15 | + | second, err := acquireAgentLease(stateDir) | |||
| 16 | + | if !errors.Is(err, ErrAgentLeaseHeld) { | |||
| 17 | + | if second != nil { | |||
| 18 | + | _ = second.Close() | |||
| 19 | + | } | |||
| 20 | + | t.Fatalf("second lease = %v, want ErrAgentLeaseHeld", err) | |||
| 21 | + | } | |||
| 22 | + | if err := first.Close(); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | third, err := acquireAgentLease(stateDir) | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | if err := third.Close(); err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | } | |||
A packages/agent/subagents/lease_unix.go +36 -0
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | //go:build !windows | |||
| 2 | + | ||||
| 3 | + | package subagents | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "syscall" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func acquireAgentLease(stateDir string) (*agentLease, error) { | |||
| 11 | + | file, err := openAgentLeaseFile(stateDir) | |||
| 12 | + | if err != nil { | |||
| 13 | + | return nil, err | |||
| 14 | + | } | |||
| 15 | + | if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |||
| 16 | + | _ = file.Close() | |||
| 17 | + | if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { | |||
| 18 | + | return nil, ErrAgentLeaseHeld | |||
| 19 | + | } | |||
| 20 | + | return nil, err | |||
| 21 | + | } | |||
| 22 | + | return &agentLease{file: file}, nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (l *agentLease) Close() error { | |||
| 26 | + | if l == nil || l.file == nil { | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) | |||
| 30 | + | closeErr := l.file.Close() | |||
| 31 | + | l.file = nil | |||
| 32 | + | if unlockErr != nil { | |||
| 33 | + | return unlockErr | |||
| 34 | + | } | |||
| 35 | + | return closeErr | |||
| 36 | + | } | |||
A packages/agent/subagents/lease_windows.go +40 -0
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | //go:build windows | |||
| 2 | + | ||||
| 3 | + | package subagents | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | ||||
| 8 | + | "golang.org/x/sys/windows" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func acquireAgentLease(stateDir string) (*agentLease, error) { | |||
| 12 | + | file, err := openAgentLeaseFile(stateDir) | |||
| 13 | + | if err != nil { | |||
| 14 | + | return nil, err | |||
| 15 | + | } | |||
| 16 | + | flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY) | |||
| 17 | + | var overlapped windows.Overlapped | |||
| 18 | + | if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil { | |||
| 19 | + | _ = file.Close() | |||
| 20 | + | if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { | |||
| 21 | + | return nil, ErrAgentLeaseHeld | |||
| 22 | + | } | |||
| 23 | + | return nil, err | |||
| 24 | + | } | |||
| 25 | + | return &agentLease{file: file}, nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (l *agentLease) Close() error { | |||
| 29 | + | if l == nil || l.file == nil { | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | var overlapped windows.Overlapped | |||
| 33 | + | unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, &overlapped) | |||
| 34 | + | closeErr := l.file.Close() | |||
| 35 | + | l.file = nil | |||
| 36 | + | if unlockErr != nil { | |||
| 37 | + | return unlockErr | |||
| 38 | + | } | |||
| 39 | + | return closeErr | |||
| 40 | + | } | |||
A packages/agent/subagents/model.go +119 -0
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // ProcessState describes the lifetime of the supervised child process. It is | |||
| 6 | + | // intentionally independent from TurnState: an alive worker can be idle | |||
| 7 | + | // between turns, and a disconnected supervisor does not imply that the | |||
| 8 | + | // worker's current turn failed. | |||
| 9 | + | type ProcessState string | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | ProcessPending ProcessState = "pending" | |||
| 13 | + | ProcessStarting ProcessState = "starting" | |||
| 14 | + | ProcessAlive ProcessState = "alive" | |||
| 15 | + | ProcessDetached ProcessState = "detached" | |||
| 16 | + | ProcessExited ProcessState = "exited" | |||
| 17 | + | ProcessKilled ProcessState = "killed" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // TurnState describes one delegated prompt inside a worker process. | |||
| 21 | + | type TurnState string | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | TurnIdle TurnState = "idle" | |||
| 25 | + | TurnQueued TurnState = "queued" | |||
| 26 | + | TurnRunning TurnState = "running" | |||
| 27 | + | TurnCanceling TurnState = "canceling" | |||
| 28 | + | TurnSucceeded TurnState = "succeeded" | |||
| 29 | + | TurnFailed TurnState = "failed" | |||
| 30 | + | TurnCanceled TurnState = "canceled" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // WorkspaceMode selects how a child is allowed to access the repository. | |||
| 34 | + | type WorkspaceMode string | |||
| 35 | + | ||||
| 36 | + | const ( | |||
| 37 | + | WorkspaceShared WorkspaceMode = "shared" | |||
| 38 | + | WorkspaceWorktree WorkspaceMode = "worktree" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // CaptureMode controls which durable worktree output is collected. | |||
| 42 | + | type CaptureMode string | |||
| 43 | + | ||||
| 44 | + | const ( | |||
| 45 | + | CapturePatch CaptureMode = "patch" | |||
| 46 | + | CaptureDiff CaptureMode = "diff" | |||
| 47 | + | ) | |||
| 48 | + | ||||
| 49 | + | // SubagentPolicy contains manager-owned safety and resource limits. normalize | |||
| 50 | + | // replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and | |||
| 51 | + | // DefaultTimeout values with positive manager defaults. Other zero values retain | |||
| 52 | + | // their documented semantics. | |||
| 53 | + | type SubagentPolicy struct { | |||
| 54 | + | MaxConcurrent int | |||
| 55 | + | MaxConcurrentPerParent int | |||
| 56 | + | MaxTotalSpawned int | |||
| 57 | + | QueueTimeout time.Duration | |||
| 58 | + | DefaultTimeout time.Duration | |||
| 59 | + | MaxTurns int | |||
| 60 | + | MaxOutputBytes int | |||
| 61 | + | MaxOutputLines int | |||
| 62 | + | AllowedTools []string | |||
| 63 | + | AllowedRoots []string | |||
| 64 | + | HeartbeatInterval time.Duration | |||
| 65 | + | IdleTimeout time.Duration | |||
| 66 | + | ReconnectTimeout time.Duration | |||
| 67 | + | CancelGracePeriod time.Duration | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (p *SubagentPolicy) normalize() { | |||
| 71 | + | if p.MaxConcurrent <= 0 { | |||
| 72 | + | p.MaxConcurrent = 8 | |||
| 73 | + | } | |||
| 74 | + | if p.MaxConcurrentPerParent <= 0 { | |||
| 75 | + | p.MaxConcurrentPerParent = 4 | |||
| 76 | + | } | |||
| 77 | + | if p.MaxTotalSpawned <= 0 { | |||
| 78 | + | p.MaxTotalSpawned = 32 | |||
| 79 | + | } | |||
| 80 | + | if p.QueueTimeout <= 0 { | |||
| 81 | + | p.QueueTimeout = 30 * time.Minute | |||
| 82 | + | } | |||
| 83 | + | if p.DefaultTimeout <= 0 { | |||
| 84 | + | p.DefaultTimeout = 20 * time.Minute | |||
| 85 | + | } | |||
| 86 | + | if p.MaxTurns <= 0 { | |||
| 87 | + | p.MaxTurns = 3 | |||
| 88 | + | } | |||
| 89 | + | if p.MaxOutputBytes <= 0 { | |||
| 90 | + | p.MaxOutputBytes = 500_000 | |||
| 91 | + | } | |||
| 92 | + | if p.MaxOutputLines <= 0 { | |||
| 93 | + | p.MaxOutputLines = 5_000 | |||
| 94 | + | } | |||
| 95 | + | if p.HeartbeatInterval <= 0 { | |||
| 96 | + | p.HeartbeatInterval = 10 * time.Second | |||
| 97 | + | } | |||
| 98 | + | if p.IdleTimeout <= 0 { | |||
| 99 | + | p.IdleTimeout = 7 * time.Minute | |||
| 100 | + | } | |||
| 101 | + | if p.ReconnectTimeout <= 0 { | |||
| 102 | + | p.ReconnectTimeout = 5 * time.Second | |||
| 103 | + | } | |||
| 104 | + | if p.CancelGracePeriod <= 0 { | |||
| 105 | + | p.CancelGracePeriod = 10 * time.Second | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (p SubagentPolicy) allowedTool(name string) bool { | |||
| 110 | + | if len(p.AllowedTools) == 0 { | |||
| 111 | + | return true | |||
| 112 | + | } | |||
| 113 | + | for _, allowed := range p.AllowedTools { | |||
| 114 | + | if allowed == name { | |||
| 115 | + | return true | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | return false | |||
| 119 | + | } | |||
A packages/agent/subagents/persist.go +796 -0
| @@ -0,0 +1,796 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | // On-disk persistence for subagent agents. | |||
| 4 | + | // | |||
| 5 | + | // Every Spawn writes a meta.json next to the agent's events.jsonl and | |||
| 6 | + | // session.json. The file captures the immutable identity bits (id, | |||
| 7 | + | // task, branch, dir) plus the paths the runner needs to resume the | |||
| 8 | + | // agent later. On a fresh zot launch, Supervisor.Reload() walks | |||
| 9 | + | // <root>/agents/*/meta.json and re-registers every agent it finds in | |||
| 10 | + | // StatusDetached so the user can see, view, resume, or remove them | |||
| 11 | + | // from the dashboard. | |||
| 12 | + | // | |||
| 13 | + | // We don't try to keep meta.json in sync with mutable state (status, | |||
| 14 | + | // activity, transcript). Those live in the events log (durable) and | |||
| 15 | + | // in-memory Agent fields (rebuilt by Reload from the log tail). | |||
| 16 | + | // Keeping meta.json immutable means we never have to worry about | |||
| 17 | + | // concurrent writers stomping on each other and the file matters | |||
| 18 | + | // only on the spawn/reload boundary. | |||
| 19 | + | ||||
| 20 | + | import ( | |||
| 21 | + | "context" | |||
| 22 | + | "encoding/json" | |||
| 23 | + | "errors" | |||
| 24 | + | "fmt" | |||
| 25 | + | "os" | |||
| 26 | + | "path/filepath" | |||
| 27 | + | "sort" | |||
| 28 | + | "strings" | |||
| 29 | + | "time" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // agentMeta is the durable identity record for one agent. Only fields | |||
| 33 | + | // the supervisor needs to rebuild an Agent after a restart live here. | |||
| 34 | + | // Adding a field leaves it zero when absent; removing or renaming a field | |||
| 35 | + | // changes the persisted contract. | |||
| 36 | + | // | |||
| 37 | + | // Historical fields like `branch` and `isolated` are silently dropped | |||
| 38 | + | // by encoding/json's permissive decoder when an older meta.json is | |||
| 39 | + | // loaded; we don't need to keep them in the struct. | |||
| 40 | + | type agentMeta struct { | |||
| 41 | + | ID string `json:"id"` | |||
| 42 | + | Task string `json:"task"` | |||
| 43 | + | OriginalTask string `json:"original_task,omitempty"` | |||
| 44 | + | Dir string `json:"dir"` | |||
| 45 | + | RepositoryRoot string `json:"repository_root,omitempty"` | |||
| 46 | + | Started time.Time `json:"started"` | |||
| 47 | + | ParentID string `json:"parent_id,omitempty"` | |||
| 48 | + | BatchID string `json:"batch_id,omitempty"` | |||
| 49 | + | RootSessionID string `json:"root_session_id,omitempty"` | |||
| 50 | + | Model string `json:"model,omitempty"` | |||
| 51 | + | Provider string `json:"provider,omitempty"` | |||
| 52 | + | BaseURL string `json:"base_url,omitempty"` | |||
| 53 | + | InsecureTLS bool `json:"insecure_tls,omitempty"` | |||
| 54 | + | Reasoning string `json:"reasoning,omitempty"` | |||
| 55 | + | FastMode *bool `json:"fast_mode,omitempty"` | |||
| 56 | + | Subagent string `json:"subagent,omitempty"` | |||
| 57 | + | WorkspaceMode WorkspaceMode `json:"workspace_mode,omitempty"` | |||
| 58 | + | WorkspacePath string `json:"workspace_path,omitempty"` | |||
| 59 | + | WorkspaceBase string `json:"workspace_base,omitempty"` | |||
| 60 | + | WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"` | |||
| 61 | + | MaxTurns int `json:"max_turns,omitempty"` | |||
| 62 | + | Timeout time.Duration `json:"timeout,omitempty"` | |||
| 63 | + | Tools []string `json:"tools,omitempty"` | |||
| 64 | + | ProcessState ProcessState `json:"process_state,omitempty"` | |||
| 65 | + | TurnState TurnState `json:"turn_state,omitempty"` | |||
| 66 | + | CurrentTurnID string `json:"current_turn_id,omitempty"` | |||
| 67 | + | Attempt int `json:"attempt,omitempty"` | |||
| 68 | + | ProcessPID int `json:"process_pid,omitempty"` | |||
| 69 | + | UpdatedAt time.Time `json:"updated_at,omitempty"` | |||
| 70 | + | LastActivity time.Time `json:"last_activity,omitempty"` | |||
| 71 | + | ResultRef string `json:"result_ref,omitempty"` | |||
| 72 | + | PatchRef string `json:"patch_ref,omitempty"` | |||
| 73 | + | ChangedFiles []string `json:"changed_files,omitempty"` | |||
| 74 | + | InboxPath string `json:"inbox_path"` | |||
| 75 | + | EventLogPath string `json:"event_log_path"` | |||
| 76 | + | SessionPath string `json:"session_path"` | |||
| 77 | + | ||||
| 78 | + | // SessionID, when non-empty, scopes the agent to a particular | |||
| 79 | + | // host zot session: the dashboard only shows agents whose | |||
| 80 | + | // SessionID matches the active session. Older meta.json files | |||
| 81 | + | // (and agents spawned outside of any session, e.g. by tests or | |||
| 82 | + | // scripted callers that didn't call SetActiveSession) have an | |||
| 83 | + | // empty SessionID and are visible from every session as a | |||
| 84 | + | // unscoped-agent fallback. | |||
| 85 | + | SessionID string `json:"session_id,omitempty"` | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | |||
| 89 | + | ||||
| 90 | + | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | |||
| 91 | + | // write is atomic (tmp + rename) so a crash mid-write can't leave a | |||
| 92 | + | // half-parsable file that fails Reload. | |||
| 93 | + | func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 94 | + | fastMode := a.FastMode | |||
| 95 | + | a.lifecycleMu.Lock() | |||
| 96 | + | processState := a.processState | |||
| 97 | + | turnState := a.turnState | |||
| 98 | + | currentTurnID := a.currentTurnID | |||
| 99 | + | attempt := a.Attempt | |||
| 100 | + | processPID := a.ProcessPID | |||
| 101 | + | updatedAt := a.updatedAt | |||
| 102 | + | lastActivity := a.lastActivity | |||
| 103 | + | resultRef := a.resultRef | |||
| 104 | + | patchRef := a.patchRef | |||
| 105 | + | changedFiles := append([]string(nil), a.changedFiles...) | |||
| 106 | + | a.lifecycleMu.Unlock() | |||
| 107 | + | m := agentMeta{ | |||
| 108 | + | ID: a.ID, | |||
| 109 | + | Task: a.Task, | |||
| 110 | + | OriginalTask: a.OriginalTask, | |||
| 111 | + | Dir: a.Dir, | |||
| 112 | + | RepositoryRoot: a.RepositoryRoot, | |||
| 113 | + | Started: a.Started, | |||
| 114 | + | ParentID: a.ParentID, | |||
| 115 | + | BatchID: a.BatchID, | |||
| 116 | + | RootSessionID: a.RootSessionID, | |||
| 117 | + | Model: a.Model, | |||
| 118 | + | Provider: a.Provider, | |||
| 119 | + | BaseURL: a.BaseURL, | |||
| 120 | + | InsecureTLS: a.InsecureTLS, | |||
| 121 | + | Reasoning: a.Reasoning, | |||
| 122 | + | FastMode: &fastMode, | |||
| 123 | + | Subagent: a.Subagent, | |||
| 124 | + | WorkspaceMode: a.WorkspaceMode, | |||
| 125 | + | WorkspacePath: a.WorkspacePath, | |||
| 126 | + | WorkspaceBase: a.WorkspaceBase, | |||
| 127 | + | WorkspaceCapture: a.WorkspaceCapture, | |||
| 128 | + | MaxTurns: a.MaxTurns, | |||
| 129 | + | Timeout: a.Timeout, | |||
| 130 | + | Tools: append([]string(nil), a.Tools...), | |||
| 131 | + | ProcessState: processState, | |||
| 132 | + | TurnState: turnState, | |||
| 133 | + | CurrentTurnID: currentTurnID, | |||
| 134 | + | Attempt: attempt, | |||
| 135 | + | ProcessPID: processPID, | |||
| 136 | + | UpdatedAt: updatedAt, | |||
| 137 | + | LastActivity: lastActivity, | |||
| 138 | + | ResultRef: resultRef, | |||
| 139 | + | PatchRef: patchRef, | |||
| 140 | + | ChangedFiles: changedFiles, | |||
| 141 | + | InboxPath: a.InboxPath, | |||
| 142 | + | EventLogPath: a.EventLogPath, | |||
| 143 | + | SessionPath: a.SessionPath, | |||
| 144 | + | SessionID: a.SessionID, | |||
| 145 | + | } | |||
| 146 | + | b, err := json.MarshalIndent(m, "", " ") | |||
| 147 | + | if err != nil { | |||
| 148 | + | return fmt.Errorf("subagent meta marshal: %w", err) | |||
| 149 | + | } | |||
| 150 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 151 | + | return fmt.Errorf("subagents meta dir: %w", err) | |||
| 152 | + | } | |||
| 153 | + | final := metaPath(stateDir) | |||
| 154 | + | tmp := final + ".tmp" | |||
| 155 | + | if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { | |||
| 156 | + | return fmt.Errorf("subagents meta write: %w", err) | |||
| 157 | + | } | |||
| 158 | + | if err := os.Rename(tmp, final); err != nil { | |||
| 159 | + | _ = os.Remove(tmp) | |||
| 160 | + | return fmt.Errorf("subagent meta rename: %w", err) | |||
| 161 | + | } | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped) | |||
| 166 | + | // when the file is missing so callers can distinguish "no such agent" | |||
| 167 | + | // from "corrupt metadata". | |||
| 168 | + | func readAgentMeta(stateDir string) (agentMeta, error) { | |||
| 169 | + | var m agentMeta | |||
| 170 | + | b, err := os.ReadFile(metaPath(stateDir)) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return m, err | |||
| 173 | + | } | |||
| 174 | + | if err := json.Unmarshal(b, &m); err != nil { | |||
| 175 | + | return m, fmt.Errorf("subagent meta parse %s: %w", stateDir, err) | |||
| 176 | + | } | |||
| 177 | + | if m.ID == "" { | |||
| 178 | + | return m, fmt.Errorf("subagent meta %s: missing id", stateDir) | |||
| 179 | + | } | |||
| 180 | + | return m, nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func safeAgentID(id string) bool { | |||
| 184 | + | return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (f *Supervisor) sanitizeReloadMeta(stateDir, root string, m *agentMeta) error { | |||
| 188 | + | if m == nil { | |||
| 189 | + | return errors.New("subagents reload: nil metadata") | |||
| 190 | + | } | |||
| 191 | + | if m.EventLogPath == "" { | |||
| 192 | + | m.EventLogPath = filepath.Join(stateDir, "events.jsonl") | |||
| 193 | + | } | |||
| 194 | + | if m.SessionPath == "" { | |||
| 195 | + | m.SessionPath = filepath.Join(stateDir, "session.json") | |||
| 196 | + | } | |||
| 197 | + | if filepath.Base(m.EventLogPath) != "events.jsonl" || !pathWithin(m.EventLogPath, stateDir) { | |||
| 198 | + | return fmt.Errorf("subagents reload %s: event log path escapes agent state", stateDir) | |||
| 199 | + | } | |||
| 200 | + | if filepath.Base(m.SessionPath) != "session.json" || !pathWithin(m.SessionPath, stateDir) { | |||
| 201 | + | return fmt.Errorf("subagents reload %s: session path escapes agent state", stateDir) | |||
| 202 | + | } | |||
| 203 | + | if m.InboxPath == "" || !validRuntimeInboxPath(m.InboxPath, m.ID) { | |||
| 204 | + | // Old metadata may point at a runtime directory from a previous | |||
| 205 | + | // environment. Recompute that transient path rather than ever | |||
| 206 | + | // dialing an arbitrary socket supplied by metadata. | |||
| 207 | + | expectedInbox, err := inboxSocketPath(root, m.ID) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return fmt.Errorf("subagents reload %s: inbox path: %w", stateDir, err) | |||
| 210 | + | } | |||
| 211 | + | m.InboxPath = expectedInbox | |||
| 212 | + | } | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func validRuntimeInboxPath(path, id string) bool { | |||
| 217 | + | if !filepath.IsAbs(path) || (filepath.Base(path) != id+".sock" && filepath.Base(path) != shortHash(id)+".sock") { | |||
| 218 | + | return false | |||
| 219 | + | } | |||
| 220 | + | dir := filepath.Dir(path) | |||
| 221 | + | if !strings.HasPrefix(filepath.Base(dir), "zot-subagents-") { | |||
| 222 | + | return false | |||
| 223 | + | } | |||
| 224 | + | bases := []string{os.TempDir(), "/tmp"} | |||
| 225 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtimeDir) { | |||
| 226 | + | bases = append(bases, runtimeDir) | |||
| 227 | + | } | |||
| 228 | + | for _, base := range bases { | |||
| 229 | + | if pathWithin(dir, filepath.Clean(base)) { | |||
| 230 | + | return true | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | return false | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func firstNonEmpty(values ...string) string { | |||
| 237 | + | for _, value := range values { | |||
| 238 | + | if strings.TrimSpace(value) != "" { | |||
| 239 | + | return value | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | return "" | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // Reload scans <root>/agents/*/meta.json and re-registers every | |||
| 246 | + | // previously-spawned agent as a StatusDetached entry. Agents already | |||
| 247 | + | // present in memory are left alone (Reload is idempotent and safe to | |||
| 248 | + | // call after Spawn, though in practice the cli invokes it exactly | |||
| 249 | + | // once just after New()). | |||
| 250 | + | // | |||
| 251 | + | // The reloaded agents have no live Runner; the user can: | |||
| 252 | + | // - view their transcript (the dashboard reads from EventLogPath), | |||
| 253 | + | // - resume them via Supervisor.Resume (starts a fresh subprocess on the | |||
| 254 | + | // same worktree / session / inbox path), | |||
| 255 | + | // - remove them (worktree + meta + events log gone). | |||
| 256 | + | // | |||
| 257 | + | // Reload returns the number of agents loaded plus any per-directory | |||
| 258 | + | // error encountered. Malformed entries are skipped rather than | |||
| 259 | + | // failing the whole reload — one bad meta.json shouldn't hide the | |||
| 260 | + | // rest of the subagents. | |||
| 261 | + | func (f *Supervisor) Reload() (loaded int, errs []error) { | |||
| 262 | + | root := filepath.Clean(f.cfg.Root) | |||
| 263 | + | if root == "." { | |||
| 264 | + | return 0, nil | |||
| 265 | + | } | |||
| 266 | + | loaded, errs = f.reloadRoot(root) | |||
| 267 | + | errs = append(errs, f.reloadBatches(root)...) | |||
| 268 | + | return loaded, errs | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) { | |||
| 272 | + | agentsDir := filepath.Join(root, "agents") | |||
| 273 | + | entries, err := os.ReadDir(agentsDir) | |||
| 274 | + | if err != nil { | |||
| 275 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 276 | + | return 0, nil | |||
| 277 | + | } | |||
| 278 | + | return 0, []error{fmt.Errorf("subagents reload %s: %w", root, err)} | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // Sort by directory name so the load order is stable across runs. | |||
| 282 | + | names := make([]string, 0, len(entries)) | |||
| 283 | + | for _, e := range entries { | |||
| 284 | + | if e.IsDir() { | |||
| 285 | + | names = append(names, e.Name()) | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | sort.Strings(names) | |||
| 289 | + | ||||
| 290 | + | for _, name := range names { | |||
| 291 | + | stateDir := filepath.Join(agentsDir, name) | |||
| 292 | + | m, err := readAgentMeta(stateDir) | |||
| 293 | + | if err != nil { | |||
| 294 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 295 | + | continue | |||
| 296 | + | } | |||
| 297 | + | errs = append(errs, err) | |||
| 298 | + | continue | |||
| 299 | + | } | |||
| 300 | + | if filepath.Base(stateDir) != m.ID || !safeAgentID(m.ID) { | |||
| 301 | + | errs = append(errs, fmt.Errorf("subagents reload %s: unsafe or mismatched agent id", stateDir)) | |||
| 302 | + | continue | |||
| 303 | + | } | |||
| 304 | + | if err := f.sanitizeReloadMeta(stateDir, root, &m); err != nil { | |||
| 305 | + | errs = append(errs, err) | |||
| 306 | + | continue | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | f.mu.Lock() | |||
| 310 | + | if _, exists := f.agents[m.ID]; exists { | |||
| 311 | + | f.mu.Unlock() | |||
| 312 | + | continue | |||
| 313 | + | } | |||
| 314 | + | a := f.buildDetachedAgent(m) | |||
| 315 | + | f.agents[m.ID] = a | |||
| 316 | + | f.order = append(f.order, m.ID) | |||
| 317 | + | f.mu.Unlock() | |||
| 318 | + | loaded++ | |||
| 319 | + | } | |||
| 320 | + | return loaded, errs | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // buildDetachedAgent constructs an Agent from a meta.json with no | |||
| 324 | + | // running Runner. The agent's transcript is populated from the tail | |||
| 325 | + | // of its event log so the dashboard immediately shows recent output; | |||
| 326 | + | // activity is inferred from the last lifecycle event. | |||
| 327 | + | // | |||
| 328 | + | // The returned Agent has a closed `done` channel because Wait should | |||
| 329 | + | // return instantly: there is nothing to wait for. | |||
| 330 | + | func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 331 | + | // Metadata without a repository identity uses the current RepoRoot; | |||
| 332 | + | // records with one retain their persisted repository so a restart from | |||
| 333 | + | // another checkout cannot silently redirect the child. | |||
| 334 | + | dir := m.Dir | |||
| 335 | + | if m.WorkspaceMode != WorkspaceWorktree && m.RepositoryRoot == "" && f.cfg.RepoRoot != "" { | |||
| 336 | + | // Preserve the best-effort migration only for records without an | |||
| 337 | + | // explicit repository identity. | |||
| 338 | + | dir = f.cfg.RepoRoot | |||
| 339 | + | } | |||
| 340 | + | if dir == "" { | |||
| 341 | + | dir = firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot) | |||
| 342 | + | } | |||
| 343 | + | fastMode := f.cfg.FastMode | |||
| 344 | + | if m.FastMode != nil { | |||
| 345 | + | fastMode = *m.FastMode | |||
| 346 | + | } | |||
| 347 | + | turnState := m.TurnState | |||
| 348 | + | if turnState == "" { | |||
| 349 | + | turnState = TurnIdle | |||
| 350 | + | } | |||
| 351 | + | lastActivity := m.LastActivity | |||
| 352 | + | if lastActivity.IsZero() { | |||
| 353 | + | lastActivity = m.Started | |||
| 354 | + | } | |||
| 355 | + | processState := ProcessDetached | |||
| 356 | + | activity := "detached" | |||
| 357 | + | if inboxLive(m.InboxPath) { | |||
| 358 | + | processState = ProcessAlive | |||
| 359 | + | activity = "live (detached)" | |||
| 360 | + | } | |||
| 361 | + | agentStateDir := f.agentStateDir(m.ID) | |||
| 362 | + | if m.EventLogPath != "" { | |||
| 363 | + | // EventLogPath is <state-dir>/events.jsonl; keep the reloaded | |||
| 364 | + | // agent's state scoped to its own directory. Using the parent | |||
| 365 | + | // agents/ directory here would make Remove delete sibling agents | |||
| 366 | + | // and would make resumed result/patch writes collide. | |||
| 367 | + | agentStateDir = filepath.Dir(m.EventLogPath) | |||
| 368 | + | } | |||
| 369 | + | a := &Agent{ | |||
| 370 | + | ID: m.ID, | |||
| 371 | + | Task: m.Task, | |||
| 372 | + | OriginalTask: firstNonEmpty(m.OriginalTask, m.Task), | |||
| 373 | + | Dir: dir, | |||
| 374 | + | RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot), | |||
| 375 | + | Started: m.Started, | |||
| 376 | + | ParentID: m.ParentID, | |||
| 377 | + | BatchID: m.BatchID, | |||
| 378 | + | RootSessionID: m.RootSessionID, | |||
| 379 | + | Model: m.Model, | |||
| 380 | + | Provider: m.Provider, | |||
| 381 | + | BaseURL: m.BaseURL, | |||
| 382 | + | InsecureTLS: m.InsecureTLS, | |||
| 383 | + | Reasoning: m.Reasoning, | |||
| 384 | + | FastMode: fastMode, | |||
| 385 | + | Subagent: m.Subagent, | |||
| 386 | + | WorkspaceMode: m.WorkspaceMode, | |||
| 387 | + | WorkspacePath: m.WorkspacePath, | |||
| 388 | + | WorkspaceBase: m.WorkspaceBase, | |||
| 389 | + | WorkspaceCapture: m.WorkspaceCapture, | |||
| 390 | + | MaxTurns: m.MaxTurns, | |||
| 391 | + | Timeout: m.Timeout, | |||
| 392 | + | Tools: append([]string(nil), m.Tools...), | |||
| 393 | + | Attempt: m.Attempt, | |||
| 394 | + | ProcessPID: m.ProcessPID, | |||
| 395 | + | InboxPath: m.InboxPath, | |||
| 396 | + | EventLogPath: m.EventLogPath, | |||
| 397 | + | SessionPath: m.SessionPath, | |||
| 398 | + | SessionID: m.SessionID, | |||
| 399 | + | inbox: NewInbox(m.InboxPath), | |||
| 400 | + | status: StatusDetached, | |||
| 401 | + | activity: activity, | |||
| 402 | + | processState: processState, | |||
| 403 | + | turnState: turnState, | |||
| 404 | + | currentTurnID: m.CurrentTurnID, | |||
| 405 | + | updatedAt: m.UpdatedAt, | |||
| 406 | + | lastActivity: lastActivity, | |||
| 407 | + | stateDir: agentStateDir, | |||
| 408 | + | resultRef: m.ResultRef, | |||
| 409 | + | patchRef: m.PatchRef, | |||
| 410 | + | changedFiles: append([]string(nil), m.ChangedFiles...), | |||
| 411 | + | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |||
| 412 | + | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |||
| 413 | + | done: make(chan struct{}), | |||
| 414 | + | turnResults: make(chan *TurnResult, 16), | |||
| 415 | + | } | |||
| 416 | + | if a.updatedAt.IsZero() { | |||
| 417 | + | a.updatedAt = lastActivity | |||
| 418 | + | } | |||
| 419 | + | a.closeDone() | |||
| 420 | + | ||||
| 421 | + | // Recover transcript + activity hints from the event log. Best | |||
| 422 | + | // effort: a missing or unreadable log just leaves the agent | |||
| 423 | + | // detached with an empty transcript. | |||
| 424 | + | if a.EventLogPath != "" { | |||
| 425 | + | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | |||
| 426 | + | replayEventsIntoAgent(a, evs) | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | // The append-only log may contain a terminal event from an earlier | |||
| 430 | + | // attempt. A live socket is stronger evidence for the current process; | |||
| 431 | + | // keep the reloaded entry detached/live so Stop and Remove cannot treat a | |||
| 432 | + | // resumed worker as already finished. | |||
| 433 | + | if inboxLive(a.InboxPath) { | |||
| 434 | + | a.mu.Lock() | |||
| 435 | + | a.status = StatusDetached | |||
| 436 | + | a.activity = "live (detached)" | |||
| 437 | + | a.mu.Unlock() | |||
| 438 | + | a.setProcessState(ProcessAlive) | |||
| 439 | + | } | |||
| 440 | + | resultDir := f.agentStateDir(a.ID) | |||
| 441 | + | if a.EventLogPath != "" { | |||
| 442 | + | resultDir = filepath.Dir(a.EventLogPath) | |||
| 443 | + | } | |||
| 444 | + | if result, err := readTurnResult(resultDir); err == nil && validateTurnResultAgent(result, a.ID) == nil { | |||
| 445 | + | a.setResult(result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)) | |||
| 446 | + | } | |||
| 447 | + | return a | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // replayEventsIntoAgent re-derives an agent's transcript and last | |||
| 451 | + | // known status hint from its event log. Mirrors applyEventToSink in | |||
| 452 | + | // runner.go but writes directly to the Agent fields (no Sink because | |||
| 453 | + | // the agent isn't being driven by a runner yet). | |||
| 454 | + | // | |||
| 455 | + | // Status precedence: explicit lifecycle events (agent_stopped) win | |||
| 456 | + | // over inferred ones (assistant_message → idle). If the log contains | |||
| 457 | + | // no terminator we keep status=StatusDetached so the user can resume. | |||
| 458 | + | func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 459 | + | terminal := false | |||
| 460 | + | for _, ev := range evs { | |||
| 461 | + | switch ev.Type { | |||
| 462 | + | case EventTurnStarted, "turn_start": | |||
| 463 | + | turnID := ev.TurnID | |||
| 464 | + | if turnID == "" { | |||
| 465 | + | if step, ok := ev.Data["step"].(float64); ok { | |||
| 466 | + | turnID = fmt.Sprintf("turn-%d", int(step)) | |||
| 467 | + | } | |||
| 468 | + | } | |||
| 469 | + | a.setTurnState(TurnRunning, turnID) | |||
| 470 | + | case EventTurnResult, "turn_result": | |||
| 471 | + | if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil { | |||
| 472 | + | a.setResult(result) | |||
| 473 | + | if result.Status == ResultFailed { | |||
| 474 | + | a.setTurnState(TurnFailed, result.TurnID) | |||
| 475 | + | } else if result.Status == ResultCanceled { | |||
| 476 | + | a.setTurnState(TurnCanceled, result.TurnID) | |||
| 477 | + | } else { | |||
| 478 | + | a.setTurnState(TurnSucceeded, result.TurnID) | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | case EventAgentIdle, "agent_idle": | |||
| 482 | + | a.setTurnState(TurnIdle, ev.TurnID) | |||
| 483 | + | case EventAgentExited, "agent_exited": | |||
| 484 | + | terminal = true | |||
| 485 | + | a.mu.Lock() | |||
| 486 | + | a.status = StatusDone | |||
| 487 | + | a.activity = "done (offline)" | |||
| 488 | + | a.mu.Unlock() | |||
| 489 | + | case "assistant_message", "user_message": | |||
| 490 | + | var text []string | |||
| 491 | + | if c, ok := ev.Data["content"].([]any); ok { | |||
| 492 | + | for _, blk := range c { | |||
| 493 | + | m, _ := blk.(map[string]any) | |||
| 494 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 495 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 496 | + | text = append(text, txt) | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | message := strings.Join(text, "\n") | |||
| 502 | + | if ev.Type == "assistant_message" { | |||
| 503 | + | a.appendAssistantMessage(message) | |||
| 504 | + | } else { | |||
| 505 | + | a.appendUserMessage(message) | |||
| 506 | + | } | |||
| 507 | + | case "stdout": | |||
| 508 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 509 | + | a.appendTranscript(txt) | |||
| 510 | + | } | |||
| 511 | + | case "stderr": | |||
| 512 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 513 | + | a.appendTranscript("stderr: " + txt) | |||
| 514 | + | } | |||
| 515 | + | case "error": | |||
| 516 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 517 | + | a.appendTranscript("error: " + msg) | |||
| 518 | + | } | |||
| 519 | + | case "agent_stopped": | |||
| 520 | + | terminal = true | |||
| 521 | + | reason, _ := ev.Data["reason"].(string) | |||
| 522 | + | a.mu.Lock() | |||
| 523 | + | switch reason { | |||
| 524 | + | case "cancelled": | |||
| 525 | + | a.status = StatusKilled | |||
| 526 | + | a.activity = "cancelled (offline)" | |||
| 527 | + | case "shutdown": | |||
| 528 | + | a.status = StatusDone | |||
| 529 | + | a.activity = "shutdown (offline)" | |||
| 530 | + | case "exit": | |||
| 531 | + | if code, ok := ev.Data["code"].(float64); ok && code != 0 { | |||
| 532 | + | a.status = StatusFailed | |||
| 533 | + | a.activity = fmt.Sprintf("exit %d (offline)", int(code)) | |||
| 534 | + | } else { | |||
| 535 | + | a.status = StatusDone | |||
| 536 | + | a.activity = "done (offline)" | |||
| 537 | + | } | |||
| 538 | + | default: | |||
| 539 | + | a.status = StatusDone | |||
| 540 | + | a.activity = "stopped (offline)" | |||
| 541 | + | } | |||
| 542 | + | a.mu.Unlock() | |||
| 543 | + | } | |||
| 544 | + | } | |||
| 545 | + | if !terminal { | |||
| 546 | + | // Non-terminal log means the previous parent died mid-run. | |||
| 547 | + | // Leave status=StatusDetached but record a hint so the | |||
| 548 | + | // dashboard shows something useful. | |||
| 549 | + | a.mu.Lock() | |||
| 550 | + | if a.activity == "detached" && len(a.transcript) > 0 { | |||
| 551 | + | a.activity = "detached (resume to continue)" | |||
| 552 | + | } | |||
| 553 | + | a.mu.Unlock() | |||
| 554 | + | } | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | // Resume re-attaches a Runner to a previously-spawned agent. Durable | |||
| 558 | + | // session and event files are kept, while the transient inbox path is | |||
| 559 | + | // recalculated for the current runtime environment. Use this to | |||
| 560 | + | // continue a subagent session across zot restarts: | |||
| 561 | + | // | |||
| 562 | + | // subagentMgr.Reload() | |||
| 563 | + | // a, err := subagentMgr.Resume(ctx, "alpha-12345") | |||
| 564 | + | // subagentMgr.SendUserTurn(a.ID, "where were we?") | |||
| 565 | + | // | |||
| 566 | + | // The agent must be in a non-running state (Detached, Done, Failed, | |||
| 567 | + | // Killed). Resuming a still-running agent returns an error so two | |||
| 568 | + | // runners don't race for the same session. | |||
| 569 | + | func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 570 | + | return f.resume(ctx, id, true) | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | // ResumeSession continues the existing child session without replaying the | |||
| 574 | + | // original task. It is the explicit canonical spelling for Resume. | |||
| 575 | + | func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) { | |||
| 576 | + | return f.resume(ctx, id, true) | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | // RestartTask intentionally runs the stored original task again in a fresh | |||
| 580 | + | // worker attempt. Callers should use ResumeSession for normal recovery. | |||
| 581 | + | func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) { | |||
| 582 | + | return f.resume(ctx, id, false) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Agent, error) { | |||
| 586 | + | f.operationMu.Lock() | |||
| 587 | + | defer f.operationMu.Unlock() | |||
| 588 | + | if ctx == nil { | |||
| 589 | + | ctx = context.Background() | |||
| 590 | + | } | |||
| 591 | + | existing := f.Get(id) | |||
| 592 | + | if existing == nil { | |||
| 593 | + | return nil, fmt.Errorf("subagent: no such agent %q", id) | |||
| 594 | + | } | |||
| 595 | + | existing.mu.Lock() | |||
| 596 | + | st := existing.status | |||
| 597 | + | existing.mu.Unlock() | |||
| 598 | + | if st == StatusRunning || st == StatusPending { | |||
| 599 | + | return nil, fmt.Errorf("subagent: agent %s is still %s; stop it first", existing.ID, st) | |||
| 600 | + | } | |||
| 601 | + | select { | |||
| 602 | + | case <-existing.done: | |||
| 603 | + | default: | |||
| 604 | + | return nil, fmt.Errorf("subagent: agent %s is still finalizing; wait before resuming", existing.ID) | |||
| 605 | + | } | |||
| 606 | + | // A reloaded worker may still be alive even though this supervisor has no | |||
| 607 | + | // in-process Runner (the old host can disappear after the child daemon | |||
| 608 | + | // starts). Fence resume before touching the shared session/worktree: a | |||
| 609 | + | // second worker must never own the same inbox and files concurrently. | |||
| 610 | + | if inboxLive(existing.InboxPath) { | |||
| 611 | + | return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID) | |||
| 612 | + | } | |||
| 613 | + | stateDir := existing.stateDirectory(f.cfg.Root) | |||
| 614 | + | lease, err := acquireAgentLease(stateDir) | |||
| 615 | + | if err != nil { | |||
| 616 | + | return nil, fmt.Errorf("subagents resume lease: %w", err) | |||
| 617 | + | } | |||
| 618 | + | leaseOwned := true | |||
| 619 | + | defer func() { | |||
| 620 | + | if leaseOwned { | |||
| 621 | + | _ = lease.Close() | |||
| 622 | + | } | |||
| 623 | + | }() | |||
| 624 | + | existingSnapshot := existing.Snapshot() | |||
| 625 | + | ||||
| 626 | + | // Rebuild from the meta record so we don't carry stale runner | |||
| 627 | + | // state from a previous incarnation. The inbox is transient and | |||
| 628 | + | // may have been persisted under an incompatible filesystem by an | |||
| 629 | + | // older zot version, so always select it again on resume. | |||
| 630 | + | inboxPath, err := inboxSocketPath(f.cfg.Root, existing.ID) | |||
| 631 | + | if err != nil { | |||
| 632 | + | return nil, fmt.Errorf("subagent inbox path: %w", err) | |||
| 633 | + | } | |||
| 634 | + | fastMode := existing.FastMode | |||
| 635 | + | m := agentMeta{ | |||
| 636 | + | ID: existing.ID, Task: existing.Task, | |||
| 637 | + | OriginalTask: existing.OriginalTask, | |||
| 638 | + | Dir: existing.Dir, RepositoryRoot: existing.RepositoryRoot, Started: existing.Started, | |||
| 639 | + | ParentID: existing.ParentID, BatchID: existing.BatchID, RootSessionID: existing.RootSessionID, | |||
| 640 | + | Model: existing.Model, Provider: existing.Provider, | |||
| 641 | + | BaseURL: existing.BaseURL, InsecureTLS: existing.InsecureTLS, | |||
| 642 | + | Reasoning: existing.Reasoning, FastMode: &fastMode, | |||
| 643 | + | Subagent: existing.Subagent, | |||
| 644 | + | WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath, | |||
| 645 | + | WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture, | |||
| 646 | + | MaxTurns: existing.MaxTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...), | |||
| 647 | + | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | |||
| 648 | + | SessionID: existing.SessionID, | |||
| 649 | + | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |||
| 650 | + | SessionPath: existing.SessionPath, | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | |||
| 654 | + | m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...) | |||
| 655 | + | } | |||
| 656 | + | if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil { | |||
| 657 | + | return nil, err | |||
| 658 | + | } | |||
| 659 | + | workspaceMode := m.WorkspaceMode | |||
| 660 | + | if workspaceMode == "" { | |||
| 661 | + | workspaceMode = WorkspaceShared | |||
| 662 | + | } | |||
| 663 | + | if workspaceMode != WorkspaceShared && workspaceMode != WorkspaceWorktree { | |||
| 664 | + | return nil, fmt.Errorf("subagents: unknown workspace mode %q", workspaceMode) | |||
| 665 | + | } | |||
| 666 | + | if m.WorkspaceCapture != "" && m.WorkspaceCapture != CapturePatch && m.WorkspaceCapture != CaptureDiff { | |||
| 667 | + | return nil, fmt.Errorf("subagents: unknown workspace capture mode %q", m.WorkspaceCapture) | |||
| 668 | + | } | |||
| 669 | + | f.mu.Lock() | |||
| 670 | + | allowedRoots := append([]string(nil), f.cfg.Policy.AllowedRoots...) | |||
| 671 | + | f.mu.Unlock() | |||
| 672 | + | repositoryRoot := firstNonEmpty(m.RepositoryRoot, m.Dir, f.cfg.RepoRoot) | |||
| 673 | + | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | |||
| 674 | + | return nil, err | |||
| 675 | + | } | |||
| 676 | + | now := f.cfg.Now() | |||
| 677 | + | maxTurns := m.MaxTurns | |||
| 678 | + | if maxTurns <= 0 { | |||
| 679 | + | maxTurns = f.cfg.Policy.MaxTurns | |||
| 680 | + | } | |||
| 681 | + | existingWorkspacePath := "" | |||
| 682 | + | if workspaceMode == WorkspaceWorktree { | |||
| 683 | + | existingWorkspacePath = m.WorkspacePath | |||
| 684 | + | } | |||
| 685 | + | workspace, err := PrepareWorkspace(ctx, WorkspaceRequest{ | |||
| 686 | + | Mode: workspaceMode, | |||
| 687 | + | RepositoryRoot: repositoryRoot, | |||
| 688 | + | StateDir: stateDir, | |||
| 689 | + | AgentID: existing.ID, | |||
| 690 | + | Base: m.WorkspaceBase, | |||
| 691 | + | Capture: m.WorkspaceCapture, | |||
| 692 | + | AllowedRoots: allowedRoots, | |||
| 693 | + | ExistingPath: existingWorkspacePath, | |||
| 694 | + | }) | |||
| 695 | + | if err != nil { | |||
| 696 | + | return nil, err | |||
| 697 | + | } | |||
| 698 | + | m.Dir = workspace.Dir() | |||
| 699 | + | m.RepositoryRoot = workspace.RepositoryRoot() | |||
| 700 | + | m.WorkspacePath = workspace.Dir() | |||
| 701 | + | m.WorkspaceMode = workspace.Mode() | |||
| 702 | + | timeout := m.Timeout | |||
| 703 | + | if timeout <= 0 { | |||
| 704 | + | timeout = f.cfg.Policy.DefaultTimeout | |||
| 705 | + | } | |||
| 706 | + | runCtx, cancel := f.workerContext(timeout) | |||
| 707 | + | a := &Agent{ | |||
| 708 | + | ID: m.ID, | |||
| 709 | + | Task: m.Task, | |||
| 710 | + | OriginalTask: firstNonEmpty(m.OriginalTask, m.Task), | |||
| 711 | + | Dir: m.Dir, | |||
| 712 | + | RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot), | |||
| 713 | + | Started: m.Started, | |||
| 714 | + | ParentID: m.ParentID, | |||
| 715 | + | BatchID: m.BatchID, | |||
| 716 | + | RootSessionID: m.RootSessionID, | |||
| 717 | + | Model: m.Model, | |||
| 718 | + | Provider: m.Provider, | |||
| 719 | + | BaseURL: m.BaseURL, | |||
| 720 | + | InsecureTLS: m.InsecureTLS, | |||
| 721 | + | Reasoning: m.Reasoning, | |||
| 722 | + | FastMode: fastMode, | |||
| 723 | + | Subagent: m.Subagent, | |||
| 724 | + | SessionID: m.SessionID, | |||
| 725 | + | WorkspaceMode: m.WorkspaceMode, | |||
| 726 | + | WorkspacePath: m.WorkspacePath, | |||
| 727 | + | WorkspaceBase: m.WorkspaceBase, | |||
| 728 | + | WorkspaceCapture: m.WorkspaceCapture, | |||
| 729 | + | MaxTurns: maxTurns, | |||
| 730 | + | Timeout: timeout, | |||
| 731 | + | Attempt: m.Attempt, | |||
| 732 | + | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | |||
| 733 | + | Tools: append([]string(nil), m.Tools...), | |||
| 734 | + | InboxPath: m.InboxPath, | |||
| 735 | + | EventLogPath: m.EventLogPath, | |||
| 736 | + | SessionPath: m.SessionPath, | |||
| 737 | + | Resuming: resuming, | |||
| 738 | + | inbox: NewInbox(m.InboxPath), | |||
| 739 | + | status: StatusPending, | |||
| 740 | + | activity: "resuming", | |||
| 741 | + | processState: ProcessPending, | |||
| 742 | + | turnState: TurnQueued, | |||
| 743 | + | updatedAt: now, | |||
| 744 | + | lastActivity: now, | |||
| 745 | + | currentTurnID: m.CurrentTurnID, | |||
| 746 | + | stateDir: stateDir, | |||
| 747 | + | lease: lease, | |||
| 748 | + | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |||
| 749 | + | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |||
| 750 | + | done: make(chan struct{}), | |||
| 751 | + | turnResults: make(chan *TurnResult, 16), | |||
| 752 | + | } | |||
| 753 | + | // Carry the previous transcript forward so the dashboard doesn't | |||
| 754 | + | // flash empty between resume and the first new event. | |||
| 755 | + | prev := existing.Transcript() | |||
| 756 | + | if len(prev) > 0 { | |||
| 757 | + | a.appendTranscript(strings.Join(prev, "\n")) | |||
| 758 | + | } | |||
| 759 | + | a.ctx, a.cancel = runCtx, cancel | |||
| 760 | + | a.persistFn = f.persistAgent | |||
| 761 | + | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | |||
| 762 | + | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | |||
| 763 | + | a.runner = f.cfg.NewRunner(a) | |||
| 764 | + | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | |||
| 765 | + | if a.cancel != nil { | |||
| 766 | + | a.cancel() | |||
| 767 | + | } | |||
| 768 | + | _ = workspace.Cleanup(context.Background()) | |||
| 769 | + | return nil, fmt.Errorf("subagents resume metadata: %w", err) | |||
| 770 | + | } | |||
| 771 | + | ||||
| 772 | + | f.mu.Lock() | |||
| 773 | + | f.agents[a.ID] = a | |||
| 774 | + | f.queue = append(f.queue, a) | |||
| 775 | + | // Keep the agent's slot in f.order; replacing in-place avoids | |||
| 776 | + | // reshuffling the dashboard's row ordering on resume. | |||
| 777 | + | found := false | |||
| 778 | + | for _, k := range f.order { | |||
| 779 | + | if k == a.ID { | |||
| 780 | + | found = true | |||
| 781 | + | break | |||
| 782 | + | } | |||
| 783 | + | } | |||
| 784 | + | if !found { | |||
| 785 | + | f.order = append(f.order, a.ID) | |||
| 786 | + | } | |||
| 787 | + | f.mu.Unlock() | |||
| 788 | + | leaseOwned = false | |||
| 789 | + | ||||
| 790 | + | // Refreshing metadata happened before queue admission, so a later | |||
| 791 | + | // supervisor can reconstruct this resumed attempt even if the runner | |||
| 792 | + | // has not reached its first event yet. | |||
| 793 | + | f.armQueueTimeout(a) | |||
| 794 | + | f.schedule() | |||
| 795 | + | return a, nil | |||
| 796 | + | } | |||
R packages/agent/swarm/persist_test.go → packages/agent/subagents/persist_test.go +328 -32 renamed, 69% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| @@ -64,7 +64,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) { | |||||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | // TestReloadRebuildsDetachedAgents simulates a zot restart by spawning | 66 | // TestReloadRebuildsDetachedAgents simulates a zot restart by spawning | ||
| 67 | - | // in one Swarm, throwing it away, then opening a fresh Swarm against | 67 | + | // in one Supervisor, throwing it away, then opening a fresh Supervisor against |
| 68 | // the same root and calling Reload. The user-visible state — id, | 68 | // the same root and calling Reload. The user-visible state — id, | ||
| 69 | // task, branch, dir — must come back, and status must be Detached so | 69 | // task, branch, dir — must come back, and status must be Detached so | ||
| 70 | // the dashboard can offer resume. | 70 | // the dashboard can offer resume. | ||
| @@ -133,6 +133,89 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { | |||||
| 133 | } | 133 | } | ||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | + | func TestResumePreservesPerAgentTimeout(t *testing.T) { | |||
| 137 | + | root := t.TempDir() | |||
| 138 | + | wantTimeout := 37 * time.Minute | |||
| 139 | + | first := New(Config{ | |||
| 140 | + | Root: root, RepoRoot: root, | |||
| 141 | + | NewRunner: func(a *Agent) Runner { | |||
| 142 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 143 | + | <-ctx.Done() | |||
| 144 | + | return ctx.Err() | |||
| 145 | + | }) | |||
| 146 | + | }, | |||
| 147 | + | }) | |||
| 148 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "timeout test", Timeout: wantTimeout}) | |||
| 149 | + | if err != nil { | |||
| 150 | + | t.Fatal(err) | |||
| 151 | + | } | |||
| 152 | + | first.StopAll() | |||
| 153 | + | a.Wait() | |||
| 154 | + | ||||
| 155 | + | observedCh := make(chan time.Duration, 1) | |||
| 156 | + | second := New(Config{ | |||
| 157 | + | Root: root, RepoRoot: root, | |||
| 158 | + | Policy: SubagentPolicy{DefaultTimeout: time.Hour}, | |||
| 159 | + | NewRunner: func(a *Agent) Runner { | |||
| 160 | + | observedCh <- a.Timeout | |||
| 161 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 162 | + | <-ctx.Done() | |||
| 163 | + | return ctx.Err() | |||
| 164 | + | }) | |||
| 165 | + | }, | |||
| 166 | + | }) | |||
| 167 | + | if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 168 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 169 | + | } | |||
| 170 | + | resumed, err := second.ResumeSession(context.Background(), a.ID) | |||
| 171 | + | if err != nil { | |||
| 172 | + | t.Fatal(err) | |||
| 173 | + | } | |||
| 174 | + | var observed time.Duration | |||
| 175 | + | deadline := time.NewTimer(time.Second) | |||
| 176 | + | defer deadline.Stop() | |||
| 177 | + | select { | |||
| 178 | + | case observed = <-observedCh: | |||
| 179 | + | case <-deadline.C: | |||
| 180 | + | t.Fatal("timed out waiting for resumed runner to observe timeout") | |||
| 181 | + | } | |||
| 182 | + | if resumed.Timeout != wantTimeout || observed != wantTimeout { | |||
| 183 | + | t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout) | |||
| 184 | + | } | |||
| 185 | + | second.StopAll() | |||
| 186 | + | resumed.Wait() | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestReloadRejectsMetadataPathsOutsideState(t *testing.T) { | |||
| 190 | + | root := t.TempDir() | |||
| 191 | + | outside := filepath.Join(t.TempDir(), "outside.json") | |||
| 192 | + | if err := os.WriteFile(outside, []byte("keep"), 0o600); err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | stateDir := filepath.Join(root, "agents", "agent-1") | |||
| 196 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 197 | + | t.Fatal(err) | |||
| 198 | + | } | |||
| 199 | + | meta := agentMeta{ | |||
| 200 | + | ID: "agent-1", Task: "malicious", Dir: root, | |||
| 201 | + | EventLogPath: outside, SessionPath: outside, InboxPath: outside, | |||
| 202 | + | } | |||
| 203 | + | data, err := json.Marshal(meta) | |||
| 204 | + | if err != nil { | |||
| 205 | + | t.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), data, 0o600); err != nil { | |||
| 208 | + | t.Fatal(err) | |||
| 209 | + | } | |||
| 210 | + | f := New(Config{Root: root, RepoRoot: root}) | |||
| 211 | + | if loaded, errs := f.Reload(); loaded != 0 || len(errs) == 0 { | |||
| 212 | + | t.Fatalf("malicious metadata reload loaded=%d errs=%v", loaded, errs) | |||
| 213 | + | } | |||
| 214 | + | if got, err := os.ReadFile(outside); err != nil || string(got) != "keep" { | |||
| 215 | + | t.Fatalf("outside path was touched: data=%q err=%v", got, err) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 136 | // TestReloadIsIdempotent calls Reload twice in a row and asserts the | 219 | // TestReloadIsIdempotent calls Reload twice in a row and asserts the | ||
| 137 | // second call neither duplicates rows nor errors. | 220 | // second call neither duplicates rows nor errors. | ||
| 138 | func TestReloadIsIdempotent(t *testing.T) { | 221 | func TestReloadIsIdempotent(t *testing.T) { | ||
| @@ -165,7 +248,7 @@ func TestReloadIsIdempotent(t *testing.T) { | |||||
| 165 | // TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl | 248 | // TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl | ||
| 166 | // next to a meta.json and checks that the transcript surfaces in the | 249 | // next to a meta.json and checks that the transcript surfaces in the | ||
| 167 | // reloaded agent. This is the user-facing payoff of Reload: opening | 250 | // reloaded agent. This is the user-facing payoff of Reload: opening | ||
| 168 | - | // /swarm logs <id> after a restart shows what the agent said before. | 251 | + | // /subagents logs <id> after a restart shows what the agent said before. |
| 169 | func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | 252 | func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | ||
| 170 | root := t.TempDir() | 253 | root := t.TempDir() | ||
| 171 | id := "alpha-9" | 254 | id := "alpha-9" | ||
| @@ -308,6 +391,7 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||||
| 308 | if a.Status() != StatusKilled { | 391 | if a.Status() != StatusKilled { | ||
| 309 | t.Fatalf("post-stop status = %q; want killed", a.Status()) | 392 | t.Fatalf("post-stop status = %q; want killed", a.Status()) | ||
| 310 | } | 393 | } | ||
| 394 | + | previousAttempt := a.Snapshot().Attempt | |||
| 311 | 395 | ||||
| 312 | // Now resume. | 396 | // Now resume. | ||
| 313 | a2, err := f.Resume(context.Background(), a.ID) | 397 | a2, err := f.Resume(context.Background(), a.ID) | ||
| @@ -326,6 +410,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||||
| 326 | if a2.InboxPath != originalInbox { | 410 | if a2.InboxPath != originalInbox { | ||
| 327 | t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox) | 411 | t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox) | ||
| 328 | } | 412 | } | ||
| 413 | + | if got := a2.Snapshot().Attempt; got != previousAttempt+1 { | |||
| 414 | + | t.Errorf("resume attempt = %d; want %d", got, previousAttempt+1) | |||
| 415 | + | } | |||
| 329 | // Two runner invocations: spawn + resume. | 416 | // Two runner invocations: spawn + resume. | ||
| 330 | deadline = time.Now().Add(time.Second) | 417 | deadline = time.Now().Add(time.Second) | ||
| 331 | for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) { | 418 | for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) { | ||
| @@ -344,9 +431,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||||
| 344 | } | 431 | } | ||
| 345 | } | 432 | } | ||
| 346 | 433 | ||||
| 347 | - | func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) { | 434 | + | func TestResumeRecalculatesInvalidStateInboxPath(t *testing.T) { |
| 348 | if runtime.GOOS == "windows" { | 435 | if runtime.GOOS == "windows" { | ||
| 349 | - | t.Skip("swarm inbox runtime relocation applies to Unix sockets") | 436 | + | t.Skip("subagent inbox runtime relocation applies to Unix sockets") |
| 350 | } | 437 | } | ||
| 351 | root := t.TempDir() | 438 | root := t.TempDir() | ||
| 352 | f := New(Config{ | 439 | f := New(Config{ | ||
| @@ -355,21 +442,21 @@ func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) { | |||||
| 355 | return RunnerFunc(func(context.Context, Sink) error { return nil }) | 442 | return RunnerFunc(func(context.Context, Sink) error { return nil }) | ||
| 356 | }, | 443 | }, | ||
| 357 | }) | 444 | }) | ||
| 358 | - | a, err := f.Spawn(context.Background(), "legacy inbox") | 445 | + | a, err := f.Spawn(context.Background(), "invalid inbox") |
| 359 | if err != nil { | 446 | if err != nil { | ||
| 360 | t.Fatal(err) | 447 | t.Fatal(err) | ||
| 361 | } | 448 | } | ||
| 362 | a.Wait() | 449 | a.Wait() | ||
| 363 | 450 | ||||
| 364 | - | legacyPath := filepath.Join(root, "agents", a.ID, "in.sock") | 451 | + | invalidPath := filepath.Join(root, "agents", a.ID, "in.sock") |
| 365 | - | a.InboxPath = legacyPath | 452 | + | a.InboxPath = invalidPath |
| 366 | a2, err := f.Resume(context.Background(), a.ID) | 453 | a2, err := f.Resume(context.Background(), a.ID) | ||
| 367 | if err != nil { | 454 | if err != nil { | ||
| 368 | t.Fatalf("resume: %v", err) | 455 | t.Fatalf("resume: %v", err) | ||
| 369 | } | 456 | } | ||
| 370 | a2.Wait() | 457 | a2.Wait() | ||
| 371 | - | if a2.InboxPath == legacyPath { | 458 | + | if a2.InboxPath == invalidPath { |
| 372 | - | t.Fatalf("resume kept legacy state-directory inbox path %q", legacyPath) | 459 | + | t.Fatalf("resume kept state-directory inbox path %q", invalidPath) |
| 373 | } | 460 | } | ||
| 374 | if strings.HasPrefix(a2.InboxPath, root+string(filepath.Separator)) { | 461 | if strings.HasPrefix(a2.InboxPath, root+string(filepath.Separator)) { | ||
| 375 | t.Fatalf("resumed inbox %q remains below state root %q", a2.InboxPath, root) | 462 | t.Fatalf("resumed inbox %q remains below state root %q", a2.InboxPath, root) | ||
| @@ -478,7 +565,7 @@ func TestResumeAfterReload(t *testing.T) { | |||||
| 478 | a.StopAll() | 565 | a.StopAll() | ||
| 479 | ag.Wait() | 566 | ag.Wait() | ||
| 480 | 567 | ||||
| 481 | - | // Process B: a new Swarm against the same root. | 568 | + | // Process B: a new Supervisor against the same root. |
| 482 | resumed := make(chan struct{}, 1) | 569 | resumed := make(chan struct{}, 1) | ||
| 483 | b := New(Config{ | 570 | b := New(Config{ | ||
| 484 | Root: root, RepoRoot: root, | 571 | Root: root, RepoRoot: root, | ||
| @@ -525,6 +612,183 @@ func TestResumeAfterReload(t *testing.T) { | |||||
| 525 | t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript()) | 612 | t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript()) | ||
| 526 | } | 613 | } | ||
| 527 | 614 | ||||
| 615 | + | func TestStopMustFinishBeforeResumeOrRemove(t *testing.T) { | |||
| 616 | + | root := t.TempDir() | |||
| 617 | + | started := make(chan struct{}) | |||
| 618 | + | release := make(chan struct{}) | |||
| 619 | + | f := New(Config{ | |||
| 620 | + | Root: root, RepoRoot: root, | |||
| 621 | + | NewRunner: func(*Agent) Runner { | |||
| 622 | + | return RunnerFunc(func(context.Context, Sink) error { | |||
| 623 | + | close(started) | |||
| 624 | + | <-release | |||
| 625 | + | return nil | |||
| 626 | + | }) | |||
| 627 | + | }, | |||
| 628 | + | }) | |||
| 629 | + | a, err := f.Spawn(context.Background(), "slow stop") | |||
| 630 | + | if err != nil { | |||
| 631 | + | t.Fatal(err) | |||
| 632 | + | } | |||
| 633 | + | <-started | |||
| 634 | + | if err := f.Stop(a.ID); err != nil { | |||
| 635 | + | t.Fatal(err) | |||
| 636 | + | } | |||
| 637 | + | if _, err := f.Resume(context.Background(), a.ID); err == nil || !strings.Contains(err.Error(), "finalizing") { | |||
| 638 | + | t.Fatalf("immediate resume error = %v, want finalizing", err) | |||
| 639 | + | } | |||
| 640 | + | if err := f.Remove(a.ID); err == nil || !strings.Contains(err.Error(), "finalizing") { | |||
| 641 | + | t.Fatalf("immediate remove error = %v, want finalizing", err) | |||
| 642 | + | } | |||
| 643 | + | close(release) | |||
| 644 | + | a.Wait() | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | func TestResumeFencesLiveReloadedWorker(t *testing.T) { | |||
| 648 | + | if runtime.GOOS == "windows" { | |||
| 649 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 650 | + | } | |||
| 651 | + | root := t.TempDir() | |||
| 652 | + | first := New(Config{ | |||
| 653 | + | Root: root, RepoRoot: root, | |||
| 654 | + | NewRunner: func(*Agent) Runner { | |||
| 655 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 656 | + | }, | |||
| 657 | + | }) | |||
| 658 | + | a, err := first.Spawn(context.Background(), "live worker") | |||
| 659 | + | if err != nil { | |||
| 660 | + | t.Fatal(err) | |||
| 661 | + | } | |||
| 662 | + | a.Wait() | |||
| 663 | + | ln, err := Listen(a.InboxPath) | |||
| 664 | + | if err != nil { | |||
| 665 | + | t.Fatal(err) | |||
| 666 | + | } | |||
| 667 | + | defer ln.Close() | |||
| 668 | + | ||||
| 669 | + | second := New(Config{Root: root, RepoRoot: root}) | |||
| 670 | + | if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 671 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 672 | + | } | |||
| 673 | + | if got := second.Get(a.ID).ProcessState(); got != ProcessAlive { | |||
| 674 | + | t.Fatalf("reloaded process state = %s, want alive", got) | |||
| 675 | + | } | |||
| 676 | + | if _, err := second.Resume(context.Background(), a.ID); err == nil || !strings.Contains(err.Error(), "live worker") { | |||
| 677 | + | t.Fatalf("resume error = %v, want live-worker fence", err) | |||
| 678 | + | } | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | func TestSupervisorProviderSettingsPersistAcrossReloadAndResume(t *testing.T) { | |||
| 682 | + | root := t.TempDir() | |||
| 683 | + | newSupervisor := func(baseURL string, insecureTLS bool) *Supervisor { | |||
| 684 | + | return New(Config{ | |||
| 685 | + | Root: root, | |||
| 686 | + | RepoRoot: root, | |||
| 687 | + | BaseURL: baseURL, | |||
| 688 | + | InsecureTLS: insecureTLS, | |||
| 689 | + | NewRunner: func(a *Agent) Runner { | |||
| 690 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 691 | + | <-ctx.Done() | |||
| 692 | + | return ctx.Err() | |||
| 693 | + | }) | |||
| 694 | + | }, | |||
| 695 | + | }) | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | first := newSupervisor("https://parent.example.test/v1", true) | |||
| 699 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | |||
| 700 | + | if err != nil { | |||
| 701 | + | t.Fatalf("spawn: %v", err) | |||
| 702 | + | } | |||
| 703 | + | if a.BaseURL != "https://parent.example.test/v1" || !a.InsecureTLS { | |||
| 704 | + | t.Fatalf("spawned settings = (%q, %v), want inherited config", a.BaseURL, a.InsecureTLS) | |||
| 705 | + | } | |||
| 706 | + | if snap := a.Snapshot(); snap.BaseURL != a.BaseURL || snap.InsecureTLS != a.InsecureTLS { | |||
| 707 | + | t.Fatalf("snapshot settings = (%q, %v), want (%q, %v)", snap.BaseURL, snap.InsecureTLS, a.BaseURL, a.InsecureTLS) | |||
| 708 | + | } | |||
| 709 | + | if err := first.Stop(a.ID); err != nil { | |||
| 710 | + | t.Fatalf("stop: %v", err) | |||
| 711 | + | } | |||
| 712 | + | a.Wait() | |||
| 713 | + | ||||
| 714 | + | metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json")) | |||
| 715 | + | if err != nil { | |||
| 716 | + | t.Fatalf("read meta.json: %v", err) | |||
| 717 | + | } | |||
| 718 | + | var meta agentMeta | |||
| 719 | + | if err := json.Unmarshal(metaBytes, &meta); err != nil { | |||
| 720 | + | t.Fatalf("decode meta.json: %v", err) | |||
| 721 | + | } | |||
| 722 | + | if meta.BaseURL != a.BaseURL || !meta.InsecureTLS { | |||
| 723 | + | t.Fatalf("metadata settings = (%q, %v), want (%q, %v)", meta.BaseURL, meta.InsecureTLS, a.BaseURL, a.InsecureTLS) | |||
| 724 | + | } | |||
| 725 | + | if strings.Contains(string(metaBytes), "api_key") || strings.Contains(string(metaBytes), "parent-secret") { | |||
| 726 | + | t.Fatalf("metadata contains credential material: %s", metaBytes) | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | second := newSupervisor("https://different.example.test/v1", false) | |||
| 730 | + | if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 731 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 732 | + | } | |||
| 733 | + | reloaded := second.Get(a.ID) | |||
| 734 | + | if reloaded == nil || reloaded.BaseURL != a.BaseURL || !reloaded.InsecureTLS { | |||
| 735 | + | t.Fatalf("reloaded settings = (%q, %v), want (%q, %v)", reloaded.BaseURL, reloaded.InsecureTLS, a.BaseURL, a.InsecureTLS) | |||
| 736 | + | } | |||
| 737 | + | resumed, err := second.Resume(context.Background(), a.ID) | |||
| 738 | + | if err != nil { | |||
| 739 | + | t.Fatalf("resume: %v", err) | |||
| 740 | + | } | |||
| 741 | + | if resumed.BaseURL != a.BaseURL || !resumed.InsecureTLS { | |||
| 742 | + | t.Fatalf("resumed settings = (%q, %v), want (%q, %v)", resumed.BaseURL, resumed.InsecureTLS, a.BaseURL, a.InsecureTLS) | |||
| 743 | + | } | |||
| 744 | + | if err := second.Stop(resumed.ID); err != nil { | |||
| 745 | + | t.Fatalf("stop resumed: %v", err) | |||
| 746 | + | } | |||
| 747 | + | resumed.Wait() | |||
| 748 | + | } | |||
| 749 | + | ||||
| 750 | + | func TestExplicitChildProviderDoesNotInheritHostEndpoint(t *testing.T) { | |||
| 751 | + | f := New(Config{ | |||
| 752 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), Provider: "anthropic", | |||
| 753 | + | BaseURL: "https://host.example.test/v1", InsecureTLS: true, | |||
| 754 | + | NewRunner: func(*Agent) Runner { | |||
| 755 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 756 | + | }, | |||
| 757 | + | }) | |||
| 758 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x", Provider: "openai"}) | |||
| 759 | + | if err != nil { | |||
| 760 | + | t.Fatal(err) | |||
| 761 | + | } | |||
| 762 | + | a.Wait() | |||
| 763 | + | if a.BaseURL != "" || a.InsecureTLS { | |||
| 764 | + | t.Fatalf("explicit provider inherited host settings: (%q, %v)", a.BaseURL, a.InsecureTLS) | |||
| 765 | + | } | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | func TestSpawnRequestProviderSettingsOverrideConfig(t *testing.T) { | |||
| 769 | + | root := t.TempDir() | |||
| 770 | + | f := New(Config{ | |||
| 771 | + | Root: root, | |||
| 772 | + | RepoRoot: root, | |||
| 773 | + | BaseURL: "https://config.example.test/v1", | |||
| 774 | + | NewRunner: func(*Agent) Runner { | |||
| 775 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 776 | + | }, | |||
| 777 | + | }) | |||
| 778 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | |||
| 779 | + | Task: "x", | |||
| 780 | + | BaseURL: "https://request.example.test/v1", | |||
| 781 | + | InsecureTLS: true, | |||
| 782 | + | }) | |||
| 783 | + | if err != nil { | |||
| 784 | + | t.Fatalf("spawn: %v", err) | |||
| 785 | + | } | |||
| 786 | + | a.Wait() | |||
| 787 | + | if a.BaseURL != "https://request.example.test/v1" || !a.InsecureTLS { | |||
| 788 | + | t.Fatalf("spawned settings = (%q, %v), want request override", a.BaseURL, a.InsecureTLS) | |||
| 789 | + | } | |||
| 790 | + | } | |||
| 791 | + | ||||
| 528 | // TestSpawnReqPersistsModel confirms that the per-agent model | 792 | // TestSpawnReqPersistsModel confirms that the per-agent model | ||
| 529 | // override is captured at Spawn time, surfaced via Snapshot, and | 793 | // override is captured at Spawn time, surfaced via Snapshot, and | ||
| 530 | // written to meta.json so a later Reload + Resume reuses it. | 794 | // written to meta.json so a later Reload + Resume reuses it. | ||
| @@ -570,7 +834,7 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 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) | 834 | 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) | ||
| 571 | } | 835 | } | ||
| 572 | 836 | ||||
| 573 | - | // Reload in a fresh Swarm and confirm the detached agent still | 837 | + | // Reload in a fresh Supervisor and confirm the detached agent still |
| 574 | // carries the model/provider so Resume can route the child | 838 | // carries the model/provider so Resume can route the child | ||
| 575 | // subprocess back to the same model. | 839 | // subprocess back to the same model. | ||
| 576 | g := New(Config{ | 840 | g := New(Config{ | ||
| @@ -603,12 +867,12 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 603 | resumed.Wait() | 867 | resumed.Wait() | ||
| 604 | } | 868 | } | ||
| 605 | 869 | ||||
| 606 | - | // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that | 870 | + | // TestSubagentWorkerArgsIncludesModelFlags pins the argv contract that |
| 607 | // connects the supervisor's per-agent model override to the child's | 871 | // connects the supervisor's per-agent model override to the child's | ||
| 608 | // --model / --provider flag set. Adding a new path to argv without | 872 | // --model / --provider flag set. Adding a new path to argv without | ||
| 609 | // updating this assertion is the failure mode this catches. | 873 | // updating this assertion is the failure mode this catches. | ||
| 610 | - | func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) { | 874 | + | func TestSubagentWorkerArgsIncludesModelFlags(t *testing.T) { |
| 611 | - | args := swarmAgentArgs(swarmAgentArgsOpts{ | 875 | + | args := subagentWorkerArgs(subagentWorkerArgsOpts{ |
| 612 | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | 876 | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | ||
| 613 | Task: "do x", Model: "gpt-5", Provider: "openai", | 877 | Task: "do x", Model: "gpt-5", Provider: "openai", | ||
| 614 | }) | 878 | }) | ||
| @@ -634,14 +898,14 @@ func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) { | |||||
| 634 | 898 | ||||
| 635 | // TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the | 899 | // TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the | ||
| 636 | // segfault that hit when the user pressed 'k' on a reloaded | 900 | // segfault that hit when the user pressed 'k' on a reloaded | ||
| 637 | - | // (detached) agent: Swarm.Stop unconditionally called a.cancel(), | 901 | + | // (detached) agent: Supervisor.Stop unconditionally called a.cancel(), |
| 638 | // but buildDetachedAgent never assigns a cancel func because there's | 902 | // but buildDetachedAgent never assigns a cancel func because there's | ||
| 639 | // no in-process runner to cancel. The fix is to short-circuit Stop | 903 | // no in-process runner to cancel. The fix is to short-circuit Stop | ||
| 640 | // for StatusDetached and — belt-and-braces — nil-check a.cancel. | 904 | // for StatusDetached and — belt-and-braces — nil-check a.cancel. | ||
| 641 | func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | 905 | func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | ||
| 642 | root := t.TempDir() | 906 | root := t.TempDir() | ||
| 643 | // Build a detached agent the same way Reload does: drop a | 907 | // Build a detached agent the same way Reload does: drop a | ||
| 644 | - | // meta.json on disk, then ask a fresh Swarm to pick it up. | 908 | + | // meta.json on disk, then ask a fresh Supervisor to pick it up. |
| 645 | id := "alpha-1" | 909 | id := "alpha-1" | ||
| 646 | stateDir := filepath.Join(root, "agents", id) | 910 | stateDir := filepath.Join(root, "agents", id) | ||
| 647 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | 911 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | ||
| @@ -666,7 +930,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | |||||
| 666 | } | 930 | } | ||
| 667 | a := f.Get(id) | 931 | a := f.Get(id) | ||
| 668 | if a == nil { | 932 | if a == nil { | ||
| 669 | - | t.Fatal("detached agent missing from swarm") | 933 | + | t.Fatal("detached agent missing from subagent") |
| 670 | } | 934 | } | ||
| 671 | if a.Status() != StatusDetached { | 935 | if a.Status() != StatusDetached { | ||
| 672 | t.Fatalf("setup: agent status = %q; want detached", a.Status()) | 936 | t.Fatalf("setup: agent status = %q; want detached", a.Status()) | ||
| @@ -720,15 +984,46 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { | |||||
| 720 | t.Fatalf("state dir should be gone after remove; got %v", err) | 984 | t.Fatalf("state dir should be gone after remove; got %v", err) | ||
| 721 | } | 985 | } | ||
| 722 | 986 | ||||
| 723 | - | // A fresh Swarm + Reload should find nothing. | 987 | + | // A fresh Supervisor + Reload should find nothing. |
| 724 | g := New(Config{Root: root, RepoRoot: root}) | 988 | g := New(Config{Root: root, RepoRoot: root}) | ||
| 725 | if loaded, _ := g.Reload(); loaded != 0 { | 989 | if loaded, _ := g.Reload(); loaded != 0 { | ||
| 726 | t.Fatalf("reload after remove loaded=%d; want 0", loaded) | 990 | t.Fatalf("reload after remove loaded=%d; want 0", loaded) | ||
| 727 | } | 991 | } | ||
| 728 | } | 992 | } | ||
| 729 | 993 | ||||
| 994 | + | func TestRemoveReloadedAgentKeepsSiblingState(t *testing.T) { | |||
| 995 | + | root := t.TempDir() | |||
| 996 | + | first := New(Config{ | |||
| 997 | + | Root: root, RepoRoot: root, | |||
| 998 | + | NewRunner: func(*Agent) Runner { | |||
| 999 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 1000 | + | }, | |||
| 1001 | + | }) | |||
| 1002 | + | a1, err := first.Spawn(context.Background(), "first") | |||
| 1003 | + | if err != nil { | |||
| 1004 | + | t.Fatal(err) | |||
| 1005 | + | } | |||
| 1006 | + | a2, err := first.Spawn(context.Background(), "second") | |||
| 1007 | + | if err != nil { | |||
| 1008 | + | t.Fatal(err) | |||
| 1009 | + | } | |||
| 1010 | + | a1.Wait() | |||
| 1011 | + | a2.Wait() | |||
| 1012 | + | ||||
| 1013 | + | second := New(Config{Root: root, RepoRoot: root}) | |||
| 1014 | + | if loaded, errs := second.Reload(); loaded != 2 || len(errs) != 0 { | |||
| 1015 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 1016 | + | } | |||
| 1017 | + | if err := second.Remove(a1.ID); err != nil { | |||
| 1018 | + | t.Fatal(err) | |||
| 1019 | + | } | |||
| 1020 | + | if _, err := os.Stat(filepath.Join(root, "agents", a2.ID, "meta.json")); err != nil { | |||
| 1021 | + | t.Fatalf("removing reloaded sibling deleted surviving metadata: %v", err) | |||
| 1022 | + | } | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 730 | // TestActiveSessionScopesSnapshotAll proves the session filter the | 1025 | // TestActiveSessionScopesSnapshotAll proves the session filter the | ||
| 731 | - | // user asked for: a single Swarm with agents spawned under two | 1026 | + | // user asked for: a single Supervisor with agents spawned under two |
| 732 | // different active sessions only surfaces the agents matching the | 1027 | // different active sessions only surfaces the agents matching the | ||
| 733 | // currently-active session via SnapshotAll. Switching the active | 1028 | // currently-active session via SnapshotAll. Switching the active | ||
| 734 | // session re-narrows the view without touching agent state. | 1029 | // session re-narrows the view without touching agent state. | ||
| @@ -790,14 +1085,14 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |||||
| 790 | } | 1085 | } | ||
| 791 | 1086 | ||||
| 792 | // TestSessionIDPersistsAcrossReload confirms the SessionID field | 1087 | // TestSessionIDPersistsAcrossReload confirms the SessionID field | ||
| 793 | - | // survives a Swarm restart: an agent spawned under session A in one | 1088 | + | // survives a Supervisor restart: an agent spawned under session A in one |
| 794 | - | // Swarm instance is still SessionID="sess-A" after a fresh New + | 1089 | + | // Supervisor instance is still SessionID="sess-A" after a fresh New + |
| 795 | // Reload reads it back from meta.json. Without persistence, the | 1090 | // Reload reads it back from meta.json. Without persistence, the | ||
| 796 | // scope filter would forget which session owned each agent after | 1091 | // scope filter would forget which session owned each agent after | ||
| 797 | // a zot restart and the dashboard would leak everything again. | 1092 | // a zot restart and the dashboard would leak everything again. | ||
| 798 | func TestSessionIDPersistsAcrossReload(t *testing.T) { | 1093 | func TestSessionIDPersistsAcrossReload(t *testing.T) { | ||
| 799 | root := t.TempDir() | 1094 | root := t.TempDir() | ||
| 800 | - | mkSwarm := func() *Swarm { | 1095 | + | mkSupervisor := func() *Supervisor { |
| 801 | return New(Config{ | 1096 | return New(Config{ | ||
| 802 | Root: root, RepoRoot: root, | 1097 | Root: root, RepoRoot: root, | ||
| 803 | NewRunner: func(a *Agent) Runner { | 1098 | NewRunner: func(a *Agent) Runner { | ||
| @@ -806,7 +1101,7 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||||
| 806 | }) | 1101 | }) | ||
| 807 | } | 1102 | } | ||
| 808 | 1103 | ||||
| 809 | - | f := mkSwarm() | 1104 | + | f := mkSupervisor() |
| 810 | f.SetActiveSession("sess-keep") | 1105 | f.SetActiveSession("sess-keep") | ||
| 811 | a, err := f.Spawn(context.Background(), "persist me") | 1106 | a, err := f.Spawn(context.Background(), "persist me") | ||
| 812 | if err != nil { | 1107 | if err != nil { | ||
| @@ -815,8 +1110,8 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||||
| 815 | _ = f.Stop(a.ID) | 1110 | _ = f.Stop(a.ID) | ||
| 816 | a.Wait() | 1111 | a.Wait() | ||
| 817 | 1112 | ||||
| 818 | - | // Fresh Swarm, same root, reload from disk. | 1113 | + | // Fresh Supervisor, same root, reload from disk. |
| 819 | - | g := mkSwarm() | 1114 | + | g := mkSupervisor() |
| 820 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | 1115 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | ||
| 821 | t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs) | 1116 | t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs) | ||
| 822 | } | 1117 | } | ||
| @@ -826,14 +1121,15 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||||
| 826 | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | 1121 | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | ||
| 827 | } | 1122 | } | ||
| 828 | 1123 | ||||
| 829 | - | // Scope to a different session: agent must be hidden. | 1124 | + | // A terminal persisted agent remains visible from another host session |
| 1125 | + | // so a normal restart does not hide durable recovery state. | |||
| 830 | g.SetActiveSession("sess-other") | 1126 | g.SetActiveSession("sess-other") | ||
| 831 | - | if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 { | 1127 | + | if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID { |
| 832 | - | t.Errorf("scoped to other session, ids = %v; want []", got) | 1128 | + | t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID) |
| 833 | } | 1129 | } | ||
| 834 | } | 1130 | } | ||
| 835 | 1131 | ||||
| 836 | - | // TestEmptySessionIDIsVisibleFromAnyScope pins the backward-compat | 1132 | + | // TestEmptySessionIDIsVisibleFromAnyScope pins the unscoped-agent |
| 837 | // rule: agents whose meta.json predates the SessionID field (or who | 1133 | // rule: agents whose meta.json predates the SessionID field (or who | ||
| 838 | // were spawned without an active session, e.g. via a test rig or | 1134 | // were spawned without an active session, e.g. via a test rig or | ||
| 839 | // scripted caller) carry SessionID == "" and remain visible from | 1135 | // scripted caller) carry SessionID == "" and remain visible from | ||
| @@ -848,7 +1144,7 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||||
| 848 | }, | 1144 | }, | ||
| 849 | }) | 1145 | }) | ||
| 850 | // No SetActiveSession call → agent spawned with empty SessionID. | 1146 | // No SetActiveSession call → agent spawned with empty SessionID. | ||
| 851 | - | a, err := f.Spawn(context.Background(), "legacy") | 1147 | + | a, err := f.Spawn(context.Background(), "unscoped") |
| 852 | if err != nil { | 1148 | if err != nil { | ||
| 853 | t.Fatal(err) | 1149 | t.Fatal(err) | ||
| 854 | } | 1150 | } | ||
| @@ -860,7 +1156,7 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||||
| 860 | f.SetActiveSession(scope) | 1156 | f.SetActiveSession(scope) | ||
| 861 | got := snapshotIDs(f.SnapshotAll()) | 1157 | got := snapshotIDs(f.SnapshotAll()) | ||
| 862 | if len(got) != 1 || got[0] != a.ID { | 1158 | if len(got) != 1 || got[0] != a.ID { | ||
| 863 | - | t.Errorf("scope=%q: ids = %v; want legacy agent visible", scope, got) | 1159 | + | t.Errorf("scope=%q: ids = %v; want unscoped agent visible", scope, got) |
| 864 | } | 1160 | } | ||
| 865 | } | 1161 | } | ||
| 866 | 1162 | ||||
A packages/agent/subagents/process_unix.go +69 -0
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | //go:build !windows | |||
| 2 | + | ||||
| 3 | + | package subagents | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "io" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "sync" | |||
| 9 | + | "syscall" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // configureWorkerProcess makes cancellation target the worker's process | |||
| 14 | + | // group rather than only the worker itself. CommandContext calls cmd.Cancel | |||
| 15 | + | // from its context watcher, so descendants that inherited the worker's group | |||
| 16 | + | // are terminated before execRunner waits for their output pipes to close. | |||
| 17 | + | func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) { | |||
| 18 | + | setProcessGroup(cmd) | |||
| 19 | + | cmd.WaitDelay = workerProcessWaitDelay | |||
| 20 | + | var closePipes sync.Once | |||
| 21 | + | cmd.Cancel = func() error { | |||
| 22 | + | err := killProcessGroup(cmd) | |||
| 23 | + | closePipes.Do(func() { closeWorkerPipes(pipes) }) | |||
| 24 | + | return err | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | const workerProcessWaitDelay = 5 * time.Second | |||
| 29 | + | ||||
| 30 | + | // closeWorkerPipes closes the parent-side readers returned by StdoutPipe and | |||
| 31 | + | // StderrPipe. Closing these descriptors lets the custom reader goroutines exit | |||
| 32 | + | // if a descendant has outlived the worker process. | |||
| 33 | + | func closeWorkerPipes(pipes []io.Closer) { | |||
| 34 | + | for _, pipe := range pipes { | |||
| 35 | + | if pipe != nil { | |||
| 36 | + | _ = pipe.Close() | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // setProcessGroup puts a worker in a process group of its own. This keeps a | |||
| 42 | + | // canceled worker from sharing a group with the supervising zot process. | |||
| 43 | + | func setProcessGroup(cmd *exec.Cmd) { | |||
| 44 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // killProcessGroup forcefully terminates the worker group. Cancellation is a | |||
| 48 | + | // forceful path: the supervisor already offers a graceful shutdown through | |||
| 49 | + | // the worker inbox, while SIGKILL ensures a descendant cannot keep the | |||
| 50 | + | // worker's stdout or stderr pipe open indefinitely. | |||
| 51 | + | func killProcessGroup(cmd *exec.Cmd) error { | |||
| 52 | + | if cmd == nil || cmd.Process == nil { | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | return forceKillProcess(cmd.Process.Pid) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func forceKillProcess(pid int) error { | |||
| 59 | + | if pid <= 0 { | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | // With Setpgid, the worker's PID is also the process-group ID. A negative | |||
| 63 | + | // PID addresses the whole group rather than just the worker process. | |||
| 64 | + | err := syscall.Kill(-pid, syscall.SIGKILL) | |||
| 65 | + | if err == syscall.ESRCH { | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | return err | |||
| 69 | + | } | |||
A packages/agent/subagents/process_unix_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | //go:build !windows | |||
| 2 | + | ||||
| 3 | + | package subagents | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "syscall" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestWorkerCancellationKillsProcessGroup(t *testing.T) { | |||
| 20 | + | shell, err := exec.LookPath("sh") | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Skip("sh is not available") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | pidPath := t.TempDir() + "/child.pid" | |||
| 26 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 27 | + | defer cancel() | |||
| 28 | + | ||||
| 29 | + | cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath)) | |||
| 30 | + | configureWorkerProcess(cmd) | |||
| 31 | + | if cmd.WaitDelay != workerProcessWaitDelay { | |||
| 32 | + | t.Fatalf("WaitDelay = %s, want %s", cmd.WaitDelay, workerProcessWaitDelay) | |||
| 33 | + | } | |||
| 34 | + | if err := cmd.Start(); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | childPID := waitForWorkerChildPID(t, pidPath) | |||
| 39 | + | cancel() | |||
| 40 | + | ||||
| 41 | + | waitDone := make(chan error, 1) | |||
| 42 | + | go func() { waitDone <- cmd.Wait() }() | |||
| 43 | + | select { | |||
| 44 | + | case <-time.After(2 * time.Second): | |||
| 45 | + | t.Fatal("worker did not exit after context cancellation") | |||
| 46 | + | case err := <-waitDone: | |||
| 47 | + | if err == nil { | |||
| 48 | + | t.Fatal("worker exited successfully after context cancellation") | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 53 | + | for time.Now().Before(deadline) && processExists(childPID) { | |||
| 54 | + | time.Sleep(10 * time.Millisecond) | |||
| 55 | + | } | |||
| 56 | + | if processExists(childPID) { | |||
| 57 | + | t.Fatalf("worker descendant pid %d survived process-group cancellation", childPID) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestWorkerCancellationClosesOutputPipes(t *testing.T) { | |||
| 62 | + | cmd := exec.Command("sh") | |||
| 63 | + | stdout, err := cmd.StdoutPipe() | |||
| 64 | + | if err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | stderr, err := cmd.StderrPipe() | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | defer stdout.Close() | |||
| 72 | + | defer stderr.Close() | |||
| 73 | + | ||||
| 74 | + | configureWorkerProcess(cmd, stdout, stderr) | |||
| 75 | + | if err := cmd.Cancel(); err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} { | |||
| 79 | + | data := make([]byte, 1) | |||
| 80 | + | if _, err := pipe.Read(data); !errors.Is(err, os.ErrClosed) { | |||
| 81 | + | t.Errorf("%s read error = %v, want os.ErrClosed", name, err) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func waitForWorkerChildPID(t *testing.T, path string) int { | |||
| 87 | + | t.Helper() | |||
| 88 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 89 | + | for time.Now().Before(deadline) { | |||
| 90 | + | data, err := os.ReadFile(path) | |||
| 91 | + | if err == nil { | |||
| 92 | + | pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data))) | |||
| 93 | + | if parseErr == nil && pid > 0 { | |||
| 94 | + | return pid | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | time.Sleep(10 * time.Millisecond) | |||
| 98 | + | } | |||
| 99 | + | t.Fatalf("timed out waiting for child pid in %s", path) | |||
| 100 | + | return 0 | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func processExists(pid int) bool { | |||
| 104 | + | err := syscall.Kill(pid, 0) | |||
| 105 | + | return err == nil || errors.Is(err, syscall.EPERM) | |||
| 106 | + | } | |||
A packages/agent/subagents/process_windows.go +99 -0
| @@ -0,0 +1,99 @@ | |||||
| 1 | + | //go:build windows | |||
| 2 | + | ||||
| 3 | + | package subagents | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "os/exec" | |||
| 11 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 13 | + | "sync" | |||
| 14 | + | "syscall" | |||
| 15 | + | "time" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // configureWorkerProcess makes CommandContext cancellation terminate the | |||
| 19 | + | // worker and its descendants on Windows. Windows does not provide a Unix-like | |||
| 20 | + | // process-group kill operation, so killProcessGroup uses taskkill's tree mode. | |||
| 21 | + | func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) { | |||
| 22 | + | setProcessGroup(cmd) | |||
| 23 | + | cmd.WaitDelay = workerProcessWaitDelay | |||
| 24 | + | var closePipes sync.Once | |||
| 25 | + | cmd.Cancel = func() error { | |||
| 26 | + | err := killProcessGroup(cmd) | |||
| 27 | + | closePipes.Do(func() { closeWorkerPipes(pipes) }) | |||
| 28 | + | return err | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func closeWorkerPipes(pipes []io.Closer) { | |||
| 33 | + | for _, pipe := range pipes { | |||
| 34 | + | if pipe != nil { | |||
| 35 | + | _ = pipe.Close() | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | const workerProcessWaitDelay = 5 * time.Second | |||
| 41 | + | ||||
| 42 | + | // setProcessGroup gives the worker its own Windows process group. The group | |||
| 43 | + | // flag prevents it from being attached to the supervisor's console group; | |||
| 44 | + | // taskkill /T below is still needed because Windows group creation alone does | |||
| 45 | + | // not terminate descendants. | |||
| 46 | + | func setProcessGroup(cmd *exec.Cmd) { | |||
| 47 | + | cmd.SysProcAttr = &syscall.SysProcAttr{ | |||
| 48 | + | CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // killProcessGroup terminates the worker process tree. taskkill is part of | |||
| 53 | + | // Windows and /T follows the parent-child relationships to include workers | |||
| 54 | + | // that spawned their own subprocesses. | |||
| 55 | + | func killProcessGroup(cmd *exec.Cmd) error { | |||
| 56 | + | if cmd == nil || cmd.Process == nil { | |||
| 57 | + | return nil | |||
| 58 | + | } | |||
| 59 | + | err := forceKillProcess(cmd.Process.Pid) | |||
| 60 | + | var taskkillErr *taskkillError | |||
| 61 | + | if errors.As(err, &taskkillErr) && taskkillErr.processDone() { | |||
| 62 | + | return os.ErrProcessDone | |||
| 63 | + | } | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func forceKillProcess(pid int) error { | |||
| 68 | + | if pid <= 0 { | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | output, err := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").CombinedOutput() | |||
| 72 | + | if err == nil { | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | return &taskkillError{pid: pid, err: err, diagnostics: string(output)} | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | type taskkillError struct { | |||
| 79 | + | pid int | |||
| 80 | + | err error | |||
| 81 | + | diagnostics string | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (e *taskkillError) Error() string { | |||
| 85 | + | if diagnostics := strings.TrimSpace(e.diagnostics); diagnostics != "" { | |||
| 86 | + | return fmt.Sprintf("taskkill process tree for PID %d: %v: %s", e.pid, e.err, diagnostics) | |||
| 87 | + | } | |||
| 88 | + | return fmt.Sprintf("taskkill process tree for PID %d: %v", e.pid, e.err) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (e *taskkillError) Unwrap() error { return e.err } | |||
| 92 | + | ||||
| 93 | + | // taskkill returns exit code 128 when the requested process no longer exists. | |||
| 94 | + | // Other taskkill failures remain errors so callers can distinguish a | |||
| 95 | + | // cancellation race from a real process-control failure. | |||
| 96 | + | func (e *taskkillError) processDone() bool { | |||
| 97 | + | var exitErr *exec.ExitError | |||
| 98 | + | return errors.As(e.err, &exitErr) && exitErr.ExitCode() == 128 | |||
| 99 | + | } | |||
M packages/agent/subagents/profiles.go +2 -2
| @@ -1,5 +1,5 @@ | |||||
| 1 | // Package subagents discovers named agent profiles that can be selected by | 1 | // Package subagents discovers named agent profiles that can be selected by | ||
| 2 | - | // zot's swarm supervisor. Profiles use the common markdown/frontmatter layout: | 2 | + | // zot's subagent supervisor. Profiles use the common markdown/frontmatter layout: |
| 3 | // a YAML-like metadata block followed by the agent's system prompt. | 3 | // a YAML-like metadata block followed by the agent's system prompt. | ||
| 4 | // | 4 | // | ||
| 5 | // Discovery prefers explicitly configured directories, then the shared | 5 | // Discovery prefers explicitly configured directories, then the shared | ||
| @@ -132,7 +132,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 132 | } | 132 | } | ||
| 133 | var sb strings.Builder | 133 | var sb strings.Builder | ||
| 134 | sb.WriteString("[subagents_list]\n") | 134 | sb.WriteString("[subagents_list]\n") | ||
| 135 | - | 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, tool limits, and fast-mode preference are applied to the child.\n") | 135 | + | sb.WriteString("Named subagents available to subagent_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, tool limits, and fast-mode preference are applied to the child.\n") |
| 136 | for _, profile := range profiles { | 136 | for _, profile := range profiles { | ||
| 137 | if profile == nil { | 137 | if profile == nil { | ||
| 138 | continue | 138 | continue | ||
A packages/agent/subagents/protocol.go +629 -0
| @@ -0,0 +1,629 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/google/uuid" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // ProtocolVersion is the version of the supervisor/worker JSONL protocol. | |||
| 17 | + | const ProtocolVersion = 1 | |||
| 18 | + | ||||
| 19 | + | // Canonical parent-to-worker command names. | |||
| 20 | + | const ( | |||
| 21 | + | CommandAgentPing = "agent.ping" | |||
| 22 | + | CommandTurnStart = "turn.start" | |||
| 23 | + | CommandTurnCancel = "turn.cancel" | |||
| 24 | + | CommandAgentShutdown = "agent.shutdown" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // Canonical worker-to-parent event names. | |||
| 28 | + | const ( | |||
| 29 | + | EventAgentReady = "agent.ready" | |||
| 30 | + | EventAgentHeartbeat = "agent.heartbeat" | |||
| 31 | + | EventTurnStarted = "turn.started" | |||
| 32 | + | EventTurnProgress = "turn.progress" | |||
| 33 | + | EventToolStarted = "tool.started" | |||
| 34 | + | EventToolFinished = "tool.finished" | |||
| 35 | + | EventMessageDelta = "message.delta" | |||
| 36 | + | EventTurnResult = "turn.result" | |||
| 37 | + | EventTurnFailed = "turn.failed" | |||
| 38 | + | EventAgentIdle = "agent.idle" | |||
| 39 | + | EventAgentExited = "agent.exited" | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | var ( | |||
| 43 | + | // ErrEmptyProtocolLine is returned for a blank JSONL line or command. | |||
| 44 | + | ErrEmptyProtocolLine = errors.New("subagent protocol: empty line") | |||
| 45 | + | // ErrNotCommand is returned when ParseCommand is given a JSON event. | |||
| 46 | + | ErrNotCommand = errors.New("subagent protocol: message is not a command") | |||
| 47 | + | // ErrNotEvent is returned when ParseEvent is given a JSON command. | |||
| 48 | + | ErrNotEvent = errors.New("subagent protocol: message is not an event") | |||
| 49 | + | // ErrUnsupportedProtocolVersion is returned by Validate for a version | |||
| 50 | + | // this implementation cannot interpret. | |||
| 51 | + | ErrUnsupportedProtocolVersion = errors.New("subagent protocol: unsupported version") | |||
| 52 | + | ) | |||
| 53 | + | ||||
| 54 | + | var commandNames = map[string]struct{}{ | |||
| 55 | + | CommandAgentPing: {}, | |||
| 56 | + | CommandTurnStart: {}, | |||
| 57 | + | CommandTurnCancel: {}, | |||
| 58 | + | CommandAgentShutdown: {}, | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | var eventNames = map[string]struct{}{ | |||
| 62 | + | EventAgentReady: {}, | |||
| 63 | + | EventAgentHeartbeat: {}, | |||
| 64 | + | EventTurnStarted: {}, | |||
| 65 | + | EventTurnProgress: {}, | |||
| 66 | + | EventToolStarted: {}, | |||
| 67 | + | EventToolFinished: {}, | |||
| 68 | + | EventMessageDelta: {}, | |||
| 69 | + | EventTurnResult: {}, | |||
| 70 | + | EventTurnFailed: {}, | |||
| 71 | + | EventAgentIdle: {}, | |||
| 72 | + | EventAgentExited: {}, | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // IsCommandName reports whether name is one of the commands defined by this | |||
| 76 | + | // version of the protocol. Readers intentionally do not use this as a parse | |||
| 77 | + | // gate: a newer command can still be decoded and forwarded by an older | |||
| 78 | + | // supervisor. | |||
| 79 | + | func IsCommandName(name string) bool { | |||
| 80 | + | _, ok := commandNames[name] | |||
| 81 | + | return ok | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // IsEventName reports whether name is one of the events defined by this | |||
| 85 | + | // version of the protocol. Unknown event names are valid protocol messages. | |||
| 86 | + | func IsEventName(name string) bool { | |||
| 87 | + | _, ok := eventNames[name] | |||
| 88 | + | return ok | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // MessageID returns a fresh opaque id suitable for correlating a command and | |||
| 92 | + | // its result. UUIDs are used rather than a counter so ids remain unique when | |||
| 93 | + | // several supervisors or workers are running at once. | |||
| 94 | + | func NewMessageID() string { return uuid.NewString() } | |||
| 95 | + | ||||
| 96 | + | // IsMessageID reports whether id has the UUID form emitted by NewMessageID. | |||
| 97 | + | // Message ids are otherwise opaque on the wire, so readers do not require | |||
| 98 | + | // this form when accepting messages from a newer implementation. | |||
| 99 | + | func IsMessageID(id string) bool { | |||
| 100 | + | if id == "" { | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | _, err := uuid.Parse(id) | |||
| 104 | + | return err == nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Envelope is one versioned JSONL command or event. Payload is deliberately a | |||
| 108 | + | // RawMessage: an old supervisor can retain an event it does not know about, | |||
| 109 | + | // including fields added inside that event's payload, and write it back | |||
| 110 | + | // without decoding it into a lossy map. | |||
| 111 | + | // | |||
| 112 | + | // The JSON representation of a current message is: | |||
| 113 | + | // | |||
| 114 | + | // {"version":1,"type":"...","message_id":"...","agent_id":"...", | |||
| 115 | + | // "turn_id":"...","timestamp":"...","payload":{...}} | |||
| 116 | + | // | |||
| 117 | + | // TurnID is omitted when it is empty. Unknown top-level fields are retained in | |||
| 118 | + | // Unknown. | |||
| 119 | + | type Envelope struct { | |||
| 120 | + | Version int `json:"version"` | |||
| 121 | + | Type string `json:"type"` | |||
| 122 | + | MessageID string `json:"message_id,omitempty"` | |||
| 123 | + | AgentID string `json:"agent_id,omitempty"` | |||
| 124 | + | TurnID string `json:"turn_id,omitempty"` | |||
| 125 | + | Timestamp time.Time `json:"timestamp"` | |||
| 126 | + | Payload json.RawMessage `json:"payload"` | |||
| 127 | + | ||||
| 128 | + | // Unknown contains unrecognised top-level fields from a versioned | |||
| 129 | + | // envelope. Payload itself retains unrecognised payload fields. | |||
| 130 | + | Unknown map[string]json.RawMessage `json:"-"` | |||
| 131 | + | ||||
| 132 | + | // constructionErr records a payload-construction failure for the | |||
| 133 | + | // compatibility-preserving value constructor. It is surfaced by Validate | |||
| 134 | + | // and MarshalJSON rather than being turned into a valid null payload. | |||
| 135 | + | constructionErr error | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // NewEnvelope creates a current-protocol envelope and assigns a message id | |||
| 139 | + | // and timestamp. payload may be any JSON-marshalable value. A nil payload is | |||
| 140 | + | // encoded as an empty object, which keeps command and event payloads uniform. | |||
| 141 | + | // | |||
| 142 | + | // For a marshalable payload this helper cannot fail. Call | |||
| 143 | + | // NewEnvelopeWithPayload when construction errors must be reported instead of | |||
| 144 | + | // being represented as a null payload. | |||
| 145 | + | func NewEnvelope(typ, agentID, turnID string, payload ...any) Envelope { | |||
| 146 | + | e, err := NewEnvelopeWithPayload(typ, agentID, turnID, firstPayload(payload)) | |||
| 147 | + | if err == nil { | |||
| 148 | + | return e | |||
| 149 | + | } | |||
| 150 | + | // Preserve the value-returning API used by command/event call sites, but | |||
| 151 | + | // retain the construction failure so validation and wire encoding fail | |||
| 152 | + | // instead of silently sending a null payload. | |||
| 153 | + | return Envelope{ | |||
| 154 | + | Version: ProtocolVersion, | |||
| 155 | + | Type: typ, | |||
| 156 | + | MessageID: NewMessageID(), | |||
| 157 | + | AgentID: agentID, | |||
| 158 | + | TurnID: turnID, | |||
| 159 | + | Timestamp: time.Now().UTC(), | |||
| 160 | + | Payload: nil, | |||
| 161 | + | constructionErr: fmt.Errorf("subagent protocol payload: %w", err), | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // NewEnvelopeWithPayload is the checked form of NewEnvelope. | |||
| 166 | + | func NewEnvelopeWithPayload(typ, agentID, turnID string, payload any) (Envelope, error) { | |||
| 167 | + | if strings.TrimSpace(typ) == "" { | |||
| 168 | + | return Envelope{}, errors.New("subagent protocol: empty message type") | |||
| 169 | + | } | |||
| 170 | + | p, err := marshalPayload(payload) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return Envelope{}, fmt.Errorf("subagent protocol payload: %w", err) | |||
| 173 | + | } | |||
| 174 | + | return Envelope{ | |||
| 175 | + | Version: ProtocolVersion, | |||
| 176 | + | Type: typ, | |||
| 177 | + | MessageID: NewMessageID(), | |||
| 178 | + | AgentID: agentID, | |||
| 179 | + | TurnID: turnID, | |||
| 180 | + | Timestamp: time.Now().UTC(), | |||
| 181 | + | Payload: p, | |||
| 182 | + | }, nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // NewCommand creates a current-protocol command. Unknown command names are | |||
| 186 | + | // allowed so a bridge can forward commands introduced by a newer peer. | |||
| 187 | + | func NewCommand(name, agentID, turnID string, payload ...any) Envelope { | |||
| 188 | + | return NewEnvelope(name, agentID, turnID, payload...) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // NewEventEnvelope creates a current-protocol event. The name is not checked | |||
| 192 | + | // against the known event registry so unknown future events remain forward-compatible. | |||
| 193 | + | func NewEventEnvelope(name, agentID, turnID string, payload ...any) Envelope { | |||
| 194 | + | return NewEnvelope(name, agentID, turnID, payload...) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func firstPayload(payload []any) any { | |||
| 198 | + | if len(payload) == 0 || payload[0] == nil { | |||
| 199 | + | return map[string]any{} | |||
| 200 | + | } | |||
| 201 | + | return payload[0] | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func marshalPayload(payload any) (json.RawMessage, error) { | |||
| 205 | + | if payload == nil { | |||
| 206 | + | return json.RawMessage("{}"), nil | |||
| 207 | + | } | |||
| 208 | + | b, err := json.Marshal(payload) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return nil, err | |||
| 211 | + | } | |||
| 212 | + | return append(json.RawMessage(nil), b...), nil | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // Validate checks the required fields of a current-protocol envelope. It is | |||
| 216 | + | // intentionally separate from ParseEnvelope: parsing an unknown future event | |||
| 217 | + | // should succeed, while a sender can opt into strict validation before write. | |||
| 218 | + | func (e Envelope) Validate() error { | |||
| 219 | + | if e.constructionErr != nil { | |||
| 220 | + | return e.constructionErr | |||
| 221 | + | } | |||
| 222 | + | if e.Version != ProtocolVersion { | |||
| 223 | + | return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version) | |||
| 224 | + | } | |||
| 225 | + | if e.Type == "" { | |||
| 226 | + | return errors.New("subagent protocol: missing type") | |||
| 227 | + | } | |||
| 228 | + | if e.MessageID == "" { | |||
| 229 | + | return errors.New("subagent protocol: missing message_id") | |||
| 230 | + | } | |||
| 231 | + | if e.AgentID == "" { | |||
| 232 | + | return errors.New("subagent protocol: missing agent_id") | |||
| 233 | + | } | |||
| 234 | + | if e.Timestamp.IsZero() { | |||
| 235 | + | return errors.New("subagent protocol: missing timestamp") | |||
| 236 | + | } | |||
| 237 | + | if len(e.Payload) == 0 || !json.Valid(e.Payload) { | |||
| 238 | + | return errors.New("subagent protocol: invalid payload") | |||
| 239 | + | } | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // IsCommand reports whether this envelope has a known command name. | |||
| 244 | + | func (e Envelope) IsCommand() bool { return IsCommandName(e.Type) } | |||
| 245 | + | ||||
| 246 | + | // IsEvent reports whether this envelope has a known event name. | |||
| 247 | + | func (e Envelope) IsEvent() bool { return IsEventName(e.Type) } | |||
| 248 | + | ||||
| 249 | + | // DecodePayload decodes the payload into dst without changing the preserved | |||
| 250 | + | // raw payload. This is the preferred way for a consumer to inspect a known | |||
| 251 | + | // event while still retaining it for a later forward-compatible write. | |||
| 252 | + | func (e Envelope) DecodePayload(dst any) error { | |||
| 253 | + | if dst == nil { | |||
| 254 | + | return errors.New("subagent protocol: nil payload destination") | |||
| 255 | + | } | |||
| 256 | + | if len(e.Payload) == 0 { | |||
| 257 | + | return errors.New("subagent protocol: missing payload") | |||
| 258 | + | } | |||
| 259 | + | return json.Unmarshal(e.Payload, dst) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // PayloadFields returns an object payload as raw fields. It returns an error | |||
| 263 | + | // for scalar or array payloads, rather than silently discarding their shape. | |||
| 264 | + | func (e Envelope) PayloadFields() (map[string]json.RawMessage, error) { | |||
| 265 | + | var fields map[string]json.RawMessage | |||
| 266 | + | if err := e.DecodePayload(&fields); err != nil { | |||
| 267 | + | return nil, err | |||
| 268 | + | } | |||
| 269 | + | if fields == nil { | |||
| 270 | + | return nil, errors.New("subagent protocol: payload is not an object") | |||
| 271 | + | } | |||
| 272 | + | return fields, nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // PayloadValue returns a decoded generic payload. PayloadFields is preferable | |||
| 276 | + | // when the caller needs to preserve exact nested JSON values. | |||
| 277 | + | func (e Envelope) PayloadValue() (any, error) { | |||
| 278 | + | var value any | |||
| 279 | + | if err := e.DecodePayload(&value); err != nil { | |||
| 280 | + | return nil, err | |||
| 281 | + | } | |||
| 282 | + | return value, nil | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // SetPayload replaces the payload while keeping the envelope metadata and | |||
| 286 | + | // message id intact. | |||
| 287 | + | func (e *Envelope) SetPayload(payload any) error { | |||
| 288 | + | if e == nil { | |||
| 289 | + | return errors.New("subagent protocol: nil envelope") | |||
| 290 | + | } | |||
| 291 | + | p, err := marshalPayload(payload) | |||
| 292 | + | if err != nil { | |||
| 293 | + | return fmt.Errorf("subagent protocol payload: %w", err) | |||
| 294 | + | } | |||
| 295 | + | e.Payload = p | |||
| 296 | + | return nil | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // ParseEnvelope parses one current-protocol envelope. Unknown event types and | |||
| 300 | + | // unknown payload fields are valid and retained. | |||
| 301 | + | func ParseEnvelope(data []byte) (Envelope, error) { | |||
| 302 | + | data = bytes.TrimSpace(data) | |||
| 303 | + | if len(data) == 0 { | |||
| 304 | + | return Envelope{}, ErrEmptyProtocolLine | |||
| 305 | + | } | |||
| 306 | + | var raw map[string]json.RawMessage | |||
| 307 | + | if err := json.Unmarshal(data, &raw); err != nil { | |||
| 308 | + | return Envelope{}, fmt.Errorf("subagent protocol: decode envelope: %w", err) | |||
| 309 | + | } | |||
| 310 | + | if raw == nil { | |||
| 311 | + | return Envelope{}, errors.New("subagent protocol: envelope must be an object") | |||
| 312 | + | } | |||
| 313 | + | var e Envelope | |||
| 314 | + | if err := unmarshalVersionedEnvelope(raw, &e); err != nil { | |||
| 315 | + | return Envelope{}, err | |||
| 316 | + | } | |||
| 317 | + | return e, nil | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // ParseJSONL parses one newline-delimited JSON message. It accepts a final | |||
| 321 | + | // line without a newline, as is customary for a stream after a clean EOF. | |||
| 322 | + | func ParseJSONL(line []byte) (Envelope, error) { return ParseEnvelope(line) } | |||
| 323 | + | ||||
| 324 | + | // ParseEvent parses a current-protocol JSON event. A known command is | |||
| 325 | + | // rejected; unknown types are accepted because an older peer cannot know | |||
| 326 | + | // whether a future event name is present in its event registry. | |||
| 327 | + | func ParseEvent(data []byte) (Envelope, error) { | |||
| 328 | + | e, err := ParseEnvelope(data) | |||
| 329 | + | if err != nil { | |||
| 330 | + | return Envelope{}, err | |||
| 331 | + | } | |||
| 332 | + | if e.IsCommand() { | |||
| 333 | + | return Envelope{}, ErrNotEvent | |||
| 334 | + | } | |||
| 335 | + | return e, nil | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // ParseCommand parses one current-protocol JSON command. | |||
| 339 | + | func ParseCommand(line string) (Envelope, error) { | |||
| 340 | + | raw := strings.TrimSpace(line) | |||
| 341 | + | if raw == "" { | |||
| 342 | + | return Envelope{}, ErrEmptyProtocolLine | |||
| 343 | + | } | |||
| 344 | + | e, err := ParseEnvelope([]byte(raw)) | |||
| 345 | + | if err != nil { | |||
| 346 | + | return Envelope{}, err | |||
| 347 | + | } | |||
| 348 | + | if IsEventName(e.Type) { | |||
| 349 | + | return Envelope{}, ErrNotCommand | |||
| 350 | + | } | |||
| 351 | + | return e, nil | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // MarshalEnvelope returns one JSON object without a trailing newline. | |||
| 355 | + | func MarshalEnvelope(e Envelope) ([]byte, error) { | |||
| 356 | + | return json.Marshal(e) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // MarshalJSONL returns one newline-terminated JSONL message. | |||
| 360 | + | func MarshalJSONL(e Envelope) ([]byte, error) { | |||
| 361 | + | b, err := MarshalEnvelope(e) | |||
| 362 | + | if err != nil { | |||
| 363 | + | return nil, err | |||
| 364 | + | } | |||
| 365 | + | return append(b, '\n'), nil | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | // WriteEnvelope writes one complete newline-terminated JSONL message. | |||
| 369 | + | func WriteEnvelope(w io.Writer, e Envelope) error { | |||
| 370 | + | if w == nil { | |||
| 371 | + | return errors.New("subagent protocol: nil writer") | |||
| 372 | + | } | |||
| 373 | + | b, err := MarshalJSONL(e) | |||
| 374 | + | if err != nil { | |||
| 375 | + | return err | |||
| 376 | + | } | |||
| 377 | + | n, err := w.Write(b) | |||
| 378 | + | if err == nil && n != len(b) { | |||
| 379 | + | return io.ErrShortWrite | |||
| 380 | + | } | |||
| 381 | + | return err | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // ReadEnvelope reads exactly one JSONL message from a buffered reader. A | |||
| 385 | + | // caller reading many messages should reuse the same bufio.Reader. | |||
| 386 | + | func ReadEnvelope(r *bufio.Reader) (Envelope, error) { | |||
| 387 | + | if r == nil { | |||
| 388 | + | return Envelope{}, errors.New("subagent protocol: nil reader") | |||
| 389 | + | } | |||
| 390 | + | line, err := r.ReadBytes('\n') | |||
| 391 | + | if len(bytes.TrimSpace(line)) == 0 && err == io.EOF { | |||
| 392 | + | return Envelope{}, io.EOF | |||
| 393 | + | } | |||
| 394 | + | if len(line) != 0 { | |||
| 395 | + | parsed, parseErr := ParseJSONL(line) | |||
| 396 | + | if parseErr != nil { | |||
| 397 | + | return Envelope{}, parseErr | |||
| 398 | + | } | |||
| 399 | + | return parsed, nil | |||
| 400 | + | } | |||
| 401 | + | return Envelope{}, err | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | func (e Envelope) MarshalJSON() ([]byte, error) { | |||
| 405 | + | return e.marshalVersionedJSON() | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func (e Envelope) marshalVersionedJSON() ([]byte, error) { | |||
| 409 | + | if e.constructionErr != nil { | |||
| 410 | + | return nil, e.constructionErr | |||
| 411 | + | } | |||
| 412 | + | version := e.Version | |||
| 413 | + | if version == 0 { | |||
| 414 | + | version = ProtocolVersion | |||
| 415 | + | } | |||
| 416 | + | messageID := e.MessageID | |||
| 417 | + | if messageID == "" { | |||
| 418 | + | messageID = NewMessageID() | |||
| 419 | + | } | |||
| 420 | + | timestamp := e.Timestamp | |||
| 421 | + | if timestamp.IsZero() { | |||
| 422 | + | timestamp = time.Now().UTC() | |||
| 423 | + | } | |||
| 424 | + | payload := e.Payload | |||
| 425 | + | if len(payload) == 0 { | |||
| 426 | + | payload = json.RawMessage("{}") | |||
| 427 | + | } | |||
| 428 | + | if !json.Valid(payload) { | |||
| 429 | + | return nil, errors.New("subagent protocol: invalid payload JSON") | |||
| 430 | + | } | |||
| 431 | + | out := make(map[string]json.RawMessage, len(e.Unknown)+7) | |||
| 432 | + | for key, value := range e.Unknown { | |||
| 433 | + | if isEnvelopeField(key) { | |||
| 434 | + | continue | |||
| 435 | + | } | |||
| 436 | + | out[key] = cloneRaw(value) | |||
| 437 | + | } | |||
| 438 | + | putRaw(out, "version", version) | |||
| 439 | + | putRaw(out, "type", e.Type) | |||
| 440 | + | putRaw(out, "message_id", messageID) | |||
| 441 | + | putRaw(out, "agent_id", e.AgentID) | |||
| 442 | + | if e.TurnID != "" { | |||
| 443 | + | putRaw(out, "turn_id", e.TurnID) | |||
| 444 | + | } | |||
| 445 | + | putRaw(out, "timestamp", timestamp) | |||
| 446 | + | out["payload"] = cloneRaw(payload) | |||
| 447 | + | return json.Marshal(out) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func (e *Envelope) UnmarshalJSON(data []byte) error { | |||
| 451 | + | var raw map[string]json.RawMessage | |||
| 452 | + | if err := json.Unmarshal(data, &raw); err != nil { | |||
| 453 | + | return err | |||
| 454 | + | } | |||
| 455 | + | if raw == nil { | |||
| 456 | + | return errors.New("subagent protocol: envelope must be an object") | |||
| 457 | + | } | |||
| 458 | + | var parsed Envelope | |||
| 459 | + | if err := unmarshalVersionedEnvelope(raw, &parsed); err != nil { | |||
| 460 | + | return err | |||
| 461 | + | } | |||
| 462 | + | *e = parsed | |||
| 463 | + | return nil | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func unmarshalVersionedEnvelope(raw map[string]json.RawMessage, e *Envelope) error { | |||
| 467 | + | if err := decodeRequired(raw, "version", &e.Version); err != nil { | |||
| 468 | + | return err | |||
| 469 | + | } | |||
| 470 | + | if e.Version != ProtocolVersion { | |||
| 471 | + | return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version) | |||
| 472 | + | } | |||
| 473 | + | if err := decodeRequired(raw, "type", &e.Type); err != nil { | |||
| 474 | + | return err | |||
| 475 | + | } | |||
| 476 | + | if err := decodeOptionalString(raw, "message_id", &e.MessageID); err != nil { | |||
| 477 | + | return err | |||
| 478 | + | } | |||
| 479 | + | if err := decodeOptionalString(raw, "agent_id", &e.AgentID); err != nil { | |||
| 480 | + | return err | |||
| 481 | + | } | |||
| 482 | + | if err := decodeOptionalString(raw, "turn_id", &e.TurnID); err != nil { | |||
| 483 | + | return err | |||
| 484 | + | } | |||
| 485 | + | if timestamp, ok := raw["timestamp"]; ok { | |||
| 486 | + | if err := json.Unmarshal(timestamp, &e.Timestamp); err != nil { | |||
| 487 | + | return fmt.Errorf("subagent protocol: timestamp: %w", err) | |||
| 488 | + | } | |||
| 489 | + | } | |||
| 490 | + | if payload, ok := raw["payload"]; ok { | |||
| 491 | + | e.Payload = cloneRaw(payload) | |||
| 492 | + | } else { | |||
| 493 | + | e.Payload = json.RawMessage("{}") | |||
| 494 | + | } | |||
| 495 | + | e.Unknown = unknownFields(raw, map[string]struct{}{ | |||
| 496 | + | "version": {}, "type": {}, "message_id": {}, "agent_id": {}, | |||
| 497 | + | "turn_id": {}, "timestamp": {}, "payload": {}, | |||
| 498 | + | }) | |||
| 499 | + | return nil | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func decodeRequired(raw map[string]json.RawMessage, name string, dst any) error { | |||
| 503 | + | value, ok := raw[name] | |||
| 504 | + | if !ok { | |||
| 505 | + | return fmt.Errorf("subagent protocol: missing %s", name) | |||
| 506 | + | } | |||
| 507 | + | if err := json.Unmarshal(value, dst); err != nil { | |||
| 508 | + | return fmt.Errorf("subagent protocol: %s: %w", name, err) | |||
| 509 | + | } | |||
| 510 | + | if name == "type" { | |||
| 511 | + | if text, ok := dst.(*string); ok && strings.TrimSpace(*text) == "" { | |||
| 512 | + | return errors.New("subagent protocol: empty message type") | |||
| 513 | + | } | |||
| 514 | + | } | |||
| 515 | + | return nil | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func decodeOptionalString(raw map[string]json.RawMessage, name string, dst *string) error { | |||
| 519 | + | value, ok := raw[name] | |||
| 520 | + | if !ok { | |||
| 521 | + | return nil | |||
| 522 | + | } | |||
| 523 | + | if err := json.Unmarshal(value, dst); err != nil { | |||
| 524 | + | return fmt.Errorf("subagent protocol: %s: %w", name, err) | |||
| 525 | + | } | |||
| 526 | + | return nil | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func unknownFields(raw map[string]json.RawMessage, known map[string]struct{}) map[string]json.RawMessage { | |||
| 530 | + | var out map[string]json.RawMessage | |||
| 531 | + | for key, value := range raw { | |||
| 532 | + | if _, ok := known[key]; ok { | |||
| 533 | + | continue | |||
| 534 | + | } | |||
| 535 | + | if out == nil { | |||
| 536 | + | out = make(map[string]json.RawMessage) | |||
| 537 | + | } | |||
| 538 | + | out[key] = cloneRaw(value) | |||
| 539 | + | } | |||
| 540 | + | return out | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func isEnvelopeField(key string) bool { | |||
| 544 | + | switch key { | |||
| 545 | + | case "version", "type", "message_id", "agent_id", "turn_id", "timestamp", "payload": | |||
| 546 | + | return true | |||
| 547 | + | default: | |||
| 548 | + | return false | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func putRaw(out map[string]json.RawMessage, key string, value any) { | |||
| 553 | + | b, err := json.Marshal(value) | |||
| 554 | + | if err == nil { | |||
| 555 | + | out[key] = b | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | func cloneRaw(value json.RawMessage) json.RawMessage { | |||
| 560 | + | return append(json.RawMessage(nil), value...) | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | // The following small payload types document the stable fields of the | |||
| 564 | + | // protocol without forcing a receiver to use them. Receivers can always use | |||
| 565 | + | // Envelope.Payload to retain fields added in later versions. | |||
| 566 | + | type AgentPingPayload struct{} | |||
| 567 | + | type AgentShutdownPayload struct{} | |||
| 568 | + | type AgentReadyPayload struct { | |||
| 569 | + | // Version is the worker/application version. WorkerVersion is the | |||
| 570 | + | // unambiguous spelling for new producers. | |||
| 571 | + | Version string `json:"version,omitempty"` | |||
| 572 | + | WorkerVersion string `json:"worker_version,omitempty"` | |||
| 573 | + | CWD string `json:"cwd,omitempty"` | |||
| 574 | + | Model string `json:"model,omitempty"` | |||
| 575 | + | Provider string `json:"provider,omitempty"` | |||
| 576 | + | } | |||
| 577 | + | type AgentHeartbeatPayload struct { | |||
| 578 | + | Activity string `json:"activity,omitempty"` | |||
| 579 | + | } | |||
| 580 | + | type TurnStartPayload struct { | |||
| 581 | + | Prompt string `json:"prompt"` | |||
| 582 | + | } | |||
| 583 | + | type TurnCancelPayload struct { | |||
| 584 | + | Reason string `json:"reason,omitempty"` | |||
| 585 | + | } | |||
| 586 | + | type TurnProgressPayload struct { | |||
| 587 | + | Text string `json:"text,omitempty"` | |||
| 588 | + | Percent float64 `json:"percent,omitempty"` | |||
| 589 | + | CurrentTool string `json:"current_tool,omitempty"` | |||
| 590 | + | } | |||
| 591 | + | type ToolStartedPayload struct { | |||
| 592 | + | ToolID string `json:"tool_id,omitempty"` | |||
| 593 | + | Name string `json:"name"` | |||
| 594 | + | } | |||
| 595 | + | type ToolFinishedPayload struct { | |||
| 596 | + | ToolID string `json:"tool_id,omitempty"` | |||
| 597 | + | Name string `json:"name,omitempty"` | |||
| 598 | + | Error string `json:"error,omitempty"` | |||
| 599 | + | } | |||
| 600 | + | type MessageDeltaPayload struct { | |||
| 601 | + | Text string `json:"text,omitempty"` | |||
| 602 | + | } | |||
| 603 | + | type ArtifactReference struct { | |||
| 604 | + | Name string `json:"name,omitempty"` | |||
| 605 | + | Ref string `json:"ref"` | |||
| 606 | + | Mime string `json:"mime,omitempty"` | |||
| 607 | + | Size int64 `json:"size,omitempty"` | |||
| 608 | + | } | |||
| 609 | + | type ProtocolError struct { | |||
| 610 | + | Code string `json:"code,omitempty"` | |||
| 611 | + | Message string `json:"message,omitempty"` | |||
| 612 | + | } | |||
| 613 | + | type TurnResultPayload struct { | |||
| 614 | + | Status string `json:"status"` | |||
| 615 | + | Summary string `json:"summary,omitempty"` | |||
| 616 | + | Output string `json:"output,omitempty"` | |||
| 617 | + | Structured json.RawMessage `json:"structured,omitempty"` | |||
| 618 | + | Artifacts []ArtifactReference `json:"artifacts,omitempty"` | |||
| 619 | + | ChangedFiles []string `json:"changed_files,omitempty"` | |||
| 620 | + | Usage map[string]any `json:"usage,omitempty"` | |||
| 621 | + | Error *ProtocolError `json:"error"` | |||
| 622 | + | } | |||
| 623 | + | type TurnFailedPayload struct { | |||
| 624 | + | Error *ProtocolError `json:"error,omitempty"` | |||
| 625 | + | } | |||
| 626 | + | type AgentExitedPayload struct { | |||
| 627 | + | Code int `json:"code,omitempty"` | |||
| 628 | + | Reason string `json:"reason,omitempty"` | |||
| 629 | + | } | |||
A packages/agent/subagents/protocol_test.go +285 -0
| @@ -0,0 +1,285 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestProtocolEnvelopeRoundTrip(t *testing.T) { | |||
| 14 | + | e := NewEventEnvelope(EventTurnResult, "agent-123", "turn-4", map[string]any{ | |||
| 15 | + | "status": "succeeded", | |||
| 16 | + | "output": "done", | |||
| 17 | + | }) | |||
| 18 | + | if e.Version != ProtocolVersion { | |||
| 19 | + | t.Fatalf("version = %d, want %d", e.Version, ProtocolVersion) | |||
| 20 | + | } | |||
| 21 | + | if e.MessageID == "" || !IsMessageID(e.MessageID) { | |||
| 22 | + | t.Fatalf("message id = %q, want a UUID", e.MessageID) | |||
| 23 | + | } | |||
| 24 | + | if e.Timestamp.IsZero() { | |||
| 25 | + | t.Fatal("constructor did not set timestamp") | |||
| 26 | + | } | |||
| 27 | + | if err := e.Validate(); err != nil { | |||
| 28 | + | t.Fatalf("Validate: %v", err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | line, err := MarshalJSONL(e) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatalf("MarshalJSONL: %v", err) | |||
| 34 | + | } | |||
| 35 | + | if !bytes.HasSuffix(line, []byte{'\n'}) { | |||
| 36 | + | t.Fatal("JSONL message is not newline terminated") | |||
| 37 | + | } | |||
| 38 | + | var wire map[string]any | |||
| 39 | + | if err := json.Unmarshal(bytes.TrimSpace(line), &wire); err != nil { | |||
| 40 | + | t.Fatalf("wire JSON: %v", err) | |||
| 41 | + | } | |||
| 42 | + | for _, field := range []string{"version", "type", "message_id", "agent_id", "turn_id", "timestamp", "payload"} { | |||
| 43 | + | if _, ok := wire[field]; !ok { | |||
| 44 | + | t.Errorf("wire message lacks %q", field) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | got, err := ParseJSONL(line) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatalf("ParseJSONL: %v", err) | |||
| 51 | + | } | |||
| 52 | + | if got.Version != e.Version || got.Type != e.Type || got.MessageID != e.MessageID || | |||
| 53 | + | got.AgentID != e.AgentID || got.TurnID != e.TurnID { | |||
| 54 | + | t.Fatalf("metadata changed on round trip: got %+v, want %+v", got, e) | |||
| 55 | + | } | |||
| 56 | + | var result TurnResultPayload | |||
| 57 | + | if err := got.DecodePayload(&result); err != nil { | |||
| 58 | + | t.Fatalf("DecodePayload: %v", err) | |||
| 59 | + | } | |||
| 60 | + | if result.Status != "succeeded" || result.Output != "done" { | |||
| 61 | + | t.Fatalf("decoded payload = %+v", result) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestProtocolPreservesUnknownEventAndPayload(t *testing.T) { | |||
| 66 | + | input := `{"version":1,"type":"future.event","message_id":"msg-future","agent_id":"agent-1","turn_id":"turn-1","timestamp":"2026-08-04T12:00:00Z","future_top":{"enabled":true},"payload":{"known":"yes","future":{"nested":[1,2,{"x":true}]}}}` | |||
| 67 | + | e, err := ParseEnvelope([]byte(input)) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatalf("ParseEnvelope: %v", err) | |||
| 70 | + | } | |||
| 71 | + | if e.Type != "future.event" { | |||
| 72 | + | t.Fatalf("type = %q", e.Type) | |||
| 73 | + | } | |||
| 74 | + | if e.IsEvent() { | |||
| 75 | + | t.Fatal("unknown event was reported as a known event") | |||
| 76 | + | } | |||
| 77 | + | if _, ok := e.Unknown["future_top"]; !ok { | |||
| 78 | + | t.Fatalf("unknown top-level field was not retained: %#v", e.Unknown) | |||
| 79 | + | } | |||
| 80 | + | fields, err := e.PayloadFields() | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatalf("PayloadFields: %v", err) | |||
| 83 | + | } | |||
| 84 | + | if string(fields["future"]) != `{"nested":[1,2,{"x":true}]}` { | |||
| 85 | + | t.Fatalf("unknown payload field changed: %s", fields["future"]) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | encoded, err := MarshalEnvelope(e) | |||
| 89 | + | if err != nil { | |||
| 90 | + | t.Fatalf("MarshalEnvelope: %v", err) | |||
| 91 | + | } | |||
| 92 | + | var roundTripped map[string]any | |||
| 93 | + | if err := json.Unmarshal(encoded, &roundTripped); err != nil { | |||
| 94 | + | t.Fatalf("round-trip JSON: %v", err) | |||
| 95 | + | } | |||
| 96 | + | if _, ok := roundTripped["future_top"]; !ok { | |||
| 97 | + | t.Fatal("unknown top-level field was dropped") | |||
| 98 | + | } | |||
| 99 | + | payload, ok := roundTripped["payload"].(map[string]any) | |||
| 100 | + | if !ok || payload["future"] == nil { | |||
| 101 | + | t.Fatalf("unknown payload was dropped: %#v", roundTripped["payload"]) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) { | |||
| 106 | + | command := NewCommand(CommandTurnStart, "agent-1", "turn-2", TurnStartPayload{Prompt: "hello"}) | |||
| 107 | + | line, err := MarshalJSONL(command) | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatalf("MarshalJSONL: %v", err) | |||
| 110 | + | } | |||
| 111 | + | got, err := ParseCommand(string(line)) | |||
| 112 | + | if err != nil { | |||
| 113 | + | t.Fatalf("ParseCommand: %v", err) | |||
| 114 | + | } | |||
| 115 | + | if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart { | |||
| 116 | + | t.Fatalf("direction = command:%v event:%v type:%q", got.IsCommand(), got.IsEvent(), got.Type) | |||
| 117 | + | } | |||
| 118 | + | if _, err := ParseEvent(line); !errors.Is(err, ErrNotEvent) { | |||
| 119 | + | t.Fatalf("ParseEvent(command) = %v, want ErrNotEvent", err) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | event := NewEventEnvelope(EventAgentHeartbeat, "agent-1", "", AgentHeartbeatPayload{Activity: "idle"}) | |||
| 123 | + | eventLine, err := MarshalJSONL(event) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatalf("Marshal event: %v", err) | |||
| 126 | + | } | |||
| 127 | + | if _, err := ParseCommand(string(eventLine)); !errors.Is(err, ErrNotCommand) { | |||
| 128 | + | t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestProtocolStreamReadWrite(t *testing.T) { | |||
| 133 | + | first := NewCommand(CommandAgentPing, "agent-1", "", nil) | |||
| 134 | + | second := NewEventEnvelope(EventAgentReady, "agent-1", "", nil) | |||
| 135 | + | var stream bytes.Buffer | |||
| 136 | + | if err := WriteEnvelope(&stream, first); err != nil { | |||
| 137 | + | t.Fatalf("write first: %v", err) | |||
| 138 | + | } | |||
| 139 | + | if err := WriteEnvelope(&stream, second); err != nil { | |||
| 140 | + | t.Fatalf("write second: %v", err) | |||
| 141 | + | } | |||
| 142 | + | reader := bufio.NewReader(&stream) | |||
| 143 | + | gotFirst, err := ReadEnvelope(reader) | |||
| 144 | + | if err != nil { | |||
| 145 | + | t.Fatalf("read first: %v", err) | |||
| 146 | + | } | |||
| 147 | + | gotSecond, err := ReadEnvelope(reader) | |||
| 148 | + | if err != nil { | |||
| 149 | + | t.Fatalf("read second: %v", err) | |||
| 150 | + | } | |||
| 151 | + | if gotFirst.Type != CommandAgentPing || gotSecond.Type != EventAgentReady { | |||
| 152 | + | t.Fatalf("stream types = %q, %q", gotFirst.Type, gotSecond.Type) | |||
| 153 | + | } | |||
| 154 | + | if _, err := ReadEnvelope(reader); !errors.Is(err, io.EOF) { | |||
| 155 | + | t.Fatalf("read after stream = %v, want EOF", err) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestProtocolMessageIDsAreUnique(t *testing.T) { | |||
| 160 | + | seen := make(map[string]struct{}, 100) | |||
| 161 | + | for i := 0; i < 100; i++ { | |||
| 162 | + | id := NewMessageID() | |||
| 163 | + | if !IsMessageID(id) { | |||
| 164 | + | t.Fatalf("NewMessageID returned non-UUID %q", id) | |||
| 165 | + | } | |||
| 166 | + | if _, exists := seen[id]; exists { | |||
| 167 | + | t.Fatalf("duplicate message id %q", id) | |||
| 168 | + | } | |||
| 169 | + | seen[id] = struct{}{} | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestProtocolPayloadMutationKeepsMessageID(t *testing.T) { | |||
| 174 | + | e := NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "before"}) | |||
| 175 | + | id := e.MessageID | |||
| 176 | + | if err := e.SetPayload(TurnStartPayload{Prompt: "after"}); err != nil { | |||
| 177 | + | t.Fatalf("SetPayload: %v", err) | |||
| 178 | + | } | |||
| 179 | + | if e.MessageID != id { | |||
| 180 | + | t.Fatalf("SetPayload changed message id from %q to %q", id, e.MessageID) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestProtocolUnsupportedVersion(t *testing.T) { | |||
| 185 | + | _, err := ParseEnvelope([]byte(`{"version":2,"type":"future.event","message_id":"m","agent_id":"a","timestamp":"2026-08-04T12:00:00Z","payload":{}}`)) | |||
| 186 | + | if !errors.Is(err, ErrUnsupportedProtocolVersion) { | |||
| 187 | + | t.Fatalf("ParseEnvelope version 2 = %v, want unsupported version", err) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestProtocolEmptyAndMalformedInputs(t *testing.T) { | |||
| 192 | + | for _, input := range []string{"", " ", "not a command"} { | |||
| 193 | + | if _, err := ParseCommand(input); err == nil { | |||
| 194 | + | t.Errorf("ParseCommand(%q) succeeded", input) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | if _, err := ParseEnvelope([]byte(`{"payload":{}}`)); err == nil { | |||
| 198 | + | t.Fatal("ParseEnvelope without type succeeded") | |||
| 199 | + | } | |||
| 200 | + | if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil { | |||
| 201 | + | t.Fatal("ParseEnvelope malformed JSON succeeded") | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | invalid := NewEnvelope("x", "agent-1", "turn-1", func() {}) | |||
| 205 | + | if err := invalid.Validate(); err == nil { | |||
| 206 | + | t.Fatal("NewEnvelope accepted an unmarshalable payload") | |||
| 207 | + | } | |||
| 208 | + | if _, err := MarshalEnvelope(invalid); err == nil { | |||
| 209 | + | t.Fatal("MarshalEnvelope accepted an unmarshalable payload") | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | zeroVersion := Envelope{ | |||
| 213 | + | Type: "x", | |||
| 214 | + | MessageID: "message-1", | |||
| 215 | + | AgentID: "agent-1", | |||
| 216 | + | Timestamp: time.Now().UTC(), | |||
| 217 | + | Payload: json.RawMessage(`{}`), | |||
| 218 | + | } | |||
| 219 | + | wire, err := MarshalEnvelope(zeroVersion) | |||
| 220 | + | if err != nil { | |||
| 221 | + | t.Fatalf("MarshalEnvelope zero version: %v", err) | |||
| 222 | + | } | |||
| 223 | + | var fields map[string]json.RawMessage | |||
| 224 | + | if err := json.Unmarshal(wire, &fields); err != nil { | |||
| 225 | + | t.Fatalf("decode zero-version envelope: %v", err) | |||
| 226 | + | } | |||
| 227 | + | var version int | |||
| 228 | + | if err := json.Unmarshal(fields["version"], &version); err != nil { | |||
| 229 | + | t.Fatalf("decode version: %v", err) | |||
| 230 | + | } | |||
| 231 | + | if version != ProtocolVersion { | |||
| 232 | + | t.Fatalf("version = %d, want %d", version, ProtocolVersion) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func TestProtocolResultShape(t *testing.T) { | |||
| 237 | + | result := TurnResultPayload{ | |||
| 238 | + | Status: "succeeded", | |||
| 239 | + | Summary: "implemented parser", | |||
| 240 | + | Output: "full output", | |||
| 241 | + | Structured: json.RawMessage(`{"ok":true}`), | |||
| 242 | + | Artifacts: []ArtifactReference{{Name: "patch", Ref: "subagent://a/patch"}}, | |||
| 243 | + | ChangedFiles: []string{"protocol.go"}, | |||
| 244 | + | Usage: map[string]any{"input_tokens": 3}, | |||
| 245 | + | } | |||
| 246 | + | e := NewEventEnvelope(EventTurnResult, "a", "t", result) | |||
| 247 | + | var decoded TurnResultPayload | |||
| 248 | + | if err := e.DecodePayload(&decoded); err != nil { | |||
| 249 | + | t.Fatalf("DecodePayload: %v", err) | |||
| 250 | + | } | |||
| 251 | + | if decoded.Status != "succeeded" || len(decoded.Artifacts) != 1 || decoded.Artifacts[0].Ref != "subagent://a/patch" { | |||
| 252 | + | t.Fatalf("decoded result = %+v", decoded) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func TestProtocolTimestampIsRFC3339(t *testing.T) { | |||
| 257 | + | e := NewEventEnvelope(EventAgentReady, "a", "", nil) | |||
| 258 | + | wire, err := MarshalEnvelope(e) | |||
| 259 | + | if err != nil { | |||
| 260 | + | t.Fatalf("MarshalEnvelope: %v", err) | |||
| 261 | + | } | |||
| 262 | + | var fields map[string]json.RawMessage | |||
| 263 | + | if err := json.Unmarshal(wire, &fields); err != nil { | |||
| 264 | + | t.Fatal(err) | |||
| 265 | + | } | |||
| 266 | + | var timestamp string | |||
| 267 | + | if err := json.Unmarshal(fields["timestamp"], ×tamp); err != nil { | |||
| 268 | + | t.Fatal(err) | |||
| 269 | + | } | |||
| 270 | + | if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil { | |||
| 271 | + | t.Fatalf("timestamp %q is not RFC3339Nano: %v", timestamp, err) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestProtocolKnownNames(t *testing.T) { | |||
| 276 | + | if !IsCommandName(CommandAgentPing) || !IsCommandName(CommandTurnStart) || !IsCommandName(CommandTurnCancel) || !IsCommandName(CommandAgentShutdown) { | |||
| 277 | + | t.Fatal("one or more canonical command names are not registered") | |||
| 278 | + | } | |||
| 279 | + | if !IsEventName(EventAgentReady) || !IsEventName(EventAgentHeartbeat) || !IsEventName(EventTurnStarted) || !IsEventName(EventTurnProgress) || !IsEventName(EventToolStarted) || !IsEventName(EventToolFinished) || !IsEventName(EventMessageDelta) || !IsEventName(EventTurnResult) || !IsEventName(EventTurnFailed) || !IsEventName(EventAgentIdle) || !IsEventName(EventAgentExited) { | |||
| 280 | + | t.Fatal("one or more canonical event names are not registered") | |||
| 281 | + | } | |||
| 282 | + | if IsEventName("future.event") || IsCommandName("future.command") { | |||
| 283 | + | t.Fatal("future names should not be reported as known names") | |||
| 284 | + | } | |||
| 285 | + | } | |||
A packages/agent/subagents/result.go +584 -0
| @@ -0,0 +1,584 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | "unicode/utf8" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // TurnStatus is the durable outcome of one delegated turn. | |||
| 17 | + | type TurnStatus string | |||
| 18 | + | ||||
| 19 | + | const ( | |||
| 20 | + | ResultSucceeded TurnStatus = "succeeded" | |||
| 21 | + | ResultFailed TurnStatus = "failed" | |||
| 22 | + | ResultCanceled TurnStatus = "canceled" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // ArtifactRef points to a durable file without exposing the supervisor's | |||
| 26 | + | // local state layout to callers. | |||
| 27 | + | type ArtifactRef struct { | |||
| 28 | + | Name string `json:"name,omitempty"` | |||
| 29 | + | Ref string `json:"ref"` | |||
| 30 | + | MediaType string `json:"media_type,omitempty"` | |||
| 31 | + | Size int64 `json:"size,omitempty"` | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // ResultError is intentionally free of credentials and argv details. | |||
| 35 | + | type ResultError struct { | |||
| 36 | + | Code string `json:"code,omitempty"` | |||
| 37 | + | Message string `json:"message,omitempty"` | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // TurnResult is the protocol-level result for a delegated turn. Output is a | |||
| 41 | + | // bounded inline preview; the full session/history remains available through | |||
| 42 | + | // HistoryRef and the complete JSON result through ResultRef. | |||
| 43 | + | type TurnResult struct { | |||
| 44 | + | Version int `json:"version"` | |||
| 45 | + | AgentID string `json:"agent_id,omitempty"` | |||
| 46 | + | TurnID string `json:"turn_id,omitempty"` | |||
| 47 | + | Status TurnStatus `json:"status"` | |||
| 48 | + | Summary string `json:"summary,omitempty"` | |||
| 49 | + | Output string `json:"output,omitempty"` | |||
| 50 | + | Structured json.RawMessage `json:"structured,omitempty"` | |||
| 51 | + | Artifacts []ArtifactRef `json:"artifacts,omitempty"` | |||
| 52 | + | ChangedFiles []string `json:"changed_files,omitempty"` | |||
| 53 | + | Usage map[string]any `json:"usage,omitempty"` | |||
| 54 | + | Error *ResultError `json:"error,omitempty"` | |||
| 55 | + | CreatedAt time.Time `json:"created_at"` | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (r *TurnResult) Validate(maxBytes, maxLines int) error { | |||
| 59 | + | if r == nil { | |||
| 60 | + | return errors.New("subagents: nil turn result") | |||
| 61 | + | } | |||
| 62 | + | if r.Version == 0 { | |||
| 63 | + | r.Version = ProtocolVersion | |||
| 64 | + | } | |||
| 65 | + | if r.Version != ProtocolVersion { | |||
| 66 | + | return fmt.Errorf("subagents: unsupported result version %d", r.Version) | |||
| 67 | + | } | |||
| 68 | + | if r.Status != ResultSucceeded && r.Status != ResultFailed && r.Status != ResultCanceled { | |||
| 69 | + | return fmt.Errorf("subagents: invalid result status %q", r.Status) | |||
| 70 | + | } | |||
| 71 | + | if r.AgentID == "" { | |||
| 72 | + | return errors.New("subagents: result missing agent id") | |||
| 73 | + | } | |||
| 74 | + | if r.TurnID == "" { | |||
| 75 | + | return errors.New("subagents: result missing turn id") | |||
| 76 | + | } | |||
| 77 | + | if len(r.Structured) > 0 && !json.Valid(r.Structured) { | |||
| 78 | + | return errors.New("subagents: result structured payload is invalid JSON") | |||
| 79 | + | } | |||
| 80 | + | if maxBytes > 0 && len([]byte(r.Output)) > maxBytes { | |||
| 81 | + | return fmt.Errorf("subagents: result output exceeds %d bytes", maxBytes) | |||
| 82 | + | } | |||
| 83 | + | if maxLines > 0 && countLines(r.Output) > maxLines { | |||
| 84 | + | return fmt.Errorf("subagents: result output exceeds %d lines", maxLines) | |||
| 85 | + | } | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Bounded returns a copy with inline output capped by bytes and lines. It | |||
| 90 | + | // never mutates the durable result's caller-owned value. | |||
| 91 | + | func (r *TurnResult) Bounded(maxBytes, maxLines int) *TurnResult { | |||
| 92 | + | out := cloneTurnResult(r) | |||
| 93 | + | if out == nil { | |||
| 94 | + | return nil | |||
| 95 | + | } | |||
| 96 | + | if len([]byte(out.Summary)) > 4*1024 { | |||
| 97 | + | out.Summary = truncateUTF8(out.Summary, 4*1024) | |||
| 98 | + | } | |||
| 99 | + | out.Output = boundInlineText(out.Output, maxBytes, maxLines) | |||
| 100 | + | return out | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // boundInlineText applies the shared line, byte, and UTF-8-safe bounds used | |||
| 104 | + | // for inline assistant output and durable turn-result previews. | |||
| 105 | + | func boundInlineText(value string, maxBytes, maxLines int) string { | |||
| 106 | + | const marker = "...[output truncated]" | |||
| 107 | + | if maxLines > 0 { | |||
| 108 | + | lines := strings.Split(value, "\n") | |||
| 109 | + | if len(lines) > maxLines { | |||
| 110 | + | if maxLines == 1 { | |||
| 111 | + | value = marker | |||
| 112 | + | } else { | |||
| 113 | + | value = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | if maxBytes > 0 && len([]byte(value)) > maxBytes { | |||
| 118 | + | if maxBytes <= len(marker) { | |||
| 119 | + | return truncateUTF8(value, maxBytes) | |||
| 120 | + | } | |||
| 121 | + | return truncateUTF8(value, maxBytes-len(marker)) + marker | |||
| 122 | + | } | |||
| 123 | + | return value | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func truncateUTF8(value string, maxBytes int) string { | |||
| 127 | + | if maxBytes <= 0 { | |||
| 128 | + | return "" | |||
| 129 | + | } | |||
| 130 | + | data := []byte(value) | |||
| 131 | + | if len(data) <= maxBytes { | |||
| 132 | + | return value | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | cut := maxBytes | |||
| 136 | + | // Only inspect the short suffix that can belong to a UTF-8 rune ending | |||
| 137 | + | // at the cut. Validating the entire prefix would make this quadratic for | |||
| 138 | + | // invalid input and would incorrectly discard valid bytes before an | |||
| 139 | + | // earlier invalid byte. | |||
| 140 | + | start := cut - 1 | |||
| 141 | + | minimum := cut - utf8.UTFMax | |||
| 142 | + | if minimum < 0 { | |||
| 143 | + | minimum = 0 | |||
| 144 | + | } | |||
| 145 | + | for start >= minimum && isUTF8Continuation(data[start]) { | |||
| 146 | + | start-- | |||
| 147 | + | } | |||
| 148 | + | if start >= minimum { | |||
| 149 | + | if size := utf8LeadSize(data[start]); size > cut-start && isUTF8Prefix(data[start:cut], size) { | |||
| 150 | + | cut = start | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | return string(data[:cut]) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func isUTF8Continuation(b byte) bool { | |||
| 157 | + | return b&0xc0 == 0x80 | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func utf8LeadSize(b byte) int { | |||
| 161 | + | switch { | |||
| 162 | + | case b < utf8.RuneSelf: | |||
| 163 | + | return 1 | |||
| 164 | + | case b >= 0xc2 && b <= 0xdf: | |||
| 165 | + | return 2 | |||
| 166 | + | case b >= 0xe0 && b <= 0xef: | |||
| 167 | + | return 3 | |||
| 168 | + | case b >= 0xf0 && b <= 0xf4: | |||
| 169 | + | return 4 | |||
| 170 | + | default: | |||
| 171 | + | return 0 | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func isUTF8Prefix(data []byte, size int) bool { | |||
| 176 | + | if len(data) <= 1 { | |||
| 177 | + | return true | |||
| 178 | + | } | |||
| 179 | + | if !isUTF8Continuation(data[1]) { | |||
| 180 | + | return false | |||
| 181 | + | } | |||
| 182 | + | switch data[0] { | |||
| 183 | + | case 0xe0: | |||
| 184 | + | if data[1] < 0xa0 { | |||
| 185 | + | return false | |||
| 186 | + | } | |||
| 187 | + | case 0xed: | |||
| 188 | + | if data[1] > 0x9f { | |||
| 189 | + | return false | |||
| 190 | + | } | |||
| 191 | + | case 0xf0: | |||
| 192 | + | if data[1] < 0x90 { | |||
| 193 | + | return false | |||
| 194 | + | } | |||
| 195 | + | case 0xf4: | |||
| 196 | + | if data[1] > 0x8f { | |||
| 197 | + | return false | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | for _, b := range data[2:] { | |||
| 201 | + | if !isUTF8Continuation(b) { | |||
| 202 | + | return false | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return len(data) < size | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func validateTurnResultAgent(result *TurnResult, agentID string) error { | |||
| 209 | + | if result == nil { | |||
| 210 | + | return errors.New("subagents: nil turn result") | |||
| 211 | + | } | |||
| 212 | + | if agentID != "" && result.AgentID != agentID { | |||
| 213 | + | return fmt.Errorf("subagents: turn result belongs to agent %q, want %q", result.AgentID, agentID) | |||
| 214 | + | } | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func decodeTurnResultEvent(ev Event, agentID string, maxBytes, maxLines int) (*TurnResult, error) { | |||
| 219 | + | data, err := json.Marshal(ev.Data) | |||
| 220 | + | if err != nil { | |||
| 221 | + | return nil, fmt.Errorf("subagents: encode turn result event: %w", err) | |||
| 222 | + | } | |||
| 223 | + | var result TurnResult | |||
| 224 | + | if err := json.Unmarshal(data, &result); err != nil { | |||
| 225 | + | return nil, fmt.Errorf("subagents: decode turn result event: %w", err) | |||
| 226 | + | } | |||
| 227 | + | if result.AgentID == "" { | |||
| 228 | + | result.AgentID = firstNonEmpty(ev.AgentID, agentID) | |||
| 229 | + | } | |||
| 230 | + | if err := validateTurnResultAgent(&result, agentID); err != nil { | |||
| 231 | + | return nil, err | |||
| 232 | + | } | |||
| 233 | + | if result.TurnID == "" { | |||
| 234 | + | result.TurnID = ev.TurnID | |||
| 235 | + | } | |||
| 236 | + | if result.Version == 0 { | |||
| 237 | + | result.Version = ProtocolVersion | |||
| 238 | + | } | |||
| 239 | + | if result.CreatedAt.IsZero() { | |||
| 240 | + | result.CreatedAt = ev.Time | |||
| 241 | + | if result.CreatedAt.IsZero() { | |||
| 242 | + | result.CreatedAt = time.Now().UTC() | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | resultPtr := result.Bounded(maxBytes, maxLines) | |||
| 246 | + | if err := resultPtr.Validate(0, 0); err != nil { | |||
| 247 | + | return nil, err | |||
| 248 | + | } | |||
| 249 | + | return resultPtr, nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func cloneTurnResult(r *TurnResult) *TurnResult { | |||
| 253 | + | if r == nil { | |||
| 254 | + | return nil | |||
| 255 | + | } | |||
| 256 | + | copy := *r | |||
| 257 | + | copy.Structured = append(json.RawMessage(nil), r.Structured...) | |||
| 258 | + | copy.Artifacts = append([]ArtifactRef(nil), r.Artifacts...) | |||
| 259 | + | copy.ChangedFiles = append([]string(nil), r.ChangedFiles...) | |||
| 260 | + | if r.Usage != nil { | |||
| 261 | + | copy.Usage = make(map[string]any, len(r.Usage)) | |||
| 262 | + | for key, value := range r.Usage { | |||
| 263 | + | copy.Usage[key] = value | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | if r.Error != nil { | |||
| 267 | + | errCopy := *r.Error | |||
| 268 | + | copy.Error = &errCopy | |||
| 269 | + | } | |||
| 270 | + | return © | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func countLines(s string) int { | |||
| 274 | + | if s == "" { | |||
| 275 | + | return 0 | |||
| 276 | + | } | |||
| 277 | + | return 1 + strings.Count(s, "\n") | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func AgentRef(id string) string { return "subagent://" + id } | |||
| 281 | + | func HistoryRef(id string) string { return AgentRef(id) + "/history" } | |||
| 282 | + | func ResultRef(id string) string { return AgentRef(id) + "/result" } | |||
| 283 | + | func PatchRef(id string) string { return AgentRef(id) + "/patch" } | |||
| 284 | + | ||||
| 285 | + | const maxResultFileBytes = 8 * 1024 * 1024 | |||
| 286 | + | ||||
| 287 | + | const ( | |||
| 288 | + | workspaceCaptureErrorCode = "workspace_capture_failed" | |||
| 289 | + | workspaceCaptureErrorMessage = "workspace capture failed" | |||
| 290 | + | ) | |||
| 291 | + | ||||
| 292 | + | func resultPath(stateDir string) string { return filepath.Join(stateDir, "result.json") } | |||
| 293 | + | func patchPath(stateDir string) string { return filepath.Join(stateDir, "patch.diff") } | |||
| 294 | + | ||||
| 295 | + | // writeSyncedFile writes a complete temporary file and closes it after the | |||
| 296 | + | // contents have been synced. Keeping this separate makes it harder for an | |||
| 297 | + | // atomic rename caller to accidentally publish an unflushed or open file. | |||
| 298 | + | func writeSyncedFile(path string, data []byte, perm os.FileMode) (err error) { | |||
| 299 | + | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) | |||
| 300 | + | if err != nil { | |||
| 301 | + | return fmt.Errorf("open: %w", err) | |||
| 302 | + | } | |||
| 303 | + | defer func() { | |||
| 304 | + | closeErr := file.Close() | |||
| 305 | + | if closeErr == nil { | |||
| 306 | + | return | |||
| 307 | + | } | |||
| 308 | + | closeErr = fmt.Errorf("close: %w", closeErr) | |||
| 309 | + | if err == nil { | |||
| 310 | + | err = closeErr | |||
| 311 | + | } else { | |||
| 312 | + | err = errors.Join(err, closeErr) | |||
| 313 | + | } | |||
| 314 | + | }() | |||
| 315 | + | ||||
| 316 | + | for written := 0; written < len(data); { | |||
| 317 | + | n, writeErr := file.Write(data[written:]) | |||
| 318 | + | written += n | |||
| 319 | + | if writeErr != nil { | |||
| 320 | + | return fmt.Errorf("write: %w", writeErr) | |||
| 321 | + | } | |||
| 322 | + | if n == 0 { | |||
| 323 | + | return fmt.Errorf("write: %w", io.ErrShortWrite) | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | if err := file.Sync(); err != nil { | |||
| 327 | + | return fmt.Errorf("sync: %w", err) | |||
| 328 | + | } | |||
| 329 | + | return nil | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func writeTurnResult(stateDir string, result *TurnResult) (err error) { | |||
| 333 | + | if result == nil { | |||
| 334 | + | return errors.New("subagents: nil result") | |||
| 335 | + | } | |||
| 336 | + | if result.CreatedAt.IsZero() { | |||
| 337 | + | result.CreatedAt = time.Now().UTC() | |||
| 338 | + | } | |||
| 339 | + | if result.Version == 0 { | |||
| 340 | + | result.Version = ProtocolVersion | |||
| 341 | + | } | |||
| 342 | + | if err := result.Validate(0, 0); err != nil { | |||
| 343 | + | return err | |||
| 344 | + | } | |||
| 345 | + | data, err := json.MarshalIndent(result, "", " ") | |||
| 346 | + | if err != nil { | |||
| 347 | + | return fmt.Errorf("subagents result marshal: %w", err) | |||
| 348 | + | } | |||
| 349 | + | if len(data) > maxResultFileBytes { | |||
| 350 | + | return fmt.Errorf("subagents result exceeds %d bytes", maxResultFileBytes) | |||
| 351 | + | } | |||
| 352 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 353 | + | return fmt.Errorf("subagents result dir: %w", err) | |||
| 354 | + | } | |||
| 355 | + | tmp := resultPath(stateDir) + ".tmp" | |||
| 356 | + | defer func() { | |||
| 357 | + | if err != nil { | |||
| 358 | + | _ = os.Remove(tmp) | |||
| 359 | + | } | |||
| 360 | + | }() | |||
| 361 | + | if err := writeSyncedFile(tmp, append(data, '\n'), 0o600); err != nil { | |||
| 362 | + | return fmt.Errorf("subagents result write: %w", err) | |||
| 363 | + | } | |||
| 364 | + | if err := os.Rename(tmp, resultPath(stateDir)); err != nil { | |||
| 365 | + | return fmt.Errorf("subagents result rename: %w", err) | |||
| 366 | + | } | |||
| 367 | + | return nil | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func (f *Supervisor) captureWorkspace(a *Agent) { | |||
| 371 | + | if a == nil || a.workspaceCapture == nil { | |||
| 372 | + | return | |||
| 373 | + | } | |||
| 374 | + | stateDir := a.stateDirectory(f.cfg.Root) | |||
| 375 | + | if _, err := os.Stat(stateDir); err != nil { | |||
| 376 | + | recordWorkspaceCaptureFailure(a) | |||
| 377 | + | return | |||
| 378 | + | } | |||
| 379 | + | capture, err := a.workspaceCapture() | |||
| 380 | + | if err != nil { | |||
| 381 | + | recordWorkspaceCaptureFailure(a) | |||
| 382 | + | return | |||
| 383 | + | } | |||
| 384 | + | a.lifecycleMu.Lock() | |||
| 385 | + | a.changedFiles = append([]string(nil), capture.ChangedFiles...) | |||
| 386 | + | a.lifecycleMu.Unlock() | |||
| 387 | + | if len(capture.Patch) == 0 { | |||
| 388 | + | return | |||
| 389 | + | } | |||
| 390 | + | path := patchPath(stateDir) | |||
| 391 | + | tmp := path + ".tmp" | |||
| 392 | + | if err := writeSyncedFile(tmp, capture.Patch, 0o600); err != nil { | |||
| 393 | + | _ = os.Remove(tmp) | |||
| 394 | + | recordWorkspaceCaptureFailure(a) | |||
| 395 | + | return | |||
| 396 | + | } | |||
| 397 | + | if err := os.Rename(tmp, path); err != nil { | |||
| 398 | + | _ = os.Remove(tmp) | |||
| 399 | + | recordWorkspaceCaptureFailure(a) | |||
| 400 | + | return | |||
| 401 | + | } | |||
| 402 | + | a.lifecycleMu.Lock() | |||
| 403 | + | a.patchRef = PatchRef(a.ID) | |||
| 404 | + | a.lifecycleMu.Unlock() | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // recordWorkspaceCaptureFailure records a stable, non-sensitive error in the | |||
| 408 | + | // result already received from the child (or creates the durable fallback). | |||
| 409 | + | // The underlying filesystem/capture error can contain private paths or data, | |||
| 410 | + | // so it is deliberately not copied into ResultError. | |||
| 411 | + | func recordWorkspaceCaptureFailure(a *Agent) { | |||
| 412 | + | if a == nil { | |||
| 413 | + | return | |||
| 414 | + | } | |||
| 415 | + | result := a.Result() | |||
| 416 | + | if result == nil || validateTurnResultAgent(result, a.ID) != nil { | |||
| 417 | + | result = &TurnResult{AgentID: a.ID} | |||
| 418 | + | } | |||
| 419 | + | result.Version = ProtocolVersion | |||
| 420 | + | if result.AgentID == "" { | |||
| 421 | + | result.AgentID = a.ID | |||
| 422 | + | } | |||
| 423 | + | if result.TurnID == "" { | |||
| 424 | + | turnID := a.CurrentTurnID() | |||
| 425 | + | if turnID == "" { | |||
| 426 | + | turnID = fmt.Sprintf("turn-%d", a.AttemptValue()) | |||
| 427 | + | } | |||
| 428 | + | result.TurnID = turnID | |||
| 429 | + | } | |||
| 430 | + | if result.CreatedAt.IsZero() { | |||
| 431 | + | result.CreatedAt = time.Now().UTC() | |||
| 432 | + | } | |||
| 433 | + | result.Status = ResultFailed | |||
| 434 | + | result.Error = &ResultError{ | |||
| 435 | + | Code: workspaceCaptureErrorCode, | |||
| 436 | + | Message: workspaceCaptureErrorMessage, | |||
| 437 | + | } | |||
| 438 | + | a.setResult(result) | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 442 | + | if a == nil { | |||
| 443 | + | return | |||
| 444 | + | } | |||
| 445 | + | attempt := a.AttemptValue() | |||
| 446 | + | result := a.Result() | |||
| 447 | + | if result != nil { | |||
| 448 | + | if err := validateTurnResultAgent(result, a.ID); err != nil { | |||
| 449 | + | result = nil | |||
| 450 | + | } | |||
| 451 | + | } | |||
| 452 | + | if result != nil { | |||
| 453 | + | // A child may omit agent/turn metadata in a result event; the | |||
| 454 | + | // supervisor supplies it before validating the durable fallback. | |||
| 455 | + | if result.AgentID == "" { | |||
| 456 | + | result.AgentID = a.ID | |||
| 457 | + | } | |||
| 458 | + | if result.TurnID == "" { | |||
| 459 | + | result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt)) | |||
| 460 | + | } | |||
| 461 | + | if result.Version == 0 { | |||
| 462 | + | result.Version = ProtocolVersion | |||
| 463 | + | } | |||
| 464 | + | if result.CreatedAt.IsZero() { | |||
| 465 | + | result.CreatedAt = time.Now().UTC() | |||
| 466 | + | } | |||
| 467 | + | if err := result.Validate(0, 0); err != nil { | |||
| 468 | + | result = nil | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | if result == nil { | |||
| 472 | + | turnID := a.CurrentTurnID() | |||
| 473 | + | if turnID == "" { | |||
| 474 | + | turnID = fmt.Sprintf("turn-%d", attempt) | |||
| 475 | + | } | |||
| 476 | + | a.mu.Lock() | |||
| 477 | + | output := a.lastAssistant | |||
| 478 | + | a.mu.Unlock() | |||
| 479 | + | resultStatus := ResultSucceeded | |||
| 480 | + | switch { | |||
| 481 | + | case status == StatusFailed && errors.Is(runErr, context.Canceled): | |||
| 482 | + | resultStatus = ResultCanceled | |||
| 483 | + | case status == StatusFailed: | |||
| 484 | + | resultStatus = ResultFailed | |||
| 485 | + | case status == StatusKilled: | |||
| 486 | + | resultStatus = ResultCanceled | |||
| 487 | + | } | |||
| 488 | + | result = &TurnResult{ | |||
| 489 | + | Version: ProtocolVersion, | |||
| 490 | + | AgentID: a.ID, | |||
| 491 | + | TurnID: turnID, | |||
| 492 | + | Status: resultStatus, | |||
| 493 | + | Output: output, | |||
| 494 | + | CreatedAt: time.Now().UTC(), | |||
| 495 | + | } | |||
| 496 | + | } | |||
| 497 | + | if result.AgentID == "" { | |||
| 498 | + | result.AgentID = a.ID | |||
| 499 | + | } | |||
| 500 | + | if result.TurnID == "" { | |||
| 501 | + | result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt)) | |||
| 502 | + | } | |||
| 503 | + | if result.Version == 0 { | |||
| 504 | + | result.Version = ProtocolVersion | |||
| 505 | + | } | |||
| 506 | + | if result.CreatedAt.IsZero() { | |||
| 507 | + | result.CreatedAt = time.Now().UTC() | |||
| 508 | + | } | |||
| 509 | + | if runErr != nil { | |||
| 510 | + | if status == StatusKilled || errors.Is(runErr, context.Canceled) { | |||
| 511 | + | result.Status = ResultCanceled | |||
| 512 | + | } else { | |||
| 513 | + | result.Status = ResultFailed | |||
| 514 | + | } | |||
| 515 | + | if result.Error == nil { | |||
| 516 | + | result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)} | |||
| 517 | + | } | |||
| 518 | + | } | |||
| 519 | + | if len(result.ChangedFiles) == 0 { | |||
| 520 | + | a.lifecycleMu.Lock() | |||
| 521 | + | result.ChangedFiles = append([]string(nil), a.changedFiles...) | |||
| 522 | + | a.lifecycleMu.Unlock() | |||
| 523 | + | } | |||
| 524 | + | if result.Summary == "" && result.Output != "" { | |||
| 525 | + | result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0] | |||
| 526 | + | } | |||
| 527 | + | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | |||
| 528 | + | a.setResult(result) | |||
| 529 | + | stateDir := a.stateDirectory(f.cfg.Root) | |||
| 530 | + | if err := writeTurnResult(stateDir, result); err == nil { | |||
| 531 | + | a.lifecycleMu.Lock() | |||
| 532 | + | a.resultRef = ResultRef(a.ID) | |||
| 533 | + | a.lifecycleMu.Unlock() | |||
| 534 | + | } | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | // ReadResult returns the durable structured result for an agent. | |||
| 538 | + | func (f *Supervisor) ReadResult(id string) (*TurnResult, error) { | |||
| 539 | + | a := f.Get(id) | |||
| 540 | + | if a == nil { | |||
| 541 | + | return nil, fmt.Errorf("subagents: no such agent %q", id) | |||
| 542 | + | } | |||
| 543 | + | if result := a.Result(); result != nil { | |||
| 544 | + | if err := validateTurnResultAgent(result, a.ID); err != nil { | |||
| 545 | + | return nil, err | |||
| 546 | + | } | |||
| 547 | + | return result, nil | |||
| 548 | + | } | |||
| 549 | + | result, err := readTurnResult(a.stateDirectory(f.cfg.Root)) | |||
| 550 | + | if err != nil { | |||
| 551 | + | return nil, err | |||
| 552 | + | } | |||
| 553 | + | if err := validateTurnResultAgent(result, a.ID); err != nil { | |||
| 554 | + | return nil, err | |||
| 555 | + | } | |||
| 556 | + | return result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines), nil | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | func (f *Supervisor) ResultReference(id string) string { return ResultRef(id) } | |||
| 560 | + | func (f *Supervisor) HistoryReference(id string) string { return HistoryRef(id) } | |||
| 561 | + | func (f *Supervisor) PatchReference(id string) string { return PatchRef(id) } | |||
| 562 | + | ||||
| 563 | + | func readTurnResult(stateDir string) (*TurnResult, error) { | |||
| 564 | + | file, err := os.Open(resultPath(stateDir)) | |||
| 565 | + | if err != nil { | |||
| 566 | + | return nil, err | |||
| 567 | + | } | |||
| 568 | + | defer file.Close() | |||
| 569 | + | data, err := io.ReadAll(io.LimitReader(file, maxResultFileBytes+1)) | |||
| 570 | + | if err != nil { | |||
| 571 | + | return nil, err | |||
| 572 | + | } | |||
| 573 | + | if len(data) > maxResultFileBytes { | |||
| 574 | + | return nil, fmt.Errorf("subagents result exceeds %d bytes", maxResultFileBytes) | |||
| 575 | + | } | |||
| 576 | + | var result TurnResult | |||
| 577 | + | if err := json.Unmarshal(data, &result); err != nil { | |||
| 578 | + | return nil, fmt.Errorf("subagents result parse: %w", err) | |||
| 579 | + | } | |||
| 580 | + | if err := result.Validate(0, 0); err != nil { | |||
| 581 | + | return nil, err | |||
| 582 | + | } | |||
| 583 | + | return &result, nil | |||
| 584 | + | } | |||
A packages/agent/subagents/result_test.go +309 -0
| @@ -0,0 +1,309 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "runtime" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "unicode/utf8" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestTurnResultValidationAndBoundedOutput(t *testing.T) { | |||
| 16 | + | result := &TurnResult{ | |||
| 17 | + | Version: ProtocolVersion, | |||
| 18 | + | AgentID: "agent-1", | |||
| 19 | + | TurnID: "turn-1", | |||
| 20 | + | Status: ResultSucceeded, | |||
| 21 | + | Output: "one\ntwo\nthree", | |||
| 22 | + | Structured: json.RawMessage(`{"ok":true}`), | |||
| 23 | + | } | |||
| 24 | + | if err := result.Validate(100, 3); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | bounded := result.Bounded(32, 2) | |||
| 28 | + | if bounded == result || !strings.Contains(bounded.Output, "truncated") { | |||
| 29 | + | t.Fatalf("bounded result = %#v", bounded) | |||
| 30 | + | } | |||
| 31 | + | if result.Output != "one\ntwo\nthree" { | |||
| 32 | + | t.Fatal("Bounded mutated the original result") | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) { | |||
| 37 | + | result := &TurnResult{ | |||
| 38 | + | Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded, | |||
| 39 | + | Output: "😀\n第二行\n第三行", | |||
| 40 | + | } | |||
| 41 | + | bounded := result.Bounded(10, 2) | |||
| 42 | + | if countLines(bounded.Output) > 2 { | |||
| 43 | + | t.Fatalf("bounded lines = %d: %q", countLines(bounded.Output), bounded.Output) | |||
| 44 | + | } | |||
| 45 | + | if len([]byte(bounded.Output)) > 10 { | |||
| 46 | + | t.Fatalf("bounded bytes = %d: %q", len([]byte(bounded.Output)), bounded.Output) | |||
| 47 | + | } | |||
| 48 | + | if !utf8.ValidString(bounded.Output) { | |||
| 49 | + | t.Fatal("bounded output is not valid UTF-8") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestTruncateUTF8CutsOnlyIncompleteTrailingRune(t *testing.T) { | |||
| 54 | + | invalidPrefix := string([]byte{0xff, 'a', 'b', 'c'}) | |||
| 55 | + | tests := []struct { | |||
| 56 | + | name string | |||
| 57 | + | value string | |||
| 58 | + | maxBytes int | |||
| 59 | + | want string | |||
| 60 | + | }{ | |||
| 61 | + | { | |||
| 62 | + | name: "multibyte", | |||
| 63 | + | value: "prefix😀suffix", | |||
| 64 | + | maxBytes: len([]byte("prefix")) + 2, | |||
| 65 | + | want: "prefix", | |||
| 66 | + | }, | |||
| 67 | + | { | |||
| 68 | + | name: "invalid byte before incomplete rune", | |||
| 69 | + | value: invalidPrefix + string([]byte{0xe2, 0x82}) + "tail", | |||
| 70 | + | maxBytes: len([]byte(invalidPrefix)) + 2, | |||
| 71 | + | want: invalidPrefix, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "invalid byte before ascii cut", | |||
| 75 | + | value: invalidPrefix + "😀", | |||
| 76 | + | maxBytes: len([]byte(invalidPrefix)), | |||
| 77 | + | want: invalidPrefix, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "invalid trailing byte is preserved", | |||
| 81 | + | value: string([]byte{'a', 0xff, 0x80}) + "😀", | |||
| 82 | + | maxBytes: 3, | |||
| 83 | + | want: string([]byte{'a', 0xff, 0x80}), | |||
| 84 | + | }, | |||
| 85 | + | } | |||
| 86 | + | for _, tt := range tests { | |||
| 87 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 88 | + | if got := truncateUTF8(tt.value, tt.maxBytes); got != tt.want { | |||
| 89 | + | t.Fatalf("truncateUTF8() = %q (%x), want %q (%x)", got, got, tt.want, tt.want) | |||
| 90 | + | } | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) { | |||
| 96 | + | invalid := NewEvent(EventTurnResult, map[string]any{ | |||
| 97 | + | "status": "unknown", | |||
| 98 | + | "turn_id": "turn-1", | |||
| 99 | + | }) | |||
| 100 | + | if _, err := decodeTurnResultEvent(invalid, "agent-1", 100, 10); err == nil { | |||
| 101 | + | t.Fatal("invalid result status was accepted") | |||
| 102 | + | } | |||
| 103 | + | mismatched := NewEvent(EventTurnResult, map[string]any{ | |||
| 104 | + | "agent_id": "other", | |||
| 105 | + | "status": string(ResultSucceeded), | |||
| 106 | + | "turn_id": "turn-1", | |||
| 107 | + | }) | |||
| 108 | + | if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil { | |||
| 109 | + | t.Fatal("mismatched result agent was accepted") | |||
| 110 | + | } | |||
| 111 | + | valid := NewEvent(EventTurnResult, map[string]any{"status": string(ResultSucceeded)}) | |||
| 112 | + | valid.AgentID = "agent-1" | |||
| 113 | + | valid.TurnID = "turn-1" | |||
| 114 | + | result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10) | |||
| 115 | + | if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" { | |||
| 116 | + | t.Fatalf("valid result = %#v, err=%v", result, err) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestTurnResultReferencesAndPersistence(t *testing.T) { | |||
| 121 | + | if got := AgentRef("a"); got != "subagent://a" { | |||
| 122 | + | t.Fatal(got) | |||
| 123 | + | } | |||
| 124 | + | if got := HistoryRef("a"); got != "subagent://a/history" { | |||
| 125 | + | t.Fatal(got) | |||
| 126 | + | } | |||
| 127 | + | if got := ResultRef("a"); got != "subagent://a/result" { | |||
| 128 | + | t.Fatal(got) | |||
| 129 | + | } | |||
| 130 | + | if got := PatchRef("a"); got != "subagent://a/patch" { | |||
| 131 | + | t.Fatal(got) | |||
| 132 | + | } | |||
| 133 | + | state := t.TempDir() | |||
| 134 | + | want := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultFailed, Error: &ResultError{Code: "x", Message: "failed"}} | |||
| 135 | + | if err := writeTurnResult(state, want); err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | got, err := readTurnResult(state) | |||
| 139 | + | if err != nil { | |||
| 140 | + | t.Fatal(err) | |||
| 141 | + | } | |||
| 142 | + | if got.Status != want.Status || got.Error == nil || got.Error.Code != "x" { | |||
| 143 | + | t.Fatalf("got = %#v", got) | |||
| 144 | + | } | |||
| 145 | + | info, err := os.Stat(filepath.Join(state, "result.json")) | |||
| 146 | + | if err != nil { | |||
| 147 | + | t.Fatal(err) | |||
| 148 | + | } | |||
| 149 | + | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { | |||
| 150 | + | t.Fatalf("result permissions = %o, want 600", info.Mode().Perm()) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) { | |||
| 155 | + | state := t.TempDir() | |||
| 156 | + | if err := os.Mkdir(resultPath(state), 0o700); err != nil { | |||
| 157 | + | t.Fatal(err) | |||
| 158 | + | } | |||
| 159 | + | result := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultSucceeded} | |||
| 160 | + | if err := writeTurnResult(state, result); err == nil { | |||
| 161 | + | t.Fatal("writeTurnResult succeeded when the result path was a directory") | |||
| 162 | + | } | |||
| 163 | + | if _, err := os.Stat(resultPath(state) + ".tmp"); !os.IsNotExist(err) { | |||
| 164 | + | t.Fatalf("temporary result file still exists, stat error = %v", err) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) { | |||
| 169 | + | makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent { | |||
| 170 | + | return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture} | |||
| 171 | + | } | |||
| 172 | + | assertFailure := func(t *testing.T, supervisor *Supervisor, agent *Agent, stateDir string) { | |||
| 173 | + | t.Helper() | |||
| 174 | + | supervisor.captureWorkspace(agent) | |||
| 175 | + | supervisor.ensureResult(agent, StatusDone, nil) | |||
| 176 | + | checkCaptureFailureResult(t, agent, stateDir) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | t.Run("stat", func(t *testing.T) { | |||
| 180 | + | stateDir := filepath.Join(t.TempDir(), "missing") | |||
| 181 | + | agent := makeAgent(stateDir, func() (WorkspaceCapture, error) { | |||
| 182 | + | t.Fatal("workspace capture should not run when the state directory cannot be stat'ed") | |||
| 183 | + | return WorkspaceCapture{}, nil | |||
| 184 | + | }) | |||
| 185 | + | assertFailure(t, &Supervisor{}, agent, stateDir) | |||
| 186 | + | }) | |||
| 187 | + | ||||
| 188 | + | t.Run("capture", func(t *testing.T) { | |||
| 189 | + | stateDir := t.TempDir() | |||
| 190 | + | agent := makeAgent(stateDir, func() (WorkspaceCapture, error) { | |||
| 191 | + | return WorkspaceCapture{}, errors.New("capture leaked /private/secret") | |||
| 192 | + | }) | |||
| 193 | + | assertFailure(t, &Supervisor{}, agent, stateDir) | |||
| 194 | + | }) | |||
| 195 | + | ||||
| 196 | + | t.Run("temp write", func(t *testing.T) { | |||
| 197 | + | stateDir := t.TempDir() | |||
| 198 | + | if err := os.Mkdir(patchPath(stateDir)+".tmp", 0o700); err != nil { | |||
| 199 | + | t.Fatal(err) | |||
| 200 | + | } | |||
| 201 | + | agent := makeAgent(stateDir, func() (WorkspaceCapture, error) { | |||
| 202 | + | return WorkspaceCapture{Patch: []byte("patch")}, nil | |||
| 203 | + | }) | |||
| 204 | + | assertFailure(t, &Supervisor{}, agent, stateDir) | |||
| 205 | + | }) | |||
| 206 | + | ||||
| 207 | + | t.Run("rename", func(t *testing.T) { | |||
| 208 | + | stateDir := t.TempDir() | |||
| 209 | + | if err := os.Mkdir(patchPath(stateDir), 0o700); err != nil { | |||
| 210 | + | t.Fatal(err) | |||
| 211 | + | } | |||
| 212 | + | agent := makeAgent(stateDir, func() (WorkspaceCapture, error) { | |||
| 213 | + | return WorkspaceCapture{Patch: []byte("patch")}, nil | |||
| 214 | + | }) | |||
| 215 | + | assertFailure(t, &Supervisor{}, agent, stateDir) | |||
| 216 | + | if _, err := os.Stat(patchPath(stateDir) + ".tmp"); !os.IsNotExist(err) { | |||
| 217 | + | t.Fatalf("workspace temporary patch still exists, stat error = %v", err) | |||
| 218 | + | } | |||
| 219 | + | }) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestCaptureWorkspaceSuccessPersistsPatch(t *testing.T) { | |||
| 223 | + | stateDir := t.TempDir() | |||
| 224 | + | agent := &Agent{ | |||
| 225 | + | ID: "agent-1", | |||
| 226 | + | stateDir: stateDir, | |||
| 227 | + | workspaceCapture: func() (WorkspaceCapture, error) { | |||
| 228 | + | return WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"file.txt"}}, nil | |||
| 229 | + | }, | |||
| 230 | + | } | |||
| 231 | + | (&Supervisor{}).captureWorkspace(agent) | |||
| 232 | + | ||||
| 233 | + | patch, err := os.ReadFile(patchPath(stateDir)) | |||
| 234 | + | if err != nil { | |||
| 235 | + | t.Fatal(err) | |||
| 236 | + | } | |||
| 237 | + | if string(patch) != "patch" { | |||
| 238 | + | t.Fatalf("patch = %q, want %q", patch, "patch") | |||
| 239 | + | } | |||
| 240 | + | agent.lifecycleMu.Lock() | |||
| 241 | + | patchRef := agent.patchRef | |||
| 242 | + | changedFiles := append([]string(nil), agent.changedFiles...) | |||
| 243 | + | agent.lifecycleMu.Unlock() | |||
| 244 | + | if patchRef != PatchRef(agent.ID) || len(changedFiles) != 1 || changedFiles[0] != "file.txt" { | |||
| 245 | + | t.Fatalf("capture metadata = ref %q, changed files %#v", patchRef, changedFiles) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) { | |||
| 250 | + | t.Helper() | |||
| 251 | + | result := agent.Result() | |||
| 252 | + | if result == nil || result.Status != ResultFailed || result.Error == nil { | |||
| 253 | + | t.Fatalf("capture failure result = %#v", result) | |||
| 254 | + | } | |||
| 255 | + | if result.Error.Code != workspaceCaptureErrorCode || result.Error.Message != workspaceCaptureErrorMessage { | |||
| 256 | + | t.Fatalf("capture failure error = %#v", result.Error) | |||
| 257 | + | } | |||
| 258 | + | if strings.Contains(result.Error.Message, stateDir) || strings.Contains(result.Error.Message, "secret") { | |||
| 259 | + | t.Fatalf("capture failure leaked private data: %#v", result.Error) | |||
| 260 | + | } | |||
| 261 | + | durable, err := readTurnResult(stateDir) | |||
| 262 | + | if err != nil { | |||
| 263 | + | t.Fatal(err) | |||
| 264 | + | } | |||
| 265 | + | if durable.Error == nil || durable.Error.Code != workspaceCaptureErrorCode || durable.Status != ResultFailed { | |||
| 266 | + | t.Fatalf("durable capture failure = %#v", durable) | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) { | |||
| 271 | + | tests := []struct { | |||
| 272 | + | name string | |||
| 273 | + | status Status | |||
| 274 | + | runErr error | |||
| 275 | + | wantState TurnStatus | |||
| 276 | + | }{ | |||
| 277 | + | {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed}, | |||
| 278 | + | {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled}, | |||
| 279 | + | {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled}, | |||
| 280 | + | } | |||
| 281 | + | for _, tt := range tests { | |||
| 282 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 283 | + | stateDir := t.TempDir() | |||
| 284 | + | agent := &Agent{ | |||
| 285 | + | ID: "agent-1", | |||
| 286 | + | stateDir: stateDir, | |||
| 287 | + | result: &TurnResult{ | |||
| 288 | + | AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded, | |||
| 289 | + | }, | |||
| 290 | + | } | |||
| 291 | + | supervisor := &Supervisor{} | |||
| 292 | + | supervisor.ensureResult(agent, tt.status, tt.runErr) | |||
| 293 | + | result := agent.Result() | |||
| 294 | + | if result == nil || result.Status != tt.wantState { | |||
| 295 | + | t.Fatalf("result status = %#v, want %q", result, tt.wantState) | |||
| 296 | + | } | |||
| 297 | + | if result.Error == nil { | |||
| 298 | + | t.Fatal("runner error was not recorded") | |||
| 299 | + | } | |||
| 300 | + | durable, err := readTurnResult(stateDir) | |||
| 301 | + | if err != nil { | |||
| 302 | + | t.Fatal(err) | |||
| 303 | + | } | |||
| 304 | + | if durable.Status != tt.wantState { | |||
| 305 | + | t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState) | |||
| 306 | + | } | |||
| 307 | + | }) | |||
| 308 | + | } | |||
| 309 | + | } | |||
A packages/agent/subagents/runner.go +705 -0
| @@ -0,0 +1,705 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | "time" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // execRunner spawns `zot --subagent-worker <inbox> --session <path>` in | |||
| 19 | + | // Agent.Dir (the shared repository root or an isolated worktree) and consumes | |||
| 20 | + | // its JSONL event stream on stdout. | |||
| 21 | + | // | |||
| 22 | + | // Why a long-lived daemon and not `zot --print`: the supervisor and | |||
| 23 | + | // the user expect agents to keep accepting follow-up prompts. A | |||
| 24 | + | // one-shot subprocess can't do that; this design gives each subagent | |||
| 25 | + | // agent a persistent session file plus an inbox socket the parent | |||
| 26 | + | // writes to, mirroring Claude Code's "Agents view" model. | |||
| 27 | + | // | |||
| 28 | + | // Events flow: | |||
| 29 | + | // | |||
| 30 | + | // child stdout --> decoder --> EventLog (events.jsonl) | |||
| 31 | + | // --> Sink (Activity/Transcript) | |||
| 32 | + | // | |||
| 33 | + | // The on-disk log is the durable record. The Sink updates are an | |||
| 34 | + | // in-memory mirror so the dashboard doesn't have to tail the file | |||
| 35 | + | // for the parent's own agents. /subagents open in a separate zot would | |||
| 36 | + | // read the log directly. | |||
| 37 | + | type execRunner struct { | |||
| 38 | + | agent *Agent | |||
| 39 | + | resolveCredential func(context.Context, string) (Credential, error) | |||
| 40 | + | ||||
| 41 | + | // Command overrides the default `zot --subagent-worker ...` | |||
| 42 | + | // invocation. Tests set this to a fake binary (or `go run` | |||
| 43 | + | // against a tiny stub program) so the supervisor logic can be | |||
| 44 | + | // tested without a real child. Production code leaves it nil. | |||
| 45 | + | Command []string | |||
| 46 | + | ||||
| 47 | + | // SessionPath is the agent's session file. Empty means "defer | |||
| 48 | + | // to r.agent.SessionPath", which Supervisor.Spawn always populates | |||
| 49 | + | // with <subagent-root>/agents/<id>/session.json. Tests that | |||
| 50 | + | // hand-build an Agent without going through Spawn must set | |||
| 51 | + | // one of the two; the runner refuses to invent a fallback | |||
| 52 | + | // because the only plausible one (<Dir>/.zot/session.json) | |||
| 53 | + | // would litter the user's repo — every agent's Dir points | |||
| 54 | + | // at it directly. | |||
| 55 | + | SessionPath string | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs. | |||
| 59 | + | // The fields map 1:1 onto child CLI flags; empty values omit the flag | |||
| 60 | + | // entirely and let the child resolve a default the same way a normal | |||
| 61 | + | // `zot` invocation does. | |||
| 62 | + | type subagentWorkerArgsOpts struct { | |||
| 63 | + | Exe string | |||
| 64 | + | Dir string | |||
| 65 | + | SessionPath string | |||
| 66 | + | InboxPath string | |||
| 67 | + | Task string | |||
| 68 | + | Model string | |||
| 69 | + | Provider string | |||
| 70 | + | BaseURL string | |||
| 71 | + | InsecureTLS bool | |||
| 72 | + | Reasoning string | |||
| 73 | + | FastMode bool | |||
| 74 | + | FastModeSet bool | |||
| 75 | + | Subagent string | |||
| 76 | + | MaxTurns int | |||
| 77 | + | Tools []string | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // defaultChildArgs builds the argv execRunner uses when its Command | |||
| 81 | + | // override is empty. Centralised so the spawn-vs-resume decision | |||
| 82 | + | // (whether to pass the original Task as a positional) lives in one | |||
| 83 | + | // place that tests can hit directly without going through Run's | |||
| 84 | + | // side effects. | |||
| 85 | + | // | |||
| 86 | + | // On Spawn (Resuming==false) we pass the task so the child's first | |||
| 87 | + | // turn runs immediately. On Resume (Resuming==true) we omit it: the | |||
| 88 | + | // child reopens the existing session file, loads the prior | |||
| 89 | + | // conversation, and just waits on the inbox for the next prompt. | |||
| 90 | + | // Re-firing the task on every resume produces a duplicate turn that | |||
| 91 | + | // collides with whatever the user types next, surfacing the | |||
| 92 | + | // "agent busy; send 'cancel' first" error between two assistant | |||
| 93 | + | // messages — which is exactly the bug this helper fixes. | |||
| 94 | + | func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string { | |||
| 95 | + | task := a.Task | |||
| 96 | + | if a.Resuming { | |||
| 97 | + | task = "" | |||
| 98 | + | } | |||
| 99 | + | return subagentWorkerArgs(subagentWorkerArgsOpts{ | |||
| 100 | + | Exe: exe, | |||
| 101 | + | Dir: a.Dir, | |||
| 102 | + | SessionPath: sessionPath, | |||
| 103 | + | InboxPath: inboxPath, | |||
| 104 | + | Task: task, | |||
| 105 | + | Model: a.Model, | |||
| 106 | + | Provider: a.Provider, | |||
| 107 | + | BaseURL: a.BaseURL, | |||
| 108 | + | InsecureTLS: a.InsecureTLS, | |||
| 109 | + | Reasoning: a.Reasoning, | |||
| 110 | + | FastMode: a.FastMode, | |||
| 111 | + | FastModeSet: true, | |||
| 112 | + | Subagent: a.Subagent, | |||
| 113 | + | MaxTurns: a.MaxTurns, | |||
| 114 | + | Tools: a.Tools, | |||
| 115 | + | }) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // subagentWorkerArgs builds the argv used when execRunner.Command is | |||
| 119 | + | // empty. Pulled out so tests can lock in the flag set without | |||
| 120 | + | // actually spawning a subprocess. | |||
| 121 | + | func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string { | |||
| 122 | + | args := []string{ | |||
| 123 | + | opts.Exe, | |||
| 124 | + | "--subagent-worker", opts.InboxPath, | |||
| 125 | + | "--session", opts.SessionPath, | |||
| 126 | + | "--cwd", opts.Dir, | |||
| 127 | + | } | |||
| 128 | + | if opts.Model != "" { | |||
| 129 | + | args = append(args, "--model", opts.Model) | |||
| 130 | + | } | |||
| 131 | + | if opts.Provider != "" { | |||
| 132 | + | args = append(args, "--provider", opts.Provider) | |||
| 133 | + | } | |||
| 134 | + | if opts.BaseURL != "" { | |||
| 135 | + | args = append(args, "--base-url", opts.BaseURL) | |||
| 136 | + | } | |||
| 137 | + | if opts.InsecureTLS { | |||
| 138 | + | args = append(args, "--insecure") | |||
| 139 | + | } | |||
| 140 | + | if opts.Reasoning != "" { | |||
| 141 | + | args = append(args, "--reasoning", opts.Reasoning) | |||
| 142 | + | } | |||
| 143 | + | if opts.FastModeSet { | |||
| 144 | + | if opts.FastMode { | |||
| 145 | + | args = append(args, "--fast-mode") | |||
| 146 | + | } else { | |||
| 147 | + | args = append(args, "--no-fast-mode") | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if opts.Subagent != "" { | |||
| 151 | + | args = append(args, "--subagent", opts.Subagent) | |||
| 152 | + | } | |||
| 153 | + | if opts.MaxTurns > 0 { | |||
| 154 | + | args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns)) | |||
| 155 | + | } | |||
| 156 | + | if len(opts.Tools) > 0 { | |||
| 157 | + | args = append(args, "--tools", strings.Join(opts.Tools, ",")) | |||
| 158 | + | } | |||
| 159 | + | if opts.Task != "" { | |||
| 160 | + | // First task is positional so the child treats it as the | |||
| 161 | + | // initial user turn; the terminator keeps flag-like task text | |||
| 162 | + | // from being parsed as a child CLI option. | |||
| 163 | + | args = append(args, "--", opts.Task) | |||
| 164 | + | } | |||
| 165 | + | return args | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 169 | + | // SessionPath resolution order: | |||
| 170 | + | // 1. explicit r.SessionPath set by the test / caller | |||
| 171 | + | // 2. r.agent.SessionPath baked in by Supervisor.Spawn — the | |||
| 172 | + | // production path. Always lives under | |||
| 173 | + | // <subagent-root>/agents/<id>/session.json so the per- | |||
| 174 | + | // agent state is entirely outside the working tree. | |||
| 175 | + | // Crucial because Agent.Dir points at the user's repo; | |||
| 176 | + | // any .zot/ scratch directory under Dir would litter | |||
| 177 | + | // their source tree. | |||
| 178 | + | // | |||
| 179 | + | // There is no third fallback. If neither path is set we | |||
| 180 | + | // refuse to start instead of inventing a directory; that | |||
| 181 | + | // way a misconfigured caller fails loudly the first time | |||
| 182 | + | // instead of silently dumping session data into someone's | |||
| 183 | + | // repo. | |||
| 184 | + | sessionPath := r.SessionPath | |||
| 185 | + | if sessionPath == "" { | |||
| 186 | + | sessionPath = r.agent.SessionPath | |||
| 187 | + | } | |||
| 188 | + | if sessionPath == "" { | |||
| 189 | + | return fmt.Errorf("subagent: agent missing session path (set SpawnRequest via Supervisor.SpawnReq, or hand-build Agent with SessionPath populated)") | |||
| 190 | + | } | |||
| 191 | + | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { | |||
| 192 | + | return fmt.Errorf("session dir: %w", err) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | inboxPath := r.agent.InboxPath | |||
| 196 | + | logPath := r.agent.EventLogPath | |||
| 197 | + | if logPath == "" { | |||
| 198 | + | return fmt.Errorf("subagent: agent missing event log path") | |||
| 199 | + | } | |||
| 200 | + | log, err := OpenEventLog(logPath) | |||
| 201 | + | if err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | defer log.Close() | |||
| 205 | + | ||||
| 206 | + | args := r.Command | |||
| 207 | + | if len(args) == 0 { | |||
| 208 | + | exe, err := os.Executable() | |||
| 209 | + | if err != nil { | |||
| 210 | + | return fmt.Errorf("locate self: %w", err) | |||
| 211 | + | } | |||
| 212 | + | args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | cmd := exec.CommandContext(ctx, args[0], args[1:]...) | |||
| 216 | + | cmd.Dir = r.agent.Dir | |||
| 217 | + | cmd.Env = append(workerEnvironment(r.agent.Provider), | |||
| 218 | + | "ZOT_SUBAGENT_AGENT_ID="+r.agent.ID, | |||
| 219 | + | "ZOT_SUBAGENT_EVENT_LOG="+logPath, | |||
| 220 | + | ) | |||
| 221 | + | if r.agent.HeartbeatInterval > 0 { | |||
| 222 | + | cmd.Env = append(cmd.Env, "ZOT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String()) | |||
| 223 | + | } | |||
| 224 | + | if r.agent.maxOutputBytes > 0 { | |||
| 225 | + | cmd.Env = append(cmd.Env, fmt.Sprintf("ZOT_SUBAGENT_MAX_OUTPUT_BYTES=%d", r.agent.maxOutputBytes)) | |||
| 226 | + | } | |||
| 227 | + | if r.agent.maxOutputLines > 0 { | |||
| 228 | + | cmd.Env = append(cmd.Env, fmt.Sprintf("ZOT_SUBAGENT_MAX_OUTPUT_LINES=%d", r.agent.maxOutputLines)) | |||
| 229 | + | } | |||
| 230 | + | if r.resolveCredential != nil { | |||
| 231 | + | credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider) | |||
| 232 | + | if resolveErr != nil { | |||
| 233 | + | return fmt.Errorf("resolve subagent credential for %s: %w", r.agent.Provider, resolveErr) | |||
| 234 | + | } | |||
| 235 | + | if credential.Value != "" { | |||
| 236 | + | encoded, encodeErr := json.Marshal(credential) | |||
| 237 | + | if encodeErr != nil { | |||
| 238 | + | return fmt.Errorf("encode subagent credential: %w", encodeErr) | |||
| 239 | + | } | |||
| 240 | + | cmd.Stdin = bytes.NewReader(encoded) | |||
| 241 | + | cmd.Env = append(cmd.Env, "ZOT_SUBAGENT_CREDENTIAL_STDIN=1") | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | stdout, err := cmd.StdoutPipe() | |||
| 246 | + | if err != nil { | |||
| 247 | + | return err | |||
| 248 | + | } | |||
| 249 | + | stderr, err := cmd.StderrPipe() | |||
| 250 | + | if err != nil { | |||
| 251 | + | return err | |||
| 252 | + | } | |||
| 253 | + | configureWorkerProcess(cmd, stdout, stderr) | |||
| 254 | + | ||||
| 255 | + | // "spawning" is briefly shown until the first event arrives; | |||
| 256 | + | // the child's "spawned" lifecycle event then overwrites it. | |||
| 257 | + | sink.Activity("starting") | |||
| 258 | + | if err := cmd.Start(); err != nil { | |||
| 259 | + | return err | |||
| 260 | + | } | |||
| 261 | + | r.agent.setProcessPID(cmd.Process.Pid) | |||
| 262 | + | if r.agent.persistFn != nil { | |||
| 263 | + | r.agent.persistFn(r.agent) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | var logErrMu sync.Mutex | |||
| 267 | + | var logErr error | |||
| 268 | + | var stopOnLogErr sync.Once | |||
| 269 | + | appendLog := func(ev Event) { | |||
| 270 | + | if err := log.Append(ev); err != nil { | |||
| 271 | + | logErrMu.Lock() | |||
| 272 | + | if logErr == nil { | |||
| 273 | + | logErr = err | |||
| 274 | + | } | |||
| 275 | + | logErrMu.Unlock() | |||
| 276 | + | // A durable event log is part of the runner's recovery contract. | |||
| 277 | + | // Stop the worker rather than letting it continue in a state the | |||
| 278 | + | // supervisor cannot reconstruct after restart. | |||
| 279 | + | stopOnLogErr.Do(func() { _ = killProcessGroup(cmd) }) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | firstLogErr := func() error { | |||
| 283 | + | logErrMu.Lock() | |||
| 284 | + | defer logErrMu.Unlock() | |||
| 285 | + | return logErr | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // stdout: parsed as JSONL. Every well-formed event is appended | |||
| 289 | + | // to the durable log AND forwarded to the in-memory sink so the | |||
| 290 | + | // dashboard updates without having to tail the file. Malformed | |||
| 291 | + | // lines are surfaced as plain transcript so they don't vanish. | |||
| 292 | + | done := make(chan struct{}, 2) | |||
| 293 | + | go func() { | |||
| 294 | + | defer func() { done <- struct{}{} }() | |||
| 295 | + | dec := bufio.NewReader(stdout) | |||
| 296 | + | lineLimit := r.agent.maxOutputBytes + 64*1024 | |||
| 297 | + | if lineLimit <= 0 { | |||
| 298 | + | lineLimit = 512 * 1024 | |||
| 299 | + | } | |||
| 300 | + | if lineLimit > maxEventLogLineBytes { | |||
| 301 | + | lineLimit = maxEventLogLineBytes | |||
| 302 | + | } | |||
| 303 | + | for { | |||
| 304 | + | line, err, truncated := readBoundedLine(dec, lineLimit) | |||
| 305 | + | if len(line) > 0 { | |||
| 306 | + | trimmed := strings.TrimRight(string(line), "\r\n") | |||
| 307 | + | if trimmed != "" && !truncated { | |||
| 308 | + | if ev, ok := parseEventLine(trimmed); ok { | |||
| 309 | + | appendLog(ev) | |||
| 310 | + | updateAgentFromEvent(r.agent, ev) | |||
| 311 | + | applyEventToSink(ev, sink) | |||
| 312 | + | // Fan prompt-level task completions up to any | |||
| 313 | + | // subscriber on the supervised Agent. The child | |||
| 314 | + | // also forwards provider/tool-loop turn_end | |||
| 315 | + | // events (for example stop=tool_use); those do | |||
| 316 | + | // not contain step and must not be treated as | |||
| 317 | + | // subagent task completion. | |||
| 318 | + | notifyPromptTurnEnd(r.agent, ev) | |||
| 319 | + | } else { | |||
| 320 | + | sink.Transcript(trimmed) | |||
| 321 | + | appendLog(NewEvent("stdout", map[string]any{"text": trimmed})) | |||
| 322 | + | } | |||
| 323 | + | } else if trimmed != "" { | |||
| 324 | + | trimmed += "\n...[line truncated]" | |||
| 325 | + | sink.Transcript(trimmed) | |||
| 326 | + | appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true})) | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | if err != nil { | |||
| 330 | + | return | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | }() | |||
| 334 | + | ||||
| 335 | + | // stderr: lifecycle/error chatter from the child. Every line | |||
| 336 | + | // is mirrored as a stderr event in the durable log AND surfaced | |||
| 337 | + | // in the transcript so users can diagnose a failing agent | |||
| 338 | + | // without leaving the dashboard. | |||
| 339 | + | go func() { | |||
| 340 | + | defer func() { done <- struct{}{} }() | |||
| 341 | + | br := bufio.NewReader(stderr) | |||
| 342 | + | lineLimit := r.agent.maxOutputBytes + 64*1024 | |||
| 343 | + | if lineLimit <= 0 { | |||
| 344 | + | lineLimit = 512 * 1024 | |||
| 345 | + | } | |||
| 346 | + | if lineLimit > maxEventLogLineBytes { | |||
| 347 | + | lineLimit = maxEventLogLineBytes | |||
| 348 | + | } | |||
| 349 | + | for { | |||
| 350 | + | line, err, truncated := readBoundedLine(br, lineLimit) | |||
| 351 | + | if len(line) > 0 { | |||
| 352 | + | txt := strings.TrimRight(string(line), "\r\n") | |||
| 353 | + | if truncated { | |||
| 354 | + | txt += "\n...[line truncated]" | |||
| 355 | + | } | |||
| 356 | + | sink.Transcript("stderr: " + txt) | |||
| 357 | + | appendLog(NewEvent("stderr", map[string]any{"text": txt, "truncated": truncated})) | |||
| 358 | + | } | |||
| 359 | + | if err != nil { | |||
| 360 | + | return | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | }() | |||
| 364 | + | ||||
| 365 | + | <-done | |||
| 366 | + | <-done | |||
| 367 | + | ||||
| 368 | + | err = cmd.Wait() | |||
| 369 | + | exit := 0 | |||
| 370 | + | if ee, ok := err.(*exec.ExitError); ok { | |||
| 371 | + | exit = ee.ExitCode() | |||
| 372 | + | } | |||
| 373 | + | if err != nil && ctx.Err() != nil { | |||
| 374 | + | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})) | |||
| 375 | + | if logErr := firstLogErr(); logErr != nil { | |||
| 376 | + | return fmt.Errorf("subagent event log: %w", logErr) | |||
| 377 | + | } | |||
| 378 | + | return ctx.Err() | |||
| 379 | + | } | |||
| 380 | + | if err != nil { | |||
| 381 | + | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | |||
| 382 | + | if logErr := firstLogErr(); logErr != nil { | |||
| 383 | + | return fmt.Errorf("subagent event log: %v; worker: %w", logErr, err) | |||
| 384 | + | } | |||
| 385 | + | return err | |||
| 386 | + | } | |||
| 387 | + | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | |||
| 388 | + | if logErr := firstLogErr(); logErr != nil { | |||
| 389 | + | return fmt.Errorf("subagent event log: %w", logErr) | |||
| 390 | + | } | |||
| 391 | + | sink.Activity("done") | |||
| 392 | + | return nil | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func workerEnvironment(provider string) []string { | |||
| 396 | + | // Provider credentials are resolved by the supervisor and transferred | |||
| 397 | + | // over the child's stdin. Do not inherit API-key/token variables into the | |||
| 398 | + | // worker environment, where child tools, crash reports, or diagnostics | |||
| 399 | + | // could expose them. Bedrock's current client still resolves bearer/IAM | |||
| 400 | + | // credentials directly from AWS environment variables, so preserve that | |||
| 401 | + | // provider's ambient AWS chain until it has a structured stdin handoff. | |||
| 402 | + | provider = strings.ToLower(strings.TrimSpace(provider)) | |||
| 403 | + | var env []string | |||
| 404 | + | for _, entry := range os.Environ() { | |||
| 405 | + | name, _, ok := strings.Cut(entry, "=") | |||
| 406 | + | if ok && workerSecretEnvName(name) && !(provider == "amazon-bedrock" && isBedrockCredentialEnv(name)) { | |||
| 407 | + | continue | |||
| 408 | + | } | |||
| 409 | + | env = append(env, entry) | |||
| 410 | + | } | |||
| 411 | + | return env | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func isBedrockCredentialEnv(name string) bool { | |||
| 415 | + | switch strings.ToUpper(strings.TrimSpace(name)) { | |||
| 416 | + | case "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN": | |||
| 417 | + | return true | |||
| 418 | + | default: | |||
| 419 | + | return false | |||
| 420 | + | } | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func workerSecretEnvName(name string) bool { | |||
| 424 | + | name = strings.ToUpper(strings.TrimSpace(name)) | |||
| 425 | + | for _, suffix := range []string{"_KEY", "_API_KEY", "_OAUTH_TOKEN", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_CREDENTIALS"} { | |||
| 426 | + | if strings.HasSuffix(name, suffix) { | |||
| 427 | + | return true | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | switch name { | |||
| 431 | + | case "AWS_ACCESS_KEY_ID", "HF_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS": | |||
| 432 | + | return true | |||
| 433 | + | default: | |||
| 434 | + | return false | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func readBoundedLine(r *bufio.Reader, limit int) ([]byte, error, bool) { | |||
| 439 | + | line, err, truncated, _, _ := readBoundedLineStats(r, limit) | |||
| 440 | + | return line, err, truncated | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | // readBoundedLineStats reads through one newline or EOF while retaining the | |||
| 444 | + | // number of source bytes consumed and whether a newline terminated the record. | |||
| 445 | + | // The follower uses those extra values to avoid advancing past a partial final | |||
| 446 | + | // JSONL record that may be completed by a concurrent append. | |||
| 447 | + | func readBoundedLineStats(r *bufio.Reader, limit int) ([]byte, error, bool, int64, bool) { | |||
| 448 | + | if limit <= 0 { | |||
| 449 | + | limit = 512 * 1024 | |||
| 450 | + | } | |||
| 451 | + | var line []byte | |||
| 452 | + | var consumed int64 | |||
| 453 | + | truncated := false | |||
| 454 | + | for { | |||
| 455 | + | chunk, err := r.ReadSlice('\n') | |||
| 456 | + | consumed += int64(len(chunk)) | |||
| 457 | + | if len(chunk) > 0 { | |||
| 458 | + | if len(line) < limit { | |||
| 459 | + | keep := len(chunk) | |||
| 460 | + | if remaining := limit - len(line); keep > remaining { | |||
| 461 | + | keep = remaining | |||
| 462 | + | truncated = true | |||
| 463 | + | } | |||
| 464 | + | line = append(line, chunk[:keep]...) | |||
| 465 | + | } else { | |||
| 466 | + | truncated = true | |||
| 467 | + | } | |||
| 468 | + | } | |||
| 469 | + | if err != bufio.ErrBufferFull { | |||
| 470 | + | return line, err, truncated, consumed, len(chunk) > 0 && chunk[len(chunk)-1] == '\n' | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | // parseEventLine attempts to decode one current-protocol JSONL line as an Event. | |||
| 476 | + | func parseEventLine(line string) (Event, bool) { | |||
| 477 | + | if len(line) == 0 || line[0] != '{' { | |||
| 478 | + | return Event{}, false | |||
| 479 | + | } | |||
| 480 | + | env, err := ParseEvent([]byte(line)) | |||
| 481 | + | if err != nil { | |||
| 482 | + | return Event{}, false | |||
| 483 | + | } | |||
| 484 | + | fields, err := env.PayloadFields() | |||
| 485 | + | if err != nil { | |||
| 486 | + | return Event{}, false | |||
| 487 | + | } | |||
| 488 | + | data := make(map[string]any, len(fields)) | |||
| 489 | + | for key, raw := range fields { | |||
| 490 | + | var value any | |||
| 491 | + | if err := json.Unmarshal(raw, &value); err != nil { | |||
| 492 | + | return Event{}, false | |||
| 493 | + | } | |||
| 494 | + | data[key] = value | |||
| 495 | + | } | |||
| 496 | + | when := env.Timestamp | |||
| 497 | + | if when.IsZero() { | |||
| 498 | + | when = time.Now() | |||
| 499 | + | } | |||
| 500 | + | return Event{ | |||
| 501 | + | Time: when, Type: env.Type, Version: env.Version, | |||
| 502 | + | MessageID: env.MessageID, AgentID: env.AgentID, TurnID: env.TurnID, | |||
| 503 | + | Data: data, | |||
| 504 | + | }, true | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func updateAgentFromEvent(a *Agent, ev Event) { | |||
| 508 | + | if a == nil { | |||
| 509 | + | return | |||
| 510 | + | } | |||
| 511 | + | now := ev.Time | |||
| 512 | + | if now.IsZero() { | |||
| 513 | + | now = time.Now() | |||
| 514 | + | } | |||
| 515 | + | a.markActivity(now) | |||
| 516 | + | persist := false | |||
| 517 | + | switch ev.Type { | |||
| 518 | + | case EventAgentReady, "agent_ready": | |||
| 519 | + | a.setProcessState(ProcessAlive) | |||
| 520 | + | a.setTurnState(TurnIdle, ev.TurnID) | |||
| 521 | + | persist = true | |||
| 522 | + | case EventAgentHeartbeat, "agent_heartbeat": | |||
| 523 | + | a.setProcessState(ProcessAlive) | |||
| 524 | + | persist = true | |||
| 525 | + | case EventTurnStarted, "turn_start": | |||
| 526 | + | turnID := ev.TurnID | |||
| 527 | + | if turnID == "" { | |||
| 528 | + | if step, ok := ev.Data["step"].(float64); ok { | |||
| 529 | + | turnID = fmt.Sprintf("turn-%d", int(step)) | |||
| 530 | + | } | |||
| 531 | + | } | |||
| 532 | + | a.setProcessState(ProcessAlive) | |||
| 533 | + | a.setTurnState(TurnRunning, turnID) | |||
| 534 | + | persist = true | |||
| 535 | + | case EventTurnResult, "turn_result": | |||
| 536 | + | if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil { | |||
| 537 | + | a.setResult(result) | |||
| 538 | + | if a.turnResults != nil { | |||
| 539 | + | select { | |||
| 540 | + | case a.turnResults <- result: | |||
| 541 | + | default: | |||
| 542 | + | } | |||
| 543 | + | } | |||
| 544 | + | if a.stateDir != "" { | |||
| 545 | + | if err := writeTurnResult(a.stateDir, result); err == nil { | |||
| 546 | + | a.lifecycleMu.Lock() | |||
| 547 | + | a.resultRef = ResultRef(a.ID) | |||
| 548 | + | a.lifecycleMu.Unlock() | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | switch result.Status { | |||
| 552 | + | case ResultCanceled: | |||
| 553 | + | a.setTurnState(TurnCanceled, result.TurnID) | |||
| 554 | + | case ResultFailed: | |||
| 555 | + | a.setTurnState(TurnFailed, result.TurnID) | |||
| 556 | + | default: | |||
| 557 | + | a.setTurnState(TurnSucceeded, result.TurnID) | |||
| 558 | + | } | |||
| 559 | + | } | |||
| 560 | + | persist = true | |||
| 561 | + | case EventTurnFailed, "turn_failed": | |||
| 562 | + | a.setTurnState(TurnFailed, ev.TurnID) | |||
| 563 | + | persist = true | |||
| 564 | + | case EventAgentIdle, "agent_idle": | |||
| 565 | + | a.setTurnState(TurnIdle, ev.TurnID) | |||
| 566 | + | persist = true | |||
| 567 | + | case EventAgentExited, "agent_exited", "agent_stopped": | |||
| 568 | + | a.setProcessState(ProcessExited) | |||
| 569 | + | persist = true | |||
| 570 | + | case "turn_end": | |||
| 571 | + | // Provider/tool-loop turn_end events (for example stop=tool_use) | |||
| 572 | + | // do not carry the daemon's prompt step and are not terminal for | |||
| 573 | + | // the delegated task. They remain in the event log, but must not | |||
| 574 | + | // overwrite the delegated turn state or trigger persistence. | |||
| 575 | + | if _, ok := ev.Data["step"].(float64); !ok { | |||
| 576 | + | break | |||
| 577 | + | } | |||
| 578 | + | if message, _ := ev.Data["error"].(string); message != "" { | |||
| 579 | + | a.setTurnState(TurnFailed, ev.TurnID) | |||
| 580 | + | } else { | |||
| 581 | + | a.setTurnState(TurnSucceeded, ev.TurnID) | |||
| 582 | + | } | |||
| 583 | + | persist = true | |||
| 584 | + | } | |||
| 585 | + | if persist && a.persistFn != nil { | |||
| 586 | + | a.persistFn(a) | |||
| 587 | + | } | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent | |||
| 591 | + | // daemon's prompt-level completion event. Provider/tool-loop | |||
| 592 | + | // turn_end events (such as stop=tool_use) do not include step and | |||
| 593 | + | // are not terminal for the delegated task. | |||
| 594 | + | func notifyPromptTurnEnd(a *Agent, ev Event) { | |||
| 595 | + | if a == nil || ev.Type != "turn_end" { | |||
| 596 | + | return | |||
| 597 | + | } | |||
| 598 | + | step, ok := ev.Data["step"].(float64) | |||
| 599 | + | if !ok { | |||
| 600 | + | return | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | errMsg, _ := ev.Data["error"].(string) | |||
| 604 | + | a.mu.Lock() | |||
| 605 | + | fn := a.OnTurnEnd | |||
| 606 | + | if fn == nil { | |||
| 607 | + | a.pendingTurnEnds = append(a.pendingTurnEnds, turnEndNotice{step: int(step), errMsg: errMsg}) | |||
| 608 | + | a.mu.Unlock() | |||
| 609 | + | return | |||
| 610 | + | } | |||
| 611 | + | a.mu.Unlock() | |||
| 612 | + | go fn(int(step), errMsg) | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | // applyEventToSink translates an Event into Sink updates. Only a | |||
| 616 | + | // few event types are interpreted; the rest still land in the | |||
| 617 | + | // durable log via the caller. | |||
| 618 | + | func applyEventToSink(ev Event, sink Sink) { | |||
| 619 | + | type roleSink interface { | |||
| 620 | + | userMessage(string) | |||
| 621 | + | assistantMessage(string) | |||
| 622 | + | } | |||
| 623 | + | appendMessage := func(text string, assistant bool) { | |||
| 624 | + | if text == "" { | |||
| 625 | + | return | |||
| 626 | + | } | |||
| 627 | + | if roles, ok := sink.(roleSink); ok { | |||
| 628 | + | if assistant { | |||
| 629 | + | roles.assistantMessage(text) | |||
| 630 | + | } else { | |||
| 631 | + | roles.userMessage(text) | |||
| 632 | + | } | |||
| 633 | + | return | |||
| 634 | + | } | |||
| 635 | + | if assistant { | |||
| 636 | + | sink.Transcript(text) | |||
| 637 | + | } else { | |||
| 638 | + | sink.Transcript("user: " + text) | |||
| 639 | + | } | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | switch ev.Type { | |||
| 643 | + | case "assistant_message", "user_message": | |||
| 644 | + | var text []string | |||
| 645 | + | if c, ok := ev.Data["content"].([]any); ok { | |||
| 646 | + | for _, blk := range c { | |||
| 647 | + | m, _ := blk.(map[string]any) | |||
| 648 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 649 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 650 | + | text = append(text, txt) | |||
| 651 | + | } | |||
| 652 | + | } | |||
| 653 | + | } | |||
| 654 | + | } | |||
| 655 | + | appendMessage(strings.Join(text, "\n"), ev.Type == "assistant_message") | |||
| 656 | + | if ev.Type == "assistant_message" { | |||
| 657 | + | sink.Activity("idle") | |||
| 658 | + | } | |||
| 659 | + | case "turn_start", EventTurnStarted: | |||
| 660 | + | sink.Activity("thinking") | |||
| 661 | + | case "tool_call", EventToolStarted: | |||
| 662 | + | if name, _ := ev.Data["name"].(string); name != "" { | |||
| 663 | + | sink.Activity("tool: " + truncate(name, 60)) | |||
| 664 | + | } | |||
| 665 | + | case "tool_result", EventToolFinished: | |||
| 666 | + | sink.Activity("idle") | |||
| 667 | + | case "turn_end", EventTurnResult, EventAgentIdle: | |||
| 668 | + | sink.Activity("idle") | |||
| 669 | + | case "agent_ready", EventAgentReady, EventAgentHeartbeat: | |||
| 670 | + | sink.Activity("idle") | |||
| 671 | + | case "agent_stopped": | |||
| 672 | + | // terminal status is decided by Supervisor.run from the runner's | |||
| 673 | + | // return value, not from this event. Don't overwrite the | |||
| 674 | + | // activity here. | |||
| 675 | + | case "error": | |||
| 676 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 677 | + | sink.Transcript("error: " + msg) | |||
| 678 | + | } | |||
| 679 | + | } | |||
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | // RunnerFunc adapts a plain function into a Runner. Useful for tests | |||
| 683 | + | // and for callers who don't need their own type. | |||
| 684 | + | type RunnerFunc func(ctx context.Context, sink Sink) error | |||
| 685 | + | ||||
| 686 | + | func (f RunnerFunc) Run(ctx context.Context, sink Sink) error { return f(ctx, sink) } | |||
| 687 | + | ||||
| 688 | + | // streamLines kept around for any caller still using it directly. | |||
| 689 | + | // | |||
| 690 | + | // Deprecated: the runner now parses JSONL from stdout via | |||
| 691 | + | // parseEventLine; this helper is unused inside the package but | |||
| 692 | + | // remains exported via internal use by tests in the runner_test | |||
| 693 | + | // suite that pre-date the daemon switch. | |||
| 694 | + | func streamLines(r io.Reader, fn func(string)) { | |||
| 695 | + | br := bufio.NewReader(r) | |||
| 696 | + | for { | |||
| 697 | + | line, err := br.ReadString('\n') | |||
| 698 | + | if line != "" { | |||
| 699 | + | fn(strings.TrimRight(line, "\r\n")) | |||
| 700 | + | } | |||
| 701 | + | if err != nil { | |||
| 702 | + | return | |||
| 703 | + | } | |||
| 704 | + | } | |||
| 705 | + | } | |||
R packages/agent/swarm/runner_e2e_test.go → packages/agent/subagents/runner_e2e_test.go +10 -9 renamed, 88% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| @@ -15,11 +15,11 @@ import ( | |||||
| 15 | // TestRunnerEndToEndWithStubChild is the integration test for the | 15 | // TestRunnerEndToEndWithStubChild is the integration test for the | ||
| 16 | // new daemon-mode runner. It compiles the stubchild binary in | 16 | // new daemon-mode runner. It compiles the stubchild binary in | ||
| 17 | // testdata/cmd/stubchild, points an execRunner at it, and drives | 17 | // testdata/cmd/stubchild, points an execRunner at it, and drives | ||
| 18 | - | // a Swarm through one Spawn + one SendUserTurn + Stop cycle. | 18 | + | // a Supervisor through one Spawn + one SendUserTurn + Stop cycle. |
| 19 | // | 19 | // | ||
| 20 | // What this proves: | 20 | // What this proves: | ||
| 21 | // | 21 | // | ||
| 22 | - | // - The default argv shape (swarmAgentArgs) is one the child can | 22 | + | // - The default argv shape (subagentWorkerArgs) is one the child can |
| 23 | // actually parse — locks the shape against silent breakage. | 23 | // actually parse — locks the shape against silent breakage. | ||
| 24 | // - The stdout JSONL parser ingests events and writes them to the | 24 | // - The stdout JSONL parser ingests events and writes them to the | ||
| 25 | // durable log. | 25 | // durable log. | ||
| @@ -40,6 +40,7 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | exe := buildStubChild(t) | 42 | exe := buildStubChild(t) | ||
| 43 | + | t.Setenv("ZOT_STUB_PROTOCOL", "1") | |||
| 43 | 44 | ||||
| 44 | root := t.TempDir() | 45 | root := t.TempDir() | ||
| 45 | repo := t.TempDir() | 46 | repo := t.TempDir() | ||
| @@ -49,7 +50,7 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||||
| 49 | NewRunner: func(a *Agent) Runner { | 50 | NewRunner: func(a *Agent) Runner { | ||
| 50 | return &execRunner{ | 51 | return &execRunner{ | ||
| 51 | agent: a, | 52 | agent: a, | ||
| 52 | - | Command: swarmAgentArgs(swarmAgentArgsOpts{ | 53 | + | Command: subagentWorkerArgs(subagentWorkerArgsOpts{ |
| 53 | Exe: exe, | 54 | Exe: exe, | ||
| 54 | Dir: a.Dir, | 55 | Dir: a.Dir, | ||
| 55 | SessionPath: a.SessionPath, | 56 | SessionPath: a.SessionPath, | ||
| @@ -93,13 +94,13 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||||
| 93 | 94 | ||||
| 94 | // Send a follow-up over the inbox. The stub echoes the text into | 95 | // Send a follow-up over the inbox. The stub echoes the text into | ||
| 95 | // an assistant_message we can poll for in the log. | 96 | // an assistant_message we can poll for in the log. | ||
| 96 | - | if err := retrySend(f, a.ID, "user follow up", time.Second); err != nil { | 97 | + | if err := retrySend(f, a.ID, "follow up", time.Second); err != nil { |
| 97 | - | t.Fatalf("SendInput: %v", err) | 98 | + | t.Fatalf("SendUserTurn: %v", err) |
| 98 | } | 99 | } | ||
| 99 | waitFor("echo: follow up") | 100 | waitFor("echo: follow up") | ||
| 100 | 101 | ||||
| 101 | // Shut the agent down gracefully via the inbox. | 102 | // Shut the agent down gracefully via the inbox. | ||
| 102 | - | if err := f.SendInput(a.ID, "shutdown"); err != nil && !errors.Is(err, ErrNotReady) { | 103 | + | if err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil && !errors.Is(err, ErrNotReady) { |
| 103 | t.Fatalf("shutdown send: %v", err) | 104 | t.Fatalf("shutdown send: %v", err) | ||
| 104 | } | 105 | } | ||
| 105 | a.Wait() | 106 | a.Wait() | ||
| @@ -111,11 +112,11 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||||
| 111 | // retrySend exists because the inbox dial races against the child | 112 | // retrySend exists because the inbox dial races against the child | ||
| 112 | // opening the socket. Production callers handle ErrNotReady with a | 113 | // opening the socket. Production callers handle ErrNotReady with a | ||
| 113 | // status message; tests retry within a small window. | 114 | // status message; tests retry within a small window. | ||
| 114 | - | func retrySend(f *Swarm, id, msg string, timeout time.Duration) error { | 115 | + | func retrySend(f *Supervisor, id, msg string, timeout time.Duration) error { |
| 115 | deadline := time.Now().Add(timeout) | 116 | deadline := time.Now().Add(timeout) | ||
| 116 | var lastErr error | 117 | var lastErr error | ||
| 117 | for time.Now().Before(deadline) { | 118 | for time.Now().Before(deadline) { | ||
| 118 | - | err := f.SendInput(id, msg) | 119 | + | err := f.SendUserTurn(id, msg) |
| 119 | if err == nil { | 120 | if err == nil { | ||
| 120 | return nil | 121 | return nil | ||
| 121 | } | 122 | } | ||
R packages/agent/swarm/runner_test.go → packages/agent/subagents/runner_test.go +151 -16 renamed, 58% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | "time" | 11 | "time" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | - | // TestSwarmAgentArgs locks in the exact flag set the subprocess | 14 | + | // TestSubagentWorkerArgs locks in the exact flag set the subprocess |
| 15 | - | // runner uses to start a swarm agent in daemon mode. Past | 15 | + | // runner uses to start a subagent agent in daemon mode. Past |
| 16 | // regressions in this area: | 16 | // regressions in this area: | ||
| 17 | // | 17 | // | ||
| 18 | // - "--no-sess" instead of "--no-session" (old print-mode runner): | 18 | // - "--no-sess" instead of "--no-session" (old print-mode runner): | ||
| @@ -31,10 +31,10 @@ import ( | |||||
| 31 | // positions. If a flag is renamed, update both the runner and this | 31 | // positions. If a flag is renamed, update both the runner and this | ||
| 32 | // test so we notice immediately. | 32 | // test so we notice immediately. | ||
| 33 | func TestCredentialStdinHelperProcess(t *testing.T) { | 33 | func TestCredentialStdinHelperProcess(t *testing.T) { | ||
| 34 | - | if os.Getenv("ZOT_SWARM_CREDENTIAL_HELPER") != "1" { | 34 | + | if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_HELPER") != "1" { |
| 35 | return | 35 | return | ||
| 36 | } | 36 | } | ||
| 37 | - | if os.Getenv("ZOT_SWARM_CREDENTIAL_STDIN") != "1" { | 37 | + | if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_STDIN") != "1" { |
| 38 | os.Exit(2) | 38 | os.Exit(2) | ||
| 39 | } | 39 | } | ||
| 40 | var credential Credential | 40 | var credential Credential | ||
| @@ -58,8 +58,47 @@ func TestCredentialStdinHelperProcess(t *testing.T) { | |||||
| 58 | os.Exit(0) | 58 | os.Exit(0) | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | + | func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) { | |||
| 62 | + | t.Setenv("OPENAI_API_KEY", "openai-secret") | |||
| 63 | + | t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret") | |||
| 64 | + | t.Setenv("HF_TOKEN", "hf-secret") | |||
| 65 | + | t.Setenv("GITHUB_TOKEN", "github-secret") | |||
| 66 | + | t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret") | |||
| 67 | + | t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret") | |||
| 68 | + | t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret") | |||
| 69 | + | t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json") | |||
| 70 | + | t.Setenv("ZOT_SUBAGENT_TEST_VALUE", "not-secret") | |||
| 71 | + | env := strings.Join(workerEnvironment("openai"), "\n") | |||
| 72 | + | for _, secret := range []string{ | |||
| 73 | + | "OPENAI_API_KEY=openai-secret", | |||
| 74 | + | "CUSTOM_PROVIDER_API_KEY=custom-secret", | |||
| 75 | + | "HF_TOKEN=hf-secret", | |||
| 76 | + | "GITHUB_TOKEN=github-secret", | |||
| 77 | + | "CUSTOM_PROVIDER_TOKEN=token-secret", | |||
| 78 | + | "CUSTOM_PROVIDER_KEY=key-secret", | |||
| 79 | + | "AWS_ACCESS_KEY_ID=aws-access-secret", | |||
| 80 | + | "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json", | |||
| 81 | + | } { | |||
| 82 | + | if strings.Contains(env, secret) { | |||
| 83 | + | t.Fatalf("worker environment leaked %s", secret) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | if !strings.Contains(env, "ZOT_SUBAGENT_TEST_VALUE=not-secret") { | |||
| 87 | + | t.Fatal("worker environment dropped unrelated variables") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-secret") | |||
| 91 | + | bedrockEnv := strings.Join(workerEnvironment("amazon-bedrock"), "\n") | |||
| 92 | + | if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") { | |||
| 93 | + | t.Fatal("Bedrock ambient credential chain was unexpectedly removed") | |||
| 94 | + | } | |||
| 95 | + | if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") { | |||
| 96 | + | t.Fatal("Bedrock AWS access-key credential was unexpectedly removed") | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 61 | func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) { | 100 | func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) { | ||
| 62 | - | t.Setenv("ZOT_SWARM_CREDENTIAL_HELPER", "1") | 101 | + | t.Setenv("ZOT_SUBAGENT_CREDENTIAL_HELPER", "1") |
| 63 | root := t.TempDir() | 102 | root := t.TempDir() | ||
| 64 | a := &Agent{ | 103 | a := &Agent{ | ||
| 65 | ID: "credential-test", | 104 | ID: "credential-test", | ||
| @@ -106,8 +145,8 @@ func TestApplyEventPreservesMultilineRolesAndFinalAssistant(t *testing.T) { | |||||
| 106 | } | 145 | } | ||
| 107 | } | 146 | } | ||
| 108 | 147 | ||||
| 109 | - | func TestSwarmAgentArgs(t *testing.T) { | 148 | + | func TestSubagentWorkerArgs(t *testing.T) { |
| 110 | - | args := swarmAgentArgs(swarmAgentArgsOpts{ | 149 | + | args := subagentWorkerArgs(subagentWorkerArgsOpts{ |
| 111 | Exe: "/path/to/zot", | 150 | Exe: "/path/to/zot", | ||
| 112 | Dir: "/tmp/worktree", | 151 | Dir: "/tmp/worktree", | ||
| 113 | SessionPath: "/tmp/state/session.json", | 152 | SessionPath: "/tmp/state/session.json", | ||
| @@ -129,11 +168,11 @@ func TestSwarmAgentArgs(t *testing.T) { | |||||
| 129 | } | 168 | } | ||
| 130 | 169 | ||||
| 131 | mustHave := map[string]string{ | 170 | mustHave := map[string]string{ | ||
| 132 | - | "--swarm-agent": "/tmp/state/in.sock", | 171 | + | "--subagent-worker": "/tmp/state/in.sock", |
| 133 | - | "--session": "/tmp/state/session.json", | 172 | + | "--session": "/tmp/state/session.json", |
| 134 | - | "--cwd": "/tmp/worktree", | 173 | + | "--cwd": "/tmp/worktree", |
| 135 | - | "--reasoning": "high", | 174 | + | "--reasoning": "high", |
| 136 | - | "--subagent": "reviewer", | 175 | + | "--subagent": "reviewer", |
| 137 | } | 176 | } | ||
| 138 | for flag, value := range mustHave { | 177 | for flag, value := range mustHave { | ||
| 139 | i := indexOf(args, flag) | 178 | i := indexOf(args, flag) | ||
| @@ -155,11 +194,48 @@ func TestSwarmAgentArgs(t *testing.T) { | |||||
| 155 | } | 194 | } | ||
| 156 | } | 195 | } | ||
| 157 | 196 | ||||
| 158 | - | // TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the | 197 | + | func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) { |
| 198 | + | args := subagentWorkerArgs(subagentWorkerArgsOpts{ | |||
| 199 | + | Exe: "/zot", | |||
| 200 | + | Dir: "/work", | |||
| 201 | + | SessionPath: "/state/session.json", | |||
| 202 | + | InboxPath: "/state/inbox.sock", | |||
| 203 | + | BaseURL: "https://gateway.example.test/v1", | |||
| 204 | + | InsecureTLS: true, | |||
| 205 | + | }) | |||
| 206 | + | if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != "https://gateway.example.test/v1" { | |||
| 207 | + | t.Fatalf("argv = %v, want --base-url with inherited endpoint", args) | |||
| 208 | + | } | |||
| 209 | + | if !containsArg(args, "--insecure") { | |||
| 210 | + | t.Fatalf("argv = %v, want inherited --insecure", args) | |||
| 211 | + | } | |||
| 212 | + | for _, arg := range args { | |||
| 213 | + | if strings.Contains(arg, "api-key") || strings.Contains(arg, "secret") { | |||
| 214 | + | t.Fatalf("argv contains credential material: %v", args) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestDefaultChildArgsPropagatesProviderConnectionSettings(t *testing.T) { | |||
| 220 | + | a := &Agent{ | |||
| 221 | + | Dir: "/work", | |||
| 222 | + | BaseURL: "https://gateway.example.test/v1", | |||
| 223 | + | InsecureTLS: true, | |||
| 224 | + | } | |||
| 225 | + | args := defaultChildArgs("/zot", a, "/state/session.json", "/state/inbox.sock") | |||
| 226 | + | if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != a.BaseURL { | |||
| 227 | + | t.Fatalf("argv = %v, want Agent.BaseURL propagated", args) | |||
| 228 | + | } | |||
| 229 | + | if !containsArg(args, "--insecure") { | |||
| 230 | + | t.Fatalf("argv = %v, want Agent.InsecureTLS propagated", args) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // TestSubagentWorkerArgsEmptyTaskOmitsPositional makes sure that when the | |||
| 159 | // agent is being adopted (no fresh task) we don't pass an empty | 235 | // agent is being adopted (no fresh task) we don't pass an empty | ||
| 160 | // positional which the arg parser would treat as a real prompt. | 236 | // positional which the arg parser would treat as a real prompt. | ||
| 161 | - | func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) { | 237 | + | func TestSubagentWorkerArgsEmptyTaskOmitsPositional(t *testing.T) { |
| 162 | - | args := swarmAgentArgs(swarmAgentArgsOpts{ | 238 | + | args := subagentWorkerArgs(subagentWorkerArgsOpts{ |
| 163 | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | 239 | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | ||
| 164 | }) | 240 | }) | ||
| 165 | for _, a := range args { | 241 | for _, a := range args { | ||
| @@ -212,6 +288,40 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) { | |||||
| 212 | } | 288 | } | ||
| 213 | } | 289 | } | ||
| 214 | 290 | ||||
| 291 | + | func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) { | |||
| 292 | + | a := &Agent{} | |||
| 293 | + | a.setTurnState(TurnRunning, "turn-1") | |||
| 294 | + | persisted := 0 | |||
| 295 | + | a.persistFn = func(*Agent) { persisted++ } | |||
| 296 | + | ||||
| 297 | + | updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"})) | |||
| 298 | + | ||||
| 299 | + | if got := a.TurnState(); got != TurnRunning { | |||
| 300 | + | t.Fatalf("turn state = %q, want %q", got, TurnRunning) | |||
| 301 | + | } | |||
| 302 | + | if got := a.CurrentTurnID(); got != "turn-1" { | |||
| 303 | + | t.Fatalf("turn ID = %q, want turn-1", got) | |||
| 304 | + | } | |||
| 305 | + | if persisted != 0 { | |||
| 306 | + | t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) { | |||
| 311 | + | a := &Agent{} | |||
| 312 | + | persisted := 0 | |||
| 313 | + | a.persistFn = func(*Agent) { persisted++ } | |||
| 314 | + | ||||
| 315 | + | updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"})) | |||
| 316 | + | ||||
| 317 | + | if got := a.TurnState(); got != TurnFailed { | |||
| 318 | + | t.Fatalf("turn state = %q, want %q", got, TurnFailed) | |||
| 319 | + | } | |||
| 320 | + | if persisted != 1 { | |||
| 321 | + | t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | ||||
| 215 | func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) { | 325 | func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) { | ||
| 216 | called := make(chan struct{}, 1) | 326 | called := make(chan struct{}, 1) | ||
| 217 | a := &Agent{} | 327 | a := &Agent{} | ||
| @@ -251,6 +361,31 @@ func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) { | |||||
| 251 | } | 361 | } | ||
| 252 | } | 362 | } | ||
| 253 | 363 | ||||
| 364 | + | func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) { | |||
| 365 | + | a := &Agent{} | |||
| 366 | + | notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)})) | |||
| 367 | + | notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"})) | |||
| 368 | + | ||||
| 369 | + | type notice struct { | |||
| 370 | + | step int | |||
| 371 | + | err string | |||
| 372 | + | } | |||
| 373 | + | var got []notice | |||
| 374 | + | a.SetOnTurnEnd(func(step int, errMsg string) { | |||
| 375 | + | got = append(got, notice{step: step, err: errMsg}) | |||
| 376 | + | }) | |||
| 377 | + | ||||
| 378 | + | want := []notice{{step: 1}, {step: 2, err: "boom"}} | |||
| 379 | + | if len(got) != len(want) { | |||
| 380 | + | t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got) | |||
| 381 | + | } | |||
| 382 | + | for i := range want { | |||
| 383 | + | if got[i] != want[i] { | |||
| 384 | + | t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i]) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 254 | func indexOf(xs []string, want string) int { | 389 | func indexOf(xs []string, want string) int { | ||
| 255 | for i, x := range xs { | 390 | for i, x := range xs { | ||
| 256 | if x == want { | 391 | if x == want { | ||
A packages/agent/subagents/scheduler.go +374 -0
| @@ -0,0 +1,374 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error { | |||
| 14 | + | if err := f.validateSpawnOptions(req); err != nil { | |||
| 15 | + | return err | |||
| 16 | + | } | |||
| 17 | + | mode := req.WorkspaceMode | |||
| 18 | + | if mode == "" { | |||
| 19 | + | mode = WorkspaceShared | |||
| 20 | + | } | |||
| 21 | + | if mode != WorkspaceShared && mode != WorkspaceWorktree { | |||
| 22 | + | return fmt.Errorf("subagents: unknown workspace mode %q", mode) | |||
| 23 | + | } | |||
| 24 | + | if req.WorkspaceCapture != "" && req.WorkspaceCapture != CapturePatch && req.WorkspaceCapture != CaptureDiff { | |||
| 25 | + | return fmt.Errorf("subagents: unknown workspace capture mode %q", req.WorkspaceCapture) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | f.mu.Lock() | |||
| 29 | + | root := f.cfg.RepoRoot | |||
| 30 | + | allowedRoots := append([]string(nil), f.cfg.Policy.AllowedRoots...) | |||
| 31 | + | f.mu.Unlock() | |||
| 32 | + | return validateWorkspaceRoot(root, mode, allowedRoots) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error { | |||
| 36 | + | p := f.cfg.Policy | |||
| 37 | + | if req.MaxTurns < 0 || req.MaxTurns > p.MaxTurns { | |||
| 38 | + | return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns) | |||
| 39 | + | } | |||
| 40 | + | if req.Timeout < 0 { | |||
| 41 | + | return errorsInvalid("timeout must not be negative") | |||
| 42 | + | } | |||
| 43 | + | for _, tool := range req.Tools { | |||
| 44 | + | name := strings.TrimSpace(tool) | |||
| 45 | + | if name == "" { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | if !p.allowedTool(name) { | |||
| 49 | + | return fmt.Errorf("subagents: tool %q is not allowed by policy", name) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func validateWorkspaceRoot(root string, mode WorkspaceMode, allowedRoots []string) error { | |||
| 56 | + | if root == "" { | |||
| 57 | + | return errorsInvalid("repository root is empty") | |||
| 58 | + | } | |||
| 59 | + | if len(allowedRoots) > 0 { | |||
| 60 | + | ok := false | |||
| 61 | + | for _, allowed := range allowedRoots { | |||
| 62 | + | if pathWithin(root, allowed) { | |||
| 63 | + | ok = true | |||
| 64 | + | break | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | if !ok { | |||
| 68 | + | return fmt.Errorf("subagents: repository root is outside allowed roots") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | if mode == WorkspaceWorktree { | |||
| 72 | + | if _, err := os.Stat(filepath.Join(root, ".git")); err != nil { | |||
| 73 | + | // Worktrees can be attached to repositories with a .git file or | |||
| 74 | + | // a parent checkout. Let the workspace adapter provide the | |||
| 75 | + | // detailed Git error, but fail obvious non-checkouts here. | |||
| 76 | + | if _, statErr := os.Stat(filepath.Join(root, "HEAD")); statErr != nil { | |||
| 77 | + | return fmt.Errorf("subagents: worktree mode requires a Git checkout: %w", err) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | return nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func errorsInvalid(message string) error { return fmt.Errorf("subagents: %s", message) } | |||
| 85 | + | ||||
| 86 | + | func pathWithin(path, root string) bool { | |||
| 87 | + | pathAbs, err := containmentPath(path) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return false | |||
| 90 | + | } | |||
| 91 | + | rootAbs, err := containmentPath(root) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return false | |||
| 94 | + | } | |||
| 95 | + | pathAbs = filepath.Clean(pathAbs) | |||
| 96 | + | rootAbs = filepath.Clean(rootAbs) | |||
| 97 | + | rel, err := filepath.Rel(rootAbs, pathAbs) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return false | |||
| 100 | + | } | |||
| 101 | + | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // containmentPath returns a canonical path even when the final path does not | |||
| 105 | + | // exist yet. EvalSymlinks cannot resolve a missing event or session file, so | |||
| 106 | + | // resolve the nearest existing ancestor and append the missing components. | |||
| 107 | + | // Without this, a symlinked temporary directory can make a valid child path | |||
| 108 | + | // look outside its state directory on macOS and Windows. | |||
| 109 | + | func containmentPath(path string) (string, error) { | |||
| 110 | + | absolute, err := filepath.Abs(path) | |||
| 111 | + | if err != nil { | |||
| 112 | + | return "", err | |||
| 113 | + | } | |||
| 114 | + | absolute = filepath.Clean(absolute) | |||
| 115 | + | ||||
| 116 | + | current := absolute | |||
| 117 | + | var missing []string | |||
| 118 | + | for { | |||
| 119 | + | if _, statErr := os.Lstat(current); statErr == nil { | |||
| 120 | + | evaluated, evalErr := filepath.EvalSymlinks(current) | |||
| 121 | + | if evalErr != nil { | |||
| 122 | + | return "", evalErr | |||
| 123 | + | } | |||
| 124 | + | for i := len(missing) - 1; i >= 0; i-- { | |||
| 125 | + | evaluated = filepath.Join(evaluated, missing[i]) | |||
| 126 | + | } | |||
| 127 | + | return filepath.Clean(evaluated), nil | |||
| 128 | + | } else if !os.IsNotExist(statErr) { | |||
| 129 | + | return "", statErr | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | parent := filepath.Dir(current) | |||
| 133 | + | if parent == current { | |||
| 134 | + | return absolute, nil | |||
| 135 | + | } | |||
| 136 | + | missing = append(missing, filepath.Base(current)) | |||
| 137 | + | current = parent | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (f *Supervisor) schedule() { | |||
| 142 | + | var starts []*Agent | |||
| 143 | + | f.mu.Lock() | |||
| 144 | + | for len(f.queue) > 0 && f.active < f.cfg.Policy.MaxConcurrent { | |||
| 145 | + | idx := f.nextRunnableIndexLocked() | |||
| 146 | + | if idx < 0 { | |||
| 147 | + | break | |||
| 148 | + | } | |||
| 149 | + | a := f.queue[idx] | |||
| 150 | + | f.queue = append(f.queue[:idx], f.queue[idx+1:]...) | |||
| 151 | + | ||||
| 152 | + | a.mu.Lock() | |||
| 153 | + | if a.status != StatusPending { | |||
| 154 | + | a.mu.Unlock() | |||
| 155 | + | continue | |||
| 156 | + | } | |||
| 157 | + | a.status = StatusRunning | |||
| 158 | + | a.activity = "starting" | |||
| 159 | + | a.mu.Unlock() | |||
| 160 | + | a.setProcessState(ProcessStarting) | |||
| 161 | + | a.setTurnState(TurnIdle, "") | |||
| 162 | + | a.incrementAttempt() | |||
| 163 | + | ||||
| 164 | + | f.active++ | |||
| 165 | + | key := parentKey(a) | |||
| 166 | + | f.activeByParent[key]++ | |||
| 167 | + | if a.BatchID != "" { | |||
| 168 | + | f.activeByBatch[a.BatchID]++ | |||
| 169 | + | } | |||
| 170 | + | starts = append(starts, a) | |||
| 171 | + | } | |||
| 172 | + | f.mu.Unlock() | |||
| 173 | + | ||||
| 174 | + | for _, a := range starts { | |||
| 175 | + | f.persistAgent(a) | |||
| 176 | + | f.startLifecycleMonitor(a) | |||
| 177 | + | go f.run(a) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (f *Supervisor) startLifecycleMonitor(a *Agent) { | |||
| 182 | + | if a == nil || f.cfg.Policy.IdleTimeout <= 0 { | |||
| 183 | + | return | |||
| 184 | + | } | |||
| 185 | + | interval := f.cfg.Policy.HeartbeatInterval / 2 | |||
| 186 | + | if interval <= 0 || interval > time.Minute { | |||
| 187 | + | interval = time.Minute | |||
| 188 | + | } | |||
| 189 | + | go func() { | |||
| 190 | + | ticker := time.NewTicker(interval) | |||
| 191 | + | defer ticker.Stop() | |||
| 192 | + | heartbeatGrace := f.cfg.Policy.HeartbeatInterval + f.cfg.Policy.ReconnectTimeout | |||
| 193 | + | for { | |||
| 194 | + | select { | |||
| 195 | + | case <-a.done: | |||
| 196 | + | return | |||
| 197 | + | case <-ticker.C: | |||
| 198 | + | if a.ProcessState() != ProcessAlive { | |||
| 199 | + | continue | |||
| 200 | + | } | |||
| 201 | + | last := a.LastActivity() | |||
| 202 | + | if !last.IsZero() && heartbeatGrace > 0 && time.Since(last) >= heartbeatGrace { | |||
| 203 | + | if err := f.Ping(a.ID); err != nil { | |||
| 204 | + | a.setProcessState(ProcessDetached) | |||
| 205 | + | a.setActivity("detached") | |||
| 206 | + | f.persistAgent(a) | |||
| 207 | + | continue | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | if a.TurnState() == TurnIdle && !last.IsZero() && time.Since(last) >= f.cfg.Policy.IdleTimeout { | |||
| 211 | + | _ = f.Stop(a.ID) | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | }() | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Ping asks a live worker for a heartbeat. A detached or terminal worker | |||
| 220 | + | // returns the same not-ready error as other inbox operations. | |||
| 221 | + | func (f *Supervisor) Ping(id string) error { | |||
| 222 | + | a := f.Get(id) | |||
| 223 | + | if a == nil { | |||
| 224 | + | return fmt.Errorf("subagents: no such agent %q", id) | |||
| 225 | + | } | |||
| 226 | + | if a.inbox == nil { | |||
| 227 | + | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |||
| 228 | + | } | |||
| 229 | + | return a.inbox.SendCommand(NewCommand(CommandAgentPing, a.ID, a.CurrentTurnID(), AgentPingPayload{})) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func (f *Supervisor) nextRunnableIndexLocked() int { | |||
| 233 | + | if f.active >= f.cfg.Policy.MaxConcurrent { | |||
| 234 | + | return -1 | |||
| 235 | + | } | |||
| 236 | + | for i, a := range f.queue { | |||
| 237 | + | if a == nil { | |||
| 238 | + | continue | |||
| 239 | + | } | |||
| 240 | + | a.mu.Lock() | |||
| 241 | + | status := a.status | |||
| 242 | + | a.mu.Unlock() | |||
| 243 | + | if status != StatusPending { | |||
| 244 | + | continue | |||
| 245 | + | } | |||
| 246 | + | if f.cfg.Policy.MaxConcurrentPerParent > 0 && f.activeByParent[parentKey(a)] >= f.cfg.Policy.MaxConcurrentPerParent { | |||
| 247 | + | continue | |||
| 248 | + | } | |||
| 249 | + | if a.BatchID != "" { | |||
| 250 | + | if batch := f.batches[a.BatchID]; batch != nil && batch.MaxConcurrent > 0 && f.activeByBatch[a.BatchID] >= batch.MaxConcurrent { | |||
| 251 | + | continue | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | return i | |||
| 255 | + | } | |||
| 256 | + | return -1 | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func parentKey(a *Agent) string { | |||
| 260 | + | if a == nil || strings.TrimSpace(a.ParentID) == "" { | |||
| 261 | + | return "supervisor" | |||
| 262 | + | } | |||
| 263 | + | return a.ParentID | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func (f *Supervisor) armQueueTimeout(a *Agent) { | |||
| 267 | + | if a == nil { | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 270 | + | if d := f.cfg.Policy.QueueTimeout; d > 0 { | |||
| 271 | + | time.AfterFunc(d, func() { f.expireQueued(a.ID) }) | |||
| 272 | + | } | |||
| 273 | + | go func() { | |||
| 274 | + | <-a.ctx.Done() | |||
| 275 | + | f.cancelQueued(a.ID, a.ctx.Err()) | |||
| 276 | + | }() | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func (f *Supervisor) expireQueued(id string) { f.cancelQueued(id, context.DeadlineExceeded) } | |||
| 280 | + | ||||
| 281 | + | func (f *Supervisor) removeQueued(a *Agent) { | |||
| 282 | + | if a == nil { | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | f.mu.Lock() | |||
| 286 | + | for i, queued := range f.queue { | |||
| 287 | + | if queued == a { | |||
| 288 | + | f.queue = append(f.queue[:i], f.queue[i+1:]...) | |||
| 289 | + | break | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | f.mu.Unlock() | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func (f *Supervisor) cancelQueued(id string, cause error) { | |||
| 296 | + | a := f.Get(id) | |||
| 297 | + | if a == nil { | |||
| 298 | + | return | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // schedule and Stop use f.mu as the admission lock. Make queued | |||
| 302 | + | // cancellation take the same lock while it changes status and removes the | |||
| 303 | + | // entry, so a timeout/cancel cannot finalize an agent concurrently with | |||
| 304 | + | // scheduler admission. | |||
| 305 | + | f.mu.Lock() | |||
| 306 | + | a.mu.Lock() | |||
| 307 | + | if a.status != StatusPending { | |||
| 308 | + | a.mu.Unlock() | |||
| 309 | + | f.mu.Unlock() | |||
| 310 | + | return | |||
| 311 | + | } | |||
| 312 | + | a.status = StatusFailed | |||
| 313 | + | atomic.CompareAndSwapInt32(&a.launchState, 0, 2) | |||
| 314 | + | if cause == context.Canceled { | |||
| 315 | + | a.activity = "cancelled while queued" | |||
| 316 | + | } else { | |||
| 317 | + | a.activity = "queue timeout" | |||
| 318 | + | } | |||
| 319 | + | a.lastErr = cause | |||
| 320 | + | a.finished = f.cfg.Now() | |||
| 321 | + | for i, queued := range f.queue { | |||
| 322 | + | if queued == a { | |||
| 323 | + | f.queue = append(f.queue[:i], f.queue[i+1:]...) | |||
| 324 | + | break | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | a.mu.Unlock() | |||
| 328 | + | f.mu.Unlock() | |||
| 329 | + | a.setProcessState(ProcessExited) | |||
| 330 | + | if cause == context.Canceled { | |||
| 331 | + | a.setTurnState(TurnCanceled, "") | |||
| 332 | + | } else { | |||
| 333 | + | a.setTurnState(TurnFailed, "") | |||
| 334 | + | } | |||
| 335 | + | f.captureWorkspace(a) | |||
| 336 | + | f.ensureResult(a, StatusFailed, cause) | |||
| 337 | + | if a.cancel != nil { | |||
| 338 | + | a.cancel() | |||
| 339 | + | } | |||
| 340 | + | f.persistAgent(a) | |||
| 341 | + | if a.workspaceCleanup != nil { | |||
| 342 | + | _ = a.workspaceCleanup() | |||
| 343 | + | } | |||
| 344 | + | _ = a.releaseLease() | |||
| 345 | + | a.closeDone() | |||
| 346 | + | f.schedule() | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | func (f *Supervisor) releaseCapacity(a *Agent) { | |||
| 350 | + | f.mu.Lock() | |||
| 351 | + | if f.active > 0 { | |||
| 352 | + | f.active-- | |||
| 353 | + | } | |||
| 354 | + | key := parentKey(a) | |||
| 355 | + | if f.activeByParent[key] > 0 { | |||
| 356 | + | f.activeByParent[key]-- | |||
| 357 | + | } | |||
| 358 | + | if a != nil && a.BatchID != "" && f.activeByBatch[a.BatchID] > 0 { | |||
| 359 | + | f.activeByBatch[a.BatchID]-- | |||
| 360 | + | } | |||
| 361 | + | f.mu.Unlock() | |||
| 362 | + | f.schedule() | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func (f *Supervisor) persistAgent(a *Agent) { | |||
| 366 | + | if a == nil { | |||
| 367 | + | return | |||
| 368 | + | } | |||
| 369 | + | stateDir := a.stateDirectory(f.cfg.Root) | |||
| 370 | + | if _, err := os.Stat(stateDir); err != nil { | |||
| 371 | + | return | |||
| 372 | + | } | |||
| 373 | + | _ = writeAgentMeta(stateDir, a) | |||
| 374 | + | } | |||
A packages/agent/subagents/scheduler_test.go +263 -0
| @@ -0,0 +1,263 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestPathWithinResolvesMissingDescendantThroughSymlink(t *testing.T) { | |||
| 14 | + | realRoot := t.TempDir() | |||
| 15 | + | aliasParent := filepath.Join(t.TempDir(), "alias") | |||
| 16 | + | if err := os.Symlink(realRoot, aliasParent); err != nil { | |||
| 17 | + | t.Skipf("directory symlinks unavailable: %v", err) | |||
| 18 | + | } | |||
| 19 | + | stateDir := filepath.Join(aliasParent, "state") | |||
| 20 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | missingFile := filepath.Join(stateDir, "events.jsonl") | |||
| 24 | + | if !pathWithin(missingFile, stateDir) { | |||
| 25 | + | t.Fatalf("pathWithin(%q, %q) = false; want true", missingFile, stateDir) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) { | |||
| 30 | + | root := t.TempDir() | |||
| 31 | + | started := make(chan string, 3) | |||
| 32 | + | var active, maxActive int32 | |||
| 33 | + | f := New(Config{ | |||
| 34 | + | Root: root, RepoRoot: root, | |||
| 35 | + | Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 4, MaxTotalSpawned: 3, QueueTimeout: time.Minute, DefaultTimeout: 0, IdleTimeout: time.Hour}, | |||
| 36 | + | NewRunner: func(a *Agent) Runner { | |||
| 37 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 38 | + | current := atomic.AddInt32(&active, 1) | |||
| 39 | + | for { | |||
| 40 | + | old := atomic.LoadInt32(&maxActive) | |||
| 41 | + | if current <= old || atomic.CompareAndSwapInt32(&maxActive, old, current) { | |||
| 42 | + | break | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | started <- a.ID | |||
| 46 | + | <-ctx.Done() | |||
| 47 | + | atomic.AddInt32(&active, -1) | |||
| 48 | + | return ctx.Err() | |||
| 49 | + | }) | |||
| 50 | + | }, | |||
| 51 | + | }) | |||
| 52 | + | ||||
| 53 | + | first, err := f.Spawn(context.Background(), "first") | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | second, err := f.Spawn(context.Background(), "second") | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | third, err := f.Spawn(context.Background(), "third") | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | select { | |||
| 66 | + | case got := <-started: | |||
| 67 | + | if got != first.ID { | |||
| 68 | + | t.Fatalf("first started = %q, want %q", got, first.ID) | |||
| 69 | + | } | |||
| 70 | + | case <-time.After(time.Second): | |||
| 71 | + | t.Fatal("first worker did not start") | |||
| 72 | + | } | |||
| 73 | + | if second.Status() != StatusPending || third.Status() != StatusPending { | |||
| 74 | + | t.Fatalf("queued statuses = %s/%s", second.Status(), third.Status()) | |||
| 75 | + | } | |||
| 76 | + | if got := atomic.LoadInt32(&maxActive); got > 1 { | |||
| 77 | + | t.Fatalf("max active = %d, want <= 1", got) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | if err := f.Stop(first.ID); err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | first.Wait() | |||
| 84 | + | select { | |||
| 85 | + | case got := <-started: | |||
| 86 | + | if got != second.ID { | |||
| 87 | + | t.Fatalf("second started = %q, want %q", got, second.ID) | |||
| 88 | + | } | |||
| 89 | + | case <-time.After(time.Second): | |||
| 90 | + | t.Fatal("scheduler did not release capacity") | |||
| 91 | + | } | |||
| 92 | + | f.StopAll() | |||
| 93 | + | if got := atomic.LoadInt32(&maxActive); got > 1 { | |||
| 94 | + | t.Fatalf("max active = %d after release, want <= 1", got) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestSchedulerRejectsChildSpawningAndTotalBudget(t *testing.T) { | |||
| 99 | + | f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), Policy: SubagentPolicy{MaxTotalSpawned: 1}, NewRunner: func(*Agent) Runner { | |||
| 100 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 101 | + | }}) | |||
| 102 | + | if _, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "child", RequesterAgentID: "worker-1"}); err == nil { | |||
| 103 | + | t.Fatal("child-originated spawn was accepted") | |||
| 104 | + | } | |||
| 105 | + | first, err := f.Spawn(context.Background(), "one") | |||
| 106 | + | if err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | first.Wait() | |||
| 110 | + | if _, err := f.Spawn(context.Background(), "two"); err == nil || !strings.Contains(err.Error(), "budget") { | |||
| 111 | + | t.Fatalf("second spawn error = %v, want budget rejection", err) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestStopQueuedAgentBeforeRunnerAdmission(t *testing.T) { | |||
| 116 | + | root := t.TempDir() | |||
| 117 | + | started := make(chan string, 2) | |||
| 118 | + | f := New(Config{ | |||
| 119 | + | Root: root, RepoRoot: root, | |||
| 120 | + | Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 3, IdleTimeout: time.Hour}, | |||
| 121 | + | NewRunner: func(a *Agent) Runner { | |||
| 122 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 123 | + | started <- a.ID | |||
| 124 | + | <-ctx.Done() | |||
| 125 | + | return ctx.Err() | |||
| 126 | + | }) | |||
| 127 | + | }, | |||
| 128 | + | }) | |||
| 129 | + | first, err := f.Spawn(context.Background(), "first") | |||
| 130 | + | if err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | select { | |||
| 134 | + | case got := <-started: | |||
| 135 | + | if got != first.ID { | |||
| 136 | + | t.Fatalf("first runner = %q, want %q", got, first.ID) | |||
| 137 | + | } | |||
| 138 | + | case <-time.After(time.Second): | |||
| 139 | + | t.Fatal("first runner did not start") | |||
| 140 | + | } | |||
| 141 | + | queued, err := f.Spawn(context.Background(), "queued") | |||
| 142 | + | if err != nil { | |||
| 143 | + | t.Fatal(err) | |||
| 144 | + | } | |||
| 145 | + | if err := f.Stop(queued.ID); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | queued.Wait() | |||
| 149 | + | if queued.Status() != StatusKilled { | |||
| 150 | + | t.Fatalf("queued status = %s, want killed", queued.Status()) | |||
| 151 | + | } | |||
| 152 | + | select { | |||
| 153 | + | case got := <-started: | |||
| 154 | + | t.Fatalf("stopped queued agent still reached runner: %q", got) | |||
| 155 | + | case <-time.After(50 * time.Millisecond): | |||
| 156 | + | } | |||
| 157 | + | if err := f.Stop(first.ID); err != nil { | |||
| 158 | + | t.Fatal(err) | |||
| 159 | + | } | |||
| 160 | + | first.Wait() | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestCanceledQueuedAgentIsRemovedAndGetsResult(t *testing.T) { | |||
| 164 | + | root := t.TempDir() | |||
| 165 | + | started := make(chan struct{}) | |||
| 166 | + | f := New(Config{ | |||
| 167 | + | Root: root, RepoRoot: root, | |||
| 168 | + | Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 4, IdleTimeout: time.Hour}, | |||
| 169 | + | NewRunner: func(*Agent) Runner { | |||
| 170 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 171 | + | select { | |||
| 172 | + | case started <- struct{}{}: | |||
| 173 | + | default: | |||
| 174 | + | } | |||
| 175 | + | <-ctx.Done() | |||
| 176 | + | return ctx.Err() | |||
| 177 | + | }) | |||
| 178 | + | }, | |||
| 179 | + | }) | |||
| 180 | + | first, err := f.Spawn(context.Background(), "first") | |||
| 181 | + | if err != nil { | |||
| 182 | + | t.Fatal(err) | |||
| 183 | + | } | |||
| 184 | + | <-started | |||
| 185 | + | queued, err := f.Spawn(context.Background(), "queued") | |||
| 186 | + | if err != nil { | |||
| 187 | + | t.Fatal(err) | |||
| 188 | + | } | |||
| 189 | + | if queued.Status() != StatusPending { | |||
| 190 | + | t.Fatalf("queued status = %s, want pending", queued.Status()) | |||
| 191 | + | } | |||
| 192 | + | if err := f.Stop(queued.ID); err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | queued.Wait() | |||
| 196 | + | f.mu.Lock() | |||
| 197 | + | queueLen := len(f.queue) | |||
| 198 | + | f.mu.Unlock() | |||
| 199 | + | if queueLen != 0 { | |||
| 200 | + | t.Fatalf("canceled queue entries = %d, want 0", queueLen) | |||
| 201 | + | } | |||
| 202 | + | result, err := f.ReadResult(queued.ID) | |||
| 203 | + | if err != nil { | |||
| 204 | + | t.Fatal(err) | |||
| 205 | + | } | |||
| 206 | + | if result.Status != ResultCanceled { | |||
| 207 | + | t.Fatalf("queued cancellation result = %s, want canceled", result.Status) | |||
| 208 | + | } | |||
| 209 | + | if err := f.Stop(first.ID); err != nil { | |||
| 210 | + | t.Fatal(err) | |||
| 211 | + | } | |||
| 212 | + | first.Wait() | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestSpawnIDsRemainUniqueWithSameClock(t *testing.T) { | |||
| 216 | + | now := time.Unix(123, 0) | |||
| 217 | + | f := New(Config{ | |||
| 218 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now }, | |||
| 219 | + | NewRunner: func(*Agent) Runner { | |||
| 220 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 221 | + | }, | |||
| 222 | + | }) | |||
| 223 | + | first, err := f.Spawn(context.Background(), "same task") | |||
| 224 | + | if err != nil { | |||
| 225 | + | t.Fatal(err) | |||
| 226 | + | } | |||
| 227 | + | second, err := f.Spawn(context.Background(), "same task") | |||
| 228 | + | if err != nil { | |||
| 229 | + | t.Fatal(err) | |||
| 230 | + | } | |||
| 231 | + | first.Wait() | |||
| 232 | + | second.Wait() | |||
| 233 | + | if first.ID == second.ID { | |||
| 234 | + | t.Fatalf("duplicate agent ids: %q", first.ID) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestStructuredFallbackResultIsDurable(t *testing.T) { | |||
| 239 | + | root := t.TempDir() | |||
| 240 | + | f := New(Config{ | |||
| 241 | + | Root: root, RepoRoot: root, | |||
| 242 | + | NewRunner: func(*Agent) Runner { | |||
| 243 | + | return RunnerFunc(func(context.Context, Sink) error { | |||
| 244 | + | return nil | |||
| 245 | + | }) | |||
| 246 | + | }, | |||
| 247 | + | }) | |||
| 248 | + | a, err := f.Spawn(context.Background(), "result fallback") | |||
| 249 | + | if err != nil { | |||
| 250 | + | t.Fatal(err) | |||
| 251 | + | } | |||
| 252 | + | a.Wait() | |||
| 253 | + | result, err := f.ReadResult(a.ID) | |||
| 254 | + | if err != nil { | |||
| 255 | + | t.Fatal(err) | |||
| 256 | + | } | |||
| 257 | + | if result.Status != ResultSucceeded || result.AgentID != a.ID || result.TurnID == "" { | |||
| 258 | + | t.Fatalf("result = %+v", result) | |||
| 259 | + | } | |||
| 260 | + | if _, err := os.Stat(filepath.Join(root, "agents", a.ID, "result.json")); err != nil { | |||
| 261 | + | t.Fatalf("result file missing: %v", err) | |||
| 262 | + | } | |||
| 263 | + | } | |||
R packages/agent/swarm/socketpath.go → packages/agent/subagents/socketpath.go +5 -5 renamed, 91% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "crypto/sha1" | 4 | "crypto/sha1" | ||
| @@ -18,7 +18,7 @@ import ( | |||||
| 18 | const maxUnixSocketPath = 100 | 18 | const maxUnixSocketPath = 100 | ||
| 19 | 19 | ||||
| 20 | // inboxSocketPath returns a per-agent unix-socket path in a transient | 20 | // inboxSocketPath returns a per-agent unix-socket path in a transient | ||
| 21 | - | // runtime directory. Durable swarm state can live on filesystems such as | 21 | + | // runtime directory. Durable subagent state can live on filesystems such as |
| 22 | // NFS, 9p, or shared VM mounts that support regular files but reject unix | 22 | // NFS, 9p, or shared VM mounts that support regular files but reject unix | ||
| 23 | // socket nodes, so the socket must not be placed below root. | 23 | // socket nodes, so the socket must not be placed below root. | ||
| 24 | // | 24 | // | ||
| @@ -33,7 +33,7 @@ const maxUnixSocketPath = 100 | |||||
| 33 | // stable path across parent and child processes and across Resume calls. | 33 | // stable path across parent and child processes and across Resume calls. | ||
| 34 | func inboxSocketPath(root, agentID string) (string, error) { | 34 | func inboxSocketPath(root, agentID string) (string, error) { | ||
| 35 | if runtime.GOOS == "windows" { | 35 | if runtime.GOOS == "windows" { | ||
| 36 | - | // Keep swarm construction usable for callers with custom runners. | 36 | + | // Keep subagent construction usable for callers with custom runners. |
| 37 | // The production listener still reports that its unix transport is | 37 | // The production listener still reports that its unix transport is | ||
| 38 | // unavailable, as it did before runtime-directory probing was added. | 38 | // unavailable, as it did before runtime-directory probing was added. | ||
| 39 | return filepath.Join(root, "agents", agentID, "in.sock"), nil | 39 | return filepath.Join(root, "agents", agentID, "in.sock"), nil | ||
| @@ -65,7 +65,7 @@ func inboxSocketPath(root, agentID string) (string, error) { | |||||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | func socketPathInBase(base, root, agentID string) (string, error) { | 67 | func socketPathInBase(base, root, agentID string) (string, error) { | ||
| 68 | - | dir := filepath.Join(base, "zot-swarm-"+rootTag(root)) | 68 | + | dir := filepath.Join(base, "zot-subagents-"+rootTag(root)) |
| 69 | if err := os.MkdirAll(dir, 0o700); err != nil { | 69 | if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| 70 | return "", fmt.Errorf("socket dir %s: %w", dir, err) | 70 | return "", fmt.Errorf("socket dir %s: %w", dir, err) | ||
| 71 | } | 71 | } | ||
| @@ -109,7 +109,7 @@ func probeUnixSocket(dir string) error { | |||||
| 109 | return errors.Join(closeErr, removeErr) | 109 | return errors.Join(closeErr, removeErr) | ||
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | - | // rootTag returns a stable 8-hex-char tag for the swarm root. Used | 112 | + | // rootTag returns a stable 8-hex-char tag for the subagent root. Used |
| 113 | // in the runtime-directory name so two parallel zot instances with | 113 | // in the runtime-directory name so two parallel zot instances with | ||
| 114 | // different roots don't share sockets. | 114 | // different roots don't share sockets. | ||
| 115 | func rootTag(root string) string { return shortHash(root) } | 115 | func rootTag(root string) string { return shortHash(root) } | ||
R packages/agent/swarm/socketpath_test.go → packages/agent/subagents/socketpath_test.go +4 -4 renamed, 89% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) { | 11 | func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) { | ||
| 12 | if runtime.GOOS == "windows" { | 12 | if runtime.GOOS == "windows" { | ||
| 13 | - | t.Skip("swarm inbox transport uses Unix-domain sockets") | 13 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") |
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | runtimeDir := shortSocketDir(t) | 16 | runtimeDir := shortSocketDir(t) | ||
| @@ -31,7 +31,7 @@ func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) { | |||||
| 31 | 31 | ||||
| 32 | func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) { | 32 | func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) { | ||
| 33 | if runtime.GOOS == "windows" { | 33 | if runtime.GOOS == "windows" { | ||
| 34 | - | t.Skip("swarm inbox transport uses Unix-domain sockets") | 34 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") |
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | badRuntime := filepath.Join(shortSocketDir(t), "not-a-directory") | 37 | badRuntime := filepath.Join(shortSocketDir(t), "not-a-directory") | ||
| @@ -53,7 +53,7 @@ func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) { | |||||
| 53 | 53 | ||||
| 54 | func TestInboxSocketPathShortensLongAgentID(t *testing.T) { | 54 | func TestInboxSocketPathShortensLongAgentID(t *testing.T) { | ||
| 55 | if runtime.GOOS == "windows" { | 55 | if runtime.GOOS == "windows" { | ||
| 56 | - | t.Skip("swarm inbox transport uses Unix-domain sockets") | 56 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") |
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | t.Setenv("XDG_RUNTIME_DIR", shortSocketDir(t)) | 59 | t.Setenv("XDG_RUNTIME_DIR", shortSocketDir(t)) | ||
A packages/agent/subagents/supervisor.go +1190 -0
| @@ -0,0 +1,1190 @@ | |||||
| 1 | + | // Package subagents implements zot's subagent supervisor. | |||
| 2 | + | // | |||
| 3 | + | // A Supervisor manages headless, long-lived zot subprocesses and keeps their | |||
| 4 | + | // process/turn state, event logs, sessions, results, and optional worktree | |||
| 5 | + | // artifacts durable across supervisor restarts. | |||
| 6 | + | // | |||
| 7 | + | // Shared workspaces preserve the historical behavior, while an explicit | |||
| 8 | + | // worktree request gives a child an isolated checkout whose patch is captured | |||
| 9 | + | // before cleanup. The Runner abstraction lets tests inject a fake process; | |||
| 10 | + | // production uses `zot --subagent-worker ...`. | |||
| 11 | + | package subagents | |||
| 12 | + | ||||
| 13 | + | import ( | |||
| 14 | + | "context" | |||
| 15 | + | "errors" | |||
| 16 | + | "fmt" | |||
| 17 | + | "os" | |||
| 18 | + | "path/filepath" | |||
| 19 | + | "sort" | |||
| 20 | + | "strings" | |||
| 21 | + | "sync" | |||
| 22 | + | "sync/atomic" | |||
| 23 | + | "time" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // Status is the high-level lifecycle state of an Agent. | |||
| 27 | + | type Status string | |||
| 28 | + | ||||
| 29 | + | const ( | |||
| 30 | + | StatusPending Status = "pending" // created, not started yet | |||
| 31 | + | StatusRunning Status = "running" // Runner.Run is in flight | |||
| 32 | + | StatusDone Status = "done" // Runner.Run returned nil | |||
| 33 | + | StatusFailed Status = "failed" // Runner.Run returned an error | |||
| 34 | + | StatusKilled Status = "killed" // Stop() called before completion | |||
| 35 | + | StatusDetached Status = "detached" // reloaded from disk; no live runner | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | // Config configures a Supervisor. | |||
| 39 | + | type Config struct { | |||
| 40 | + | // Context is the supervisor lifetime. Child workers use it as their | |||
| 41 | + | // parent context rather than inheriting an individual tool/model-turn | |||
| 42 | + | // context. A nil value means context.Background(). | |||
| 43 | + | Context context.Context | |||
| 44 | + | ||||
| 45 | + | // Root is the directory under which per-agent state files live. | |||
| 46 | + | // Typically <ZotHome>/subagents, but tests pass a tempdir. | |||
| 47 | + | Root string | |||
| 48 | + | ||||
| 49 | + | // Policy owns concurrency, timeout, output, and path safety limits. | |||
| 50 | + | Policy SubagentPolicy | |||
| 51 | + | ||||
| 52 | + | // RepoRoot is the repository used by spawned agents. Shared-mode children | |||
| 53 | + | // run here; worktree-mode children use an isolated checkout created from | |||
| 54 | + | // this root. | |||
| 55 | + | RepoRoot string | |||
| 56 | + | ||||
| 57 | + | // Provider is the active host provider. It is used only to decide whether | |||
| 58 | + | // an explicit child provider may inherit the host endpoint settings. | |||
| 59 | + | Provider string | |||
| 60 | + | ||||
| 61 | + | // FastMode is copied to every newly spawned child and persisted so | |||
| 62 | + | // child subprocesses use the same OpenAI service-tier setting. | |||
| 63 | + | FastMode bool | |||
| 64 | + | ||||
| 65 | + | // BaseURL and InsecureTLS are the effective provider connection | |||
| 66 | + | // settings inherited by newly spawned children. They are copied to | |||
| 67 | + | // each Agent and persisted so a reload/resume does not silently fall | |||
| 68 | + | // back to the child's independent provider configuration. | |||
| 69 | + | BaseURL string | |||
| 70 | + | InsecureTLS bool | |||
| 71 | + | ||||
| 72 | + | // NewRunner produces the Runner for an Agent. If nil, the default | |||
| 73 | + | // `zot --subagent-worker ...` exec runner is used. Tests inject a fake | |||
| 74 | + | // here. | |||
| 75 | + | NewRunner func(a *Agent) Runner | |||
| 76 | + | ||||
| 77 | + | // ResolveCredential resolves the credential inherited by a child | |||
| 78 | + | // process. The default runner transfers it over the child's stdin, | |||
| 79 | + | // keeping command-backed credentials out of argv and the environment. | |||
| 80 | + | ResolveCredential func(ctx context.Context, provider string) (Credential, error) | |||
| 81 | + | ||||
| 82 | + | // Now is a clock seam for tests; defaults to time.Now. | |||
| 83 | + | Now func() time.Time | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Credential is the resolved authentication state inherited by a subagent child. | |||
| 87 | + | type Credential struct { | |||
| 88 | + | Value string `json:"value"` | |||
| 89 | + | Method string `json:"method"` | |||
| 90 | + | AccountID string `json:"account_id,omitempty"` | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Runner executes one agent task. Run blocks until the task finishes, | |||
| 94 | + | // is cancelled via ctx, or hits an unrecoverable error. | |||
| 95 | + | // | |||
| 96 | + | // Run should report progress by writing short human-readable strings | |||
| 97 | + | // to the activity channel and final transcript text to transcript. | |||
| 98 | + | // Both channels are non-blocking sinks owned by the Supervisor; if the | |||
| 99 | + | // dashboard isn't reading, sends are dropped. | |||
| 100 | + | type Runner interface { | |||
| 101 | + | Run(ctx context.Context, sink Sink) error | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Sink is how a Runner reports activity and transcript back to the | |||
| 105 | + | // supervisor. All methods are safe to call from any goroutine and | |||
| 106 | + | // never block. | |||
| 107 | + | type Sink interface { | |||
| 108 | + | // Activity sets the one-line "what is this agent doing right now" | |||
| 109 | + | // string shown in the dashboard. | |||
| 110 | + | Activity(msg string) | |||
| 111 | + | // Transcript appends a chunk of agent output (typically a final | |||
| 112 | + | // assistant message) to the agent's running transcript. | |||
| 113 | + | Transcript(chunk string) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Supervisor supervises a set of Agents. | |||
| 117 | + | type Supervisor struct { | |||
| 118 | + | cfg Config | |||
| 119 | + | ||||
| 120 | + | mu sync.Mutex | |||
| 121 | + | operationMu sync.Mutex | |||
| 122 | + | lifetimeCtx context.Context | |||
| 123 | + | lifetimeCancel context.CancelFunc | |||
| 124 | + | agents map[string]*Agent | |||
| 125 | + | order []string // creation order for stable listing | |||
| 126 | + | queue []*Agent | |||
| 127 | + | active int | |||
| 128 | + | activeByParent map[string]int | |||
| 129 | + | activeByBatch map[string]int | |||
| 130 | + | totalSpawned int | |||
| 131 | + | batches map[string]*Batch | |||
| 132 | + | ||||
| 133 | + | // activeSession is the host session id the dashboard is | |||
| 134 | + | // currently scoped to. When non-empty, SnapshotAll filters out | |||
| 135 | + | // agents whose SessionID doesn't match (unscoped | |||
| 136 | + | // agents with SessionID == "" are always shown). Spawn stamps | |||
| 137 | + | // new agents with this value so they appear only in the session | |||
| 138 | + | // that created them. When empty (the default), the historical | |||
| 139 | + | // "show everything" behaviour is preserved — important for | |||
| 140 | + | // tests and any scripted use of the Supervisor that doesn't bother | |||
| 141 | + | // with sessions. | |||
| 142 | + | activeSession string | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // New constructs a Supervisor from cfg. Missing config fields are filled | |||
| 146 | + | // with defaults. | |||
| 147 | + | func New(cfg Config) *Supervisor { | |||
| 148 | + | if cfg.Now == nil { | |||
| 149 | + | cfg.Now = time.Now | |||
| 150 | + | } | |||
| 151 | + | if cfg.NewRunner == nil { | |||
| 152 | + | cfg.NewRunner = func(a *Agent) Runner { | |||
| 153 | + | return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential} | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | cfg.Policy.normalize() | |||
| 157 | + | lifetimeParent := cfg.Context | |||
| 158 | + | if lifetimeParent == nil { | |||
| 159 | + | lifetimeParent = context.Background() | |||
| 160 | + | } | |||
| 161 | + | lifetimeCtx, lifetimeCancel := context.WithCancel(lifetimeParent) | |||
| 162 | + | return &Supervisor{ | |||
| 163 | + | cfg: cfg, | |||
| 164 | + | lifetimeCtx: lifetimeCtx, | |||
| 165 | + | lifetimeCancel: lifetimeCancel, | |||
| 166 | + | agents: map[string]*Agent{}, | |||
| 167 | + | activeByParent: map[string]int{}, | |||
| 168 | + | activeByBatch: map[string]int{}, | |||
| 169 | + | batches: map[string]*Batch{}, | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // workerContext derives a child worker context from the supervisor lifetime. | |||
| 174 | + | // A positive timeout limits the worker lifetime; otherwise it is cancellable | |||
| 175 | + | // only when the supervisor shuts down or the agent is stopped. | |||
| 176 | + | func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, context.CancelFunc) { | |||
| 177 | + | base := f.lifetimeCtx | |||
| 178 | + | if base == nil { | |||
| 179 | + | base = context.Background() | |||
| 180 | + | } | |||
| 181 | + | if timeout > 0 { | |||
| 182 | + | return context.WithTimeout(base, timeout) | |||
| 183 | + | } | |||
| 184 | + | return context.WithCancel(base) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // SetRepoRoot updates the shared working directory for subsequently | |||
| 188 | + | // spawned agents. Existing agents keep the directory they were started | |||
| 189 | + | // with; callers use this when the host session changes cwd. | |||
| 190 | + | func (f *Supervisor) SetRepoRoot(root string) { | |||
| 191 | + | f.mu.Lock() | |||
| 192 | + | defer f.mu.Unlock() | |||
| 193 | + | f.cfg.RepoRoot = root | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // RepoRoot returns the working directory used by subsequently spawned | |||
| 197 | + | // agents. | |||
| 198 | + | func (f *Supervisor) RepoRoot() string { | |||
| 199 | + | f.mu.Lock() | |||
| 200 | + | defer f.mu.Unlock() | |||
| 201 | + | return f.cfg.RepoRoot | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // SetFastMode updates the fast-mode setting for subsequently spawned | |||
| 205 | + | // children. Existing agents keep the setting captured at their spawn | |||
| 206 | + | // boundary so resumes remain deterministic. | |||
| 207 | + | func (f *Supervisor) SetFastMode(enabled bool) { | |||
| 208 | + | f.mu.Lock() | |||
| 209 | + | f.cfg.FastMode = enabled | |||
| 210 | + | f.mu.Unlock() | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // FastMode reports the setting used for subsequently spawned children. | |||
| 214 | + | func (f *Supervisor) FastMode() bool { | |||
| 215 | + | f.mu.Lock() | |||
| 216 | + | defer f.mu.Unlock() | |||
| 217 | + | return f.cfg.FastMode | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // SetProvider updates the provider used by subsequently spawned children. | |||
| 221 | + | // Existing agents keep their persisted provider selection. | |||
| 222 | + | func (f *Supervisor) SetProvider(provider string) { | |||
| 223 | + | f.mu.Lock() | |||
| 224 | + | f.cfg.Provider = strings.TrimSpace(provider) | |||
| 225 | + | f.mu.Unlock() | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // SetProviderSettings updates the effective connection settings used by | |||
| 229 | + | // subsequently spawned children. Existing agents keep their persisted values | |||
| 230 | + | // so a model/provider switch cannot silently change a running or resumed | |||
| 231 | + | // worker's endpoint. | |||
| 232 | + | func (f *Supervisor) SetProviderSettings(baseURL string, insecureTLS bool) { | |||
| 233 | + | f.mu.Lock() | |||
| 234 | + | f.cfg.BaseURL = strings.TrimSpace(baseURL) | |||
| 235 | + | f.cfg.InsecureTLS = insecureTLS | |||
| 236 | + | f.mu.Unlock() | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // ProviderSettings returns the settings used by subsequently spawned | |||
| 240 | + | // children. It is primarily useful to host integrations that need to verify | |||
| 241 | + | // the manager follows the active provider selection. | |||
| 242 | + | func (f *Supervisor) ProviderSettings() (string, bool) { | |||
| 243 | + | f.mu.Lock() | |||
| 244 | + | defer f.mu.Unlock() | |||
| 245 | + | return f.cfg.BaseURL, f.cfg.InsecureTLS | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |||
| 249 | + | // to a particular host zot session id. Pass empty to clear the | |||
| 250 | + | // scope and revert to "show every agent" (the original behaviour). | |||
| 251 | + | // | |||
| 252 | + | // Existing in-memory agents keep their SessionID; only the filter | |||
| 253 | + | // applied at snapshot time changes. So swapping the active session | |||
| 254 | + | // with /sessions instantly re-narrows the dashboard without | |||
| 255 | + | // touching any agent state. | |||
| 256 | + | func (f *Supervisor) SetActiveSession(id string) { | |||
| 257 | + | f.mu.Lock() | |||
| 258 | + | defer f.mu.Unlock() | |||
| 259 | + | f.activeSession = id | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // ActiveSession returns the current scope, mostly for tests and | |||
| 263 | + | // diagnostics. Empty means "no scope; show everything". | |||
| 264 | + | func (f *Supervisor) ActiveSession() string { | |||
| 265 | + | f.mu.Lock() | |||
| 266 | + | defer f.mu.Unlock() | |||
| 267 | + | return f.activeSession | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // agentStateDir is the per-agent state directory laid out as: | |||
| 271 | + | // | |||
| 272 | + | // <root>/agents/<id>/ | |||
| 273 | + | // events.jsonl durable event log (runner-owned) | |||
| 274 | + | // session.json persistent agent session (child-owned) | |||
| 275 | + | // meta.json static metadata (id, task) | |||
| 276 | + | // | |||
| 277 | + | // The transient unix socket inbox lives in a probed runtime directory, | |||
| 278 | + | // which may be on a different filesystem from this durable state. | |||
| 279 | + | func (f *Supervisor) agentStateDir(id string) string { | |||
| 280 | + | return filepath.Join(f.cfg.Root, "agents", id) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func (f *Supervisor) uniqueAgentIDLocked(base string) string { | |||
| 284 | + | id := base | |||
| 285 | + | for suffix := 2; ; suffix++ { | |||
| 286 | + | _, inMemory := f.agents[id] | |||
| 287 | + | _, onDiskErr := os.Stat(f.agentStateDir(id)) | |||
| 288 | + | if !inMemory && onDiskErr != nil { | |||
| 289 | + | return id | |||
| 290 | + | } | |||
| 291 | + | id = fmt.Sprintf("%s-%d", base, suffix) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // SpawnRequest configures a Spawn. Only Task is required; the rest | |||
| 296 | + | // are optional. Model + Provider, when set, get baked into the | |||
| 297 | + | // child argv as --model / --provider so the agent runs against the | |||
| 298 | + | // chosen model regardless of the parent's current selection. A named | |||
| 299 | + | // Subagent is passed to the child as --subagent, where it resolves the | |||
| 300 | + | // markdown profile and applies its instructions. Reasoning is passed | |||
| 301 | + | // as --reasoning when set. FastMode restricts the host setting when a | |||
| 302 | + | // selected profile declares a fastMode preference. | |||
| 303 | + | type SpawnRequest struct { | |||
| 304 | + | Task string | |||
| 305 | + | Model string // optional override; child resolves default if empty | |||
| 306 | + | Provider string // optional override; usually paired with Model | |||
| 307 | + | BaseURL string // optional provider endpoint override | |||
| 308 | + | // InsecureTLS enables the child to skip TLS verification for its | |||
| 309 | + | // effective custom provider endpoint. | |||
| 310 | + | InsecureTLS bool | |||
| 311 | + | Reasoning string // optional reasoning/thinking level override | |||
| 312 | + | // FastMode is an optional profile restriction. A non-nil false value | |||
| 313 | + | // disables fast mode for this child; true still cannot enable fast mode | |||
| 314 | + | // when Config.FastMode is false. Nil inherits Config.FastMode. | |||
| 315 | + | FastMode *bool | |||
| 316 | + | Subagent string // optional named markdown profile | |||
| 317 | + | ||||
| 318 | + | // ParentID is metadata used for per-parent scheduling limits. A | |||
| 319 | + | // non-empty RequesterAgentID identifies an untrusted child request; | |||
| 320 | + | // recursive spawning is rejected in v1. | |||
| 321 | + | ParentID string | |||
| 322 | + | BatchID string | |||
| 323 | + | RootSessionID string | |||
| 324 | + | RequesterAgentID string | |||
| 325 | + | ||||
| 326 | + | Timeout time.Duration | |||
| 327 | + | MaxTurns int | |||
| 328 | + | Tools []string | |||
| 329 | + | WorkspaceMode WorkspaceMode | |||
| 330 | + | WorkspaceBase string | |||
| 331 | + | WorkspaceCapture CaptureMode | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // Spawn creates a new Agent for the given task, allocates its on-disk | |||
| 335 | + | // state directory (events log, inbox socket path, session file path), and | |||
| 336 | + | // queues it for the manager-owned scheduler. The returned Agent is pending | |||
| 337 | + | // when the concurrency budget is full and transitions to running when a slot | |||
| 338 | + | // opens. This is the historical signature; callers that want to override the | |||
| 339 | + | // child's model or workspace use SpawnReq instead. | |||
| 340 | + | func (f *Supervisor) Spawn(ctx context.Context, task string) (*Agent, error) { | |||
| 341 | + | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest. | |||
| 345 | + | // Existing callers can keep using Spawn; new code that wants to pin the | |||
| 346 | + | // child's model, policy, or workspace uses this. | |||
| 347 | + | func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | |||
| 348 | + | task := strings.TrimSpace(req.Task) | |||
| 349 | + | if task == "" { | |||
| 350 | + | return nil, errors.New("subagents: empty task") | |||
| 351 | + | } | |||
| 352 | + | if req.RequesterAgentID != "" { | |||
| 353 | + | return nil, errors.New("subagents: child-originated spawning is disabled") | |||
| 354 | + | } | |||
| 355 | + | if err := f.validateSpawnRequest(req); err != nil { | |||
| 356 | + | return nil, err | |||
| 357 | + | } | |||
| 358 | + | if ctx == nil { | |||
| 359 | + | ctx = context.Background() | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | now := f.cfg.Now() | |||
| 363 | + | baseID := newAgentID(task, now) | |||
| 364 | + | ||||
| 365 | + | // Reserve a spawn budget before touching disk. This makes the total | |||
| 366 | + | // budget race-free across slash commands, tools, and batch callers. | |||
| 367 | + | f.mu.Lock() | |||
| 368 | + | id := f.uniqueAgentIDLocked(baseID) | |||
| 369 | + | if f.totalSpawned >= f.cfg.Policy.MaxTotalSpawned { | |||
| 370 | + | f.mu.Unlock() | |||
| 371 | + | return nil, fmt.Errorf("subagents: total spawn budget exhausted (%d)", f.cfg.Policy.MaxTotalSpawned) | |||
| 372 | + | } | |||
| 373 | + | f.totalSpawned++ | |||
| 374 | + | dir := f.cfg.RepoRoot | |||
| 375 | + | fastMode := f.cfg.FastMode | |||
| 376 | + | configProvider := f.cfg.Provider | |||
| 377 | + | configBaseURL := f.cfg.BaseURL | |||
| 378 | + | configInsecureTLS := f.cfg.InsecureTLS | |||
| 379 | + | sessionID := f.activeSession | |||
| 380 | + | f.mu.Unlock() | |||
| 381 | + | if req.FastMode != nil { | |||
| 382 | + | fastMode = fastMode && *req.FastMode | |||
| 383 | + | } | |||
| 384 | + | childProvider := strings.TrimSpace(req.Provider) | |||
| 385 | + | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | |||
| 386 | + | baseURL := strings.TrimSpace(req.BaseURL) | |||
| 387 | + | if baseURL == "" && inheritProviderSettings { | |||
| 388 | + | baseURL = strings.TrimSpace(configBaseURL) | |||
| 389 | + | } | |||
| 390 | + | insecureTLS := req.InsecureTLS || (inheritProviderSettings && configInsecureTLS) | |||
| 391 | + | reserved := true | |||
| 392 | + | defer func() { | |||
| 393 | + | if reserved { | |||
| 394 | + | f.mu.Lock() | |||
| 395 | + | f.totalSpawned-- | |||
| 396 | + | f.mu.Unlock() | |||
| 397 | + | } | |||
| 398 | + | }() | |||
| 399 | + | ||||
| 400 | + | stateDir := f.agentStateDir(id) | |||
| 401 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 402 | + | return nil, fmt.Errorf("subagents state dir: %w", err) | |||
| 403 | + | } | |||
| 404 | + | lease, err := acquireAgentLease(stateDir) | |||
| 405 | + | if err != nil { | |||
| 406 | + | return nil, fmt.Errorf("subagents agent lease: %w", err) | |||
| 407 | + | } | |||
| 408 | + | leaseOwned := true | |||
| 409 | + | defer func() { | |||
| 410 | + | if leaseOwned { | |||
| 411 | + | _ = lease.Close() | |||
| 412 | + | } | |||
| 413 | + | }() | |||
| 414 | + | logPath := filepath.Join(stateDir, "events.jsonl") | |||
| 415 | + | sessionPath := filepath.Join(stateDir, "session.json") | |||
| 416 | + | // Keep the transient unix socket outside the durable state root. | |||
| 417 | + | // ZOT_HOME may live on a shared or network filesystem that supports | |||
| 418 | + | // regular state files but cannot host unix socket nodes. | |||
| 419 | + | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | |||
| 420 | + | if err != nil { | |||
| 421 | + | _ = lease.Close() | |||
| 422 | + | leaseOwned = false | |||
| 423 | + | _ = os.RemoveAll(stateDir) | |||
| 424 | + | return nil, fmt.Errorf("subagents inbox path: %w", err) | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | workspaceMode := req.WorkspaceMode | |||
| 428 | + | if workspaceMode == "" { | |||
| 429 | + | workspaceMode = WorkspaceShared | |||
| 430 | + | } | |||
| 431 | + | workspace, err := PrepareWorkspace(ctx, WorkspaceRequest{ | |||
| 432 | + | Mode: workspaceMode, | |||
| 433 | + | RepositoryRoot: dir, | |||
| 434 | + | StateDir: stateDir, | |||
| 435 | + | AgentID: id, | |||
| 436 | + | Base: req.WorkspaceBase, | |||
| 437 | + | Capture: req.WorkspaceCapture, | |||
| 438 | + | AllowedRoots: append([]string(nil), f.cfg.Policy.AllowedRoots...), | |||
| 439 | + | }) | |||
| 440 | + | if err != nil { | |||
| 441 | + | _ = lease.Close() | |||
| 442 | + | leaseOwned = false | |||
| 443 | + | _ = os.RemoveAll(stateDir) | |||
| 444 | + | return nil, err | |||
| 445 | + | } | |||
| 446 | + | dir = workspace.Dir() | |||
| 447 | + | maxTurns := req.MaxTurns | |||
| 448 | + | if maxTurns <= 0 { | |||
| 449 | + | maxTurns = f.cfg.Policy.MaxTurns | |||
| 450 | + | } | |||
| 451 | + | timeout := req.Timeout | |||
| 452 | + | if timeout <= 0 { | |||
| 453 | + | timeout = f.cfg.Policy.DefaultTimeout | |||
| 454 | + | } | |||
| 455 | + | runCtx, cancel := f.workerContext(timeout) | |||
| 456 | + | tools := append([]string(nil), req.Tools...) | |||
| 457 | + | if len(tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | |||
| 458 | + | tools = append([]string(nil), f.cfg.Policy.AllowedTools...) | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | a := &Agent{ | |||
| 462 | + | ID: id, | |||
| 463 | + | Task: task, | |||
| 464 | + | OriginalTask: task, | |||
| 465 | + | Dir: dir, | |||
| 466 | + | RepositoryRoot: workspace.RepositoryRoot(), | |||
| 467 | + | WorkspacePath: workspace.Dir(), | |||
| 468 | + | Started: now, | |||
| 469 | + | ParentID: strings.TrimSpace(req.ParentID), | |||
| 470 | + | BatchID: strings.TrimSpace(req.BatchID), | |||
| 471 | + | RootSessionID: strings.TrimSpace(req.RootSessionID), | |||
| 472 | + | Model: strings.TrimSpace(req.Model), | |||
| 473 | + | Provider: strings.TrimSpace(req.Provider), | |||
| 474 | + | BaseURL: baseURL, | |||
| 475 | + | InsecureTLS: insecureTLS, | |||
| 476 | + | Reasoning: strings.TrimSpace(req.Reasoning), | |||
| 477 | + | FastMode: fastMode, | |||
| 478 | + | Subagent: strings.TrimSpace(req.Subagent), | |||
| 479 | + | SessionID: sessionID, | |||
| 480 | + | WorkspaceMode: workspaceMode, | |||
| 481 | + | WorkspaceBase: strings.TrimSpace(req.WorkspaceBase), | |||
| 482 | + | WorkspaceCapture: req.WorkspaceCapture, | |||
| 483 | + | MaxTurns: maxTurns, | |||
| 484 | + | Timeout: timeout, | |||
| 485 | + | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | |||
| 486 | + | Tools: tools, | |||
| 487 | + | InboxPath: inboxPath, | |||
| 488 | + | EventLogPath: logPath, | |||
| 489 | + | SessionPath: sessionPath, | |||
| 490 | + | inbox: NewInbox(inboxPath), | |||
| 491 | + | status: StatusPending, | |||
| 492 | + | activity: "queued", | |||
| 493 | + | processState: ProcessPending, | |||
| 494 | + | turnState: TurnQueued, | |||
| 495 | + | updatedAt: now, | |||
| 496 | + | lastActivity: now, | |||
| 497 | + | stateDir: stateDir, | |||
| 498 | + | lease: lease, | |||
| 499 | + | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |||
| 500 | + | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |||
| 501 | + | done: make(chan struct{}), | |||
| 502 | + | turnResults: make(chan *TurnResult, 16), | |||
| 503 | + | } | |||
| 504 | + | if a.RootSessionID == "" { | |||
| 505 | + | a.RootSessionID = sessionID | |||
| 506 | + | } | |||
| 507 | + | a.ctx, a.cancel = runCtx, cancel | |||
| 508 | + | a.persistFn = f.persistAgent | |||
| 509 | + | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | |||
| 510 | + | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | |||
| 511 | + | a.runner = f.cfg.NewRunner(a) | |||
| 512 | + | if err := writeAgentMeta(stateDir, a); err != nil { | |||
| 513 | + | if a.cancel != nil { | |||
| 514 | + | a.cancel() | |||
| 515 | + | } | |||
| 516 | + | _ = workspace.Cleanup(context.Background()) | |||
| 517 | + | _ = lease.Close() | |||
| 518 | + | leaseOwned = false | |||
| 519 | + | _ = os.RemoveAll(stateDir) | |||
| 520 | + | return nil, fmt.Errorf("subagents initial metadata: %w", err) | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | f.mu.Lock() | |||
| 524 | + | f.agents[id] = a | |||
| 525 | + | f.order = append(f.order, id) | |||
| 526 | + | f.queue = append(f.queue, a) | |||
| 527 | + | f.mu.Unlock() | |||
| 528 | + | leaseOwned = false | |||
| 529 | + | ||||
| 530 | + | // Persisted metadata was written before queue admission, so a later | |||
| 531 | + | // supervisor can reconstruct work that never reached a process. | |||
| 532 | + | f.armQueueTimeout(a) | |||
| 533 | + | f.schedule() | |||
| 534 | + | reserved = false | |||
| 535 | + | return a, nil | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | // SendUserTurn is sugar for the common "send the next user turn" | |||
| 539 | + | // case. It quotes nothing and forwards verbatim; callers are | |||
| 540 | + | // expected to have already trimmed and expanded the text. | |||
| 541 | + | // CancelTurn requests cancellation of the current turn while keeping the | |||
| 542 | + | // worker process alive for a later follow-up. | |||
| 543 | + | func (f *Supervisor) CancelTurn(id string) error { | |||
| 544 | + | a := f.Get(id) | |||
| 545 | + | if a == nil { | |||
| 546 | + | return fmt.Errorf("subagents: no such agent %q", id) | |||
| 547 | + | } | |||
| 548 | + | if a.inbox == nil { | |||
| 549 | + | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |||
| 550 | + | } | |||
| 551 | + | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | |||
| 552 | + | return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"})) | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func (f *Supervisor) SendUserTurn(id, text string) error { | |||
| 556 | + | a := f.Get(id) | |||
| 557 | + | if a == nil { | |||
| 558 | + | return fmt.Errorf("subagents: no such agent %q", id) | |||
| 559 | + | } | |||
| 560 | + | if a.inbox == nil { | |||
| 561 | + | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |||
| 562 | + | } | |||
| 563 | + | return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text})) | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | func (f *Supervisor) run(a *Agent) { | |||
| 567 | + | a.mu.Lock() | |||
| 568 | + | killedBeforeStart := a.status == StatusKilled | |||
| 569 | + | ctxErr := error(nil) | |||
| 570 | + | if a.ctx != nil { | |||
| 571 | + | ctxErr = a.ctx.Err() | |||
| 572 | + | } | |||
| 573 | + | a.mu.Unlock() | |||
| 574 | + | var err error | |||
| 575 | + | if killedBeforeStart || ctxErr != nil || !atomic.CompareAndSwapInt32(&a.launchState, 0, 1) { | |||
| 576 | + | if ctxErr != nil { | |||
| 577 | + | err = ctxErr | |||
| 578 | + | } else { | |||
| 579 | + | err = context.Canceled | |||
| 580 | + | } | |||
| 581 | + | } else { | |||
| 582 | + | a.setActivity("starting") | |||
| 583 | + | if a.runner == nil { | |||
| 584 | + | err = errors.New("subagents: agent has no runner") | |||
| 585 | + | } else { | |||
| 586 | + | err = a.runner.Run(a.ctx, agentSink{a: a}) | |||
| 587 | + | } | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | a.mu.Lock() | |||
| 591 | + | a.finished = f.cfg.Now() | |||
| 592 | + | switch { | |||
| 593 | + | case a.status == StatusKilled: | |||
| 594 | + | // Stop requested a terminal state; preserve it. | |||
| 595 | + | case errors.Is(err, context.Canceled): | |||
| 596 | + | a.status = StatusKilled | |||
| 597 | + | a.activity = "cancelled" | |||
| 598 | + | a.lastErr = err | |||
| 599 | + | case errors.Is(err, context.DeadlineExceeded): | |||
| 600 | + | a.status = StatusFailed | |||
| 601 | + | a.activity = "timeout" | |||
| 602 | + | a.lastErr = err | |||
| 603 | + | case err != nil: | |||
| 604 | + | a.status = StatusFailed | |||
| 605 | + | a.activity = "error: " + truncate(err.Error(), 120) | |||
| 606 | + | a.lastErr = err | |||
| 607 | + | default: | |||
| 608 | + | a.status = StatusDone | |||
| 609 | + | a.activity = "done" | |||
| 610 | + | } | |||
| 611 | + | finalStatus := a.status | |||
| 612 | + | a.mu.Unlock() | |||
| 613 | + | ||||
| 614 | + | switch finalStatus { | |||
| 615 | + | case StatusKilled: | |||
| 616 | + | a.setProcessState(ProcessKilled) | |||
| 617 | + | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | |||
| 618 | + | case StatusFailed: | |||
| 619 | + | a.setProcessState(ProcessExited) | |||
| 620 | + | a.setTurnState(TurnFailed, a.CurrentTurnID()) | |||
| 621 | + | default: | |||
| 622 | + | a.setProcessState(ProcessExited) | |||
| 623 | + | if a.TurnState() == TurnRunning || a.TurnState() == TurnIdle { | |||
| 624 | + | a.setTurnState(TurnSucceeded, a.CurrentTurnID()) | |||
| 625 | + | } | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | // The process slot is no longer occupied once Runner.Run has returned. | |||
| 629 | + | // Release it before potentially slow artifact capture and workspace | |||
| 630 | + | // cleanup so queued work can start without waiting on git/filesystem I/O. | |||
| 631 | + | f.releaseCapacity(a) | |||
| 632 | + | ||||
| 633 | + | // Capture isolated output and write the structured result before the | |||
| 634 | + | // temporary workspace is removed. This ordering is the recovery | |||
| 635 | + | // invariant: a user can always inspect a durable result after failure. | |||
| 636 | + | f.captureWorkspace(a) | |||
| 637 | + | f.ensureResult(a, finalStatus, err) | |||
| 638 | + | if a.cancel != nil { | |||
| 639 | + | a.cancel() | |||
| 640 | + | } | |||
| 641 | + | if a.inbox != nil { | |||
| 642 | + | _ = a.inbox.Close() | |||
| 643 | + | } | |||
| 644 | + | a.setProcessPID(0) | |||
| 645 | + | f.persistAgent(a) | |||
| 646 | + | if a.workspaceCleanup != nil { | |||
| 647 | + | _ = a.workspaceCleanup() | |||
| 648 | + | } | |||
| 649 | + | _ = a.releaseLease() | |||
| 650 | + | a.closeDone() | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | // List returns a snapshot of every agent in creation order. The | |||
| 654 | + | // returned slice is a copy; callers may iterate without holding the | |||
| 655 | + | // subagent lock. Agent fields are read under their own mutex during | |||
| 656 | + | // formatting in Snapshot. | |||
| 657 | + | func (f *Supervisor) List() []*Agent { | |||
| 658 | + | f.mu.Lock() | |||
| 659 | + | defer f.mu.Unlock() | |||
| 660 | + | out := make([]*Agent, 0, len(f.order)) | |||
| 661 | + | for _, id := range f.order { | |||
| 662 | + | out = append(out, f.agents[id]) | |||
| 663 | + | } | |||
| 664 | + | return out | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | // Get returns the agent with the given (possibly truncated) id, or | |||
| 668 | + | // nil. Matching is prefix-based so the user can type the first few | |||
| 669 | + | // characters of a long id. | |||
| 670 | + | func (f *Supervisor) Get(id string) *Agent { | |||
| 671 | + | f.mu.Lock() | |||
| 672 | + | defer f.mu.Unlock() | |||
| 673 | + | if a, ok := f.agents[id]; ok { | |||
| 674 | + | return a | |||
| 675 | + | } | |||
| 676 | + | // Prefix match. | |||
| 677 | + | var hits []*Agent | |||
| 678 | + | for k, a := range f.agents { | |||
| 679 | + | if strings.HasPrefix(k, id) { | |||
| 680 | + | hits = append(hits, a) | |||
| 681 | + | } | |||
| 682 | + | } | |||
| 683 | + | if len(hits) == 1 { | |||
| 684 | + | return hits[0] | |||
| 685 | + | } | |||
| 686 | + | return nil | |||
| 687 | + | } | |||
| 688 | + | ||||
| 689 | + | var errDetachedStopTimeout = errors.New("detached worker stop timeout") | |||
| 690 | + | ||||
| 691 | + | const detachedStopPollInterval = 20 * time.Millisecond | |||
| 692 | + | ||||
| 693 | + | // waitForDetachedWorker waits for the worker inbox to disappear, returning a | |||
| 694 | + | // sentinel when the grace period expires. Polling is interruptible so a | |||
| 695 | + | // caller's context is not made to wait for the next fixed sleep interval. | |||
| 696 | + | func waitForDetachedWorker(ctx context.Context, path string, timeout time.Duration) error { | |||
| 697 | + | if ctx == nil { | |||
| 698 | + | ctx = context.Background() | |||
| 699 | + | } | |||
| 700 | + | timer := time.NewTimer(timeout) | |||
| 701 | + | defer timer.Stop() | |||
| 702 | + | ticker := time.NewTicker(detachedStopPollInterval) | |||
| 703 | + | defer ticker.Stop() | |||
| 704 | + | for { | |||
| 705 | + | if err := ctx.Err(); err != nil { | |||
| 706 | + | return err | |||
| 707 | + | } | |||
| 708 | + | if !inboxLive(path) { | |||
| 709 | + | return nil | |||
| 710 | + | } | |||
| 711 | + | select { | |||
| 712 | + | case <-ctx.Done(): | |||
| 713 | + | return ctx.Err() | |||
| 714 | + | case <-timer.C: | |||
| 715 | + | if !inboxLive(path) { | |||
| 716 | + | return nil | |||
| 717 | + | } | |||
| 718 | + | return errDetachedStopTimeout | |||
| 719 | + | case <-ticker.C: | |||
| 720 | + | } | |||
| 721 | + | } | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | // Stop cancels the agent's context. The Runner should observe the | |||
| 725 | + | // cancellation and return promptly; the goroutine then finalises the | |||
| 726 | + | // agent in StatusKilled. Also closes the supervisor-side inbox handle | |||
| 727 | + | // so any pending send retries fail fast instead of dialing a | |||
| 728 | + | // socket about to be unlinked. Stop uses a background context; callers | |||
| 729 | + | // that need cancellation while waiting for a detached worker should use | |||
| 730 | + | // StopContext. | |||
| 731 | + | // | |||
| 732 | + | // Stop is a no-op for any agent that's not in a live runnable state | |||
| 733 | + | // — Done / Failed / Killed (already finalised). Reloaded Detached agents | |||
| 734 | + | // are probed first: if their worker survived the supervisor, Stop sends the | |||
| 735 | + | // shutdown command through the durable inbox instead of pretending no | |||
| 736 | + | // process exists. | |||
| 737 | + | func (f *Supervisor) Stop(id string) error { | |||
| 738 | + | return f.stop(context.Background(), id) | |||
| 739 | + | } | |||
| 740 | + | ||||
| 741 | + | // StopContext is Stop with a context that can interrupt detached-worker | |||
| 742 | + | // shutdown and its graceful and force-stop waits. The context is not held | |||
| 743 | + | // under the supervisor operation lock while those waits run. | |||
| 744 | + | func (f *Supervisor) StopContext(ctx context.Context, id string) error { | |||
| 745 | + | return f.stop(ctx, id) | |||
| 746 | + | } | |||
| 747 | + | ||||
| 748 | + | func (f *Supervisor) stop(ctx context.Context, id string) error { | |||
| 749 | + | if ctx == nil { | |||
| 750 | + | ctx = context.Background() | |||
| 751 | + | } | |||
| 752 | + | f.operationMu.Lock() | |||
| 753 | + | a := f.Get(id) | |||
| 754 | + | if a == nil { | |||
| 755 | + | f.operationMu.Unlock() | |||
| 756 | + | return fmt.Errorf("no such agent %q", id) | |||
| 757 | + | } | |||
| 758 | + | ||||
| 759 | + | // Coordinate the status transition with schedule. Holding f.mu while | |||
| 760 | + | // checking and changing a.status closes the small window where Stop could | |||
| 761 | + | // observe pending, schedule could promote the same agent to running, and | |||
| 762 | + | // the pending cleanup below could then finalize an agent that was already | |||
| 763 | + | // admitted to the runner. | |||
| 764 | + | f.mu.Lock() | |||
| 765 | + | a.mu.Lock() | |||
| 766 | + | status := a.status | |||
| 767 | + | if status == StatusDone || status == StatusFailed || status == StatusKilled { | |||
| 768 | + | a.mu.Unlock() | |||
| 769 | + | f.mu.Unlock() | |||
| 770 | + | f.operationMu.Unlock() | |||
| 771 | + | return nil | |||
| 772 | + | } | |||
| 773 | + | if status == StatusDetached { | |||
| 774 | + | path := a.InboxPath | |||
| 775 | + | inbox := a.inbox | |||
| 776 | + | pid := a.ProcessPIDValue() | |||
| 777 | + | a.mu.Unlock() | |||
| 778 | + | f.mu.Unlock() | |||
| 779 | + | // The detached worker may take the full grace period to exit. Do not | |||
| 780 | + | // serialize unrelated supervisor operations for that entire wait. | |||
| 781 | + | f.operationMu.Unlock() | |||
| 782 | + | return f.stopDetached(ctx, a, path, inbox, pid) | |||
| 783 | + | } | |||
| 784 | + | a.status = StatusKilled | |||
| 785 | + | a.activity = "canceling" | |||
| 786 | + | atomic.CompareAndSwapInt32(&a.launchState, 0, 2) | |||
| 787 | + | if status == StatusPending { | |||
| 788 | + | for i, queued := range f.queue { | |||
| 789 | + | if queued == a { | |||
| 790 | + | f.queue = append(f.queue[:i], f.queue[i+1:]...) | |||
| 791 | + | break | |||
| 792 | + | } | |||
| 793 | + | } | |||
| 794 | + | } | |||
| 795 | + | a.mu.Unlock() | |||
| 796 | + | f.mu.Unlock() | |||
| 797 | + | f.operationMu.Unlock() | |||
| 798 | + | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | |||
| 799 | + | ||||
| 800 | + | if status == StatusPending { | |||
| 801 | + | a.setProcessState(ProcessKilled) | |||
| 802 | + | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | |||
| 803 | + | f.captureWorkspace(a) | |||
| 804 | + | f.ensureResult(a, StatusKilled, context.Canceled) | |||
| 805 | + | if a.cancel != nil { | |||
| 806 | + | a.cancel() | |||
| 807 | + | } | |||
| 808 | + | f.persistAgent(a) | |||
| 809 | + | if a.workspaceCleanup != nil { | |||
| 810 | + | _ = a.workspaceCleanup() | |||
| 811 | + | } | |||
| 812 | + | _ = a.releaseLease() | |||
| 813 | + | a.closeDone() | |||
| 814 | + | f.schedule() | |||
| 815 | + | if err := ctx.Err(); err != nil { | |||
| 816 | + | return err | |||
| 817 | + | } | |||
| 818 | + | return nil | |||
| 819 | + | } | |||
| 820 | + | ||||
| 821 | + | // Ask a live worker to shut down cleanly first. If the socket is not | |||
| 822 | + | // ready, fall back immediately; a forceful context cancellation is | |||
| 823 | + | // safer than leaving a supervisor entry running forever. | |||
| 824 | + | if err := ctx.Err(); err != nil { | |||
| 825 | + | if a.cancel != nil { | |||
| 826 | + | a.cancel() | |||
| 827 | + | } | |||
| 828 | + | return err | |||
| 829 | + | } | |||
| 830 | + | graceful := false | |||
| 831 | + | if a.inbox != nil { | |||
| 832 | + | err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) | |||
| 833 | + | if err == nil { | |||
| 834 | + | graceful = true | |||
| 835 | + | } | |||
| 836 | + | } | |||
| 837 | + | if !graceful && a.cancel != nil { | |||
| 838 | + | a.cancel() | |||
| 839 | + | } | |||
| 840 | + | if graceful { | |||
| 841 | + | grace := f.cfg.Policy.CancelGracePeriod | |||
| 842 | + | if grace <= 0 { | |||
| 843 | + | grace = 10 * time.Second | |||
| 844 | + | } | |||
| 845 | + | go func() { | |||
| 846 | + | timer := time.NewTimer(grace) | |||
| 847 | + | defer timer.Stop() | |||
| 848 | + | select { | |||
| 849 | + | case <-a.done: | |||
| 850 | + | case <-timer.C: | |||
| 851 | + | if a.cancel != nil { | |||
| 852 | + | a.cancel() | |||
| 853 | + | } | |||
| 854 | + | if a.inbox != nil { | |||
| 855 | + | _ = a.inbox.Close() | |||
| 856 | + | } | |||
| 857 | + | } | |||
| 858 | + | }() | |||
| 859 | + | } | |||
| 860 | + | if err := ctx.Err(); err != nil { | |||
| 861 | + | if a.cancel != nil { | |||
| 862 | + | a.cancel() | |||
| 863 | + | } | |||
| 864 | + | if a.inbox != nil { | |||
| 865 | + | _ = a.inbox.Close() | |||
| 866 | + | } | |||
| 867 | + | return err | |||
| 868 | + | } | |||
| 869 | + | return nil | |||
| 870 | + | } | |||
| 871 | + | ||||
| 872 | + | func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, inbox *Inbox, pid int) error { | |||
| 873 | + | if inbox == nil { | |||
| 874 | + | return nil | |||
| 875 | + | } | |||
| 876 | + | if err := ctx.Err(); err != nil { | |||
| 877 | + | return err | |||
| 878 | + | } | |||
| 879 | + | if !inboxLive(path) { | |||
| 880 | + | return nil | |||
| 881 | + | } | |||
| 882 | + | if err := ctx.Err(); err != nil { | |||
| 883 | + | return err | |||
| 884 | + | } | |||
| 885 | + | if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil { | |||
| 886 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 887 | + | return ctxErr | |||
| 888 | + | } | |||
| 889 | + | return err | |||
| 890 | + | } | |||
| 891 | + | a.setActivity("shutdown requested") | |||
| 892 | + | grace := f.cfg.Policy.CancelGracePeriod | |||
| 893 | + | if grace <= 0 { | |||
| 894 | + | grace = 10 * time.Second | |||
| 895 | + | } | |||
| 896 | + | waitErr := waitForDetachedWorker(ctx, path, grace) | |||
| 897 | + | if waitErr != nil && !errors.Is(waitErr, errDetachedStopTimeout) { | |||
| 898 | + | return waitErr | |||
| 899 | + | } | |||
| 900 | + | if waitErr == nil { | |||
| 901 | + | f.markDetachedKilled(a) | |||
| 902 | + | return nil | |||
| 903 | + | } | |||
| 904 | + | if err := ctx.Err(); err != nil { | |||
| 905 | + | return err | |||
| 906 | + | } | |||
| 907 | + | if pid <= 0 { | |||
| 908 | + | return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace) | |||
| 909 | + | } | |||
| 910 | + | if killErr := forceKillProcess(pid); killErr != nil { | |||
| 911 | + | return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr) | |||
| 912 | + | } | |||
| 913 | + | if waitErr := waitForDetachedWorker(ctx, path, time.Second); waitErr != nil { | |||
| 914 | + | if errors.Is(waitErr, errDetachedStopTimeout) { | |||
| 915 | + | return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID) | |||
| 916 | + | } | |||
| 917 | + | return waitErr | |||
| 918 | + | } | |||
| 919 | + | f.markDetachedKilled(a) | |||
| 920 | + | return nil | |||
| 921 | + | } | |||
| 922 | + | ||||
| 923 | + | func (f *Supervisor) markDetachedKilled(a *Agent) { | |||
| 924 | + | f.operationMu.Lock() | |||
| 925 | + | defer f.operationMu.Unlock() | |||
| 926 | + | a.mu.Lock() | |||
| 927 | + | a.status = StatusKilled | |||
| 928 | + | a.activity = "killed" | |||
| 929 | + | a.mu.Unlock() | |||
| 930 | + | a.setProcessState(ProcessKilled) | |||
| 931 | + | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | |||
| 932 | + | a.setProcessPID(0) | |||
| 933 | + | f.persistAgent(a) | |||
| 934 | + | } | |||
| 935 | + | ||||
| 936 | + | // StopAll cancels every running agent. Used on shutdown. | |||
| 937 | + | func (f *Supervisor) StopAll() { | |||
| 938 | + | _ = f.stopAll(context.Background()) | |||
| 939 | + | } | |||
| 940 | + | ||||
| 941 | + | // StopAllContext is the context-aware shutdown variant. It returns the | |||
| 942 | + | // context error when cancellation interrupts a detached-worker wait. | |||
| 943 | + | func (f *Supervisor) StopAllContext(ctx context.Context) error { | |||
| 944 | + | return f.stopAll(ctx) | |||
| 945 | + | } | |||
| 946 | + | ||||
| 947 | + | func (f *Supervisor) stopAll(ctx context.Context) error { | |||
| 948 | + | if ctx == nil { | |||
| 949 | + | ctx = context.Background() | |||
| 950 | + | } | |||
| 951 | + | agents := f.List() | |||
| 952 | + | var firstErr error | |||
| 953 | + | for _, a := range agents { | |||
| 954 | + | if err := f.stop(ctx, a.ID); err != nil && firstErr == nil { | |||
| 955 | + | firstErr = err | |||
| 956 | + | } | |||
| 957 | + | } | |||
| 958 | + | defer func() { | |||
| 959 | + | if f.lifetimeCancel != nil { | |||
| 960 | + | f.lifetimeCancel() | |||
| 961 | + | } | |||
| 962 | + | }() | |||
| 963 | + | for _, a := range agents { | |||
| 964 | + | select { | |||
| 965 | + | case <-a.done: | |||
| 966 | + | case <-ctx.Done(): | |||
| 967 | + | if firstErr == nil { | |||
| 968 | + | firstErr = ctx.Err() | |||
| 969 | + | } | |||
| 970 | + | return firstErr | |||
| 971 | + | } | |||
| 972 | + | } | |||
| 973 | + | return firstErr | |||
| 974 | + | } | |||
| 975 | + | ||||
| 976 | + | // Remove tears down the per-agent state for a terminated agent. It | |||
| 977 | + | // is an error to remove an agent that's still running; call Stop | |||
| 978 | + | // first and wait for the status to settle. Detached agents | |||
| 979 | + | // (reloaded from disk) remove cleanly because they have no live | |||
| 980 | + | // runner racing for the same files. | |||
| 981 | + | // | |||
| 982 | + | // Shared-workspace agents never touch source files during Remove; worktree | |||
| 983 | + | // agents have already captured their output and removed the isolated checkout | |||
| 984 | + | // during finalization. Remove then deletes the agent's durable state directory | |||
| 985 | + | // under <root>/agents/<id>/. | |||
| 986 | + | func (f *Supervisor) Remove(id string) error { | |||
| 987 | + | f.operationMu.Lock() | |||
| 988 | + | defer f.operationMu.Unlock() | |||
| 989 | + | a := f.Get(id) | |||
| 990 | + | if a == nil { | |||
| 991 | + | return fmt.Errorf("no such agent %q", id) | |||
| 992 | + | } | |||
| 993 | + | a.mu.Lock() | |||
| 994 | + | st := a.status | |||
| 995 | + | inboxPath := a.InboxPath | |||
| 996 | + | a.mu.Unlock() | |||
| 997 | + | if st == StatusRunning || st == StatusPending { | |||
| 998 | + | return fmt.Errorf("agent %s still %s", a.ID, st) | |||
| 999 | + | } | |||
| 1000 | + | if inboxLive(inboxPath) { | |||
| 1001 | + | return fmt.Errorf("agent %s still has a live worker; stop it first", a.ID) | |||
| 1002 | + | } | |||
| 1003 | + | select { | |||
| 1004 | + | case <-a.done: | |||
| 1005 | + | default: | |||
| 1006 | + | return fmt.Errorf("agent %s is still finalizing", a.ID) | |||
| 1007 | + | } | |||
| 1008 | + | lease, err := acquireAgentLease(a.stateDirectory(f.cfg.Root)) | |||
| 1009 | + | if err != nil { | |||
| 1010 | + | return fmt.Errorf("agent %s state is owned by another supervisor: %w", a.ID, err) | |||
| 1011 | + | } | |||
| 1012 | + | _ = lease.Close() | |||
| 1013 | + | // Best-effort cleanup of the per-agent state directory | |||
| 1014 | + | // (meta.json, events.jsonl, session.json). Failing here would leave | |||
| 1015 | + | // the user with no recourse, | |||
| 1016 | + | // so swallow the error. | |||
| 1017 | + | _ = os.RemoveAll(a.stateDirectory(f.cfg.Root)) | |||
| 1018 | + | f.mu.Lock() | |||
| 1019 | + | delete(f.agents, a.ID) | |||
| 1020 | + | for i, k := range f.order { | |||
| 1021 | + | if k == a.ID { | |||
| 1022 | + | f.order = append(f.order[:i], f.order[i+1:]...) | |||
| 1023 | + | break | |||
| 1024 | + | } | |||
| 1025 | + | } | |||
| 1026 | + | f.mu.Unlock() | |||
| 1027 | + | return nil | |||
| 1028 | + | } | |||
| 1029 | + | ||||
| 1030 | + | // Snapshot returns a read-only view of one agent. Safe for the TUI | |||
| 1031 | + | // goroutine to call repeatedly; never blocks on the Runner. | |||
| 1032 | + | type AgentSnapshot struct { | |||
| 1033 | + | ID string | |||
| 1034 | + | Task string | |||
| 1035 | + | Dir string | |||
| 1036 | + | Status Status | |||
| 1037 | + | ProcessState ProcessState | |||
| 1038 | + | TurnState TurnState | |||
| 1039 | + | CurrentTurnID string | |||
| 1040 | + | Attempt int | |||
| 1041 | + | Activity string | |||
| 1042 | + | Started time.Time | |||
| 1043 | + | Finished time.Time | |||
| 1044 | + | CreatedAt time.Time | |||
| 1045 | + | UpdatedAt time.Time | |||
| 1046 | + | LastActivity time.Time | |||
| 1047 | + | Err string | |||
| 1048 | + | Tail string // last few transcript lines, joined with "\n" | |||
| 1049 | + | Lines []string // full transcript (already capped by Agent.appendTranscript) | |||
| 1050 | + | LastAssistant string // complete final assistant message, without role formatting | |||
| 1051 | + | Result *TurnResult | |||
| 1052 | + | ResultRef string | |||
| 1053 | + | PatchRef string | |||
| 1054 | + | ChangedFiles []string | |||
| 1055 | + | OutputTruncated bool | |||
| 1056 | + | ||||
| 1057 | + | // Model, Provider, Reasoning, and the provider connection settings | |||
| 1058 | + | // expose the per-agent configuration captured at Spawn time. Empty | |||
| 1059 | + | // values inherit the child's defaults. Subagent is the selected named | |||
| 1060 | + | // markdown profile. The dashboard surfaces these so the user can | |||
| 1061 | + | // confirm the child configuration. | |||
| 1062 | + | Model string | |||
| 1063 | + | Provider string | |||
| 1064 | + | BaseURL string | |||
| 1065 | + | InsecureTLS bool | |||
| 1066 | + | Reasoning string | |||
| 1067 | + | FastMode bool | |||
| 1068 | + | Subagent string | |||
| 1069 | + | WorkspaceMode WorkspaceMode | |||
| 1070 | + | WorkspacePath string | |||
| 1071 | + | ||||
| 1072 | + | // Paths to the agent's durable state. Surface them in the | |||
| 1073 | + | // snapshot so the dashboard /subagents view can | |||
| 1074 | + | // read events.jsonl or resume the session without going back through the Agent. | |||
| 1075 | + | InboxPath string | |||
| 1076 | + | EventLogPath string | |||
| 1077 | + | SessionPath string | |||
| 1078 | + | } | |||
| 1079 | + | ||||
| 1080 | + | // Snapshot copies the live agent state into a value the caller can | |||
| 1081 | + | // inspect at leisure. | |||
| 1082 | + | func (a *Agent) Snapshot() AgentSnapshot { | |||
| 1083 | + | a.mu.Lock() | |||
| 1084 | + | tail := strings.Join(lastN(a.transcript, 6), "\n") | |||
| 1085 | + | lines := make([]string, len(a.transcript)) | |||
| 1086 | + | copy(lines, a.transcript) | |||
| 1087 | + | errStr := "" | |||
| 1088 | + | if a.lastErr != nil { | |||
| 1089 | + | errStr = a.lastErr.Error() | |||
| 1090 | + | } | |||
| 1091 | + | status := a.status | |||
| 1092 | + | activity := a.activity | |||
| 1093 | + | lastAssistant := a.lastAssistant | |||
| 1094 | + | finished := a.finished | |||
| 1095 | + | a.mu.Unlock() | |||
| 1096 | + | ||||
| 1097 | + | a.lifecycleMu.Lock() | |||
| 1098 | + | processState := a.processState | |||
| 1099 | + | turnState := a.turnState | |||
| 1100 | + | currentTurnID := a.currentTurnID | |||
| 1101 | + | attempt := a.Attempt | |||
| 1102 | + | lastActivity := a.lastActivity | |||
| 1103 | + | updatedAt := a.updatedAt | |||
| 1104 | + | result := cloneTurnResult(a.result) | |||
| 1105 | + | resultRef := a.resultRef | |||
| 1106 | + | patchRef := a.patchRef | |||
| 1107 | + | changedFiles := append([]string(nil), a.changedFiles...) | |||
| 1108 | + | outputTruncated := a.outputTruncated | |||
| 1109 | + | a.lifecycleMu.Unlock() | |||
| 1110 | + | return AgentSnapshot{ | |||
| 1111 | + | ID: a.ID, Task: a.Task, Dir: a.Dir, | |||
| 1112 | + | Status: status, ProcessState: processState, TurnState: turnState, | |||
| 1113 | + | CurrentTurnID: currentTurnID, Attempt: attempt, Activity: activity, | |||
| 1114 | + | Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity, | |||
| 1115 | + | Err: errStr, Tail: tail, Lines: lines, | |||
| 1116 | + | LastAssistant: lastAssistant, Result: result, ResultRef: resultRef, | |||
| 1117 | + | PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated, | |||
| 1118 | + | Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL, | |||
| 1119 | + | InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning, | |||
| 1120 | + | FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode, | |||
| 1121 | + | WorkspacePath: a.WorkspacePath, InboxPath: a.InboxPath, | |||
| 1122 | + | EventLogPath: a.EventLogPath, SessionPath: a.SessionPath, | |||
| 1123 | + | } | |||
| 1124 | + | } | |||
| 1125 | + | ||||
| 1126 | + | // SnapshotAll returns snapshots of every agent in creation order, | |||
| 1127 | + | // scoped to the active session when one is set. | |||
| 1128 | + | // | |||
| 1129 | + | // Scoping rules: | |||
| 1130 | + | // - activeSession == "": no filter, every agent is returned | |||
| 1131 | + | // (historical behaviour; used by tests and scripted callers). | |||
| 1132 | + | // - activeSession != "": include live agents whose SessionID matches | |||
| 1133 | + | // activeSession, plus unscoped agents. Detached and terminal | |||
| 1134 | + | // agents from another host session remain visible so restart/recovery | |||
| 1135 | + | // does not hide durable work. | |||
| 1136 | + | func (f *Supervisor) SnapshotAll() []AgentSnapshot { | |||
| 1137 | + | agents := f.List() | |||
| 1138 | + | f.mu.Lock() | |||
| 1139 | + | active := f.activeSession | |||
| 1140 | + | f.mu.Unlock() | |||
| 1141 | + | ||||
| 1142 | + | out := make([]AgentSnapshot, 0, len(agents)) | |||
| 1143 | + | for _, a := range agents { | |||
| 1144 | + | if active != "" && a.SessionID != "" && a.SessionID != active { | |||
| 1145 | + | // Keep detached/terminal entries visible after a normal host | |||
| 1146 | + | // restart so durable recovery remains discoverable. Hide only | |||
| 1147 | + | // live work belonging to another in-process host session. | |||
| 1148 | + | status := a.Status() | |||
| 1149 | + | if status == StatusRunning || status == StatusPending { | |||
| 1150 | + | continue | |||
| 1151 | + | } | |||
| 1152 | + | } | |||
| 1153 | + | out = append(out, a.Snapshot()) | |||
| 1154 | + | } | |||
| 1155 | + | // Sort by start time for a stable, deterministic listing. | |||
| 1156 | + | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | |||
| 1157 | + | return out | |||
| 1158 | + | } | |||
| 1159 | + | ||||
| 1160 | + | // agentSink is the Sink the Supervisor hands to each Runner. | |||
| 1161 | + | type agentSink struct{ a *Agent } | |||
| 1162 | + | ||||
| 1163 | + | func (s agentSink) Activity(msg string) { s.a.setActivity(msg) } | |||
| 1164 | + | func (s agentSink) Transcript(chunk string) { | |||
| 1165 | + | if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") { | |||
| 1166 | + | s.a.appendTranscript(chunk) | |||
| 1167 | + | return | |||
| 1168 | + | } | |||
| 1169 | + | s.a.appendAssistantMessage(chunk) | |||
| 1170 | + | } | |||
| 1171 | + | func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) } | |||
| 1172 | + | func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) } | |||
| 1173 | + | ||||
| 1174 | + | func truncate(s string, n int) string { | |||
| 1175 | + | runes := []rune(s) | |||
| 1176 | + | if len(runes) <= n { | |||
| 1177 | + | return s | |||
| 1178 | + | } | |||
| 1179 | + | if n <= 3 { | |||
| 1180 | + | return strings.Repeat(".", n) | |||
| 1181 | + | } | |||
| 1182 | + | return string(runes[:n-3]) + "..." | |||
| 1183 | + | } | |||
| 1184 | + | ||||
| 1185 | + | func lastN(lines []string, n int) []string { | |||
| 1186 | + | if len(lines) <= n { | |||
| 1187 | + | return lines | |||
| 1188 | + | } | |||
| 1189 | + | return lines[len(lines)-n:] | |||
| 1190 | + | } | |||
R packages/agent/swarm/swarm_test.go → packages/agent/subagents/supervisor_test.go +155 -17 renamed, 60% similar
| @@ -1,19 +1,19 @@ | |||||
| 1 | - | package swarm | 1 | + | package subagents |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "path/filepath" | |||
| 7 | + | "runtime" | |||
| 6 | "strings" | 8 | "strings" | ||
| 7 | "sync" | 9 | "sync" | ||
| 8 | "testing" | 10 | "testing" | ||
| 9 | "time" | 11 | "time" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 12 | - | // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory | 14 | + | // newTestSupervisor builds a Supervisor rooted in t.TempDir and configured |
| 13 | - | // and a Runner factory the test controls. Returns the swarm plus a | 15 | + | // with the Runner factory controlled by the test. |
| 14 | - | // slice of runners keyed by spawn order so tests can assert they | 16 | + | func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor { |
| 15 | - | // were actually invoked. | |||
| 16 | - | func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { | |||
| 17 | t.Helper() | 17 | t.Helper() | ||
| 18 | root := t.TempDir() | 18 | root := t.TempDir() | ||
| 19 | return New(Config{ | 19 | return New(Config{ | ||
| @@ -25,7 +25,7 @@ func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { | |||||
| 25 | 25 | ||||
| 26 | func TestSpawnRunsAndCompletes(t *testing.T) { | 26 | func TestSpawnRunsAndCompletes(t *testing.T) { | ||
| 27 | ran := make(chan string, 1) | 27 | ran := make(chan string, 1) | ||
| 28 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 28 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 29 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | 29 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | ||
| 30 | ran <- a.Task | 30 | ran <- a.Task | ||
| 31 | sink.Activity("hello") | 31 | sink.Activity("hello") | ||
| @@ -65,7 +65,7 @@ func TestSpawnRunsAndCompletes(t *testing.T) { | |||||
| 65 | // TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support: | 65 | // TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support: | ||
| 66 | // every spawned agent points its cwd at the parent zot's RepoRoot. | 66 | // every spawned agent points its cwd at the parent zot's RepoRoot. | ||
| 67 | func TestSpawnAgentSharesRepoRoot(t *testing.T) { | 67 | func TestSpawnAgentSharesRepoRoot(t *testing.T) { | ||
| 68 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 68 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 69 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 69 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
| 70 | }) | 70 | }) | ||
| 71 | a, err := f.Spawn(context.Background(), "share me") | 71 | a, err := f.Spawn(context.Background(), "share me") | ||
| @@ -79,7 +79,7 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) { | |||||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) { | 81 | func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) { | ||
| 82 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 82 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 83 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 83 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
| 84 | }) | 84 | }) | ||
| 85 | want := t.TempDir() | 85 | want := t.TempDir() | ||
| @@ -95,7 +95,7 @@ func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) { | |||||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | func TestSpawnEmptyTaskFails(t *testing.T) { | 97 | func TestSpawnEmptyTaskFails(t *testing.T) { | ||
| 98 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 98 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 99 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 99 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
| 100 | }) | 100 | }) | ||
| 101 | if _, err := f.Spawn(context.Background(), " "); err == nil { | 101 | if _, err := f.Spawn(context.Background(), " "); err == nil { | ||
| @@ -105,7 +105,7 @@ func TestSpawnEmptyTaskFails(t *testing.T) { | |||||
| 105 | 105 | ||||
| 106 | func TestRunnerErrorMarksFailed(t *testing.T) { | 106 | func TestRunnerErrorMarksFailed(t *testing.T) { | ||
| 107 | wantErr := errors.New("boom") | 107 | wantErr := errors.New("boom") | ||
| 108 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 108 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 109 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr }) | 109 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr }) | ||
| 110 | }) | 110 | }) | ||
| 111 | a, _ := f.Spawn(context.Background(), "explode") | 111 | a, _ := f.Spawn(context.Background(), "explode") | ||
| @@ -123,7 +123,7 @@ func TestRunnerErrorMarksFailed(t *testing.T) { | |||||
| 123 | 123 | ||||
| 124 | func TestStopCancelsRunningAgent(t *testing.T) { | 124 | func TestStopCancelsRunningAgent(t *testing.T) { | ||
| 125 | started := make(chan struct{}) | 125 | started := make(chan struct{}) | ||
| 126 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 126 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 127 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | 127 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | ||
| 128 | close(started) | 128 | close(started) | ||
| 129 | <-ctx.Done() | 129 | <-ctx.Done() | ||
| @@ -141,8 +141,133 @@ func TestStopCancelsRunningAgent(t *testing.T) { | |||||
| 141 | } | 141 | } | ||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | + | func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) { | |||
| 145 | + | if runtime.GOOS == "windows" { | |||
| 146 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 147 | + | } | |||
| 148 | + | const grace = 250 * time.Millisecond | |||
| 149 | + | started := make(chan struct{}) | |||
| 150 | + | canceled := make(chan struct{}) | |||
| 151 | + | f := New(Config{ | |||
| 152 | + | Root: t.TempDir(), | |||
| 153 | + | RepoRoot: t.TempDir(), | |||
| 154 | + | Policy: SubagentPolicy{ | |||
| 155 | + | CancelGracePeriod: grace, | |||
| 156 | + | }, | |||
| 157 | + | NewRunner: func(a *Agent) Runner { | |||
| 158 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 159 | + | listener, err := Listen(a.InboxPath) | |||
| 160 | + | if err != nil { | |||
| 161 | + | return err | |||
| 162 | + | } | |||
| 163 | + | defer listener.Close() | |||
| 164 | + | close(started) | |||
| 165 | + | select { | |||
| 166 | + | case <-listener.Lines(): | |||
| 167 | + | case <-ctx.Done(): | |||
| 168 | + | close(canceled) | |||
| 169 | + | return ctx.Err() | |||
| 170 | + | } | |||
| 171 | + | <-ctx.Done() | |||
| 172 | + | close(canceled) | |||
| 173 | + | return ctx.Err() | |||
| 174 | + | }) | |||
| 175 | + | }, | |||
| 176 | + | }) | |||
| 177 | + | a, err := f.Spawn(context.Background(), "graceful stop") | |||
| 178 | + | if err != nil { | |||
| 179 | + | t.Fatal(err) | |||
| 180 | + | } | |||
| 181 | + | select { | |||
| 182 | + | case <-started: | |||
| 183 | + | case <-time.After(time.Second): | |||
| 184 | + | t.Fatal("runner did not start") | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 188 | + | if err := f.StopContext(ctx, a.ID); err != nil { | |||
| 189 | + | t.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | cancel() | |||
| 192 | + | ||||
| 193 | + | early := time.NewTimer(grace / 2) | |||
| 194 | + | defer early.Stop() | |||
| 195 | + | select { | |||
| 196 | + | case <-canceled: | |||
| 197 | + | t.Fatal("agent canceled when StopContext caller context was canceled") | |||
| 198 | + | case <-early.C: | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | deadline := time.NewTimer(grace * 2) | |||
| 202 | + | defer deadline.Stop() | |||
| 203 | + | select { | |||
| 204 | + | case <-canceled: | |||
| 205 | + | case <-deadline.C: | |||
| 206 | + | t.Fatal("agent was not canceled after the grace period") | |||
| 207 | + | } | |||
| 208 | + | a.Wait() | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) { | |||
| 212 | + | if runtime.GOOS == "windows" { | |||
| 213 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 214 | + | } | |||
| 215 | + | path := filepath.Join(shortSocketDir(t), "agent.sock") | |||
| 216 | + | listener, err := Listen(path) | |||
| 217 | + | if err != nil { | |||
| 218 | + | t.Fatal(err) | |||
| 219 | + | } | |||
| 220 | + | defer listener.Close() | |||
| 221 | + | ||||
| 222 | + | f := New(Config{ | |||
| 223 | + | Root: t.TempDir(), | |||
| 224 | + | Policy: SubagentPolicy{ | |||
| 225 | + | CancelGracePeriod: time.Hour, | |||
| 226 | + | }, | |||
| 227 | + | }) | |||
| 228 | + | a := &Agent{ | |||
| 229 | + | ID: "detached-agent", | |||
| 230 | + | InboxPath: path, | |||
| 231 | + | inbox: NewInbox(path), | |||
| 232 | + | status: StatusDetached, | |||
| 233 | + | done: make(chan struct{}), | |||
| 234 | + | } | |||
| 235 | + | defer a.inbox.Close() | |||
| 236 | + | close(a.done) | |||
| 237 | + | f.mu.Lock() | |||
| 238 | + | f.agents[a.ID] = a | |||
| 239 | + | f.order = append(f.order, a.ID) | |||
| 240 | + | f.mu.Unlock() | |||
| 241 | + | ||||
| 242 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 243 | + | defer cancel() | |||
| 244 | + | stopDone := make(chan error, 1) | |||
| 245 | + | go func() { stopDone <- f.StopContext(ctx, a.ID) }() | |||
| 246 | + | ||||
| 247 | + | // The listener notification proves Stop reached its shutdown wait. The | |||
| 248 | + | // operation lock must already be available while that wait is in progress. | |||
| 249 | + | if msg := <-listener.Lines(); msg == "" { | |||
| 250 | + | t.Fatal("shutdown command was empty") | |||
| 251 | + | } | |||
| 252 | + | operationReleased := make(chan struct{}) | |||
| 253 | + | go func() { | |||
| 254 | + | f.operationMu.Lock() | |||
| 255 | + | close(operationReleased) | |||
| 256 | + | f.operationMu.Unlock() | |||
| 257 | + | }() | |||
| 258 | + | <-operationReleased | |||
| 259 | + | ||||
| 260 | + | cancel() | |||
| 261 | + | if err := <-stopDone; !errors.Is(err, context.Canceled) { | |||
| 262 | + | t.Fatalf("StopContext error = %v; want context.Canceled", err) | |||
| 263 | + | } | |||
| 264 | + | if got := a.Status(); got != StatusDetached { | |||
| 265 | + | t.Fatalf("status after canceled stop = %s; want detached", got) | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 144 | func TestStopAfterDoneIsNoop(t *testing.T) { | 269 | func TestStopAfterDoneIsNoop(t *testing.T) { | ||
| 145 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 270 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 146 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 271 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
| 147 | }) | 272 | }) | ||
| 148 | a, _ := f.Spawn(context.Background(), "quick") | 273 | a, _ := f.Spawn(context.Background(), "quick") | ||
| @@ -156,7 +281,7 @@ func TestStopAfterDoneIsNoop(t *testing.T) { | |||||
| 156 | } | 281 | } | ||
| 157 | 282 | ||||
| 158 | func TestGetPrefixMatch(t *testing.T) { | 283 | func TestGetPrefixMatch(t *testing.T) { | ||
| 159 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 284 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 160 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 285 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
| 161 | }) | 286 | }) | ||
| 162 | a, _ := f.Spawn(context.Background(), "alpha task") | 287 | a, _ := f.Spawn(context.Background(), "alpha task") | ||
| @@ -177,7 +302,7 @@ func TestGetPrefixMatch(t *testing.T) { | |||||
| 177 | 302 | ||||
| 178 | func TestRemoveRequiresTerminalState(t *testing.T) { | 303 | func TestRemoveRequiresTerminalState(t *testing.T) { | ||
| 179 | hold := make(chan struct{}) | 304 | hold := make(chan struct{}) | ||
| 180 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 305 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 181 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | 306 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | ||
| 182 | <-hold | 307 | <-hold | ||
| 183 | return nil | 308 | return nil | ||
| @@ -204,7 +329,7 @@ func TestRemoveRequiresTerminalState(t *testing.T) { | |||||
| 204 | func TestSnapshotIsStableAcrossAccess(t *testing.T) { | 329 | func TestSnapshotIsStableAcrossAccess(t *testing.T) { | ||
| 205 | var wg sync.WaitGroup | 330 | var wg sync.WaitGroup | ||
| 206 | wg.Add(1) | 331 | wg.Add(1) | ||
| 207 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 332 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 208 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | 333 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | ||
| 209 | defer wg.Done() | 334 | defer wg.Done() | ||
| 210 | for i := 0; i < 200; i++ { | 335 | for i := 0; i < 200; i++ { | ||
| @@ -236,6 +361,19 @@ func TestSnapshotIsStableAcrossAccess(t *testing.T) { | |||||
| 236 | } | 361 | } | ||
| 237 | } | 362 | } | ||
| 238 | 363 | ||||
| 364 | + | func TestTruncateIsRuneAware(t *testing.T) { | |||
| 365 | + | input := "αβγδεζη" | |||
| 366 | + | if got := truncate(input, 6); got != "αβγ..." { | |||
| 367 | + | t.Fatalf("truncate(%q, 6) = %q; want %q", input, got, "αβγ...") | |||
| 368 | + | } | |||
| 369 | + | if got := truncate(input, 3); got != "..." { | |||
| 370 | + | t.Fatalf("truncate(%q, 3) = %q; want %q", input, got, "...") | |||
| 371 | + | } | |||
| 372 | + | if got := truncate(input, 2); got != ".." { | |||
| 373 | + | t.Fatalf("truncate(%q, 2) = %q; want %q", input, got, "..") | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | ||||
| 239 | func TestTaskSlug(t *testing.T) { | 377 | func TestTaskSlug(t *testing.T) { | ||
| 240 | cases := map[string]string{ | 378 | cases := map[string]string{ | ||
| 241 | "fix the login form": "fix-the-login-form", | 379 | "fix the login form": "fix-the-login-form", | ||
| @@ -251,7 +389,7 @@ func TestTaskSlug(t *testing.T) { | |||||
| 251 | } | 389 | } | ||
| 252 | 390 | ||||
| 253 | func TestSnapshotAllSorted(t *testing.T) { | 391 | func TestSnapshotAllSorted(t *testing.T) { | ||
| 254 | - | f := newTestSwarm(t, func(a *Agent) Runner { | 392 | + | f := newTestSupervisor(t, func(a *Agent) Runner { |
| 255 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 393 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
| 256 | }) | 394 | }) | ||
| 257 | a1, _ := f.Spawn(context.Background(), "first") | 395 | a1, _ := f.Spawn(context.Background(), "first") | ||
R packages/agent/swarm/testdata/cmd/stubchild/main.go → packages/agent/subagents/testdata/cmd/stubchild/main.go +31 -16 renamed, 72% similar
| @@ -1,8 +1,8 @@ | |||||
| 1 | - | // stubchild is a minimal swarm-agent stand-in used by the runner | 1 | + | // stubchild is a minimal subagent-worker stand-in used by the runner |
| 2 | // end-to-end test. It speaks the daemon protocol the real zot | 2 | // end-to-end test. It speaks the daemon protocol the real zot | ||
| 3 | // binary will implement next: | 3 | // binary will implement next: | ||
| 4 | // | 4 | // | ||
| 5 | - | // - parses --swarm-agent <path>, --session <path>, --cwd <path>, | 5 | + | // - parses --subagent-worker <path>, --session <path>, --cwd <path>, |
| 6 | // and an optional positional task, | 6 | // and an optional positional task, | ||
| 7 | // - opens a unix-socket listener at the inbox path so the | 7 | // - opens a unix-socket listener at the inbox path so the | ||
| 8 | // supervisor's Inbox can dial through, | 8 | // supervisor's Inbox can dial through, | ||
| @@ -13,7 +13,7 @@ | |||||
| 13 | // so the runner sees the dialogue happen. | 13 | // so the runner sees the dialogue happen. | ||
| 14 | // | 14 | // | ||
| 15 | // The runner test compiles this binary into a tempdir, points | 15 | // The runner test compiles this binary into a tempdir, points | ||
| 16 | - | // swarm's execRunner at it via Command, and asserts the events | 16 | + | // subagent's execRunner at it via Command, and asserts the events |
| 17 | // flow through correctly without needing the full zot model | 17 | // flow through correctly without needing the full zot model | ||
| 18 | // machinery. | 18 | // machinery. | ||
| 19 | package main | 19 | package main | ||
| @@ -27,6 +27,8 @@ import ( | |||||
| 27 | "os" | 27 | "os" | ||
| 28 | "sync" | 28 | "sync" | ||
| 29 | "time" | 29 | "time" | ||
| 30 | + | ||||
| 31 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 30 | ) | 32 | ) | ||
| 31 | 33 | ||||
| 32 | func main() { | 34 | func main() { | ||
| @@ -35,13 +37,13 @@ func main() { | |||||
| 35 | sessionPath string | 37 | sessionPath string | ||
| 36 | cwd string | 38 | cwd string | ||
| 37 | ) | 39 | ) | ||
| 38 | - | flag.StringVar(&inboxPath, "swarm-agent", "", "inbox socket path") | 40 | + | flag.StringVar(&inboxPath, "subagent-worker", "", "inbox socket path") |
| 39 | flag.StringVar(&sessionPath, "session", "", "session file path") | 41 | flag.StringVar(&sessionPath, "session", "", "session file path") | ||
| 40 | flag.StringVar(&cwd, "cwd", "", "working directory") | 42 | flag.StringVar(&cwd, "cwd", "", "working directory") | ||
| 41 | flag.Parse() | 43 | flag.Parse() | ||
| 42 | 44 | ||||
| 43 | if inboxPath == "" { | 45 | if inboxPath == "" { | ||
| 44 | - | fmt.Fprintln(os.Stderr, "stubchild: --swarm-agent required") | 46 | + | fmt.Fprintln(os.Stderr, "stubchild: --subagent-worker required") |
| 45 | os.Exit(2) | 47 | os.Exit(2) | ||
| 46 | } | 48 | } | ||
| 47 | 49 | ||||
| @@ -49,7 +51,7 @@ func main() { | |||||
| 49 | 51 | ||||
| 50 | // Open the inbox listener BEFORE doing any work so the supervisor | 52 | // Open the inbox listener BEFORE doing any work so the supervisor | ||
| 51 | // can dial through as soon as it sees the agent_ready event. If we | 53 | // can dial through as soon as it sees the agent_ready event. If we | ||
| 52 | - | // processed the initial task first, the parent's SendInput call | 54 | + | // processed the initial task first, the parent's send call |
| 53 | // would race the stub's net.Listen and trip ErrNotReady. | 55 | // would race the stub's net.Listen and trip ErrNotReady. | ||
| 54 | ln, err := net.Listen("unix", inboxPath) | 56 | ln, err := net.Listen("unix", inboxPath) | ||
| 55 | if err != nil { | 57 | if err != nil { | ||
| @@ -81,18 +83,25 @@ func main() { | |||||
| 81 | line, err := br.ReadString('\n') | 83 | line, err := br.ReadString('\n') | ||
| 82 | if line != "" { | 84 | if line != "" { | ||
| 83 | msg := trimNL(line) | 85 | msg := trimNL(line) | ||
| 84 | - | switch { | 86 | + | command, parseErr := subagents.ParseCommand(msg) |
| 85 | - | case msg == "shutdown": | 87 | + | if parseErr == nil { |
| 86 | - | emit("agent_stopped", map[string]any{"reason": "shutdown"}) | 88 | + | switch command.Type { |
| 87 | - | _ = c.Close() | 89 | + | case subagents.CommandAgentShutdown: |
| 88 | - | return | 90 | + | emit("agent_stopped", map[string]any{"reason": "shutdown"}) |
| 89 | - | case msg == "cancel": | 91 | + | _ = c.Close() |
| 90 | - | emit("turn_end", map[string]any{"stop": "cancelled"}) | 92 | + | return |
| 91 | - | case len(msg) > 5 && msg[:5] == "user ": | 93 | + | case subagents.CommandTurnCancel: |
| 92 | - | runTurn(emit, msg[5:], turn) | 94 | + | emit("turn_end", map[string]any{"stop": "cancelled"}) |
| 93 | - | turn++ | 95 | + | case subagents.CommandTurnStart: |
| 96 | + | var payload subagents.TurnStartPayload | |||
| 97 | + | if command.DecodePayload(&payload) == nil { | |||
| 98 | + | runTurn(emit, payload.Prompt, turn) | |||
| 99 | + | turn++ | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 94 | } | 102 | } | ||
| 95 | } | 103 | } | ||
| 104 | + | ||||
| 96 | if err != nil { | 105 | if err != nil { | ||
| 97 | _ = c.Close() | 106 | _ = c.Close() | ||
| 98 | break | 107 | break | ||
| @@ -125,12 +134,18 @@ type emitter = func(string, map[string]any) | |||||
| 125 | func newEmitter() emitter { | 134 | func newEmitter() emitter { | ||
| 126 | var mu sync.Mutex | 135 | var mu sync.Mutex | ||
| 127 | enc := json.NewEncoder(os.Stdout) | 136 | enc := json.NewEncoder(os.Stdout) | ||
| 137 | + | versioned := os.Getenv("ZOT_STUB_PROTOCOL") == "1" | |||
| 128 | return func(typ string, data map[string]any) { | 138 | return func(typ string, data map[string]any) { | ||
| 129 | mu.Lock() | 139 | mu.Lock() | ||
| 130 | defer mu.Unlock() | 140 | defer mu.Unlock() | ||
| 131 | if data == nil { | 141 | if data == nil { | ||
| 132 | data = map[string]any{} | 142 | data = map[string]any{} | ||
| 133 | } | 143 | } | ||
| 144 | + | if versioned { | |||
| 145 | + | envelope := subagents.NewEventEnvelope(typ, "stub-agent", "", data) | |||
| 146 | + | _ = enc.Encode(envelope) | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 134 | data["type"] = typ | 149 | data["type"] = typ | ||
| 135 | data["time"] = time.Now().Format(time.RFC3339Nano) | 150 | data["time"] = time.Now().Format(time.RFC3339Nano) | ||
| 136 | _ = enc.Encode(data) | 151 | _ = enc.Encode(data) | ||
A packages/agent/subagents/workspace.go +384 -0
| @@ -0,0 +1,384 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // WorkspaceCapture is durable output collected before an isolated workspace | |||
| 15 | + | // is removed. The patch is intentionally kept as bytes so the caller can | |||
| 16 | + | // write it atomically beside the agent result. | |||
| 17 | + | type WorkspaceCapture struct { | |||
| 18 | + | Patch []byte | |||
| 19 | + | ChangedFiles []string | |||
| 20 | + | Base string | |||
| 21 | + | Branch string | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // WorkspaceRequest describes one workspace allocation. | |||
| 25 | + | type WorkspaceRequest struct { | |||
| 26 | + | Mode WorkspaceMode | |||
| 27 | + | RepositoryRoot string | |||
| 28 | + | StateDir string | |||
| 29 | + | AgentID string | |||
| 30 | + | Base string | |||
| 31 | + | Capture CaptureMode | |||
| 32 | + | AllowedRoots []string | |||
| 33 | + | ||||
| 34 | + | // ExistingPath is set only when resuming a detached worktree. A valid | |||
| 35 | + | // path is reused so uncommitted edits survive a supervisor restart; | |||
| 36 | + | // invalid or out-of-scope paths are rejected rather than treated as a | |||
| 37 | + | // request to remove arbitrary files. | |||
| 38 | + | ExistingPath string | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // WorkspaceHandle is the narrow seam used by the manager. SharedWorkspace | |||
| 42 | + | // and GitWorktreeWorkspace are the two supported implementations in v1. | |||
| 43 | + | type WorkspaceHandle interface { | |||
| 44 | + | Dir() string | |||
| 45 | + | RepositoryRoot() string | |||
| 46 | + | Mode() WorkspaceMode | |||
| 47 | + | Capture(context.Context) (WorkspaceCapture, error) | |||
| 48 | + | Cleanup(context.Context) error | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // SharedWorkspace leaves the host checkout untouched by the supervisor; a | |||
| 52 | + | // child may edit it exactly as older subagent workers did. | |||
| 53 | + | type SharedWorkspace struct { | |||
| 54 | + | Root string | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (w SharedWorkspace) Prepare(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | |||
| 58 | + | root := w.Root | |||
| 59 | + | if root == "" { | |||
| 60 | + | return nil, errors.New("subagents: shared workspace root is empty") | |||
| 61 | + | } | |||
| 62 | + | return &sharedWorkspaceHandle{root: root}, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | type sharedWorkspaceHandle struct{ root string } | |||
| 66 | + | ||||
| 67 | + | func (w *sharedWorkspaceHandle) Dir() string { return w.root } | |||
| 68 | + | func (w *sharedWorkspaceHandle) RepositoryRoot() string { return w.root } | |||
| 69 | + | func (w *sharedWorkspaceHandle) Mode() WorkspaceMode { return WorkspaceShared } | |||
| 70 | + | func (w *sharedWorkspaceHandle) Capture(context.Context) (WorkspaceCapture, error) { | |||
| 71 | + | return WorkspaceCapture{}, nil | |||
| 72 | + | } | |||
| 73 | + | func (w *sharedWorkspaceHandle) Cleanup(context.Context) error { return nil } | |||
| 74 | + | ||||
| 75 | + | // GitWorktreeWorkspace creates a detached temporary worktree from Base and | |||
| 76 | + | // captures its diff before cleanup. It never merges or changes the host | |||
| 77 | + | // checkout's branch. | |||
| 78 | + | type GitWorktreeWorkspace struct{} | |||
| 79 | + | ||||
| 80 | + | func (GitWorktreeWorkspace) Prepare(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) { | |||
| 81 | + | if ctx == nil { | |||
| 82 | + | ctx = context.Background() | |||
| 83 | + | } | |||
| 84 | + | root, err := filepath.Abs(req.RepositoryRoot) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, fmt.Errorf("subagents worktree root: %w", err) | |||
| 87 | + | } | |||
| 88 | + | if _, err := os.Stat(root); err != nil { | |||
| 89 | + | return nil, fmt.Errorf("subagents worktree root: %w", err) | |||
| 90 | + | } | |||
| 91 | + | actualRoot, err := gitOutput(ctx, root, "rev-parse", "--show-toplevel") | |||
| 92 | + | if err != nil { | |||
| 93 | + | return nil, fmt.Errorf("subagents worktree verify repository: %w", err) | |||
| 94 | + | } | |||
| 95 | + | actualRoot = filepath.Clean(actualRoot) | |||
| 96 | + | if len(req.AllowedRoots) > 0 { | |||
| 97 | + | allowed := false | |||
| 98 | + | for _, root := range req.AllowedRoots { | |||
| 99 | + | if pathWithin(actualRoot, root) { | |||
| 100 | + | allowed = true | |||
| 101 | + | break | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | if !allowed { | |||
| 105 | + | return nil, errors.New("subagents worktree repository is outside allowed roots") | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | base := strings.TrimSpace(req.Base) | |||
| 109 | + | if base == "" { | |||
| 110 | + | base = "HEAD" | |||
| 111 | + | } | |||
| 112 | + | if _, err := gitOutput(ctx, actualRoot, "rev-parse", "--verify", base+"^{commit}"); err != nil { | |||
| 113 | + | return nil, fmt.Errorf("subagents worktree verify base %q: %w", base, err) | |||
| 114 | + | } | |||
| 115 | + | stateDir := req.StateDir | |||
| 116 | + | if stateDir == "" { | |||
| 117 | + | return nil, errors.New("subagents: worktree state directory is empty") | |||
| 118 | + | } | |||
| 119 | + | worktreeDir := filepath.Join(stateDir, "worktree") | |||
| 120 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 121 | + | return nil, fmt.Errorf("subagents worktree state dir: %w", err) | |||
| 122 | + | } | |||
| 123 | + | reuse := false | |||
| 124 | + | if existing := strings.TrimSpace(req.ExistingPath); existing != "" { | |||
| 125 | + | var err error | |||
| 126 | + | reuse, err = validateReusableWorktree(ctx, actualRoot, stateDir, worktreeDir, existing) | |||
| 127 | + | if err != nil { | |||
| 128 | + | return nil, err | |||
| 129 | + | } | |||
| 130 | + | if reuse { | |||
| 131 | + | worktreeDir = filepath.Clean(existing) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | if !reuse { | |||
| 135 | + | _ = os.RemoveAll(worktreeDir) | |||
| 136 | + | _, _ = gitOutput(ctx, actualRoot, "worktree", "prune") | |||
| 137 | + | if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil { | |||
| 138 | + | _ = os.RemoveAll(worktreeDir) | |||
| 139 | + | _, _ = gitOutput(ctx, actualRoot, "worktree", "prune") | |||
| 140 | + | return nil, fmt.Errorf("subagents worktree create: %w", err) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | return &gitWorktreeHandle{ | |||
| 144 | + | root: actualRoot, | |||
| 145 | + | dir: worktreeDir, | |||
| 146 | + | base: base, | |||
| 147 | + | capture: req.Capture, | |||
| 148 | + | agentID: req.AgentID, | |||
| 149 | + | }, nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func validateReusableWorktree(ctx context.Context, repositoryRoot, stateDir, expectedPath, existingPath string) (bool, error) { | |||
| 153 | + | if filepath.Clean(existingPath) != filepath.Clean(expectedPath) { | |||
| 154 | + | return false, errors.New("subagents worktree resume path is outside the agent state directory") | |||
| 155 | + | } | |||
| 156 | + | info, err := os.Stat(existingPath) | |||
| 157 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 158 | + | return false, nil | |||
| 159 | + | } | |||
| 160 | + | if err != nil { | |||
| 161 | + | return false, fmt.Errorf("subagents worktree resume path: %w", err) | |||
| 162 | + | } | |||
| 163 | + | if !info.IsDir() { | |||
| 164 | + | return false, errors.New("subagents worktree resume path is not a directory") | |||
| 165 | + | } | |||
| 166 | + | stateCanonical, err := containmentPath(stateDir) | |||
| 167 | + | if err != nil { | |||
| 168 | + | return false, fmt.Errorf("subagents worktree resume state path: %w", err) | |||
| 169 | + | } | |||
| 170 | + | expectedCanonical, err := containmentPath(expectedPath) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return false, fmt.Errorf("subagents worktree resume expected path: %w", err) | |||
| 173 | + | } | |||
| 174 | + | existingCanonical, err := containmentPath(existingPath) | |||
| 175 | + | if err != nil { | |||
| 176 | + | return false, fmt.Errorf("subagents worktree resume path: %w", err) | |||
| 177 | + | } | |||
| 178 | + | if existingCanonical != expectedCanonical || !pathWithin(existingCanonical, stateCanonical) { | |||
| 179 | + | return false, errors.New("subagents worktree resume path escapes the agent state directory") | |||
| 180 | + | } | |||
| 181 | + | top, err := gitOutput(ctx, existingPath, "rev-parse", "--show-toplevel") | |||
| 182 | + | if err != nil { | |||
| 183 | + | return false, fmt.Errorf("subagents worktree resume verify checkout: %w", err) | |||
| 184 | + | } | |||
| 185 | + | topCanonical, err := containmentPath(top) | |||
| 186 | + | if err != nil { | |||
| 187 | + | return false, fmt.Errorf("subagents worktree resume checkout path: %w", err) | |||
| 188 | + | } | |||
| 189 | + | if topCanonical != existingCanonical { | |||
| 190 | + | return false, errors.New("subagents worktree resume checkout path mismatch") | |||
| 191 | + | } | |||
| 192 | + | registered, err := gitOutput(ctx, repositoryRoot, "worktree", "list", "--porcelain") | |||
| 193 | + | if err != nil { | |||
| 194 | + | return false, fmt.Errorf("subagents worktree resume list: %w", err) | |||
| 195 | + | } | |||
| 196 | + | for _, line := range strings.Split(registered, "\n") { | |||
| 197 | + | if !strings.HasPrefix(line, "worktree ") { | |||
| 198 | + | continue | |||
| 199 | + | } | |||
| 200 | + | registeredPath, pathErr := containmentPath(strings.TrimPrefix(line, "worktree ")) | |||
| 201 | + | if pathErr == nil && registeredPath == existingCanonical { | |||
| 202 | + | return true, nil | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return false, errors.New("subagents worktree resume checkout is not registered with Git") | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | type gitWorktreeHandle struct { | |||
| 209 | + | root string | |||
| 210 | + | dir string | |||
| 211 | + | base string | |||
| 212 | + | capture CaptureMode | |||
| 213 | + | agentID string | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (w *gitWorktreeHandle) Dir() string { return w.dir } | |||
| 217 | + | func (w *gitWorktreeHandle) RepositoryRoot() string { return w.root } | |||
| 218 | + | func (w *gitWorktreeHandle) Mode() WorkspaceMode { return WorkspaceWorktree } | |||
| 219 | + | ||||
| 220 | + | func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, error) { | |||
| 221 | + | if ctx == nil { | |||
| 222 | + | ctx = context.Background() | |||
| 223 | + | } | |||
| 224 | + | statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain", "-z") | |||
| 225 | + | if err != nil { | |||
| 226 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err) | |||
| 227 | + | } | |||
| 228 | + | changed := changedFilesFromStatus(statusBytes) | |||
| 229 | + | args := []string{"diff"} | |||
| 230 | + | if w.capture == CapturePatch || w.capture == "" { | |||
| 231 | + | args = append(args, "--binary") | |||
| 232 | + | } | |||
| 233 | + | args = append(args, w.base, "--") | |||
| 234 | + | patch, err := gitOutputBytes(ctx, w.dir, args...) | |||
| 235 | + | if err != nil { | |||
| 236 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree diff: %w", err) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // `git diff <base>` does not include untracked files. Add a normal | |||
| 240 | + | // no-index creation patch for each untracked file so a worktree result can | |||
| 241 | + | // reproduce new files as well as edits to tracked files. | |||
| 242 | + | untracked, err := gitOutputBytes(ctx, w.dir, "ls-files", "--others", "--exclude-standard", "-z") | |||
| 243 | + | if err != nil { | |||
| 244 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked files: %w", err) | |||
| 245 | + | } | |||
| 246 | + | for _, name := range splitNUL(untracked) { | |||
| 247 | + | extra, diffErr := gitUntrackedDiff(ctx, w.dir, name, w.capture == CapturePatch || w.capture == "") | |||
| 248 | + | if diffErr != nil { | |||
| 249 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked diff %q: %w", name, diffErr) | |||
| 250 | + | } | |||
| 251 | + | if len(extra) == 0 { | |||
| 252 | + | continue | |||
| 253 | + | } | |||
| 254 | + | if len(patch) > 0 && patch[len(patch)-1] != '\n' { | |||
| 255 | + | patch = append(patch, '\n') | |||
| 256 | + | } | |||
| 257 | + | patch = append(patch, extra...) | |||
| 258 | + | } | |||
| 259 | + | return WorkspaceCapture{Patch: patch, ChangedFiles: changed, Base: w.base}, nil | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (w *gitWorktreeHandle) Cleanup(ctx context.Context) error { | |||
| 263 | + | if ctx == nil { | |||
| 264 | + | ctx = context.Background() | |||
| 265 | + | } | |||
| 266 | + | if w.dir == "" { | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | _, err := gitOutput(ctx, w.root, "worktree", "remove", "--force", w.dir) | |||
| 270 | + | if err != nil { | |||
| 271 | + | // A failed child can leave a partially registered worktree. Remove | |||
| 272 | + | // only the isolated path; never delete files in the host checkout, | |||
| 273 | + | // then prune stale registration metadata. | |||
| 274 | + | _ = os.RemoveAll(w.dir) | |||
| 275 | + | _, _ = gitOutput(ctx, w.root, "worktree", "prune") | |||
| 276 | + | return fmt.Errorf("subagents worktree cleanup: %w", err) | |||
| 277 | + | } | |||
| 278 | + | return nil | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func PrepareWorkspace(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) { | |||
| 282 | + | mode := req.Mode | |||
| 283 | + | if mode == "" { | |||
| 284 | + | mode = WorkspaceShared | |||
| 285 | + | } | |||
| 286 | + | switch mode { | |||
| 287 | + | case WorkspaceShared: | |||
| 288 | + | return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(ctx, req) | |||
| 289 | + | case WorkspaceWorktree: | |||
| 290 | + | return GitWorktreeWorkspace{}.Prepare(ctx, req) | |||
| 291 | + | default: | |||
| 292 | + | return nil, fmt.Errorf("subagents: unknown workspace mode %q", mode) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func gitOutput(ctx context.Context, dir string, args ...string) (string, error) { | |||
| 297 | + | b, err := gitOutputBytes(ctx, dir, args...) | |||
| 298 | + | return strings.TrimSpace(string(b)), err | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func gitOutputBytes(ctx context.Context, dir string, args ...string) ([]byte, error) { | |||
| 302 | + | cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) | |||
| 303 | + | var stdout, stderr bytes.Buffer | |||
| 304 | + | cmd.Stdout = &stdout | |||
| 305 | + | cmd.Stderr = &stderr | |||
| 306 | + | if err := cmd.Run(); err != nil { | |||
| 307 | + | message := strings.TrimSpace(stderr.String()) | |||
| 308 | + | if message == "" { | |||
| 309 | + | message = err.Error() | |||
| 310 | + | } | |||
| 311 | + | return nil, errors.New(message) | |||
| 312 | + | } | |||
| 313 | + | return stdout.Bytes(), nil | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byte, error) { | |||
| 317 | + | args := []string{"-C", dir, "diff", "--no-index"} | |||
| 318 | + | if binary { | |||
| 319 | + | args = append(args, "--binary") | |||
| 320 | + | } | |||
| 321 | + | args = append(args, os.DevNull, "--", name) | |||
| 322 | + | cmd := exec.CommandContext(ctx, "git", args...) | |||
| 323 | + | var stdout, stderr bytes.Buffer | |||
| 324 | + | cmd.Stdout = &stdout | |||
| 325 | + | cmd.Stderr = &stderr | |||
| 326 | + | err := cmd.Run() | |||
| 327 | + | if err == nil { | |||
| 328 | + | return stdout.Bytes(), nil | |||
| 329 | + | } | |||
| 330 | + | var exitErr *exec.ExitError | |||
| 331 | + | if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { | |||
| 332 | + | return stdout.Bytes(), nil | |||
| 333 | + | } | |||
| 334 | + | message := strings.TrimSpace(stderr.String()) | |||
| 335 | + | if message == "" { | |||
| 336 | + | message = err.Error() | |||
| 337 | + | } | |||
| 338 | + | return nil, errors.New(message) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func splitNUL(data []byte) []string { | |||
| 342 | + | parts := strings.Split(string(data), "\x00") | |||
| 343 | + | out := make([]string, 0, len(parts)) | |||
| 344 | + | for _, part := range parts { | |||
| 345 | + | if part != "" { | |||
| 346 | + | out = append(out, part) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | return out | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func changedFilesFromStatus(status []byte) []string { | |||
| 353 | + | var files []string | |||
| 354 | + | for i := 0; i < len(status); { | |||
| 355 | + | end := bytes.IndexByte(status[i:], 0) | |||
| 356 | + | if end < 0 { | |||
| 357 | + | end = len(status) - i | |||
| 358 | + | } | |||
| 359 | + | record := status[i : i+end] | |||
| 360 | + | i += end + 1 | |||
| 361 | + | if len(record) < 4 { | |||
| 362 | + | continue | |||
| 363 | + | } | |||
| 364 | + | // With `git status --porcelain -z`, rename and copy records contain | |||
| 365 | + | // the new path in the status record, followed by the old path as the | |||
| 366 | + | // next NUL-delimited field. | |||
| 367 | + | path := record[3:] | |||
| 368 | + | if len(path) == 0 { | |||
| 369 | + | continue | |||
| 370 | + | } | |||
| 371 | + | files = append(files, string(path)) | |||
| 372 | + | isRename := record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C' | |||
| 373 | + | if isRename { | |||
| 374 | + | if i < len(status) { | |||
| 375 | + | if end := bytes.IndexByte(status[i:], 0); end >= 0 { | |||
| 376 | + | i += end + 1 | |||
| 377 | + | } else { | |||
| 378 | + | i = len(status) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | return files | |||
| 384 | + | } | |||
A packages/agent/subagents/workspace_manager_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestSpawnWorktreeCapturesBeforeCleanup(t *testing.T) { | |||
| 13 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 14 | + | t.Skip("git is required") | |||
| 15 | + | } | |||
| 16 | + | repo := initTestRepo(t) | |||
| 17 | + | if err := os.WriteFile(filepath.Join(repo, "main.txt"), []byte("host\n"), 0o600); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | runGit(t, repo, "add", "main.txt") | |||
| 21 | + | runGit(t, repo, "commit", "-m", "initial") | |||
| 22 | + | ||||
| 23 | + | state := t.TempDir() | |||
| 24 | + | f := New(Config{ | |||
| 25 | + | Root: state, RepoRoot: repo, | |||
| 26 | + | Policy: SubagentPolicy{DefaultTimeout: 0, IdleTimeout: 0}, | |||
| 27 | + | NewRunner: func(a *Agent) Runner { | |||
| 28 | + | return RunnerFunc(func(context.Context, Sink) error { | |||
| 29 | + | if err := os.WriteFile(filepath.Join(a.Dir, "main.txt"), []byte("child\n"), 0o600); err != nil { | |||
| 30 | + | return err | |||
| 31 | + | } | |||
| 32 | + | return os.WriteFile(filepath.Join(a.Dir, "new.txt"), []byte("created\n"), 0o600) | |||
| 33 | + | }) | |||
| 34 | + | }, | |||
| 35 | + | }) | |||
| 36 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "isolate", WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD"}) | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | a.Wait() | |||
| 41 | + | result, err := f.ReadResult(a.ID) | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | if result.Status != ResultSucceeded || len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "main.txt" || result.ChangedFiles[1] != "new.txt" { | |||
| 46 | + | t.Fatalf("result = %#v", result) | |||
| 47 | + | } | |||
| 48 | + | patch, err := os.ReadFile(filepath.Join(state, "agents", a.ID, "patch.diff")) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | if !strings.Contains(string(patch), "child") || !strings.Contains(string(patch), "created") { | |||
| 53 | + | t.Fatalf("patch = %q", patch) | |||
| 54 | + | } | |||
| 55 | + | host, err := os.ReadFile(filepath.Join(repo, "main.txt")) | |||
| 56 | + | if err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if string(host) != "host\n" { | |||
| 60 | + | t.Fatalf("host checkout changed: %q", host) | |||
| 61 | + | } | |||
| 62 | + | if _, err := os.Stat(a.WorkspacePath); !os.IsNotExist(err) { | |||
| 63 | + | t.Fatalf("worktree still exists: %v", err) | |||
| 64 | + | } | |||
| 65 | + | } | |||
A packages/agent/subagents/workspace_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestGitWorktreeWorkspaceIsolatesAndCapturesPatch(t *testing.T) { | |||
| 13 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 14 | + | t.Skip("git is required") | |||
| 15 | + | } | |||
| 16 | + | repo := initTestRepo(t) | |||
| 17 | + | ||||
| 18 | + | state := t.TempDir() | |||
| 19 | + | handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |||
| 20 | + | Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch, | |||
| 21 | + | }) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | if handle.Mode() != WorkspaceWorktree || handle.Dir() == repo { | |||
| 26 | + | t.Fatalf("handle = mode %s dir %q repo %q", handle.Mode(), handle.Dir(), repo) | |||
| 27 | + | } | |||
| 28 | + | if err := os.WriteFile(filepath.Join(handle.Dir(), "README.md"), []byte("after\n"), 0o600); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | before, err := os.ReadFile(filepath.Join(repo, "README.md")) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if string(before) != "before\n" { | |||
| 36 | + | t.Fatalf("host checkout changed: %q", before) | |||
| 37 | + | } | |||
| 38 | + | capture, err := handle.Capture(context.Background()) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | if len(capture.Patch) == 0 || !strings.Contains(string(capture.Patch), "after") { | |||
| 43 | + | t.Fatalf("patch = %q", capture.Patch) | |||
| 44 | + | } | |||
| 45 | + | if len(capture.ChangedFiles) != 1 || capture.ChangedFiles[0] != "README.md" { | |||
| 46 | + | t.Fatalf("changed files = %#v", capture.ChangedFiles) | |||
| 47 | + | } | |||
| 48 | + | if err := handle.Cleanup(context.Background()); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | if _, err := os.Stat(handle.Dir()); !os.IsNotExist(err) { | |||
| 52 | + | t.Fatalf("worktree still exists: %v", err) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) { | |||
| 57 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 58 | + | t.Skip("git is required") | |||
| 59 | + | } | |||
| 60 | + | repo := initTestRepo(t) | |||
| 61 | + | ||||
| 62 | + | state := t.TempDir() | |||
| 63 | + | first, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |||
| 64 | + | Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch, | |||
| 65 | + | }) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatal(err) | |||
| 68 | + | } | |||
| 69 | + | if err := os.WriteFile(filepath.Join(first.Dir(), "draft.txt"), []byte("keep me\n"), 0o600); err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | resumed, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |||
| 73 | + | Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch, | |||
| 74 | + | ExistingPath: first.Dir(), | |||
| 75 | + | }) | |||
| 76 | + | if err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | if resumed.Dir() != first.Dir() { | |||
| 80 | + | t.Fatalf("resume dir = %q, want %q", resumed.Dir(), first.Dir()) | |||
| 81 | + | } | |||
| 82 | + | if _, err := os.Stat(filepath.Join(resumed.Dir(), "draft.txt")); err != nil { | |||
| 83 | + | t.Fatalf("resumed worktree lost uncommitted file: %v", err) | |||
| 84 | + | } | |||
| 85 | + | if err := resumed.Cleanup(context.Background()); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestGitWorktreeWorkspaceCaptureIncludesUntrackedFile(t *testing.T) { | |||
| 91 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 92 | + | t.Skip("git is required") | |||
| 93 | + | } | |||
| 94 | + | repo := initTestRepo(t) | |||
| 95 | + | state := t.TempDir() | |||
| 96 | + | handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |||
| 97 | + | Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, Base: "HEAD", Capture: CapturePatch, | |||
| 98 | + | }) | |||
| 99 | + | if err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | defer func() { _ = handle.Cleanup(context.Background()) }() | |||
| 103 | + | ||||
| 104 | + | name := "new file – 日本語.txt" | |||
| 105 | + | if err := os.WriteFile(filepath.Join(handle.Dir(), name), []byte("created\n"), 0o600); err != nil { | |||
| 106 | + | t.Fatal(err) | |||
| 107 | + | } | |||
| 108 | + | capture, err := handle.Capture(context.Background()) | |||
| 109 | + | if err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | if len(capture.ChangedFiles) != 1 || capture.ChangedFiles[0] != name { | |||
| 113 | + | t.Fatalf("changed files = %#v, want [%q]", capture.ChangedFiles, name) | |||
| 114 | + | } | |||
| 115 | + | if !strings.Contains(string(capture.Patch), "created") { | |||
| 116 | + | t.Fatalf("patch = %q", capture.Patch) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestChangedFilesFromStatus(t *testing.T) { | |||
| 121 | + | status := []byte(" M path with spaces.txt\x00R renamed – 新.txt\x00original name – старый.txt\x00?? untracked – 文件.txt\x00") | |||
| 122 | + | got := changedFilesFromStatus(status) | |||
| 123 | + | want := []string{"path with spaces.txt", "renamed – 新.txt", "untracked – 文件.txt"} | |||
| 124 | + | if len(got) != len(want) { | |||
| 125 | + | t.Fatalf("changed files = %#v, want %#v", got, want) | |||
| 126 | + | } | |||
| 127 | + | for i := range want { | |||
| 128 | + | if got[i] != want[i] { | |||
| 129 | + | t.Errorf("changed files[%d] = %q, want %q", i, got[i], want[i]) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) { | |||
| 135 | + | root := t.TempDir() | |||
| 136 | + | handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{Mode: WorkspaceShared, RepositoryRoot: root}) | |||
| 137 | + | if err != nil { | |||
| 138 | + | t.Fatal(err) | |||
| 139 | + | } | |||
| 140 | + | if handle.Dir() != root || handle.RepositoryRoot() != root || handle.Mode() != WorkspaceShared { | |||
| 141 | + | t.Fatalf("shared handle = %#v", handle) | |||
| 142 | + | } | |||
| 143 | + | if err := handle.Cleanup(context.Background()); err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func initTestRepo(t *testing.T) string { | |||
| 149 | + | t.Helper() | |||
| 150 | + | home := t.TempDir() | |||
| 151 | + | t.Setenv("HOME", home) | |||
| 152 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) | |||
| 153 | + | t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) | |||
| 154 | + | t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) | |||
| 155 | + | unsetenvForTest(t, "GIT_DIR") | |||
| 156 | + | unsetenvForTest(t, "GIT_WORK_TREE") | |||
| 157 | + | ||||
| 158 | + | repo := t.TempDir() | |||
| 159 | + | runGit(t, repo, "init") | |||
| 160 | + | runGit(t, repo, "config", "user.email", "test@example.invalid") | |||
| 161 | + | runGit(t, repo, "config", "user.name", "Test User") | |||
| 162 | + | runGit(t, repo, "config", "commit.gpgsign", "false") | |||
| 163 | + | if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | runGit(t, repo, "add", "README.md") | |||
| 167 | + | runGit(t, repo, "commit", "-m", "initial") | |||
| 168 | + | return repo | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func unsetenvForTest(t *testing.T, name string) { | |||
| 172 | + | t.Helper() | |||
| 173 | + | value, ok := os.LookupEnv(name) | |||
| 174 | + | if err := os.Unsetenv(name); err != nil { | |||
| 175 | + | t.Fatal(err) | |||
| 176 | + | } | |||
| 177 | + | t.Cleanup(func() { | |||
| 178 | + | if ok { | |||
| 179 | + | _ = os.Setenv(name, value) | |||
| 180 | + | } else { | |||
| 181 | + | _ = os.Unsetenv(name) | |||
| 182 | + | } | |||
| 183 | + | }) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func runGit(t *testing.T, dir string, args ...string) { | |||
| 187 | + | t.Helper() | |||
| 188 | + | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |||
| 189 | + | if output, err := cmd.CombinedOutput(); err != nil { | |||
| 190 | + | t.Fatalf("git %v: %v\n%s", args, err, output) | |||
| 191 | + | } | |||
| 192 | + | } | |||
D packages/agent/swarm/agent.go +0 -228
| @@ -1,228 +0,0 @@ | |||||
| 1 | - | package swarm | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "fmt" | |||
| 6 | - | "strings" | |||
| 7 | - | "sync" | |||
| 8 | - | "time" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | // Agent is one supervised task. Public fields are immutable after | |||
| 12 | - | // Spawn; mutable state (status, activity, transcript) lives behind | |||
| 13 | - | // the embedded mutex. | |||
| 14 | - | type Agent struct { | |||
| 15 | - | ID string | |||
| 16 | - | Task string | |||
| 17 | - | Dir string // always the host's RepoRoot; agents share its cwd. | |||
| 18 | - | Started time.Time | |||
| 19 | - | ||||
| 20 | - | // Model and Provider, when non-empty, override the child | |||
| 21 | - | // subprocess's model resolution. Empty means "inherit whatever | |||
| 22 | - | // the child resolves on its own from config / env / flags" — | |||
| 23 | - | // the historical behaviour. Persisted in meta.json so Resume | |||
| 24 | - | // keeps using the same model across zot restarts. | |||
| 25 | - | Model string | |||
| 26 | - | Provider string | |||
| 27 | - | Reasoning string | |||
| 28 | - | FastMode bool | |||
| 29 | - | Subagent string | |||
| 30 | - | ||||
| 31 | - | // SessionID, when non-empty, scopes the agent to a particular | |||
| 32 | - | // host zot session: the dashboard only surfaces agents whose | |||
| 33 | - | // SessionID matches the active session. Empty means "unscoped" | |||
| 34 | - | // (legacy meta files from before the field existed, or agents | |||
| 35 | - | // spawned without a session context such as in tests). Set at | |||
| 36 | - | // Spawn time from Swarm.activeSession and persisted in | |||
| 37 | - | // meta.json so the scope survives restarts. | |||
| 38 | - | SessionID string | |||
| 39 | - | ||||
| 40 | - | // Resuming is true when this Agent struct was built by Resume | |||
| 41 | - | // rather than Spawn. The runner consults it to decide whether | |||
| 42 | - | // to pass the original Task as a positional argv to the child: | |||
| 43 | - | // on first Spawn we want the child to run the task immediately; | |||
| 44 | - | // on Resume the task was already run last time and replaying it | |||
| 45 | - | // would produce a second turn that collides ("agent busy; send | |||
| 46 | - | // 'cancel' first") with whatever the user types next. Not | |||
| 47 | - | // persisted — every Resume sets it explicitly. | |||
| 48 | - | Resuming bool | |||
| 49 | - | ||||
| 50 | - | // InboxPath is the unix socket the child agent listens on for | |||
| 51 | - | // follow-up prompts and control messages. The supervisor opens | |||
| 52 | - | // an Inbox at this path; the child opens a Listener. | |||
| 53 | - | InboxPath string | |||
| 54 | - | ||||
| 55 | - | // EventLogPath is the durable JSONL event log for this agent. | |||
| 56 | - | // The runner appends every well-formed event from the child | |||
| 57 | - | // (plus lifecycle events of its own) here. /swarm open in any | |||
| 58 | - | // zot process reads from this file to replay the full history. | |||
| 59 | - | EventLogPath string | |||
| 60 | - | ||||
| 61 | - | // SessionPath is the child's persistent session file. Surfaced | |||
| 62 | - | // so the dashboard / /swarm open can resume the agent later. | |||
| 63 | - | SessionPath string | |||
| 64 | - | ||||
| 65 | - | // inbox is the supervisor-side socket handle. Populated by | |||
| 66 | - | // Swarm.Spawn so SendInput can route messages without each | |||
| 67 | - | // caller redialing. | |||
| 68 | - | inbox *Inbox | |||
| 69 | - | ||||
| 70 | - | mu sync.Mutex | |||
| 71 | - | status Status | |||
| 72 | - | activity string | |||
| 73 | - | transcript []string | |||
| 74 | - | lastAssistant string | |||
| 75 | - | finished time.Time | |||
| 76 | - | lastErr error | |||
| 77 | - | ||||
| 78 | - | // OnTurnEnd, if set, fires once per prompt-level turn_end event | |||
| 79 | - | // emitted by the swarm daemon wrapper. Provider/tool-loop | |||
| 80 | - | // turn_end events (for example stop=tool_use) are ignored by the | |||
| 81 | - | // runner because they do not mean the sub-agent task finished. | |||
| 82 | - | // Used by auto-swarm watchers to detect that a sub-agent's first | |||
| 83 | - | // (or n-th) task has finished without waiting for the long-lived | |||
| 84 | - | // daemon itself to exit — sub-agents keep running on the inbox | |||
| 85 | - | // even after the initial task completes, so Wait() never unblocks | |||
| 86 | - | // for them. | |||
| 87 | - | OnTurnEnd func(step int, errMsg string) | |||
| 88 | - | ||||
| 89 | - | ctx context.Context | |||
| 90 | - | cancel context.CancelFunc | |||
| 91 | - | runner Runner | |||
| 92 | - | ||||
| 93 | - | // done closes when the run goroutine finalises the agent's | |||
| 94 | - | // status (done / failed / killed). Wait blocks on this so | |||
| 95 | - | // callers don't have to poll. | |||
| 96 | - | done chan struct{} | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | // Inbox exposes the supervisor-side socket handle. Returns nil for | |||
| 100 | - | // agents without inboxes (e.g. tests using a custom Runner that | |||
| 101 | - | // doesn't speak the daemon protocol). | |||
| 102 | - | func (a *Agent) Inbox() *Inbox { return a.inbox } | |||
| 103 | - | ||||
| 104 | - | // Status returns the current high-level status. Cheap; safe from any goroutine. | |||
| 105 | - | func (a *Agent) Status() Status { | |||
| 106 | - | a.mu.Lock() | |||
| 107 | - | defer a.mu.Unlock() | |||
| 108 | - | return a.status | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // Activity returns the current one-line activity string. | |||
| 112 | - | func (a *Agent) Activity() string { | |||
| 113 | - | a.mu.Lock() | |||
| 114 | - | defer a.mu.Unlock() | |||
| 115 | - | return a.activity | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | // Transcript returns a copy of the running transcript. | |||
| 119 | - | func (a *Agent) Transcript() []string { | |||
| 120 | - | a.mu.Lock() | |||
| 121 | - | defer a.mu.Unlock() | |||
| 122 | - | out := make([]string, len(a.transcript)) | |||
| 123 | - | copy(out, a.transcript) | |||
| 124 | - | return out | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | // Err returns the runner's terminal error, if any. | |||
| 128 | - | func (a *Agent) Err() error { | |||
| 129 | - | a.mu.Lock() | |||
| 130 | - | defer a.mu.Unlock() | |||
| 131 | - | return a.lastErr | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | // Wait blocks until the agent reaches a terminal state. Used by tests | |||
| 135 | - | // and by /swarm wait <id>. | |||
| 136 | - | func (a *Agent) Wait() { <-a.done } | |||
| 137 | - | ||||
| 138 | - | // SetOnTurnEnd installs (or clears, with nil) the callback fired | |||
| 139 | - | // from the runner when the child daemon emits a prompt-level | |||
| 140 | - | // turn_end event with a step field. Safe to call from any goroutine: | |||
| 141 | - | // the runner reads the callback under the same mutex. | |||
| 142 | - | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | |||
| 143 | - | a.mu.Lock() | |||
| 144 | - | a.OnTurnEnd = fn | |||
| 145 | - | a.mu.Unlock() | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | func (a *Agent) setStatus(s Status) { | |||
| 149 | - | a.mu.Lock() | |||
| 150 | - | a.status = s | |||
| 151 | - | a.mu.Unlock() | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | func (a *Agent) setActivity(msg string) { | |||
| 155 | - | a.mu.Lock() | |||
| 156 | - | a.activity = strings.TrimSpace(msg) | |||
| 157 | - | a.mu.Unlock() | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | func (a *Agent) appendTranscript(chunk string) { | |||
| 161 | - | a.appendTranscriptLocked(chunk, "", false) | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | func (a *Agent) appendUserMessage(text string) { | |||
| 165 | - | a.appendTranscriptLocked(text, "user: ", false) | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | func (a *Agent) appendAssistantMessage(text string) { | |||
| 169 | - | a.appendTranscriptLocked(text, "", true) | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) { | |||
| 173 | - | message := chunk | |||
| 174 | - | chunk = strings.TrimRight(chunk, "\n") | |||
| 175 | - | if chunk == "" { | |||
| 176 | - | return | |||
| 177 | - | } | |||
| 178 | - | a.mu.Lock() | |||
| 179 | - | for _, line := range strings.Split(chunk, "\n") { | |||
| 180 | - | a.transcript = append(a.transcript, linePrefix+line) | |||
| 181 | - | } | |||
| 182 | - | if assistant { | |||
| 183 | - | a.lastAssistant = message | |||
| 184 | - | } | |||
| 185 | - | // Bound the transcript so dashboards don't hold gigabytes for | |||
| 186 | - | // long-running agents. 2000 lines is plenty for inspection; | |||
| 187 | - | // the durable record lives in the agent's session file. | |||
| 188 | - | const cap = 2000 | |||
| 189 | - | if len(a.transcript) > cap { | |||
| 190 | - | a.transcript = a.transcript[len(a.transcript)-cap:] | |||
| 191 | - | } | |||
| 192 | - | a.mu.Unlock() | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | // newAgentID returns a short, mostly-collision-free identifier of the | |||
| 196 | - | // form "<slug>-<nano>". The slug is derived from the task text so | |||
| 197 | - | // dashboards stay readable; the nano suffix guarantees uniqueness | |||
| 198 | - | // even when two agents are spawned in the same millisecond. | |||
| 199 | - | func newAgentID(task string, now time.Time) string { | |||
| 200 | - | slug := taskSlug(task) | |||
| 201 | - | return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000) | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | func taskSlug(task string) string { | |||
| 205 | - | task = strings.ToLower(task) | |||
| 206 | - | var b strings.Builder | |||
| 207 | - | dash := false | |||
| 208 | - | for _, r := range task { | |||
| 209 | - | switch { | |||
| 210 | - | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |||
| 211 | - | b.WriteRune(r) | |||
| 212 | - | dash = false | |||
| 213 | - | default: | |||
| 214 | - | if !dash && b.Len() > 0 { | |||
| 215 | - | b.WriteByte('-') | |||
| 216 | - | dash = true | |||
| 217 | - | } | |||
| 218 | - | } | |||
| 219 | - | if b.Len() >= 24 { | |||
| 220 | - | break | |||
| 221 | - | } | |||
| 222 | - | } | |||
| 223 | - | out := strings.Trim(b.String(), "-") | |||
| 224 | - | if out == "" { | |||
| 225 | - | out = "agent" | |||
| 226 | - | } | |||
| 227 | - | return out | |||
| 228 | - | } | |||
D packages/agent/swarm/event.go +0 -311
| @@ -1,311 +0,0 @@ | |||||
| 1 | - | package swarm | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bufio" | |||
| 5 | - | "encoding/json" | |||
| 6 | - | "errors" | |||
| 7 | - | "fmt" | |||
| 8 | - | "io" | |||
| 9 | - | "os" | |||
| 10 | - | "path/filepath" | |||
| 11 | - | "sync" | |||
| 12 | - | "time" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // Event is one structured datum in an agent's durable event log. | |||
| 16 | - | // | |||
| 17 | - | // The shape is intentionally a thin map+timestamp wrapper rather | |||
| 18 | - | // than a strict struct so the swarm package doesn't have to track | |||
| 19 | - | // every field added to core.AgentEvent. The child writes | |||
| 20 | - | // `modes.EventToJSON(ev)` plus a few swarm-internal types | |||
| 21 | - | // (lifecycle, user_input echo, error). The supervisor doesn't | |||
| 22 | - | // interpret most fields; it just keeps them for replay and lifts | |||
| 23 | - | // a few well-known ones into AgentSnapshot (Activity, Status, | |||
| 24 | - | // Tail) for the dashboard. | |||
| 25 | - | // | |||
| 26 | - | // The on-disk file (events.jsonl) is append-only, one event per | |||
| 27 | - | // line, newline-terminated JSON. Reading is forward-only; readers | |||
| 28 | - | // stat the file size and read from their last offset on every | |||
| 29 | - | // poll. | |||
| 30 | - | type Event struct { | |||
| 31 | - | Time time.Time `json:"time"` | |||
| 32 | - | Type string `json:"type"` | |||
| 33 | - | Data map[string]any `json:"-"` | |||
| 34 | - | Raw map[string]any `json:"-"` // includes type+time+data for replay | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | // MarshalJSON flattens Data into the top-level object so consumers | |||
| 38 | - | // see {type, time, ...fields} rather than {type, time, data:{...}}. | |||
| 39 | - | func (e Event) MarshalJSON() ([]byte, error) { | |||
| 40 | - | out := make(map[string]any, len(e.Data)+2) | |||
| 41 | - | for k, v := range e.Data { | |||
| 42 | - | out[k] = v | |||
| 43 | - | } | |||
| 44 | - | out["type"] = e.Type | |||
| 45 | - | out["time"] = e.Time | |||
| 46 | - | return json.Marshal(out) | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // UnmarshalJSON accepts a flat object with at least type+time. | |||
| 50 | - | // All other fields land in both Data and Raw. | |||
| 51 | - | func (e *Event) UnmarshalJSON(b []byte) error { | |||
| 52 | - | m := map[string]any{} | |||
| 53 | - | if err := json.Unmarshal(b, &m); err != nil { | |||
| 54 | - | return err | |||
| 55 | - | } | |||
| 56 | - | e.Raw = m | |||
| 57 | - | e.Data = map[string]any{} | |||
| 58 | - | for k, v := range m { | |||
| 59 | - | if k == "type" || k == "time" { | |||
| 60 | - | continue | |||
| 61 | - | } | |||
| 62 | - | e.Data[k] = v | |||
| 63 | - | } | |||
| 64 | - | if t, ok := m["type"].(string); ok { | |||
| 65 | - | e.Type = t | |||
| 66 | - | } else { | |||
| 67 | - | return errors.New("swarm event: missing type") | |||
| 68 | - | } | |||
| 69 | - | if ts, ok := m["time"].(string); ok { | |||
| 70 | - | parsed, err := time.Parse(time.RFC3339Nano, ts) | |||
| 71 | - | if err == nil { | |||
| 72 | - | e.Time = parsed | |||
| 73 | - | } | |||
| 74 | - | } | |||
| 75 | - | return nil | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | // EventLog is an append-only writer for events.jsonl. Safe for | |||
| 79 | - | // concurrent writers (the child only has one writer, but tests | |||
| 80 | - | // occasionally fan out). | |||
| 81 | - | type EventLog struct { | |||
| 82 | - | path string | |||
| 83 | - | mu sync.Mutex | |||
| 84 | - | f *os.File | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | // OpenEventLog opens (or creates) the events.jsonl file at path. | |||
| 88 | - | // Parent directories are created as needed. | |||
| 89 | - | func OpenEventLog(path string) (*EventLog, error) { | |||
| 90 | - | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 91 | - | return nil, fmt.Errorf("event log dir: %w", err) | |||
| 92 | - | } | |||
| 93 | - | f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | |||
| 94 | - | if err != nil { | |||
| 95 | - | return nil, fmt.Errorf("event log open: %w", err) | |||
| 96 | - | } | |||
| 97 | - | return &EventLog{path: path, f: f}, nil | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // Path returns the absolute log path. | |||
| 101 | - | func (l *EventLog) Path() string { return l.path } | |||
| 102 | - | ||||
| 103 | - | // Append writes one event. The encoding is `<json>\n`. Concurrent | |||
| 104 | - | // callers are serialised; small enough events never need partial | |||
| 105 | - | // writes since unix guarantees atomicity for writes ≤ PIPE_BUF on | |||
| 106 | - | // regular files, and we hold a per-process mutex on top. | |||
| 107 | - | func (l *EventLog) Append(ev Event) error { | |||
| 108 | - | l.mu.Lock() | |||
| 109 | - | defer l.mu.Unlock() | |||
| 110 | - | if ev.Time.IsZero() { | |||
| 111 | - | ev.Time = time.Now() | |||
| 112 | - | } | |||
| 113 | - | b, err := json.Marshal(ev) | |||
| 114 | - | if err != nil { | |||
| 115 | - | return err | |||
| 116 | - | } | |||
| 117 | - | b = append(b, '\n') | |||
| 118 | - | _, err = l.f.Write(b) | |||
| 119 | - | return err | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | // Close flushes and closes the underlying file. | |||
| 123 | - | func (l *EventLog) Close() error { | |||
| 124 | - | l.mu.Lock() | |||
| 125 | - | defer l.mu.Unlock() | |||
| 126 | - | if l.f == nil { | |||
| 127 | - | return nil | |||
| 128 | - | } | |||
| 129 | - | err := l.f.Close() | |||
| 130 | - | l.f = nil | |||
| 131 | - | return err | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | // NewEvent is a convenience constructor that fills the timestamp. | |||
| 135 | - | func NewEvent(typ string, data map[string]any) Event { | |||
| 136 | - | if data == nil { | |||
| 137 | - | data = map[string]any{} | |||
| 138 | - | } | |||
| 139 | - | return Event{Time: time.Now(), Type: typ, Data: data} | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // ReadEventLog parses every event currently in the file. Used by | |||
| 143 | - | // the dashboard on first open and by tests; live tailing uses | |||
| 144 | - | // FollowEventLog below. | |||
| 145 | - | func ReadEventLog(path string) ([]Event, error) { | |||
| 146 | - | f, err := os.Open(path) | |||
| 147 | - | if err != nil { | |||
| 148 | - | if os.IsNotExist(err) { | |||
| 149 | - | return nil, nil | |||
| 150 | - | } | |||
| 151 | - | return nil, err | |||
| 152 | - | } | |||
| 153 | - | defer f.Close() | |||
| 154 | - | return readEvents(f) | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | func readEvents(r io.Reader) ([]Event, error) { | |||
| 158 | - | br := bufio.NewReader(r) | |||
| 159 | - | var out []Event | |||
| 160 | - | for { | |||
| 161 | - | line, err := br.ReadBytes('\n') | |||
| 162 | - | if len(line) > 0 { | |||
| 163 | - | var ev Event | |||
| 164 | - | if jerr := json.Unmarshal(line, &ev); jerr == nil { | |||
| 165 | - | if !isLikelyDoubleWrite(ev, out) { | |||
| 166 | - | out = append(out, ev) | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | // Malformed lines are skipped silently; the dashboard | |||
| 170 | - | // renders only well-formed events and the child is the | |||
| 171 | - | // only writer. | |||
| 172 | - | } | |||
| 173 | - | if err != nil { | |||
| 174 | - | if errors.Is(err, io.EOF) { | |||
| 175 | - | return out, nil | |||
| 176 | - | } | |||
| 177 | - | return out, err | |||
| 178 | - | } | |||
| 179 | - | } | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate | |||
| 183 | - | // of the most recent event in `tail` — same type and identical Data | |||
| 184 | - | // payload, with timestamps within a small window (the supervisor and | |||
| 185 | - | // the child's mirror used to BOTH write each event to disk, landing | |||
| 186 | - | // the same content twice in quick succession in events.jsonl). The | |||
| 187 | - | // historical behaviour was fixed at write time (the child's mirror | |||
| 188 | - | // is now dormant unless the supervisor is gone), but on-disk files | |||
| 189 | - | // from before the fix are still polluted, so we dedupe defensively | |||
| 190 | - | // at read time too. | |||
| 191 | - | // | |||
| 192 | - | // We deliberately bound by time (250ms) so two genuinely identical | |||
| 193 | - | // adjacent events that happen seconds apart — e.g. an agent that | |||
| 194 | - | // runs the same tool twice in a row — still both render. | |||
| 195 | - | func isLikelyDoubleWrite(ev Event, tail []Event) bool { | |||
| 196 | - | if len(tail) == 0 { | |||
| 197 | - | return false | |||
| 198 | - | } | |||
| 199 | - | prev := tail[len(tail)-1] | |||
| 200 | - | if prev.Type != ev.Type { | |||
| 201 | - | return false | |||
| 202 | - | } | |||
| 203 | - | if !ev.Time.IsZero() && !prev.Time.IsZero() { | |||
| 204 | - | dt := ev.Time.Sub(prev.Time) | |||
| 205 | - | if dt < 0 { | |||
| 206 | - | dt = -dt | |||
| 207 | - | } | |||
| 208 | - | if dt > 250*time.Millisecond { | |||
| 209 | - | return false | |||
| 210 | - | } | |||
| 211 | - | } | |||
| 212 | - | return sameEventData(prev.Data, ev.Data) | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | // sameEventData deep-compares two event payloads. Cheap because the | |||
| 216 | - | // payloads are small map[string]any trees built from JSON, and only | |||
| 217 | - | // called for adjacent same-type pairs. | |||
| 218 | - | func sameEventData(a, b map[string]any) bool { | |||
| 219 | - | if len(a) != len(b) { | |||
| 220 | - | return false | |||
| 221 | - | } | |||
| 222 | - | ab, _ := json.Marshal(a) | |||
| 223 | - | bb, _ := json.Marshal(b) | |||
| 224 | - | return string(ab) == string(bb) | |||
| 225 | - | } | |||
| 226 | - | ||||
| 227 | - | // EventFollower polls an events.jsonl file and emits new events as | |||
| 228 | - | // they're appended. The supervisor uses one per agent so dashboard | |||
| 229 | - | // snapshots stay current without reparsing the whole file on every | |||
| 230 | - | // frame. Close stops the goroutine; the channel returned by Events | |||
| 231 | - | // is closed when Close is called or the file is removed. | |||
| 232 | - | type EventFollower struct { | |||
| 233 | - | path string | |||
| 234 | - | interval time.Duration | |||
| 235 | - | out chan Event | |||
| 236 | - | done chan struct{} | |||
| 237 | - | closed chan struct{} | |||
| 238 | - | once sync.Once | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | // FollowEventLog starts polling path every interval. interval ≤ 0 | |||
| 242 | - | // defaults to 50ms which is invisible to the user and cheap on | |||
| 243 | - | // disk (one stat + one short read per agent per tick). | |||
| 244 | - | func FollowEventLog(path string, interval time.Duration) *EventFollower { | |||
| 245 | - | if interval <= 0 { | |||
| 246 | - | interval = 50 * time.Millisecond | |||
| 247 | - | } | |||
| 248 | - | f := &EventFollower{ | |||
| 249 | - | path: path, | |||
| 250 | - | interval: interval, | |||
| 251 | - | out: make(chan Event, 256), | |||
| 252 | - | done: make(chan struct{}), | |||
| 253 | - | closed: make(chan struct{}), | |||
| 254 | - | } | |||
| 255 | - | go f.loop() | |||
| 256 | - | return f | |||
| 257 | - | } | |||
| 258 | - | ||||
| 259 | - | // Events returns the receive end of the event stream. | |||
| 260 | - | func (f *EventFollower) Events() <-chan Event { return f.out } | |||
| 261 | - | ||||
| 262 | - | // Close stops polling and closes the events channel. It waits for | |||
| 263 | - | // the polling goroutine to exit so tests on Windows can remove the | |||
| 264 | - | // temp directory without racing an open file handle. | |||
| 265 | - | func (f *EventFollower) Close() { | |||
| 266 | - | f.once.Do(func() { | |||
| 267 | - | close(f.done) | |||
| 268 | - | <-f.closed | |||
| 269 | - | }) | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | func (f *EventFollower) loop() { | |||
| 273 | - | defer close(f.closed) | |||
| 274 | - | defer close(f.out) | |||
| 275 | - | var offset int64 | |||
| 276 | - | tick := time.NewTicker(f.interval) | |||
| 277 | - | defer tick.Stop() | |||
| 278 | - | for { | |||
| 279 | - | select { | |||
| 280 | - | case <-f.done: | |||
| 281 | - | return | |||
| 282 | - | case <-tick.C: | |||
| 283 | - | } | |||
| 284 | - | fi, err := os.Stat(f.path) | |||
| 285 | - | if err != nil { | |||
| 286 | - | continue | |||
| 287 | - | } | |||
| 288 | - | if fi.Size() <= offset { | |||
| 289 | - | continue | |||
| 290 | - | } | |||
| 291 | - | fh, err := os.Open(f.path) | |||
| 292 | - | if err != nil { | |||
| 293 | - | continue | |||
| 294 | - | } | |||
| 295 | - | if _, err := fh.Seek(offset, io.SeekStart); err != nil { | |||
| 296 | - | _ = fh.Close() | |||
| 297 | - | continue | |||
| 298 | - | } | |||
| 299 | - | evs, _ := readEvents(fh) | |||
| 300 | - | newOffset, _ := fh.Seek(0, io.SeekCurrent) | |||
| 301 | - | _ = fh.Close() | |||
| 302 | - | offset = newOffset | |||
| 303 | - | for _, ev := range evs { | |||
| 304 | - | select { | |||
| 305 | - | case f.out <- ev: | |||
| 306 | - | case <-f.done: | |||
| 307 | - | return | |||
| 308 | - | } | |||
| 309 | - | } | |||
| 310 | - | } | |||
| 311 | - | } | |||
D packages/agent/swarm/persist.go +0 -424
| @@ -1,424 +0,0 @@ | |||||
| 1 | - | package swarm | |||
| 2 | - | ||||
| 3 | - | // On-disk persistence for swarm agents. | |||
| 4 | - | // | |||
| 5 | - | // Every Spawn writes a meta.json next to the agent's events.jsonl and | |||
| 6 | - | // session.json. The file captures the immutable identity bits (id, | |||
| 7 | - | // task, branch, dir) plus the paths the runner needs to resume the | |||
| 8 | - | // agent later. On a fresh zot launch, Swarm.Reload() walks | |||
| 9 | - | // <root>/agents/*/meta.json and re-registers every agent it finds in | |||
| 10 | - | // StatusDetached so the user can see, view, resume, or remove them | |||
| 11 | - | // from the dashboard. | |||
| 12 | - | // | |||
| 13 | - | // We don't try to keep meta.json in sync with mutable state (status, | |||
| 14 | - | // activity, transcript). Those live in the events log (durable) and | |||
| 15 | - | // in-memory Agent fields (rebuilt by Reload from the log tail). | |||
| 16 | - | // Keeping meta.json immutable means we never have to worry about | |||
| 17 | - | // concurrent writers stomping on each other and the file matters | |||
| 18 | - | // only on the spawn/reload boundary. | |||
| 19 | - | ||||
| 20 | - | import ( | |||
| 21 | - | "context" | |||
| 22 | - | "encoding/json" | |||
| 23 | - | "errors" | |||
| 24 | - | "fmt" | |||
| 25 | - | "os" | |||
| 26 | - | "path/filepath" | |||
| 27 | - | "sort" | |||
| 28 | - | "strings" | |||
| 29 | - | "time" | |||
| 30 | - | ) | |||
| 31 | - | ||||
| 32 | - | // agentMeta is the durable identity record for one agent. Only fields | |||
| 33 | - | // the supervisor needs to rebuild an Agent after a restart live here. | |||
| 34 | - | // Adding a field is backwards-compatible (older meta.json files just | |||
| 35 | - | // leave it zero); removing or renaming one is not. | |||
| 36 | - | // | |||
| 37 | - | // Historical fields like `branch` and `isolated` are silently dropped | |||
| 38 | - | // by encoding/json's permissive decoder when an older meta.json is | |||
| 39 | - | // loaded; we don't need to keep them in the struct. | |||
| 40 | - | type agentMeta struct { | |||
| 41 | - | ID string `json:"id"` | |||
| 42 | - | Task string `json:"task"` | |||
| 43 | - | Dir string `json:"dir"` | |||
| 44 | - | Started time.Time `json:"started"` | |||
| 45 | - | Model string `json:"model,omitempty"` | |||
| 46 | - | Provider string `json:"provider,omitempty"` | |||
| 47 | - | Reasoning string `json:"reasoning,omitempty"` | |||
| 48 | - | FastMode *bool `json:"fast_mode,omitempty"` | |||
| 49 | - | Subagent string `json:"subagent,omitempty"` | |||
| 50 | - | InboxPath string `json:"inbox_path"` | |||
| 51 | - | EventLogPath string `json:"event_log_path"` | |||
| 52 | - | SessionPath string `json:"session_path"` | |||
| 53 | - | ||||
| 54 | - | // SessionID, when non-empty, scopes the agent to a particular | |||
| 55 | - | // host zot session: the dashboard only shows agents whose | |||
| 56 | - | // SessionID matches the active session. Older meta.json files | |||
| 57 | - | // (and agents spawned outside of any session, e.g. by tests or | |||
| 58 | - | // scripted callers that didn't call SetActiveSession) have an | |||
| 59 | - | // empty SessionID and are visible from every session as a | |||
| 60 | - | // backward-compat fallback. | |||
| 61 | - | SessionID string `json:"session_id,omitempty"` | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | |||
| 65 | - | ||||
| 66 | - | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | |||
| 67 | - | // write is atomic (tmp + rename) so a crash mid-write can't leave a | |||
| 68 | - | // half-parsable file that fails Reload. | |||
| 69 | - | func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 70 | - | fastMode := a.FastMode | |||
| 71 | - | m := agentMeta{ | |||
| 72 | - | ID: a.ID, | |||
| 73 | - | Task: a.Task, | |||
| 74 | - | Dir: a.Dir, | |||
| 75 | - | Started: a.Started, | |||
| 76 | - | Model: a.Model, | |||
| 77 | - | Provider: a.Provider, | |||
| 78 | - | Reasoning: a.Reasoning, | |||
| 79 | - | FastMode: &fastMode, | |||
| 80 | - | Subagent: a.Subagent, | |||
| 81 | - | InboxPath: a.InboxPath, | |||
| 82 | - | EventLogPath: a.EventLogPath, | |||
| 83 | - | SessionPath: a.SessionPath, | |||
| 84 | - | SessionID: a.SessionID, | |||
| 85 | - | } | |||
| 86 | - | b, err := json.MarshalIndent(m, "", " ") | |||
| 87 | - | if err != nil { | |||
| 88 | - | return fmt.Errorf("swarm meta marshal: %w", err) | |||
| 89 | - | } | |||
| 90 | - | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |||
| 91 | - | return fmt.Errorf("swarm meta dir: %w", err) | |||
| 92 | - | } | |||
| 93 | - | final := metaPath(stateDir) | |||
| 94 | - | tmp := final + ".tmp" | |||
| 95 | - | if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { | |||
| 96 | - | return fmt.Errorf("swarm meta write: %w", err) | |||
| 97 | - | } | |||
| 98 | - | if err := os.Rename(tmp, final); err != nil { | |||
| 99 | - | _ = os.Remove(tmp) | |||
| 100 | - | return fmt.Errorf("swarm meta rename: %w", err) | |||
| 101 | - | } | |||
| 102 | - | return nil | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped) | |||
| 106 | - | // when the file is missing so callers can distinguish "no such agent" | |||
| 107 | - | // from "corrupt metadata". | |||
| 108 | - | func readAgentMeta(stateDir string) (agentMeta, error) { | |||
| 109 | - | var m agentMeta | |||
| 110 | - | b, err := os.ReadFile(metaPath(stateDir)) | |||
| 111 | - | if err != nil { | |||
| 112 | - | return m, err | |||
| 113 | - | } | |||
| 114 | - | if err := json.Unmarshal(b, &m); err != nil { | |||
| 115 | - | return m, fmt.Errorf("swarm meta parse %s: %w", stateDir, err) | |||
| 116 | - | } | |||
| 117 | - | if m.ID == "" { | |||
| 118 | - | return m, fmt.Errorf("swarm meta %s: missing id", stateDir) | |||
| 119 | - | } | |||
| 120 | - | return m, nil | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | // Reload scans <root>/agents/*/meta.json and re-registers every | |||
| 124 | - | // previously-spawned agent as a StatusDetached entry. Agents already | |||
| 125 | - | // present in memory are left alone (Reload is idempotent and safe to | |||
| 126 | - | // call after Spawn, though in practice the cli invokes it exactly | |||
| 127 | - | // once just after New()). | |||
| 128 | - | // | |||
| 129 | - | // The reloaded agents have no live Runner; the user can: | |||
| 130 | - | // - view their transcript (the dashboard reads from EventLogPath), | |||
| 131 | - | // - resume them via Swarm.Resume (starts a fresh subprocess on the | |||
| 132 | - | // same worktree / session / inbox path), | |||
| 133 | - | // - remove them (worktree + meta + events log gone). | |||
| 134 | - | // | |||
| 135 | - | // Reload returns the number of agents loaded plus any per-directory | |||
| 136 | - | // error encountered. Malformed entries are skipped rather than | |||
| 137 | - | // failing the whole reload — one bad meta.json shouldn't hide the | |||
| 138 | - | // rest of the swarm. | |||
| 139 | - | func (f *Swarm) Reload() (loaded int, errs []error) { | |||
| 140 | - | agentsDir := filepath.Join(f.cfg.Root, "agents") | |||
| 141 | - | entries, err := os.ReadDir(agentsDir) | |||
| 142 | - | if err != nil { | |||
| 143 | - | if errors.Is(err, os.ErrNotExist) { | |||
| 144 | - | return 0, nil | |||
| 145 | - | } | |||
| 146 | - | return 0, []error{fmt.Errorf("swarm reload: %w", err)} | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | // Sort by directory name so the load order is stable across runs. | |||
| 150 | - | // agentStateDir uses the id verbatim so name-sort == id-sort, | |||
| 151 | - | // which mirrors the creation order well enough for the dashboard | |||
| 152 | - | // (we also sort by Started in SnapshotAll, but having a stable | |||
| 153 | - | // f.order helps tests). | |||
| 154 | - | names := make([]string, 0, len(entries)) | |||
| 155 | - | for _, e := range entries { | |||
| 156 | - | if e.IsDir() { | |||
| 157 | - | names = append(names, e.Name()) | |||
| 158 | - | } | |||
| 159 | - | } | |||
| 160 | - | sort.Strings(names) | |||
| 161 | - | ||||
| 162 | - | for _, name := range names { | |||
| 163 | - | stateDir := filepath.Join(agentsDir, name) | |||
| 164 | - | m, err := readAgentMeta(stateDir) | |||
| 165 | - | if err != nil { | |||
| 166 | - | if errors.Is(err, os.ErrNotExist) { | |||
| 167 | - | // Bare directory with no meta.json — probably a | |||
| 168 | - | // leftover from a Spawn that failed before | |||
| 169 | - | // writeAgentMeta. Ignore silently. | |||
| 170 | - | continue | |||
| 171 | - | } | |||
| 172 | - | errs = append(errs, err) | |||
| 173 | - | continue | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | f.mu.Lock() | |||
| 177 | - | if _, exists := f.agents[m.ID]; exists { | |||
| 178 | - | f.mu.Unlock() | |||
| 179 | - | continue | |||
| 180 | - | } | |||
| 181 | - | a := f.buildDetachedAgent(m) | |||
| 182 | - | f.agents[m.ID] = a | |||
| 183 | - | f.order = append(f.order, m.ID) | |||
| 184 | - | f.mu.Unlock() | |||
| 185 | - | loaded++ | |||
| 186 | - | } | |||
| 187 | - | return loaded, errs | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | // buildDetachedAgent constructs an Agent from a meta.json with no | |||
| 191 | - | // running Runner. The agent's transcript is populated from the tail | |||
| 192 | - | // of its event log so the dashboard immediately shows recent output; | |||
| 193 | - | // activity is inferred from the last lifecycle event. | |||
| 194 | - | // | |||
| 195 | - | // The returned Agent has a closed `done` channel because Wait should | |||
| 196 | - | // return instantly: there is nothing to wait for. | |||
| 197 | - | func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 198 | - | // Older meta.json files may still record a per-agent worktree | |||
| 199 | - | // path under Dir. They predate the decision to run every agent | |||
| 200 | - | // in the host's repo and shouldn't continue editing that stale | |||
| 201 | - | // checkout, which most likely no longer matches HEAD. Coerce | |||
| 202 | - | // the dir back to the live RepoRoot so resume picks up where | |||
| 203 | - | // the host is now. | |||
| 204 | - | dir := m.Dir | |||
| 205 | - | if f.cfg.RepoRoot != "" { | |||
| 206 | - | dir = f.cfg.RepoRoot | |||
| 207 | - | } | |||
| 208 | - | fastMode := f.cfg.FastMode | |||
| 209 | - | if m.FastMode != nil { | |||
| 210 | - | fastMode = *m.FastMode | |||
| 211 | - | } | |||
| 212 | - | a := &Agent{ | |||
| 213 | - | ID: m.ID, | |||
| 214 | - | Task: m.Task, | |||
| 215 | - | Dir: dir, | |||
| 216 | - | Started: m.Started, | |||
| 217 | - | Model: m.Model, | |||
| 218 | - | Provider: m.Provider, | |||
| 219 | - | Reasoning: m.Reasoning, | |||
| 220 | - | FastMode: fastMode, | |||
| 221 | - | Subagent: m.Subagent, | |||
| 222 | - | InboxPath: m.InboxPath, | |||
| 223 | - | EventLogPath: m.EventLogPath, | |||
| 224 | - | SessionPath: m.SessionPath, | |||
| 225 | - | SessionID: m.SessionID, | |||
| 226 | - | inbox: NewInbox(m.InboxPath), | |||
| 227 | - | status: StatusDetached, | |||
| 228 | - | activity: "detached", | |||
| 229 | - | done: make(chan struct{}), | |||
| 230 | - | } | |||
| 231 | - | // Wait() must not block for detached agents; close the channel | |||
| 232 | - | // immediately. Callers Resuming the agent will replace done with | |||
| 233 | - | // a fresh channel before starting the new runner. | |||
| 234 | - | close(a.done) | |||
| 235 | - | ||||
| 236 | - | // Recover transcript + activity hints from the event log. Best | |||
| 237 | - | // effort: a missing or unreadable log just leaves the agent | |||
| 238 | - | // detached with an empty transcript. | |||
| 239 | - | if a.EventLogPath != "" { | |||
| 240 | - | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | |||
| 241 | - | replayEventsIntoAgent(a, evs) | |||
| 242 | - | } | |||
| 243 | - | } | |||
| 244 | - | return a | |||
| 245 | - | } | |||
| 246 | - | ||||
| 247 | - | // replayEventsIntoAgent re-derives an agent's transcript and last | |||
| 248 | - | // known status hint from its event log. Mirrors applyEventToSink in | |||
| 249 | - | // runner.go but writes directly to the Agent fields (no Sink because | |||
| 250 | - | // the agent isn't being driven by a runner yet). | |||
| 251 | - | // | |||
| 252 | - | // Status precedence: explicit lifecycle events (agent_stopped) win | |||
| 253 | - | // over inferred ones (assistant_message → idle). If the log contains | |||
| 254 | - | // no terminator we keep status=StatusDetached so the user can resume. | |||
| 255 | - | func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 256 | - | terminal := false | |||
| 257 | - | for _, ev := range evs { | |||
| 258 | - | switch ev.Type { | |||
| 259 | - | case "assistant_message", "user_message": | |||
| 260 | - | var text []string | |||
| 261 | - | if c, ok := ev.Data["content"].([]any); ok { | |||
| 262 | - | for _, blk := range c { | |||
| 263 | - | m, _ := blk.(map[string]any) | |||
| 264 | - | if t, _ := m["type"].(string); t == "text" { | |||
| 265 | - | if txt, _ := m["text"].(string); txt != "" { | |||
| 266 | - | text = append(text, txt) | |||
| 267 | - | } | |||
| 268 | - | } | |||
| 269 | - | } | |||
| 270 | - | } | |||
| 271 | - | message := strings.Join(text, "\n") | |||
| 272 | - | if ev.Type == "assistant_message" { | |||
| 273 | - | a.appendAssistantMessage(message) | |||
| 274 | - | } else { | |||
| 275 | - | a.appendUserMessage(message) | |||
| 276 | - | } | |||
| 277 | - | case "stdout": | |||
| 278 | - | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 279 | - | a.appendTranscript(txt) | |||
| 280 | - | } | |||
| 281 | - | case "stderr": | |||
| 282 | - | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 283 | - | a.appendTranscript("stderr: " + txt) | |||
| 284 | - | } | |||
| 285 | - | case "error": | |||
| 286 | - | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 287 | - | a.appendTranscript("error: " + msg) | |||
| 288 | - | } | |||
| 289 | - | case "agent_stopped": | |||
| 290 | - | terminal = true | |||
| 291 | - | reason, _ := ev.Data["reason"].(string) | |||
| 292 | - | a.mu.Lock() | |||
| 293 | - | switch reason { | |||
| 294 | - | case "cancelled": | |||
| 295 | - | a.status = StatusKilled | |||
| 296 | - | a.activity = "cancelled (offline)" | |||
| 297 | - | case "shutdown": | |||
| 298 | - | a.status = StatusDone | |||
| 299 | - | a.activity = "shutdown (offline)" | |||
| 300 | - | case "exit": | |||
| 301 | - | if code, ok := ev.Data["code"].(float64); ok && code != 0 { | |||
| 302 | - | a.status = StatusFailed | |||
| 303 | - | a.activity = fmt.Sprintf("exit %d (offline)", int(code)) | |||
| 304 | - | } else { | |||
| 305 | - | a.status = StatusDone | |||
| 306 | - | a.activity = "done (offline)" | |||
| 307 | - | } | |||
| 308 | - | default: | |||
| 309 | - | a.status = StatusDone | |||
| 310 | - | a.activity = "stopped (offline)" | |||
| 311 | - | } | |||
| 312 | - | a.mu.Unlock() | |||
| 313 | - | } | |||
| 314 | - | } | |||
| 315 | - | if !terminal { | |||
| 316 | - | // Non-terminal log means the previous parent died mid-run. | |||
| 317 | - | // Leave status=StatusDetached but record a hint so the | |||
| 318 | - | // dashboard shows something useful. | |||
| 319 | - | a.mu.Lock() | |||
| 320 | - | if a.activity == "detached" && len(a.transcript) > 0 { | |||
| 321 | - | a.activity = "detached (resume to continue)" | |||
| 322 | - | } | |||
| 323 | - | a.mu.Unlock() | |||
| 324 | - | } | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | // Resume re-attaches a Runner to a previously-spawned agent. Durable | |||
| 328 | - | // session and event files are kept, while the transient inbox path is | |||
| 329 | - | // recalculated for the current runtime environment. Use this to | |||
| 330 | - | // continue a swarm session across zot restarts: | |||
| 331 | - | // | |||
| 332 | - | // swarmMgr.Reload() | |||
| 333 | - | // a, err := swarmMgr.Resume(ctx, "alpha-12345") | |||
| 334 | - | // swarmMgr.SendUserTurn(a.ID, "where were we?") | |||
| 335 | - | // | |||
| 336 | - | // The agent must be in a non-running state (Detached, Done, Failed, | |||
| 337 | - | // Killed). Resuming a still-running agent returns an error so two | |||
| 338 | - | // runners don't race for the same session. | |||
| 339 | - | func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 340 | - | existing := f.Get(id) | |||
| 341 | - | if existing == nil { | |||
| 342 | - | return nil, fmt.Errorf("swarm: no such agent %q", id) | |||
| 343 | - | } | |||
| 344 | - | existing.mu.Lock() | |||
| 345 | - | st := existing.status | |||
| 346 | - | existing.mu.Unlock() | |||
| 347 | - | if st == StatusRunning || st == StatusPending { | |||
| 348 | - | return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st) | |||
| 349 | - | } | |||
| 350 | - | ||||
| 351 | - | // Rebuild from the meta record so we don't carry stale runner | |||
| 352 | - | // state from a previous incarnation. The inbox is transient and | |||
| 353 | - | // may have been persisted under an incompatible filesystem by an | |||
| 354 | - | // older zot version, so always select it again on resume. | |||
| 355 | - | inboxPath, err := inboxSocketPath(f.cfg.Root, existing.ID) | |||
| 356 | - | if err != nil { | |||
| 357 | - | return nil, fmt.Errorf("swarm inbox path: %w", err) | |||
| 358 | - | } | |||
| 359 | - | fastMode := existing.FastMode | |||
| 360 | - | m := agentMeta{ | |||
| 361 | - | ID: existing.ID, Task: existing.Task, | |||
| 362 | - | Dir: existing.Dir, Started: existing.Started, | |||
| 363 | - | Model: existing.Model, Provider: existing.Provider, | |||
| 364 | - | Reasoning: existing.Reasoning, FastMode: &fastMode, | |||
| 365 | - | Subagent: existing.Subagent, | |||
| 366 | - | SessionID: existing.SessionID, | |||
| 367 | - | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |||
| 368 | - | SessionPath: existing.SessionPath, | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | a := &Agent{ | |||
| 372 | - | ID: m.ID, | |||
| 373 | - | Task: m.Task, | |||
| 374 | - | Dir: m.Dir, | |||
| 375 | - | Started: m.Started, | |||
| 376 | - | Model: m.Model, | |||
| 377 | - | Provider: m.Provider, | |||
| 378 | - | Reasoning: m.Reasoning, | |||
| 379 | - | FastMode: fastMode, | |||
| 380 | - | Subagent: m.Subagent, | |||
| 381 | - | SessionID: m.SessionID, | |||
| 382 | - | InboxPath: m.InboxPath, | |||
| 383 | - | EventLogPath: m.EventLogPath, | |||
| 384 | - | SessionPath: m.SessionPath, | |||
| 385 | - | Resuming: true, | |||
| 386 | - | inbox: NewInbox(m.InboxPath), | |||
| 387 | - | status: StatusPending, | |||
| 388 | - | activity: "resuming", | |||
| 389 | - | done: make(chan struct{}), | |||
| 390 | - | } | |||
| 391 | - | // Carry the previous transcript forward so the dashboard doesn't | |||
| 392 | - | // flash empty between resume and the first new event. | |||
| 393 | - | prev := existing.Transcript() | |||
| 394 | - | if len(prev) > 0 { | |||
| 395 | - | a.appendTranscript(strings.Join(prev, "\n")) | |||
| 396 | - | } | |||
| 397 | - | a.ctx, a.cancel = context.WithCancel(ctx) | |||
| 398 | - | a.runner = f.cfg.NewRunner(a) | |||
| 399 | - | ||||
| 400 | - | f.mu.Lock() | |||
| 401 | - | f.agents[a.ID] = a | |||
| 402 | - | // Keep the agent's slot in f.order; replacing in-place avoids | |||
| 403 | - | // reshuffling the dashboard's row ordering on resume. | |||
| 404 | - | found := false | |||
| 405 | - | for _, k := range f.order { | |||
| 406 | - | if k == a.ID { | |||
| 407 | - | found = true | |||
| 408 | - | break | |||
| 409 | - | } | |||
| 410 | - | } | |||
| 411 | - | if !found { | |||
| 412 | - | f.order = append(f.order, a.ID) | |||
| 413 | - | } | |||
| 414 | - | f.mu.Unlock() | |||
| 415 | - | ||||
| 416 | - | // Refresh the meta.json so any new path defaults (e.g. socket | |||
| 417 | - | // path moved into /tmp because the root got renamed) get | |||
| 418 | - | // persisted. Best-effort; resume still works if the disk is | |||
| 419 | - | // read-only because everything the runner needs is in-memory. | |||
| 420 | - | _ = writeAgentMeta(f.agentStateDir(a.ID), a) | |||
| 421 | - | ||||
| 422 | - | go f.run(a) | |||
| 423 | - | return a, nil | |||
| 424 | - | } | |||
D packages/agent/swarm/runner.go +0 -444
| @@ -1,444 +0,0 @@ | |||||
| 1 | - | package swarm | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bufio" | |||
| 5 | - | "bytes" | |||
| 6 | - | "context" | |||
| 7 | - | "encoding/json" | |||
| 8 | - | "fmt" | |||
| 9 | - | "io" | |||
| 10 | - | "os" | |||
| 11 | - | "os/exec" | |||
| 12 | - | "path/filepath" | |||
| 13 | - | "strings" | |||
| 14 | - | "time" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in | |||
| 18 | - | // the host's working directory (Agent.Dir, which is always the parent | |||
| 19 | - | // zot's RepoRoot) and consumes its JSONL event stream on stdout. | |||
| 20 | - | // | |||
| 21 | - | // Why a long-lived daemon and not `zot --print`: the supervisor and | |||
| 22 | - | // the user expect agents to keep accepting follow-up prompts. A | |||
| 23 | - | // one-shot subprocess can't do that; this design gives each swarm | |||
| 24 | - | // agent a persistent session file plus an inbox socket the parent | |||
| 25 | - | // writes to, mirroring Claude Code's "Agents view" model. | |||
| 26 | - | // | |||
| 27 | - | // Events flow: | |||
| 28 | - | // | |||
| 29 | - | // child stdout --> decoder --> EventLog (events.jsonl) | |||
| 30 | - | // --> Sink (Activity/Transcript) | |||
| 31 | - | // | |||
| 32 | - | // The on-disk log is the durable record. The Sink updates are an | |||
| 33 | - | // in-memory mirror so the dashboard doesn't have to tail the file | |||
| 34 | - | // for the parent's own agents. /swarm open in a separate zot would | |||
| 35 | - | // read the log directly. | |||
| 36 | - | type execRunner struct { | |||
| 37 | - | agent *Agent | |||
| 38 | - | resolveCredential func(context.Context, string) (Credential, error) | |||
| 39 | - | ||||
| 40 | - | // Command overrides the default `zot --swarm-agent ...` | |||
| 41 | - | // invocation. Tests set this to a fake binary (or `go run` | |||
| 42 | - | // against a tiny stub program) so the supervisor logic can be | |||
| 43 | - | // tested without a real child. Production code leaves it nil. | |||
| 44 | - | Command []string | |||
| 45 | - | ||||
| 46 | - | // SessionPath is the agent's session file. Empty means "defer | |||
| 47 | - | // to r.agent.SessionPath", which Swarm.Spawn always populates | |||
| 48 | - | // with <swarm-root>/agents/<id>/session.json. Tests that | |||
| 49 | - | // hand-build an Agent without going through Spawn must set | |||
| 50 | - | // one of the two; the runner refuses to invent a fallback | |||
| 51 | - | // because the only plausible one (<Dir>/.zot/session.json) | |||
| 52 | - | // would litter the user's repo — every agent's Dir points | |||
| 53 | - | // at it directly. | |||
| 54 | - | SessionPath string | |||
| 55 | - | } | |||
| 56 | - | ||||
| 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. | |||
| 61 | - | type swarmAgentArgsOpts struct { | |||
| 62 | - | Exe string | |||
| 63 | - | Dir string | |||
| 64 | - | SessionPath string | |||
| 65 | - | InboxPath string | |||
| 66 | - | Task string | |||
| 67 | - | Model string | |||
| 68 | - | Provider string | |||
| 69 | - | Reasoning string | |||
| 70 | - | FastMode bool | |||
| 71 | - | FastModeSet bool | |||
| 72 | - | Subagent string | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // defaultChildArgs builds the argv execRunner uses when its Command | |||
| 76 | - | // override is empty. Centralised so the spawn-vs-resume decision | |||
| 77 | - | // (whether to pass the original Task as a positional) lives in one | |||
| 78 | - | // place that tests can hit directly without going through Run's | |||
| 79 | - | // side effects. | |||
| 80 | - | // | |||
| 81 | - | // On Spawn (Resuming==false) we pass the task so the child's first | |||
| 82 | - | // turn runs immediately. On Resume (Resuming==true) we omit it: the | |||
| 83 | - | // child reopens the existing session file, loads the prior | |||
| 84 | - | // conversation, and just waits on the inbox for the next prompt. | |||
| 85 | - | // Re-firing the task on every resume produces a duplicate turn that | |||
| 86 | - | // collides with whatever the user types next, surfacing the | |||
| 87 | - | // "agent busy; send 'cancel' first" error between two assistant | |||
| 88 | - | // messages — which is exactly the bug this helper fixes. | |||
| 89 | - | func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string { | |||
| 90 | - | task := a.Task | |||
| 91 | - | if a.Resuming { | |||
| 92 | - | task = "" | |||
| 93 | - | } | |||
| 94 | - | return swarmAgentArgs(swarmAgentArgsOpts{ | |||
| 95 | - | Exe: exe, | |||
| 96 | - | Dir: a.Dir, | |||
| 97 | - | SessionPath: sessionPath, | |||
| 98 | - | InboxPath: inboxPath, | |||
| 99 | - | Task: task, | |||
| 100 | - | Model: a.Model, | |||
| 101 | - | Provider: a.Provider, | |||
| 102 | - | Reasoning: a.Reasoning, | |||
| 103 | - | FastMode: a.FastMode, | |||
| 104 | - | FastModeSet: true, | |||
| 105 | - | Subagent: a.Subagent, | |||
| 106 | - | }) | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | // swarmAgentArgs builds the argv used when execRunner.Command is | |||
| 110 | - | // empty. Pulled out so tests can lock in the flag set without | |||
| 111 | - | // actually spawning a subprocess. | |||
| 112 | - | func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||
| 113 | - | args := []string{ | |||
| 114 | - | opts.Exe, | |||
| 115 | - | "--swarm-agent", opts.InboxPath, | |||
| 116 | - | "--session", opts.SessionPath, | |||
| 117 | - | "--cwd", opts.Dir, | |||
| 118 | - | } | |||
| 119 | - | if opts.Model != "" { | |||
| 120 | - | args = append(args, "--model", opts.Model) | |||
| 121 | - | } | |||
| 122 | - | if opts.Provider != "" { | |||
| 123 | - | args = append(args, "--provider", opts.Provider) | |||
| 124 | - | } | |||
| 125 | - | if opts.Reasoning != "" { | |||
| 126 | - | args = append(args, "--reasoning", opts.Reasoning) | |||
| 127 | - | } | |||
| 128 | - | if opts.FastModeSet { | |||
| 129 | - | if opts.FastMode { | |||
| 130 | - | args = append(args, "--fast-mode") | |||
| 131 | - | } else { | |||
| 132 | - | args = append(args, "--no-fast-mode") | |||
| 133 | - | } | |||
| 134 | - | } | |||
| 135 | - | if opts.Subagent != "" { | |||
| 136 | - | args = append(args, "--subagent", opts.Subagent) | |||
| 137 | - | } | |||
| 138 | - | if opts.Task != "" { | |||
| 139 | - | // First task is positional so the child treats it as the | |||
| 140 | - | // initial user turn; subsequent turns arrive via the inbox. | |||
| 141 | - | args = append(args, opts.Task) | |||
| 142 | - | } | |||
| 143 | - | return args | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 147 | - | // SessionPath resolution order: | |||
| 148 | - | // 1. explicit r.SessionPath set by the test / caller | |||
| 149 | - | // 2. r.agent.SessionPath baked in by Swarm.Spawn — the | |||
| 150 | - | // production path. Always lives under | |||
| 151 | - | // <swarm-root>/agents/<id>/session.json so the per- | |||
| 152 | - | // agent state is entirely outside the working tree. | |||
| 153 | - | // Crucial because Agent.Dir points at the user's repo; | |||
| 154 | - | // any .zot/ scratch directory under Dir would litter | |||
| 155 | - | // their source tree. | |||
| 156 | - | // | |||
| 157 | - | // There is no third fallback. If neither path is set we | |||
| 158 | - | // refuse to start instead of inventing a directory; that | |||
| 159 | - | // way a misconfigured caller fails loudly the first time | |||
| 160 | - | // instead of silently dumping session data into someone's | |||
| 161 | - | // repo. | |||
| 162 | - | sessionPath := r.SessionPath | |||
| 163 | - | if sessionPath == "" { | |||
| 164 | - | sessionPath = r.agent.SessionPath | |||
| 165 | - | } | |||
| 166 | - | if sessionPath == "" { | |||
| 167 | - | return fmt.Errorf("swarm: agent missing session path (set SpawnRequest via Swarm.SpawnReq, or hand-build Agent with SessionPath populated)") | |||
| 168 | - | } | |||
| 169 | - | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { | |||
| 170 | - | return fmt.Errorf("session dir: %w", err) | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | inboxPath := r.agent.InboxPath | |||
| 174 | - | logPath := r.agent.EventLogPath | |||
| 175 | - | if logPath == "" { | |||
| 176 | - | return fmt.Errorf("swarm: agent missing event log path") | |||
| 177 | - | } | |||
| 178 | - | log, err := OpenEventLog(logPath) | |||
| 179 | - | if err != nil { | |||
| 180 | - | return err | |||
| 181 | - | } | |||
| 182 | - | defer log.Close() | |||
| 183 | - | ||||
| 184 | - | args := r.Command | |||
| 185 | - | if len(args) == 0 { | |||
| 186 | - | exe, err := os.Executable() | |||
| 187 | - | if err != nil { | |||
| 188 | - | return fmt.Errorf("locate self: %w", err) | |||
| 189 | - | } | |||
| 190 | - | args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath) | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | cmd := exec.CommandContext(ctx, args[0], args[1:]...) | |||
| 194 | - | cmd.Dir = r.agent.Dir | |||
| 195 | - | cmd.Env = append(os.Environ(), | |||
| 196 | - | "ZOT_SWARM_AGENT_ID="+r.agent.ID, | |||
| 197 | - | "ZOT_SWARM_EVENT_LOG="+logPath, | |||
| 198 | - | ) | |||
| 199 | - | if r.resolveCredential != nil { | |||
| 200 | - | credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider) | |||
| 201 | - | if resolveErr != nil { | |||
| 202 | - | return fmt.Errorf("resolve swarm credential for %s: %w", r.agent.Provider, resolveErr) | |||
| 203 | - | } | |||
| 204 | - | if credential.Value != "" { | |||
| 205 | - | encoded, encodeErr := json.Marshal(credential) | |||
| 206 | - | if encodeErr != nil { | |||
| 207 | - | return fmt.Errorf("encode swarm credential: %w", encodeErr) | |||
| 208 | - | } | |||
| 209 | - | cmd.Stdin = bytes.NewReader(encoded) | |||
| 210 | - | cmd.Env = append(cmd.Env, "ZOT_SWARM_CREDENTIAL_STDIN=1") | |||
| 211 | - | } | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | stdout, err := cmd.StdoutPipe() | |||
| 215 | - | if err != nil { | |||
| 216 | - | return err | |||
| 217 | - | } | |||
| 218 | - | stderr, err := cmd.StderrPipe() | |||
| 219 | - | if err != nil { | |||
| 220 | - | return err | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | // "spawning" is briefly shown until the first event arrives; | |||
| 224 | - | // the child's "spawned" lifecycle event then overwrites it. | |||
| 225 | - | sink.Activity("starting") | |||
| 226 | - | if err := cmd.Start(); err != nil { | |||
| 227 | - | return err | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | - | // stdout: parsed as JSONL. Every well-formed event is appended | |||
| 231 | - | // to the durable log AND forwarded to the in-memory sink so the | |||
| 232 | - | // dashboard updates without having to tail the file. Malformed | |||
| 233 | - | // lines are surfaced as plain transcript so they don't vanish. | |||
| 234 | - | done := make(chan struct{}, 2) | |||
| 235 | - | go func() { | |||
| 236 | - | defer func() { done <- struct{}{} }() | |||
| 237 | - | dec := bufio.NewReader(stdout) | |||
| 238 | - | for { | |||
| 239 | - | line, err := dec.ReadBytes('\n') | |||
| 240 | - | if len(line) > 0 { | |||
| 241 | - | trimmed := strings.TrimRight(string(line), "\r\n") | |||
| 242 | - | if trimmed == "" { | |||
| 243 | - | goto next | |||
| 244 | - | } | |||
| 245 | - | if ev, ok := parseEventLine(trimmed); ok { | |||
| 246 | - | _ = log.Append(ev) | |||
| 247 | - | applyEventToSink(ev, sink) | |||
| 248 | - | // Fan prompt-level task completions up to any | |||
| 249 | - | // subscriber on the supervised Agent. The child | |||
| 250 | - | // also forwards provider/tool-loop turn_end | |||
| 251 | - | // events (for example stop=tool_use); those do | |||
| 252 | - | // not contain step and must not be treated as | |||
| 253 | - | // swarm task completion. | |||
| 254 | - | notifyPromptTurnEnd(r.agent, ev) | |||
| 255 | - | } else { | |||
| 256 | - | // Non-JSON output. Keep it as transcript so an | |||
| 257 | - | // accidental fmt.Println in the child still | |||
| 258 | - | // shows up in the dashboard, and log a | |||
| 259 | - | // lifecycle event so the durable record stays | |||
| 260 | - | // in sync. | |||
| 261 | - | sink.Transcript(trimmed) | |||
| 262 | - | _ = log.Append(NewEvent("stdout", map[string]any{"text": trimmed})) | |||
| 263 | - | } | |||
| 264 | - | } | |||
| 265 | - | next: | |||
| 266 | - | if err != nil { | |||
| 267 | - | return | |||
| 268 | - | } | |||
| 269 | - | } | |||
| 270 | - | }() | |||
| 271 | - | ||||
| 272 | - | // stderr: lifecycle/error chatter from the child. Every line | |||
| 273 | - | // is mirrored as a stderr event in the durable log AND surfaced | |||
| 274 | - | // in the transcript so users can diagnose a failing agent | |||
| 275 | - | // without leaving the dashboard. | |||
| 276 | - | go func() { | |||
| 277 | - | defer func() { done <- struct{}{} }() | |||
| 278 | - | br := bufio.NewReader(stderr) | |||
| 279 | - | for { | |||
| 280 | - | line, err := br.ReadString('\n') | |||
| 281 | - | if line != "" { | |||
| 282 | - | txt := strings.TrimRight(line, "\r\n") | |||
| 283 | - | sink.Transcript("stderr: " + txt) | |||
| 284 | - | _ = log.Append(NewEvent("stderr", map[string]any{"text": txt})) | |||
| 285 | - | } | |||
| 286 | - | if err != nil { | |||
| 287 | - | return | |||
| 288 | - | } | |||
| 289 | - | } | |||
| 290 | - | }() | |||
| 291 | - | ||||
| 292 | - | <-done | |||
| 293 | - | <-done | |||
| 294 | - | ||||
| 295 | - | err = cmd.Wait() | |||
| 296 | - | exit := 0 | |||
| 297 | - | if ee, ok := err.(*exec.ExitError); ok { | |||
| 298 | - | exit = ee.ExitCode() | |||
| 299 | - | } | |||
| 300 | - | if err != nil && ctx.Err() != nil { | |||
| 301 | - | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})) | |||
| 302 | - | return ctx.Err() | |||
| 303 | - | } | |||
| 304 | - | if err != nil { | |||
| 305 | - | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | |||
| 306 | - | return err | |||
| 307 | - | } | |||
| 308 | - | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | |||
| 309 | - | sink.Activity("done") | |||
| 310 | - | return nil | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | // parseEventLine attempts to decode one JSONL line as an Event. | |||
| 314 | - | // Returns ok=false for non-JSON or JSON without a "type" field. | |||
| 315 | - | func parseEventLine(line string) (Event, bool) { | |||
| 316 | - | if len(line) == 0 || line[0] != '{' { | |||
| 317 | - | return Event{}, false | |||
| 318 | - | } | |||
| 319 | - | var ev Event | |||
| 320 | - | if err := json.Unmarshal([]byte(line), &ev); err != nil { | |||
| 321 | - | return Event{}, false | |||
| 322 | - | } | |||
| 323 | - | if ev.Type == "" { | |||
| 324 | - | return Event{}, false | |||
| 325 | - | } | |||
| 326 | - | if ev.Time.IsZero() { | |||
| 327 | - | ev.Time = time.Now() | |||
| 328 | - | } | |||
| 329 | - | return ev, true | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the swarm | |||
| 333 | - | // daemon's prompt-level completion event. Provider/tool-loop | |||
| 334 | - | // turn_end events (such as stop=tool_use) do not include step and | |||
| 335 | - | // are not terminal for the delegated task. | |||
| 336 | - | func notifyPromptTurnEnd(a *Agent, ev Event) { | |||
| 337 | - | if a == nil || ev.Type != "turn_end" { | |||
| 338 | - | return | |||
| 339 | - | } | |||
| 340 | - | step, ok := ev.Data["step"].(float64) | |||
| 341 | - | if !ok { | |||
| 342 | - | return | |||
| 343 | - | } | |||
| 344 | - | ||||
| 345 | - | a.mu.Lock() | |||
| 346 | - | fn := a.OnTurnEnd | |||
| 347 | - | a.mu.Unlock() | |||
| 348 | - | if fn != nil { | |||
| 349 | - | errMsg, _ := ev.Data["error"].(string) | |||
| 350 | - | go fn(int(step), errMsg) | |||
| 351 | - | } | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | // applyEventToSink translates an Event into Sink updates. Only a | |||
| 355 | - | // few event types are interpreted; the rest still land in the | |||
| 356 | - | // durable log via the caller. | |||
| 357 | - | func applyEventToSink(ev Event, sink Sink) { | |||
| 358 | - | type roleSink interface { | |||
| 359 | - | userMessage(string) | |||
| 360 | - | assistantMessage(string) | |||
| 361 | - | } | |||
| 362 | - | appendMessage := func(text string, assistant bool) { | |||
| 363 | - | if text == "" { | |||
| 364 | - | return | |||
| 365 | - | } | |||
| 366 | - | if roles, ok := sink.(roleSink); ok { | |||
| 367 | - | if assistant { | |||
| 368 | - | roles.assistantMessage(text) | |||
| 369 | - | } else { | |||
| 370 | - | roles.userMessage(text) | |||
| 371 | - | } | |||
| 372 | - | return | |||
| 373 | - | } | |||
| 374 | - | if assistant { | |||
| 375 | - | sink.Transcript(text) | |||
| 376 | - | } else { | |||
| 377 | - | sink.Transcript("user: " + text) | |||
| 378 | - | } | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | switch ev.Type { | |||
| 382 | - | case "assistant_message", "user_message": | |||
| 383 | - | var text []string | |||
| 384 | - | if c, ok := ev.Data["content"].([]any); ok { | |||
| 385 | - | for _, blk := range c { | |||
| 386 | - | m, _ := blk.(map[string]any) | |||
| 387 | - | if t, _ := m["type"].(string); t == "text" { | |||
| 388 | - | if txt, _ := m["text"].(string); txt != "" { | |||
| 389 | - | text = append(text, txt) | |||
| 390 | - | } | |||
| 391 | - | } | |||
| 392 | - | } | |||
| 393 | - | } | |||
| 394 | - | appendMessage(strings.Join(text, "\n"), ev.Type == "assistant_message") | |||
| 395 | - | if ev.Type == "assistant_message" { | |||
| 396 | - | sink.Activity("idle") | |||
| 397 | - | } | |||
| 398 | - | case "turn_start": | |||
| 399 | - | sink.Activity("thinking") | |||
| 400 | - | case "tool_call": | |||
| 401 | - | if name, _ := ev.Data["name"].(string); name != "" { | |||
| 402 | - | sink.Activity("tool: " + truncate(name, 60)) | |||
| 403 | - | } | |||
| 404 | - | case "tool_result": | |||
| 405 | - | sink.Activity("idle") | |||
| 406 | - | case "turn_end": | |||
| 407 | - | sink.Activity("idle") | |||
| 408 | - | case "agent_ready": | |||
| 409 | - | sink.Activity("idle") | |||
| 410 | - | case "agent_stopped": | |||
| 411 | - | // terminal status is decided by Swarm.run from the runner's | |||
| 412 | - | // return value, not from this event. Don't overwrite the | |||
| 413 | - | // activity here. | |||
| 414 | - | case "error": | |||
| 415 | - | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 416 | - | sink.Transcript("error: " + msg) | |||
| 417 | - | } | |||
| 418 | - | } | |||
| 419 | - | } | |||
| 420 | - | ||||
| 421 | - | // RunnerFunc adapts a plain function into a Runner. Useful for tests | |||
| 422 | - | // and for callers who don't need their own type. | |||
| 423 | - | type RunnerFunc func(ctx context.Context, sink Sink) error | |||
| 424 | - | ||||
| 425 | - | func (f RunnerFunc) Run(ctx context.Context, sink Sink) error { return f(ctx, sink) } | |||
| 426 | - | ||||
| 427 | - | // streamLines kept around for any caller still using it directly. | |||
| 428 | - | // | |||
| 429 | - | // Deprecated: the runner now parses JSONL from stdout via | |||
| 430 | - | // parseEventLine; this helper is unused inside the package but | |||
| 431 | - | // remains exported via internal use by tests in the runner_test | |||
| 432 | - | // suite that pre-date the daemon switch. | |||
| 433 | - | func streamLines(r io.Reader, fn func(string)) { | |||
| 434 | - | br := bufio.NewReader(r) | |||
| 435 | - | for { | |||
| 436 | - | line, err := br.ReadString('\n') | |||
| 437 | - | if line != "" { | |||
| 438 | - | fn(strings.TrimRight(line, "\r\n")) | |||
| 439 | - | } | |||
| 440 | - | if err != nil { | |||
| 441 | - | return | |||
| 442 | - | } | |||
| 443 | - | } | |||
| 444 | - | } | |||
D packages/agent/swarm/swarm.go +0 -606
| @@ -1,606 +0,0 @@ | |||||
| 1 | - | // Package swarm implements zot's multi-agent supervisor. | |||
| 2 | - | // | |||
| 3 | - | // A Swarm manages a set of headless zot subprocesses ("agents") | |||
| 4 | - | // that share the host's working directory. The interactive TUI | |||
| 5 | - | // exposes the supervisor through the /swarm slash command and a | |||
| 6 | - | // dashboard dialog; non-TUI code can drive it directly through | |||
| 7 | - | // this package. | |||
| 8 | - | // | |||
| 9 | - | // Every agent runs with cwd == the parent zot's RepoRoot — the | |||
| 10 | - | // same files the user sees, the same files the main agent edits. | |||
| 11 | - | // There is no git worktree, no per-agent branch, no isolation. If | |||
| 12 | - | // you want parallel edits on a separate branch, use normal git | |||
| 13 | - | // tooling (a real worktree, a different terminal) yourself. | |||
| 14 | - | // | |||
| 15 | - | // Each Agent has: | |||
| 16 | - | // - a unique id (short slug + nanoseconds) | |||
| 17 | - | // - a Runner (the thing that actually executes the task) | |||
| 18 | - | // - a Status string + Activity string that the dashboard reads | |||
| 19 | - | // | |||
| 20 | - | // The Runner abstraction means tests can swap a fake in instead of | |||
| 21 | - | // really spawning a subprocess; the production Runner shells out to | |||
| 22 | - | // `zot --swarm-agent ...` so we reuse zot's own model resolution | |||
| 23 | - | // and tooling without re-implementing the agent loop. | |||
| 24 | - | package swarm | |||
| 25 | - | ||||
| 26 | - | import ( | |||
| 27 | - | "context" | |||
| 28 | - | "errors" | |||
| 29 | - | "fmt" | |||
| 30 | - | "os" | |||
| 31 | - | "path/filepath" | |||
| 32 | - | "sort" | |||
| 33 | - | "strings" | |||
| 34 | - | "sync" | |||
| 35 | - | "time" | |||
| 36 | - | ) | |||
| 37 | - | ||||
| 38 | - | // Status is the high-level lifecycle state of an Agent. | |||
| 39 | - | type Status string | |||
| 40 | - | ||||
| 41 | - | const ( | |||
| 42 | - | StatusPending Status = "pending" // created, not started yet | |||
| 43 | - | StatusRunning Status = "running" // Runner.Run is in flight | |||
| 44 | - | StatusDone Status = "done" // Runner.Run returned nil | |||
| 45 | - | StatusFailed Status = "failed" // Runner.Run returned an error | |||
| 46 | - | StatusKilled Status = "killed" // Stop() called before completion | |||
| 47 | - | StatusDetached Status = "detached" // reloaded from disk; no live runner | |||
| 48 | - | ) | |||
| 49 | - | ||||
| 50 | - | // Config configures a Swarm. | |||
| 51 | - | type Config struct { | |||
| 52 | - | // Root is the directory under which per-agent state files live. | |||
| 53 | - | // Typically <ZotHome>/swarm, but tests pass a tempdir. | |||
| 54 | - | Root string | |||
| 55 | - | ||||
| 56 | - | // RepoRoot is the working directory every spawned agent runs | |||
| 57 | - | // in — the same cwd the parent zot is using. There is no | |||
| 58 | - | // per-agent isolation: agents edit the host's files directly. | |||
| 59 | - | RepoRoot string | |||
| 60 | - | ||||
| 61 | - | // FastMode is copied to every newly spawned child and persisted so | |||
| 62 | - | // child subprocesses use the same OpenAI service-tier setting. | |||
| 63 | - | FastMode bool | |||
| 64 | - | ||||
| 65 | - | // NewRunner produces the Runner for an Agent. If nil, the default | |||
| 66 | - | // `zot --swarm-agent ...` exec runner is used. Tests inject a fake | |||
| 67 | - | // here. | |||
| 68 | - | NewRunner func(a *Agent) Runner | |||
| 69 | - | ||||
| 70 | - | // ResolveCredential resolves the credential inherited by a child | |||
| 71 | - | // process. The default runner transfers it over the child's stdin, | |||
| 72 | - | // keeping command-backed credentials out of argv and the environment. | |||
| 73 | - | ResolveCredential func(ctx context.Context, provider string) (Credential, error) | |||
| 74 | - | ||||
| 75 | - | // Now is a clock seam for tests; defaults to time.Now. | |||
| 76 | - | Now func() time.Time | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // Credential is the resolved authentication state inherited by a swarm child. | |||
| 80 | - | type Credential struct { | |||
| 81 | - | Value string `json:"value"` | |||
| 82 | - | Method string `json:"method"` | |||
| 83 | - | AccountID string `json:"account_id,omitempty"` | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // Runner executes one agent task. Run blocks until the task finishes, | |||
| 87 | - | // is cancelled via ctx, or hits an unrecoverable error. | |||
| 88 | - | // | |||
| 89 | - | // Run should report progress by writing short human-readable strings | |||
| 90 | - | // to the activity channel and final transcript text to transcript. | |||
| 91 | - | // Both channels are non-blocking sinks owned by the Swarm; if the | |||
| 92 | - | // dashboard isn't reading, sends are dropped. | |||
| 93 | - | type Runner interface { | |||
| 94 | - | Run(ctx context.Context, sink Sink) error | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | // Sink is how a Runner reports activity and transcript back to the | |||
| 98 | - | // supervisor. All methods are safe to call from any goroutine and | |||
| 99 | - | // never block. | |||
| 100 | - | type Sink interface { | |||
| 101 | - | // Activity sets the one-line "what is this agent doing right now" | |||
| 102 | - | // string shown in the dashboard. | |||
| 103 | - | Activity(msg string) | |||
| 104 | - | // Transcript appends a chunk of agent output (typically a final | |||
| 105 | - | // assistant message) to the agent's running transcript. | |||
| 106 | - | Transcript(chunk string) | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | // Swarm supervises a set of Agents. | |||
| 110 | - | type Swarm struct { | |||
| 111 | - | cfg Config | |||
| 112 | - | ||||
| 113 | - | mu sync.Mutex | |||
| 114 | - | agents map[string]*Agent | |||
| 115 | - | order []string // creation order for stable listing | |||
| 116 | - | ||||
| 117 | - | // activeSession is the host session id the dashboard is | |||
| 118 | - | // currently scoped to. When non-empty, SnapshotAll filters out | |||
| 119 | - | // agents whose SessionID doesn't match (legacy / unscoped | |||
| 120 | - | // agents with SessionID == "" are always shown). Spawn stamps | |||
| 121 | - | // new agents with this value so they appear only in the session | |||
| 122 | - | // that created them. When empty (the default), the historical | |||
| 123 | - | // "show everything" behaviour is preserved — important for | |||
| 124 | - | // tests and any scripted use of the Swarm that doesn't bother | |||
| 125 | - | // with sessions. | |||
| 126 | - | activeSession string | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | // New constructs a Swarm from cfg. Missing config fields are filled | |||
| 130 | - | // with defaults. | |||
| 131 | - | func New(cfg Config) *Swarm { | |||
| 132 | - | if cfg.Now == nil { | |||
| 133 | - | cfg.Now = time.Now | |||
| 134 | - | } | |||
| 135 | - | if cfg.NewRunner == nil { | |||
| 136 | - | cfg.NewRunner = func(a *Agent) Runner { | |||
| 137 | - | return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential} | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | - | return &Swarm{ | |||
| 141 | - | cfg: cfg, | |||
| 142 | - | agents: map[string]*Agent{}, | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | // SetRepoRoot updates the shared working directory for subsequently | |||
| 147 | - | // spawned agents. Existing agents keep the directory they were started | |||
| 148 | - | // with; callers use this when the host session changes cwd. | |||
| 149 | - | func (f *Swarm) SetRepoRoot(root string) { | |||
| 150 | - | f.mu.Lock() | |||
| 151 | - | defer f.mu.Unlock() | |||
| 152 | - | f.cfg.RepoRoot = root | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | // RepoRoot returns the working directory used by subsequently spawned | |||
| 156 | - | // agents. | |||
| 157 | - | func (f *Swarm) RepoRoot() string { | |||
| 158 | - | f.mu.Lock() | |||
| 159 | - | defer f.mu.Unlock() | |||
| 160 | - | return f.cfg.RepoRoot | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // SetFastMode updates the fast-mode setting for subsequently spawned | |||
| 164 | - | // children. Existing agents keep the setting captured at their spawn | |||
| 165 | - | // boundary so resumes remain deterministic. | |||
| 166 | - | func (f *Swarm) SetFastMode(enabled bool) { | |||
| 167 | - | f.mu.Lock() | |||
| 168 | - | f.cfg.FastMode = enabled | |||
| 169 | - | f.mu.Unlock() | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | // FastMode reports the setting used for subsequently spawned children. | |||
| 173 | - | func (f *Swarm) FastMode() bool { | |||
| 174 | - | f.mu.Lock() | |||
| 175 | - | defer f.mu.Unlock() | |||
| 176 | - | return f.cfg.FastMode | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |||
| 180 | - | // to a particular host zot session id. Pass empty to clear the | |||
| 181 | - | // scope and revert to "show every agent" (the original behaviour). | |||
| 182 | - | // | |||
| 183 | - | // Existing in-memory agents keep their SessionID; only the filter | |||
| 184 | - | // applied at snapshot time changes. So swapping the active session | |||
| 185 | - | // with /sessions instantly re-narrows the dashboard without | |||
| 186 | - | // touching any agent state. | |||
| 187 | - | func (f *Swarm) SetActiveSession(id string) { | |||
| 188 | - | f.mu.Lock() | |||
| 189 | - | defer f.mu.Unlock() | |||
| 190 | - | f.activeSession = id | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | // ActiveSession returns the current scope, mostly for tests and | |||
| 194 | - | // diagnostics. Empty means "no scope; show everything". | |||
| 195 | - | func (f *Swarm) ActiveSession() string { | |||
| 196 | - | f.mu.Lock() | |||
| 197 | - | defer f.mu.Unlock() | |||
| 198 | - | return f.activeSession | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | // agentStateDir is the per-agent state directory laid out as: | |||
| 202 | - | // | |||
| 203 | - | // <root>/agents/<id>/ | |||
| 204 | - | // events.jsonl durable event log (runner-owned) | |||
| 205 | - | // session.json persistent agent session (child-owned) | |||
| 206 | - | // meta.json static metadata (id, task) | |||
| 207 | - | // | |||
| 208 | - | // The transient unix socket inbox lives in a probed runtime directory, | |||
| 209 | - | // which may be on a different filesystem from this durable state. | |||
| 210 | - | func (f *Swarm) agentStateDir(id string) string { | |||
| 211 | - | return filepath.Join(f.cfg.Root, "agents", id) | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | // SpawnRequest configures a Spawn. Only Task is required; the rest | |||
| 215 | - | // are optional. Model + Provider, when set, get baked into the | |||
| 216 | - | // child argv as --model / --provider so the agent runs against the | |||
| 217 | - | // chosen model regardless of the parent's current selection. A named | |||
| 218 | - | // Subagent is passed to the child as --subagent, where it resolves the | |||
| 219 | - | // markdown profile and applies its instructions. Reasoning is passed | |||
| 220 | - | // as --reasoning when set. FastMode restricts the host setting when a | |||
| 221 | - | // selected profile declares a fastMode preference. | |||
| 222 | - | type SpawnRequest struct { | |||
| 223 | - | Task string | |||
| 224 | - | Model string // optional override; child resolves default if empty | |||
| 225 | - | Provider string // optional override; usually paired with Model | |||
| 226 | - | Reasoning string // optional reasoning/thinking level override | |||
| 227 | - | // FastMode is an optional profile restriction. A non-nil false value | |||
| 228 | - | // disables fast mode for this child; true still cannot enable fast mode | |||
| 229 | - | // when Config.FastMode is false. Nil inherits Config.FastMode. | |||
| 230 | - | FastMode *bool | |||
| 231 | - | Subagent string // optional named markdown profile | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | // Spawn creates a new Agent for the given task, allocates its | |||
| 235 | - | // on-disk state directory (events log, inbox socket path, session | |||
| 236 | - | // file path), and starts the Runner on a background goroutine. The | |||
| 237 | - | // returned Agent is already in StatusRunning (or StatusFailed if | |||
| 238 | - | // state setup failed before the goroutine started). This is the | |||
| 239 | - | // historical signature; callers that want to override the child's | |||
| 240 | - | // model use SpawnReq instead. | |||
| 241 | - | func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | |||
| 242 | - | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | // SpawnReq is the full-fat variant of Spawn that accepts a | |||
| 246 | - | // SpawnRequest. Existing callers can keep using Spawn; new code that | |||
| 247 | - | // wants to pin the child's model uses this. | |||
| 248 | - | // | |||
| 249 | - | // Every spawned agent runs with cwd == cfg.RepoRoot — the same | |||
| 250 | - | // working directory as the host. No per-agent worktree, no branch, | |||
| 251 | - | // no isolation. The user explicitly opted out of the worktree flow. | |||
| 252 | - | func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | |||
| 253 | - | task := strings.TrimSpace(req.Task) | |||
| 254 | - | if task == "" { | |||
| 255 | - | return nil, errors.New("swarm: empty task") | |||
| 256 | - | } | |||
| 257 | - | id := newAgentID(task, f.cfg.Now()) | |||
| 258 | - | ||||
| 259 | - | // Snapshot the shared cwd together with the active session so a | |||
| 260 | - | // concurrent /cd cannot race a spawn or produce a mixed snapshot. | |||
| 261 | - | f.mu.Lock() | |||
| 262 | - | dir := f.cfg.RepoRoot | |||
| 263 | - | fastMode := f.cfg.FastMode | |||
| 264 | - | sessionID := f.activeSession | |||
| 265 | - | f.mu.Unlock() | |||
| 266 | - | if req.FastMode != nil { | |||
| 267 | - | fastMode = fastMode && *req.FastMode | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | stateDir := f.agentStateDir(id) | |||
| 271 | - | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |||
| 272 | - | return nil, fmt.Errorf("swarm state dir: %w", err) | |||
| 273 | - | } | |||
| 274 | - | logPath := filepath.Join(stateDir, "events.jsonl") | |||
| 275 | - | sessionPath := filepath.Join(stateDir, "session.json") | |||
| 276 | - | // Keep the transient unix socket outside the durable state root. | |||
| 277 | - | // ZOT_HOME may live on a shared or network filesystem that supports | |||
| 278 | - | // regular state files but cannot host unix socket nodes. | |||
| 279 | - | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | |||
| 280 | - | if err != nil { | |||
| 281 | - | return nil, fmt.Errorf("swarm inbox path: %w", err) | |||
| 282 | - | } | |||
| 283 | - | ||||
| 284 | - | a := &Agent{ | |||
| 285 | - | ID: id, | |||
| 286 | - | Task: task, | |||
| 287 | - | Dir: dir, | |||
| 288 | - | Started: f.cfg.Now(), | |||
| 289 | - | Model: strings.TrimSpace(req.Model), | |||
| 290 | - | Provider: strings.TrimSpace(req.Provider), | |||
| 291 | - | Reasoning: strings.TrimSpace(req.Reasoning), | |||
| 292 | - | FastMode: fastMode, | |||
| 293 | - | Subagent: strings.TrimSpace(req.Subagent), | |||
| 294 | - | SessionID: sessionID, | |||
| 295 | - | InboxPath: inboxPath, | |||
| 296 | - | EventLogPath: logPath, | |||
| 297 | - | SessionPath: sessionPath, | |||
| 298 | - | inbox: NewInbox(inboxPath), | |||
| 299 | - | status: StatusPending, | |||
| 300 | - | activity: "queued", | |||
| 301 | - | done: make(chan struct{}), | |||
| 302 | - | } | |||
| 303 | - | a.ctx, a.cancel = context.WithCancel(ctx) | |||
| 304 | - | a.runner = f.cfg.NewRunner(a) | |||
| 305 | - | ||||
| 306 | - | f.mu.Lock() | |||
| 307 | - | f.agents[id] = a | |||
| 308 | - | f.order = append(f.order, id) | |||
| 309 | - | f.mu.Unlock() | |||
| 310 | - | ||||
| 311 | - | // Persist the agent's identity so a later `zot` invocation can | |||
| 312 | - | // reload it from disk via Swarm.Reload. Best-effort: if the disk | |||
| 313 | - | // is read-only we still let the runner start, the user just won't | |||
| 314 | - | // see this agent on the next launch. | |||
| 315 | - | _ = writeAgentMeta(stateDir, a) | |||
| 316 | - | ||||
| 317 | - | go f.run(a) | |||
| 318 | - | return a, nil | |||
| 319 | - | } | |||
| 320 | - | ||||
| 321 | - | // SendInput delivers msg to the agent's inbox. msg should already | |||
| 322 | - | // include the leading kind ("user " + text, "cancel", "shutdown"). | |||
| 323 | - | // Returns an error wrapping ErrNotReady if the child hasn't opened | |||
| 324 | - | // its listener yet; the caller can retry or surface the wait. | |||
| 325 | - | func (f *Swarm) SendInput(id, msg string) error { | |||
| 326 | - | a := f.Get(id) | |||
| 327 | - | if a == nil { | |||
| 328 | - | return fmt.Errorf("no such agent %q", id) | |||
| 329 | - | } | |||
| 330 | - | if a.inbox == nil { | |||
| 331 | - | return fmt.Errorf("agent %s has no inbox", a.ID) | |||
| 332 | - | } | |||
| 333 | - | return a.inbox.SendInput(msg) | |||
| 334 | - | } | |||
| 335 | - | ||||
| 336 | - | // SendUserTurn is sugar for the common "send the next user turn" | |||
| 337 | - | // case. It quotes nothing and forwards verbatim; callers are | |||
| 338 | - | // expected to have already trimmed and expanded the text. | |||
| 339 | - | func (f *Swarm) SendUserTurn(id, text string) error { | |||
| 340 | - | return f.SendInput(id, "user "+text) | |||
| 341 | - | } | |||
| 342 | - | ||||
| 343 | - | func (f *Swarm) run(a *Agent) { | |||
| 344 | - | a.setStatus(StatusRunning) | |||
| 345 | - | a.setActivity("starting") | |||
| 346 | - | err := a.runner.Run(a.ctx, agentSink{a: a}) | |||
| 347 | - | a.mu.Lock() | |||
| 348 | - | a.finished = f.cfg.Now() | |||
| 349 | - | switch { | |||
| 350 | - | case a.status == StatusKilled: | |||
| 351 | - | // Already finalised by Stop. | |||
| 352 | - | case errors.Is(err, context.Canceled): | |||
| 353 | - | a.status = StatusKilled | |||
| 354 | - | a.activity = "cancelled" | |||
| 355 | - | case err != nil: | |||
| 356 | - | a.status = StatusFailed | |||
| 357 | - | a.activity = "error: " + truncate(err.Error(), 120) | |||
| 358 | - | a.lastErr = err | |||
| 359 | - | default: | |||
| 360 | - | a.status = StatusDone | |||
| 361 | - | a.activity = "done" | |||
| 362 | - | } | |||
| 363 | - | a.mu.Unlock() | |||
| 364 | - | close(a.done) | |||
| 365 | - | } | |||
| 366 | - | ||||
| 367 | - | // List returns a snapshot of every agent in creation order. The | |||
| 368 | - | // returned slice is a copy; callers may iterate without holding the | |||
| 369 | - | // swarm lock. Agent fields are read under their own mutex during | |||
| 370 | - | // formatting in Snapshot. | |||
| 371 | - | func (f *Swarm) List() []*Agent { | |||
| 372 | - | f.mu.Lock() | |||
| 373 | - | defer f.mu.Unlock() | |||
| 374 | - | out := make([]*Agent, 0, len(f.order)) | |||
| 375 | - | for _, id := range f.order { | |||
| 376 | - | out = append(out, f.agents[id]) | |||
| 377 | - | } | |||
| 378 | - | return out | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | // Get returns the agent with the given (possibly truncated) id, or | |||
| 382 | - | // nil. Matching is prefix-based so the user can type the first few | |||
| 383 | - | // characters of a long id. | |||
| 384 | - | func (f *Swarm) Get(id string) *Agent { | |||
| 385 | - | f.mu.Lock() | |||
| 386 | - | defer f.mu.Unlock() | |||
| 387 | - | if a, ok := f.agents[id]; ok { | |||
| 388 | - | return a | |||
| 389 | - | } | |||
| 390 | - | // Prefix match. | |||
| 391 | - | var hits []*Agent | |||
| 392 | - | for k, a := range f.agents { | |||
| 393 | - | if strings.HasPrefix(k, id) { | |||
| 394 | - | hits = append(hits, a) | |||
| 395 | - | } | |||
| 396 | - | } | |||
| 397 | - | if len(hits) == 1 { | |||
| 398 | - | return hits[0] | |||
| 399 | - | } | |||
| 400 | - | return nil | |||
| 401 | - | } | |||
| 402 | - | ||||
| 403 | - | // Stop cancels the agent's context. The Runner should observe the | |||
| 404 | - | // cancellation and return promptly; the goroutine then finalises the | |||
| 405 | - | // agent in StatusKilled. Also closes the supervisor-side inbox handle | |||
| 406 | - | // so any pending SendInput retries fail fast instead of dialing a | |||
| 407 | - | // socket about to be unlinked. | |||
| 408 | - | // | |||
| 409 | - | // Stop is a no-op for any agent that's not in a live runnable state | |||
| 410 | - | // — Done / Failed / Killed (already finalised) and Detached (no | |||
| 411 | - | // in-process runner; reloaded from disk). Calling Stop on a detached | |||
| 412 | - | // agent must not crash: buildDetachedAgent doesn't allocate a | |||
| 413 | - | // context/cancel pair because there's nothing to cancel. | |||
| 414 | - | func (f *Swarm) Stop(id string) error { | |||
| 415 | - | a := f.Get(id) | |||
| 416 | - | if a == nil { | |||
| 417 | - | return fmt.Errorf("no such agent %q", id) | |||
| 418 | - | } | |||
| 419 | - | a.mu.Lock() | |||
| 420 | - | switch a.status { | |||
| 421 | - | case StatusDone, StatusFailed, StatusKilled, StatusDetached: | |||
| 422 | - | a.mu.Unlock() | |||
| 423 | - | return nil | |||
| 424 | - | } | |||
| 425 | - | a.status = StatusKilled | |||
| 426 | - | a.activity = "stopped" | |||
| 427 | - | a.mu.Unlock() | |||
| 428 | - | // Belt-and-braces guard against the cancel-less case (detached | |||
| 429 | - | // agents skip this branch above, but a future code path that | |||
| 430 | - | // builds an Agent without a runner shouldn't crash the | |||
| 431 | - | // supervisor). | |||
| 432 | - | if a.cancel != nil { | |||
| 433 | - | a.cancel() | |||
| 434 | - | } | |||
| 435 | - | if a.inbox != nil { | |||
| 436 | - | _ = a.inbox.Close() | |||
| 437 | - | } | |||
| 438 | - | return nil | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | - | // StopAll cancels every running agent. Used on shutdown. | |||
| 442 | - | func (f *Swarm) StopAll() { | |||
| 443 | - | for _, a := range f.List() { | |||
| 444 | - | _ = f.Stop(a.ID) | |||
| 445 | - | } | |||
| 446 | - | } | |||
| 447 | - | ||||
| 448 | - | // Remove tears down the per-agent state for a terminated agent. It | |||
| 449 | - | // is an error to remove an agent that's still running; call Stop | |||
| 450 | - | // first and wait for the status to settle. Detached agents | |||
| 451 | - | // (reloaded from disk) remove cleanly because they have no live | |||
| 452 | - | // runner racing for the same files. | |||
| 453 | - | // | |||
| 454 | - | // Agents share the host's working tree, so Remove never touches | |||
| 455 | - | // any source file — it only deletes the agent's state directory | |||
| 456 | - | // under <root>/agents/<id>/. | |||
| 457 | - | func (f *Swarm) Remove(id string) error { | |||
| 458 | - | a := f.Get(id) | |||
| 459 | - | if a == nil { | |||
| 460 | - | return fmt.Errorf("no such agent %q", id) | |||
| 461 | - | } | |||
| 462 | - | a.mu.Lock() | |||
| 463 | - | st := a.status | |||
| 464 | - | a.mu.Unlock() | |||
| 465 | - | if st == StatusRunning || st == StatusPending { | |||
| 466 | - | return fmt.Errorf("agent %s still %s", a.ID, st) | |||
| 467 | - | } | |||
| 468 | - | // Best-effort cleanup of the per-agent state directory | |||
| 469 | - | // (meta.json, events.jsonl, session.json). Failing here would leave | |||
| 470 | - | // the user with no recourse, | |||
| 471 | - | // so swallow the error. | |||
| 472 | - | _ = os.RemoveAll(f.agentStateDir(a.ID)) | |||
| 473 | - | f.mu.Lock() | |||
| 474 | - | delete(f.agents, a.ID) | |||
| 475 | - | for i, k := range f.order { | |||
| 476 | - | if k == a.ID { | |||
| 477 | - | f.order = append(f.order[:i], f.order[i+1:]...) | |||
| 478 | - | break | |||
| 479 | - | } | |||
| 480 | - | } | |||
| 481 | - | f.mu.Unlock() | |||
| 482 | - | return nil | |||
| 483 | - | } | |||
| 484 | - | ||||
| 485 | - | // Snapshot returns a read-only view of one agent. Safe for the TUI | |||
| 486 | - | // goroutine to call repeatedly; never blocks on the Runner. | |||
| 487 | - | type AgentSnapshot struct { | |||
| 488 | - | ID string | |||
| 489 | - | Task string | |||
| 490 | - | Dir string | |||
| 491 | - | Status Status | |||
| 492 | - | Activity string | |||
| 493 | - | Started time.Time | |||
| 494 | - | Finished time.Time | |||
| 495 | - | Err string | |||
| 496 | - | Tail string // last few transcript lines, joined with "\n" | |||
| 497 | - | Lines []string // full transcript (already capped by Agent.appendTranscript) | |||
| 498 | - | LastAssistant string // complete final assistant message, without role formatting | |||
| 499 | - | ||||
| 500 | - | // Model, Provider, and Reasoning expose the per-agent overrides set | |||
| 501 | - | // at Spawn time (empty when the agent inherits the child's default | |||
| 502 | - | // resolution). Subagent is the selected named markdown profile. | |||
| 503 | - | // The dashboard surfaces these so the user can confirm the child | |||
| 504 | - | // configuration. | |||
| 505 | - | Model string | |||
| 506 | - | Provider string | |||
| 507 | - | Reasoning string | |||
| 508 | - | FastMode bool | |||
| 509 | - | Subagent string | |||
| 510 | - | ||||
| 511 | - | // Paths to the agent's durable state. Surface them in the | |||
| 512 | - | // snapshot so the dashboard / /swarm open can read events.jsonl | |||
| 513 | - | // or resume the session without going back through the Agent. | |||
| 514 | - | InboxPath string | |||
| 515 | - | EventLogPath string | |||
| 516 | - | SessionPath string | |||
| 517 | - | } | |||
| 518 | - | ||||
| 519 | - | // Snapshot copies the live agent state into a value the caller can | |||
| 520 | - | // inspect at leisure. | |||
| 521 | - | func (a *Agent) Snapshot() AgentSnapshot { | |||
| 522 | - | a.mu.Lock() | |||
| 523 | - | defer a.mu.Unlock() | |||
| 524 | - | tail := strings.Join(lastN(a.transcript, 6), "\n") | |||
| 525 | - | lines := make([]string, len(a.transcript)) | |||
| 526 | - | copy(lines, a.transcript) | |||
| 527 | - | errStr := "" | |||
| 528 | - | if a.lastErr != nil { | |||
| 529 | - | errStr = a.lastErr.Error() | |||
| 530 | - | } | |||
| 531 | - | return AgentSnapshot{ | |||
| 532 | - | ID: a.ID, Task: a.Task, Dir: a.Dir, | |||
| 533 | - | Status: a.status, Activity: a.activity, | |||
| 534 | - | Started: a.Started, Finished: a.finished, | |||
| 535 | - | Err: errStr, Tail: tail, Lines: lines, | |||
| 536 | - | LastAssistant: a.lastAssistant, | |||
| 537 | - | Model: a.Model, | |||
| 538 | - | Provider: a.Provider, | |||
| 539 | - | Reasoning: a.Reasoning, | |||
| 540 | - | FastMode: a.FastMode, | |||
| 541 | - | Subagent: a.Subagent, | |||
| 542 | - | InboxPath: a.InboxPath, | |||
| 543 | - | EventLogPath: a.EventLogPath, | |||
| 544 | - | SessionPath: a.SessionPath, | |||
| 545 | - | } | |||
| 546 | - | } | |||
| 547 | - | ||||
| 548 | - | // SnapshotAll returns snapshots of every agent in creation order, | |||
| 549 | - | // scoped to the active session when one is set. | |||
| 550 | - | // | |||
| 551 | - | // Scoping rules: | |||
| 552 | - | // - activeSession == "": no filter, every agent is returned | |||
| 553 | - | // (historical behaviour; used by tests and scripted callers). | |||
| 554 | - | // - activeSession != "": include only agents whose SessionID | |||
| 555 | - | // matches activeSession OR is empty. The empty-id pass-through | |||
| 556 | - | // keeps pre-upgrade agents (their meta.json was written before | |||
| 557 | - | // session_id existed) visible from any session so the user | |||
| 558 | - | // doesn't lose access after the schema bump. | |||
| 559 | - | func (f *Swarm) SnapshotAll() []AgentSnapshot { | |||
| 560 | - | agents := f.List() | |||
| 561 | - | f.mu.Lock() | |||
| 562 | - | active := f.activeSession | |||
| 563 | - | f.mu.Unlock() | |||
| 564 | - | ||||
| 565 | - | out := make([]AgentSnapshot, 0, len(agents)) | |||
| 566 | - | for _, a := range agents { | |||
| 567 | - | if active != "" && a.SessionID != "" && a.SessionID != active { | |||
| 568 | - | continue | |||
| 569 | - | } | |||
| 570 | - | out = append(out, a.Snapshot()) | |||
| 571 | - | } | |||
| 572 | - | // Sort by start time for a stable, deterministic listing. | |||
| 573 | - | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | |||
| 574 | - | return out | |||
| 575 | - | } | |||
| 576 | - | ||||
| 577 | - | // agentSink is the Sink the Swarm hands to each Runner. | |||
| 578 | - | type agentSink struct{ a *Agent } | |||
| 579 | - | ||||
| 580 | - | func (s agentSink) Activity(msg string) { s.a.setActivity(msg) } | |||
| 581 | - | func (s agentSink) Transcript(chunk string) { | |||
| 582 | - | if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") { | |||
| 583 | - | s.a.appendTranscript(chunk) | |||
| 584 | - | return | |||
| 585 | - | } | |||
| 586 | - | s.a.appendAssistantMessage(chunk) | |||
| 587 | - | } | |||
| 588 | - | func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) } | |||
| 589 | - | func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) } | |||
| 590 | - | ||||
| 591 | - | func truncate(s string, n int) string { | |||
| 592 | - | if len(s) <= n { | |||
| 593 | - | return s | |||
| 594 | - | } | |||
| 595 | - | if n <= 3 { | |||
| 596 | - | return strings.Repeat(".", n) | |||
| 597 | - | } | |||
| 598 | - | return s[:n-3] + "..." | |||
| 599 | - | } | |||
| 600 | - | ||||
| 601 | - | func lastN(lines []string, n int) []string { | |||
| 602 | - | if len(lines) <= n { | |||
| 603 | - | return lines | |||
| 604 | - | } | |||
| 605 | - | return lines[len(lines)-n:] | |||
| 606 | - | } | |||
D packages/agent/swarm_agent.go +0 -322
| @@ -1,322 +0,0 @@ | |||||
| 1 | - | package agent | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "encoding/json" | |||
| 6 | - | "fmt" | |||
| 7 | - | "io" | |||
| 8 | - | "os" | |||
| 9 | - | "strings" | |||
| 10 | - | "sync" | |||
| 11 | - | "time" | |||
| 12 | - | ||||
| 13 | - | "github.com/patriceckhart/zot/packages/agent/modes" | |||
| 14 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 15 | - | "github.com/patriceckhart/zot/packages/core" | |||
| 16 | - | "github.com/patriceckhart/zot/packages/provider" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | // runSwarmAgentMode is the daemon-mode entry point used by every | |||
| 20 | - | // swarm-spawned zot subprocess. It's intentionally close in shape to | |||
| 21 | - | // runJSONMode but with two key differences: | |||
| 22 | - | // | |||
| 23 | - | // - Lifetime: the process stays alive across many user turns. The | |||
| 24 | - | // initial positional task (if any) is the first turn; subsequent | |||
| 25 | - | // turns arrive through the inbox unix socket at args.SwarmAgent. | |||
| 26 | - | // | |||
| 27 | - | // - Output: every emitted JSON line is also mirrored verbatim into | |||
| 28 | - | // events.jsonl (see ZOT_SWARM_EVENT_LOG) so a separate zot | |||
| 29 | - | // process can /swarm open this agent and replay its full history | |||
| 30 | - | // even after the parent that spawned us is long gone. | |||
| 31 | - | // | |||
| 32 | - | // The runner in packages/agent/swarm/runner.go is the only caller in | |||
| 33 | - | // production; tests use the stubchild binary under | |||
| 34 | - | // packages/agent/swarm/testdata/cmd/stubchild instead of the real model | |||
| 35 | - | // loop. | |||
| 36 | - | func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 37 | - | if args.SwarmAgent == "" { | |||
| 38 | - | return fmt.Errorf("--swarm-agent requires a socket path") | |||
| 39 | - | } | |||
| 40 | - | if os.Getenv("ZOT_SWARM_CREDENTIAL_STDIN") == "1" { | |||
| 41 | - | var inherited swarm.Credential | |||
| 42 | - | dec := json.NewDecoder(io.LimitReader(os.Stdin, 1<<20)) | |||
| 43 | - | if err := dec.Decode(&inherited); err != nil { | |||
| 44 | - | return fmt.Errorf("read inherited swarm credential: %w", err) | |||
| 45 | - | } | |||
| 46 | - | if inherited.Value == "" || inherited.Method == "" { | |||
| 47 | - | return fmt.Errorf("read inherited swarm credential: missing value or method") | |||
| 48 | - | } | |||
| 49 | - | args.inheritedCredential = inherited.Value | |||
| 50 | - | args.inheritedAuthMethod = inherited.Method | |||
| 51 | - | args.inheritedAccountID = inherited.AccountID | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | r, err := Resolve(args, true) | |||
| 55 | - | if err != nil { | |||
| 56 | - | return err | |||
| 57 | - | } | |||
| 58 | - | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |||
| 59 | - | defer stopExt() | |||
| 60 | - | ||||
| 61 | - | ag := r.NewAgent() | |||
| 62 | - | initialAg := ag | |||
| 63 | - | defer func() { | |||
| 64 | - | closeAgentLSP(ag) | |||
| 65 | - | if ag != initialAg { | |||
| 66 | - | closeAgentLSP(initialAg) | |||
| 67 | - | } | |||
| 68 | - | }() | |||
| 69 | - | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |||
| 70 | - | sess, err := openOrCreateSession(args, r, ag, version) | |||
| 71 | - | if err != nil { | |||
| 72 | - | return err | |||
| 73 | - | } | |||
| 74 | - | if sess != nil { | |||
| 75 | - | var providerName, model string | |||
| 76 | - | sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag) | |||
| 77 | - | if err != nil { | |||
| 78 | - | return err | |||
| 79 | - | } | |||
| 80 | - | r.Provider, r.Model = providerName, model | |||
| 81 | - | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |||
| 82 | - | defer sess.Close() | |||
| 83 | - | } | |||
| 84 | - | announceSession(extMgr, sess) | |||
| 85 | - | ||||
| 86 | - | // Open the inbox listener BEFORE emitting agent_ready so the | |||
| 87 | - | // supervisor can dial through on the very first send. The | |||
| 88 | - | // swarm supervisor's Inbox.SendInput retries dialing for a | |||
| 89 | - | // short window, but emitting ready first and then listening | |||
| 90 | - | // would still race in tight loops. | |||
| 91 | - | ln, err := swarm.Listen(args.SwarmAgent) | |||
| 92 | - | if err != nil { | |||
| 93 | - | return fmt.Errorf("swarm-agent listen: %w", err) | |||
| 94 | - | } | |||
| 95 | - | defer ln.Close() | |||
| 96 | - | ||||
| 97 | - | // Event log is owned by the supervisor's runner via stdout, but | |||
| 98 | - | // the daemon also writes a redundant copy here when the runner's | |||
| 99 | - | // pipe is closed (e.g. parent zot exited but the agent is still | |||
| 100 | - | // running headless). The env var is set by the runner; if it's | |||
| 101 | - | // empty we silently skip the second mirror. | |||
| 102 | - | var logMirror *swarm.EventLog | |||
| 103 | - | if path := os.Getenv("ZOT_SWARM_EVENT_LOG"); path != "" { | |||
| 104 | - | logMirror, _ = swarm.OpenEventLog(path) | |||
| 105 | - | } | |||
| 106 | - | if logMirror != nil { | |||
| 107 | - | defer logMirror.Close() | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | em := newSwarmEmitter(os.Stdout, logMirror) | |||
| 111 | - | ||||
| 112 | - | em.emit("agent_ready", map[string]any{ | |||
| 113 | - | "version": version, | |||
| 114 | - | "cwd": r.CWD, | |||
| 115 | - | "model": r.Model, | |||
| 116 | - | }) | |||
| 117 | - | ||||
| 118 | - | // turnCtx is the per-turn context. We keep a per-process | |||
| 119 | - | // cancel so the "cancel" inbox message can interrupt an | |||
| 120 | - | // in-flight turn without tearing down the whole daemon. | |||
| 121 | - | var ( | |||
| 122 | - | mu sync.Mutex | |||
| 123 | - | turnCtx context.Context = ctx | |||
| 124 | - | cancelFn context.CancelFunc | |||
| 125 | - | busyTurn bool | |||
| 126 | - | stopping bool | |||
| 127 | - | turnNo int | |||
| 128 | - | turns sync.WaitGroup | |||
| 129 | - | ) | |||
| 130 | - | ||||
| 131 | - | runOne := func(prompt string) { | |||
| 132 | - | mu.Lock() | |||
| 133 | - | if busyTurn { | |||
| 134 | - | // Drop concurrent turns rather than queuing. The | |||
| 135 | - | // supervisor protocol assumes one outstanding turn per | |||
| 136 | - | // agent; if a user really wants to interrupt and start | |||
| 137 | - | // another, they should send "cancel" first. | |||
| 138 | - | mu.Unlock() | |||
| 139 | - | em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"}) | |||
| 140 | - | return | |||
| 141 | - | } | |||
| 142 | - | busyTurn = true | |||
| 143 | - | turnNo++ | |||
| 144 | - | step := turnNo | |||
| 145 | - | turnCtx, cancelFn = context.WithCancel(ctx) | |||
| 146 | - | c := turnCtx | |||
| 147 | - | mu.Unlock() | |||
| 148 | - | ||||
| 149 | - | em.emit("turn_start", map[string]any{"step": step}) | |||
| 150 | - | ||||
| 151 | - | sink := func(ev core.AgentEvent) { | |||
| 152 | - | em.emit(ev.Type(), modes.EventToJSON(ev)) | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | start := len(ag.Messages()) | |||
| 156 | - | err := ag.Prompt(c, prompt, nil, sink) | |||
| 157 | - | WriteNewTranscript(ag, sess, start) | |||
| 158 | - | ||||
| 159 | - | em.emit("turn_end", map[string]any{ | |||
| 160 | - | "step": step, | |||
| 161 | - | "error": errString(err), | |||
| 162 | - | }) | |||
| 163 | - | ||||
| 164 | - | mu.Lock() | |||
| 165 | - | busyTurn = false | |||
| 166 | - | cancelFn = nil | |||
| 167 | - | mu.Unlock() | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | startTurn := func(prompt string) { | |||
| 171 | - | mu.Lock() | |||
| 172 | - | if stopping { | |||
| 173 | - | mu.Unlock() | |||
| 174 | - | return | |||
| 175 | - | } | |||
| 176 | - | turns.Add(1) | |||
| 177 | - | mu.Unlock() | |||
| 178 | - | go func() { | |||
| 179 | - | defer turns.Done() | |||
| 180 | - | runOne(prompt) | |||
| 181 | - | }() | |||
| 182 | - | } | |||
| 183 | - | stopAndWait := func(reason string) { | |||
| 184 | - | mu.Lock() | |||
| 185 | - | stopping = true | |||
| 186 | - | if cancelFn != nil { | |||
| 187 | - | cancelFn() | |||
| 188 | - | } | |||
| 189 | - | mu.Unlock() | |||
| 190 | - | turns.Wait() | |||
| 191 | - | em.emit("agent_stopped", map[string]any{"reason": reason}) | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | // Initial task: run before processing the inbox so the agent | |||
| 195 | - | // "starts working" the moment it boots, matching what users | |||
| 196 | - | // expect from `/swarm new <task>`. | |||
| 197 | - | if args.Prompt != "" { | |||
| 198 | - | startTurn(args.Prompt) | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | // Inbox loop: one supervisor message at a time. We don't spawn | |||
| 202 | - | // a goroutine per turn because runOne already serialises them | |||
| 203 | - | // via the busyTurn flag; doing the dispatch on the main | |||
| 204 | - | // goroutine keeps the daemon's lifecycle easy to follow. | |||
| 205 | - | for { | |||
| 206 | - | select { | |||
| 207 | - | case <-ctx.Done(): | |||
| 208 | - | stopAndWait("cancelled") | |||
| 209 | - | return ctx.Err() | |||
| 210 | - | case msg, ok := <-ln.Lines(): | |||
| 211 | - | if !ok { | |||
| 212 | - | stopAndWait("inbox-closed") | |||
| 213 | - | return nil | |||
| 214 | - | } | |||
| 215 | - | switch { | |||
| 216 | - | case msg == "shutdown": | |||
| 217 | - | stopAndWait("shutdown") | |||
| 218 | - | return nil | |||
| 219 | - | case msg == "cancel": | |||
| 220 | - | mu.Lock() | |||
| 221 | - | if cancelFn != nil { | |||
| 222 | - | cancelFn() | |||
| 223 | - | } | |||
| 224 | - | mu.Unlock() | |||
| 225 | - | case strings.HasPrefix(msg, "user "): | |||
| 226 | - | prompt := strings.TrimPrefix(msg, "user ") | |||
| 227 | - | startTurn(prompt) | |||
| 228 | - | default: | |||
| 229 | - | em.emit("error", map[string]any{ | |||
| 230 | - | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | |||
| 231 | - | }) | |||
| 232 | - | } | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | // swarmEmitter serialises events to stdout and (optionally) to a | |||
| 238 | - | // durable log file. Concurrent goroutines call emit so we have to | |||
| 239 | - | // hold a mutex around the encoder. | |||
| 240 | - | type swarmEmitter struct { | |||
| 241 | - | mu sync.Mutex | |||
| 242 | - | w *os.File | |||
| 243 | - | mirror *swarm.EventLog | |||
| 244 | - | ||||
| 245 | - | // orphan flips true the first time a stdout write fails (broken | |||
| 246 | - | // pipe — the supervisor died). Until then the mirror stays | |||
| 247 | - | // dormant: the supervisor is the canonical writer to events.jsonl | |||
| 248 | - | // (it parses our stdout and Append()s each event itself). Writing | |||
| 249 | - | // from both sides used to land every event in the log twice, | |||
| 250 | - | // which showed up as a fully-duplicated transcript the next time | |||
| 251 | - | // the agent was reloaded — the exact "why is everything doubled" | |||
| 252 | - | // bug. Once orphaned the mirror takes over so the events still | |||
| 253 | - | // land on disk for the next reload. | |||
| 254 | - | orphan bool | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter { | |||
| 258 | - | return &swarmEmitter{w: w, mirror: mirror} | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | func (e *swarmEmitter) emit(typ string, data map[string]any) { | |||
| 262 | - | if data == nil { | |||
| 263 | - | data = map[string]any{} | |||
| 264 | - | } | |||
| 265 | - | data["type"] = typ | |||
| 266 | - | data["time"] = time.Now().Format(time.RFC3339Nano) | |||
| 267 | - | ||||
| 268 | - | e.mu.Lock() | |||
| 269 | - | defer e.mu.Unlock() | |||
| 270 | - | ||||
| 271 | - | if !e.orphan { | |||
| 272 | - | // Encode to a buffer first so we can detect the stdout write | |||
| 273 | - | // error — json.Encoder swallows partial writes silently, but | |||
| 274 | - | // a direct Write on *os.File returns broken-pipe immediately. | |||
| 275 | - | line, err := json.Marshal(data) | |||
| 276 | - | if err == nil { | |||
| 277 | - | line = append(line, '\n') | |||
| 278 | - | if _, werr := e.w.Write(line); werr != nil { | |||
| 279 | - | // Supervisor's stdout pipe is gone (parent zot exited | |||
| 280 | - | // but we kept running). Switch to mirror-only mode so | |||
| 281 | - | // subsequent events still get persisted; also retro- | |||
| 282 | - | // actively log this very event to the mirror so it | |||
| 283 | - | // doesn't get lost in the handoff. | |||
| 284 | - | e.orphan = true | |||
| 285 | - | } | |||
| 286 | - | } | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | if e.orphan && e.mirror != nil { | |||
| 290 | - | // Drop "type" + "time" out of data into Event fields so the | |||
| 291 | - | // mirror file matches what the supervisor would have written. | |||
| 292 | - | flat := map[string]any{} | |||
| 293 | - | for k, v := range data { | |||
| 294 | - | if k == "type" || k == "time" { | |||
| 295 | - | continue | |||
| 296 | - | } | |||
| 297 | - | flat[k] = v | |||
| 298 | - | } | |||
| 299 | - | _ = e.mirror.Append(swarm.NewEvent(typ, flat)) | |||
| 300 | - | } | |||
| 301 | - | } | |||
| 302 | - | ||||
| 303 | - | func errString(err error) string { | |||
| 304 | - | if err == nil { | |||
| 305 | - | return "" | |||
| 306 | - | } | |||
| 307 | - | return err.Error() | |||
| 308 | - | } | |||
| 309 | - | ||||
| 310 | - | func truncateForLog(s string, n int) string { | |||
| 311 | - | if len(s) <= n { | |||
| 312 | - | return s | |||
| 313 | - | } | |||
| 314 | - | if n <= 3 { | |||
| 315 | - | return strings.Repeat(".", n) | |||
| 316 | - | } | |||
| 317 | - | return s[:n-3] + "..." | |||
| 318 | - | } | |||
| 319 | - | ||||
| 320 | - | // _ keeps the provider import used; provider types may surface | |||
| 321 | - | // through ag.OnEvent / modes.EventToJSON in future iterations. | |||
| 322 | - | var _ provider.Content = provider.TextBlock{} | |||
D packages/agent/swarm_agent_test.go +0 -121
| @@ -1,121 +0,0 @@ | |||||
| 1 | - | package agent | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "encoding/json" | |||
| 6 | - | "io" | |||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "testing" | |||
| 10 | - | ||||
| 11 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the | |||
| 15 | - | // "everything is doubled after reopening a swarm agent" bug. | |||
| 16 | - | // | |||
| 17 | - | // Symptom: events.jsonl held two copies of every event because the | |||
| 18 | - | // child mirrored each event to disk AND the supervisor parsed the | |||
| 19 | - | // child's stdout and appended each event to disk too. On next zot | |||
| 20 | - | // launch the replay produced two transcript lines per real one. | |||
| 21 | - | // | |||
| 22 | - | // Fix invariant: while stdout writes succeed (i.e. the supervisor is | |||
| 23 | - | // alive on the other end of the pipe), the child's mirror writes | |||
| 24 | - | // NOTHING. Only when a stdout write fails (broken pipe → orphan) | |||
| 25 | - | // does the mirror take over so events still get persisted. | |||
| 26 | - | func TestSwarmEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |||
| 27 | - | // Real *os.File for the emitter's stdout-equivalent so the | |||
| 28 | - | // emitter's write() path (which expects *os.File) actually runs. | |||
| 29 | - | stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo") | |||
| 30 | - | stdoutFile, err := os.Create(stdoutPath) | |||
| 31 | - | if err != nil { | |||
| 32 | - | t.Fatalf("create stdout file: %v", err) | |||
| 33 | - | } | |||
| 34 | - | defer stdoutFile.Close() | |||
| 35 | - | ||||
| 36 | - | // Mirror writes go to a separate events.jsonl that we can read | |||
| 37 | - | // at the end to assert how many events the mirror emitted. | |||
| 38 | - | mirrorPath := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 39 | - | mirror, err := swarm.OpenEventLog(mirrorPath) | |||
| 40 | - | if err != nil { | |||
| 41 | - | t.Fatalf("open mirror: %v", err) | |||
| 42 | - | } | |||
| 43 | - | defer mirror.Close() | |||
| 44 | - | ||||
| 45 | - | em := newSwarmEmitter(stdoutFile, mirror) | |||
| 46 | - | ||||
| 47 | - | // Healthy stdout: emit three events. Mirror must stay empty. | |||
| 48 | - | em.emit("turn_start", map[string]any{"step": 1}) | |||
| 49 | - | em.emit("assistant_message", map[string]any{"text": "hi"}) | |||
| 50 | - | em.emit("turn_end", map[string]any{"step": 1}) | |||
| 51 | - | ||||
| 52 | - | got, err := swarm.ReadEventLog(mirrorPath) | |||
| 53 | - | if err != nil { | |||
| 54 | - | t.Fatalf("read mirror: %v", err) | |||
| 55 | - | } | |||
| 56 | - | if len(got) != 0 { | |||
| 57 | - | t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v", | |||
| 58 | - | len(got), got) | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | // Simulate supervisor death: close stdout so the next Write | |||
| 62 | - | // returns EBADF / broken pipe. The emitter must flip into | |||
| 63 | - | // orphan mode and start writing through the mirror. | |||
| 64 | - | if err := stdoutFile.Close(); err != nil { | |||
| 65 | - | t.Fatalf("close stdout: %v", err) | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | em.emit("assistant_message", map[string]any{"text": "after orphan"}) | |||
| 69 | - | em.emit("turn_end", map[string]any{"step": 2}) | |||
| 70 | - | ||||
| 71 | - | got, err = swarm.ReadEventLog(mirrorPath) | |||
| 72 | - | if err != nil { | |||
| 73 | - | t.Fatalf("read mirror post-orphan: %v", err) | |||
| 74 | - | } | |||
| 75 | - | if len(got) < 2 { | |||
| 76 | - | t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got)) | |||
| 77 | - | } | |||
| 78 | - | if got[len(got)-1].Type != "turn_end" { | |||
| 79 | - | t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type) | |||
| 80 | - | } | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | // TestSwarmEmitterStdoutShapeMatchesSupervisorParser pins the | |||
| 84 | - | // wire-format contract: each emitted event lands on stdout as one | |||
| 85 | - | // JSON object per line with type+time at top level alongside the | |||
| 86 | - | // data fields. The supervisor's runner parses this exact shape. | |||
| 87 | - | func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||
| 88 | - | // Pipe so we can read what the emitter wrote. | |||
| 89 | - | r, w, err := os.Pipe() | |||
| 90 | - | if err != nil { | |||
| 91 | - | t.Fatalf("pipe: %v", err) | |||
| 92 | - | } | |||
| 93 | - | defer r.Close() | |||
| 94 | - | ||||
| 95 | - | em := newSwarmEmitter(w, nil) | |||
| 96 | - | em.emit("turn_start", map[string]any{"step": 1}) | |||
| 97 | - | _ = w.Close() | |||
| 98 | - | ||||
| 99 | - | body, err := io.ReadAll(r) | |||
| 100 | - | if err != nil { | |||
| 101 | - | t.Fatalf("read pipe: %v", err) | |||
| 102 | - | } | |||
| 103 | - | // One trailing newline => one event line. | |||
| 104 | - | lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n")) | |||
| 105 | - | if len(lines) != 1 { | |||
| 106 | - | t.Fatalf("expected 1 event line, got %d: %q", len(lines), body) | |||
| 107 | - | } | |||
| 108 | - | var flat map[string]any | |||
| 109 | - | if err := json.Unmarshal(lines[0], &flat); err != nil { | |||
| 110 | - | t.Fatalf("not valid json: %v\n%s", err, lines[0]) | |||
| 111 | - | } | |||
| 112 | - | if flat["type"] != "turn_start" { | |||
| 113 | - | t.Errorf("type field missing or wrong: %v", flat["type"]) | |||
| 114 | - | } | |||
| 115 | - | if _, ok := flat["time"].(string); !ok { | |||
| 116 | - | t.Errorf("time field missing: %v", flat["time"]) | |||
| 117 | - | } | |||
| 118 | - | if flat["step"] != float64(1) { | |||
| 119 | - | t.Errorf("step field missing or wrong: %v", flat["step"]) | |||
| 120 | - | } | |||
| 121 | - | } | |||
R packages/agent/tools/swarm_spawn.go → packages/agent/tools/subagent_spawn.go +119 -60 renamed, 56% similar
| @@ -5,27 +5,27 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/patriceckhart/zot/packages/agent/subagents" | 10 | "github.com/patriceckhart/zot/packages/agent/subagents" | ||
| 10 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 11 | "github.com/patriceckhart/zot/packages/core" | 11 | "github.com/patriceckhart/zot/packages/core" | ||
| 12 | "github.com/patriceckhart/zot/packages/provider" | 12 | "github.com/patriceckhart/zot/packages/provider" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | // SwarmSpawnTool lets the main agent fork a background sub-agent | 15 | + | // SubagentSpawnTool lets the main agent fork a background sub-agent |
| 16 | - | // against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs | 16 | + | // against the host's cwd via subagents.Supervisor.SpawnReq. The sub-agent runs |
| 17 | // in parallel: the tool returns the agent id immediately and the main | 17 | // in parallel: the tool returns the agent id immediately and the main | ||
| 18 | // turn continues uninterrupted. The user can monitor / chat with the | 18 | // turn continues uninterrupted. The user can monitor / chat with the | ||
| 19 | - | // spawned agent via /swarm. | 19 | + | // spawned agent via /subagents. |
| 20 | // | 20 | // | ||
| 21 | - | // Gated by the auto_swarm_enabled config flag at call time so a user | 21 | + | // Gated by the auto_subagents_enabled config flag at call time so a user |
| 22 | // can flip it off mid-session and the next call refuses cleanly | 22 | // can flip it off mid-session and the next call refuses cleanly | ||
| 23 | // without re-registering the tool. | 23 | // without re-registering the tool. | ||
| 24 | - | type SwarmSpawnTool struct { | 24 | + | type SubagentSpawnTool struct { |
| 25 | - | // Swarm is the supervisor used to spawn agents. Nil means | 25 | + | // Supervisor is the supervisor used to spawn agents. Nil means |
| 26 | - | // "auto-swarm not available in this mode" and the tool always | 26 | + | // "auto-subagents not available in this mode" and the tool always |
| 27 | // errors. | 27 | // errors. | ||
| 28 | - | Swarm *swarm.Swarm | 28 | + | Supervisor *subagents.Supervisor |
| 29 | 29 | ||||
| 30 | // Enabled reads the live config flag. Lets users toggle from | 30 | // Enabled reads the live config flag. Lets users toggle from | ||
| 31 | // /settings without rebuilding the agent. When nil, the tool | 31 | // /settings without rebuilding the agent. When nil, the tool | ||
| @@ -34,7 +34,7 @@ type SwarmSpawnTool struct { | |||||
| 34 | 34 | ||||
| 35 | // DefaultModel and DefaultProvider return the host agent's resolved | 35 | // DefaultModel and DefaultProvider return the host agent's resolved | ||
| 36 | // model and provider. They are used when the tool call omits both | 36 | // model and provider. They are used when the tool call omits both | ||
| 37 | - | // fields and does not select a named profile, so auto-swarm follows | 37 | + | // fields and does not select a named profile, so auto-subagents follows |
| 38 | // the same auth route as the user sees in the parent session. | 38 | // the same auth route as the user sees in the parent session. | ||
| 39 | DefaultModel func() string | 39 | DefaultModel func() string | ||
| 40 | DefaultProvider func() string | 40 | DefaultProvider func() string | ||
| @@ -48,19 +48,22 @@ type SwarmSpawnTool struct { | |||||
| 48 | // the new agent + the task it was started with. Used by the | 48 | // the new agent + the task it was started with. Used by the | ||
| 49 | // interactive host to track agents and surface a summary back | 49 | // interactive host to track agents and surface a summary back | ||
| 50 | // in chat when all sub-agents finish. | 50 | // in chat when all sub-agents finish. | ||
| 51 | - | OnSpawned func(agent *swarm.Agent, task string) | 51 | + | OnSpawned func(agent *subagents.Agent, task string) |
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | type swarmSpawnArgs struct { | 54 | + | type subagentSpawnArgs struct { |
| 55 | Task string `json:"task"` | 55 | Task string `json:"task"` | ||
| 56 | Agent string `json:"agent,omitempty"` | 56 | Agent string `json:"agent,omitempty"` | ||
| 57 | Model string `json:"model,omitempty"` | 57 | Model string `json:"model,omitempty"` | ||
| 58 | Provider string `json:"provider,omitempty"` | 58 | Provider string `json:"provider,omitempty"` | ||
| 59 | Reasoning string `json:"reasoning,omitempty"` | 59 | Reasoning string `json:"reasoning,omitempty"` | ||
| 60 | Thinking string `json:"thinking,omitempty"` | 60 | Thinking string `json:"thinking,omitempty"` | ||
| 61 | + | Isolation string `json:"isolation,omitempty"` | |||
| 62 | + | Timeout string `json:"timeout,omitempty"` | |||
| 63 | + | MaxTurns *int `json:"max_turns,omitempty"` | |||
| 61 | } | 64 | } | ||
| 62 | 65 | ||||
| 63 | - | const swarmSpawnSchema = `{ | 66 | + | const subagentSpawnSchema = `{ |
| 64 | "type": "object", | 67 | "type": "object", | ||
| 65 | "properties": { | 68 | "properties": { | ||
| 66 | "task": { | 69 | "task": { | ||
| @@ -88,31 +91,66 @@ const swarmSpawnSchema = `{ | |||||
| 88 | "type": "string", | 91 | "type": "string", | ||
| 89 | "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"], | 92 | "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." | 93 | "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available." | ||
| 94 | + | }, | |||
| 95 | + | "isolation": { | |||
| 96 | + | "type": "string", | |||
| 97 | + | "enum": ["shared", "worktree"], | |||
| 98 | + | "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." | |||
| 99 | + | }, | |||
| 100 | + | "timeout": { | |||
| 101 | + | "type": "string", | |||
| 102 | + | "description": "Optional Go duration such as 20m." | |||
| 103 | + | }, | |||
| 104 | + | "max_turns": { | |||
| 105 | + | "type": "integer", | |||
| 106 | + | "minimum": 1, | |||
| 107 | + | "description": "Maximum prompt-level turns for this worker." | |||
| 91 | } | 108 | } | ||
| 92 | }, | 109 | }, | ||
| 93 | "required": ["task"] | 110 | "required": ["task"] | ||
| 94 | }` | 111 | }` | ||
| 95 | 112 | ||||
| 96 | - | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | 113 | + | func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" } |
| 97 | - | func (t *SwarmSpawnTool) Description() string { | 114 | + | func (t *SubagentSpawnTool) Description() string { |
| 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. Named profiles may restrict fast mode but cannot enable it when the host setting is off. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." | 115 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path." |
| 116 | + | ||||
| 99 | } | 117 | } | ||
| 100 | - | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | 118 | + | func (t *SubagentSpawnTool) Schema() json.RawMessage { return json.RawMessage(subagentSpawnSchema) } |
| 101 | 119 | ||||
| 102 | - | func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | 120 | + | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { |
| 103 | - | if t.Swarm == nil { | 121 | + | prefix := t.Name() |
| 104 | - | return protocolToolError("swarm_spawn: swarm supervisor not available in this mode") | 122 | + | if t.Supervisor == nil { |
| 123 | + | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | |||
| 105 | } | 124 | } | ||
| 106 | if t.Enabled == nil || !t.Enabled() { | 125 | if t.Enabled == nil || !t.Enabled() { | ||
| 107 | - | return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.") | 126 | + | return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before delegating sub-tasks.") |
| 108 | } | 127 | } | ||
| 109 | - | var a swarmSpawnArgs | 128 | + | var a subagentSpawnArgs |
| 110 | if err := json.Unmarshal(raw, &a); err != nil { | 129 | if err := json.Unmarshal(raw, &a); err != nil { | ||
| 111 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | 130 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | ||
| 112 | } | 131 | } | ||
| 113 | task := strings.TrimSpace(a.Task) | 132 | task := strings.TrimSpace(a.Task) | ||
| 114 | if task == "" { | 133 | if task == "" { | ||
| 115 | - | return protocolToolError("swarm_spawn: task is required") | 134 | + | return protocolToolError(prefix + ": task is required") |
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | workspaceMode := subagents.WorkspaceShared | |||
| 138 | + | if value := strings.TrimSpace(a.Isolation); value != "" { | |||
| 139 | + | workspaceMode = subagents.WorkspaceMode(value) | |||
| 140 | + | if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree { | |||
| 141 | + | return protocolToolError(prefix + ": isolation must be shared or worktree") | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | var timeout time.Duration | |||
| 145 | + | if value := strings.TrimSpace(a.Timeout); value != "" { | |||
| 146 | + | parsed, parseErr := time.ParseDuration(value) | |||
| 147 | + | if parseErr != nil || parsed <= 0 { | |||
| 148 | + | return protocolToolError(prefix + ": timeout must be a positive duration") | |||
| 149 | + | } | |||
| 150 | + | timeout = parsed | |||
| 151 | + | } | |||
| 152 | + | if a.MaxTurns != nil && *a.MaxTurns < 1 { | |||
| 153 | + | return protocolToolError(prefix + ": max_turns must be positive") | |||
| 116 | } | 154 | } | ||
| 117 | 155 | ||||
| 118 | agentName := strings.TrimSpace(a.Agent) | 156 | agentName := strings.TrimSpace(a.Agent) | ||
| @@ -120,15 +158,15 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 120 | var fastModeOverride *bool | 158 | var fastModeOverride *bool | ||
| 121 | if agentName != "" { | 159 | if agentName != "" { | ||
| 122 | if t.ResolveSubagent == nil { | 160 | if t.ResolveSubagent == nil { | ||
| 123 | - | return protocolToolError("swarm_spawn: named subagent profiles are unavailable") | 161 | + | return protocolToolError(prefix + ": named subagent profiles are unavailable") |
| 124 | } | 162 | } | ||
| 125 | var err error | 163 | var err error | ||
| 126 | profile, err = t.ResolveSubagent(agentName) | 164 | profile, err = t.ResolveSubagent(agentName) | ||
| 127 | if err != nil { | 165 | if err != nil { | ||
| 128 | - | return protocolToolError("swarm_spawn: " + err.Error()) | 166 | + | return protocolToolError(prefix + ": " + err.Error()) |
| 129 | } | 167 | } | ||
| 130 | if profile == nil { | 168 | if profile == nil { | ||
| 131 | - | return protocolToolError("swarm_spawn: unknown subagent profile " + agentName) | 169 | + | return protocolToolError(prefix + ": unknown subagent profile " + agentName) |
| 132 | } | 170 | } | ||
| 133 | agentName = profile.Name | 171 | agentName = profile.Name | ||
| 134 | fastModeOverride = profile.FastMode | 172 | fastModeOverride = profile.FastMode | ||
| @@ -136,55 +174,67 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 136 | 174 | ||||
| 137 | model := strings.TrimSpace(a.Model) | 175 | model := strings.TrimSpace(a.Model) | ||
| 138 | providerID := strings.TrimSpace(a.Provider) | 176 | providerID := strings.TrimSpace(a.Provider) | ||
| 177 | + | var profileTools []string | |||
| 178 | + | if profile != nil { | |||
| 179 | + | profileTools = append([]string(nil), profile.Tools...) | |||
| 180 | + | } | |||
| 139 | if (model == "") != (providerID == "") { | 181 | if (model == "") != (providerID == "") { | ||
| 140 | - | return protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly") | 182 | + | return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly") |
| 141 | } | 183 | } | ||
| 142 | - | if model == "" && providerID == "" && profile != nil { | 184 | + | if profile != nil { |
| 143 | - | // A fully-qualified profile model is useful metadata for the | 185 | + | // A profile may specify a qualified model, a bare model, a provider, |
| 144 | - | // supervisor and also lets it inherit the right credential route. | 186 | + | // or neither. Fill only missing pieces so explicit spawn options win; |
| 145 | - | // Bare profile model ids remain child-resolved so the configured | 187 | + | // the parent session supplies whatever the profile leaves unspecified. |
| 146 | - | // provider is preserved. | |||
| 147 | profileProvider, profileModel := profile.ModelSelection() | 188 | profileProvider, profileModel := profile.ModelSelection() | ||
| 148 | - | if profileProvider != "" && profileModel != "" { | 189 | + | if model == "" { |
| 149 | - | providerID, model = profileProvider, profileModel | 190 | + | model = profileModel |
| 150 | } | 191 | } | ||
| 151 | - | } | 192 | + | if providerID == "" { |
| 152 | - | if model == "" && providerID == "" && profile == nil { | 193 | + | providerID = profileProvider |
| 153 | - | if t.DefaultModel != nil { | |||
| 154 | - | model = strings.TrimSpace(t.DefaultModel()) | |||
| 155 | - | } | |||
| 156 | - | if t.DefaultProvider != nil { | |||
| 157 | - | providerID = strings.TrimSpace(t.DefaultProvider()) | |||
| 158 | } | 194 | } | ||
| 159 | } | 195 | } | ||
| 196 | + | if model == "" && t.DefaultModel != nil { | |||
| 197 | + | model = strings.TrimSpace(t.DefaultModel()) | |||
| 198 | + | } | |||
| 199 | + | if providerID == "" && t.DefaultProvider != nil { | |||
| 200 | + | providerID = strings.TrimSpace(t.DefaultProvider()) | |||
| 201 | + | } | |||
| 160 | 202 | ||||
| 161 | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | 203 | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | ||
| 162 | if err != nil { | 204 | if err != nil { | ||
| 163 | - | return protocolToolError("swarm_spawn: " + err.Error()) | 205 | + | return protocolToolError(prefix + ": " + err.Error()) |
| 164 | } | 206 | } | ||
| 165 | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | 207 | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | ||
| 166 | reasoning, err = reasoningOverride(profile.Thinking, "") | 208 | reasoning, err = reasoningOverride(profile.Thinking, "") | ||
| 167 | if err != nil { | 209 | if err != nil { | ||
| 168 | - | return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error()) | 210 | + | return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error()) |
| 169 | } | 211 | } | ||
| 170 | } | 212 | } | ||
| 171 | if reasoning == "" && t.DefaultReasoning != nil { | 213 | if reasoning == "" && t.DefaultReasoning != nil { | ||
| 172 | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | 214 | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | ||
| 173 | if err != nil { | 215 | if err != nil { | ||
| 174 | - | return protocolToolError("swarm_spawn: host " + err.Error()) | 216 | + | return protocolToolError(prefix + ": host " + err.Error()) |
| 175 | } | 217 | } | ||
| 176 | } | 218 | } | ||
| 177 | 219 | ||||
| 178 | - | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | 220 | + | maxTurns := 0 |
| 179 | - | Task: task, | 221 | + | if a.MaxTurns != nil { |
| 180 | - | Model: model, | 222 | + | maxTurns = *a.MaxTurns |
| 181 | - | Provider: providerID, | 223 | + | } |
| 182 | - | Reasoning: reasoning, | 224 | + | agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{ |
| 183 | - | FastMode: fastModeOverride, | 225 | + | Task: task, |
| 184 | - | Subagent: agentName, | 226 | + | Model: model, |
| 227 | + | Provider: providerID, | |||
| 228 | + | Reasoning: reasoning, | |||
| 229 | + | FastMode: fastModeOverride, | |||
| 230 | + | Subagent: agentName, | |||
| 231 | + | Timeout: timeout, | |||
| 232 | + | MaxTurns: maxTurns, | |||
| 233 | + | WorkspaceMode: workspaceMode, | |||
| 234 | + | Tools: profileTools, | |||
| 185 | }) | 235 | }) | ||
| 186 | if err != nil { | 236 | if err != nil { | ||
| 187 | - | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | 237 | + | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) |
| 188 | } | 238 | } | ||
| 189 | if t.OnSpawned != nil { | 239 | if t.OnSpawned != nil { | ||
| 190 | t.OnSpawned(agent, task) | 240 | t.OnSpawned(agent, task) | ||
| @@ -192,6 +242,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 192 | 242 | ||||
| 193 | var sb strings.Builder | 243 | var sb strings.Builder | ||
| 194 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | 244 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | ||
| 245 | + | fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState()) | |||
| 246 | + | fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode) | |||
| 195 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | 247 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | ||
| 196 | if agentName != "" { | 248 | if agentName != "" { | ||
| 197 | fmt.Fprintf(&sb, "agent: %s\n", agentName) | 249 | fmt.Fprintf(&sb, "agent: %s\n", agentName) | ||
| @@ -208,18 +260,25 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 208 | if agent.FastMode { | 260 | if agent.FastMode { | ||
| 209 | sb.WriteString("fast mode: enabled\n") | 261 | sb.WriteString("fast mode: enabled\n") | ||
| 210 | } | 262 | } | ||
| 211 | - | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | 263 | + | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") |
| 212 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | 264 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | ||
| 213 | return core.ToolResult{ | 265 | return core.ToolResult{ | ||
| 214 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | 266 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | ||
| 215 | Details: map[string]any{ | 267 | Details: map[string]any{ | ||
| 216 | - | "agent_id": agent.ID, | 268 | + | "agent_id": agent.ID, |
| 217 | - | "task": task, | 269 | + | "task": task, |
| 218 | - | "agent": agentName, | 270 | + | "agent": agentName, |
| 219 | - | "model": model, | 271 | + | "model": model, |
| 220 | - | "provider": providerID, | 272 | + | "provider": providerID, |
| 221 | - | "reasoning": reasoning, | 273 | + | "reasoning": reasoning, |
| 222 | - | "fast_mode": agent.FastMode, | 274 | + | "fast_mode": agent.FastMode, |
| 275 | + | "isolation": string(workspaceMode), | |||
| 276 | + | "timeout": agent.Timeout.String(), | |||
| 277 | + | "max_turns": agent.MaxTurns, | |||
| 278 | + | "state": agent.Status(), | |||
| 279 | + | "process_state": string(agent.ProcessState()), | |||
| 280 | + | "turn_state": string(agent.TurnState()), | |||
| 281 | + | "result_ref": subagents.ResultRef(agent.ID), | |||
| 223 | }, | 282 | }, | ||
| 224 | }, nil | 283 | }, nil | ||
| 225 | } | 284 | } | ||
R packages/agent/tools/swarm_spawn_test.go → packages/agent/tools/subagent_spawn_test.go +101 -39 renamed, 60% similar
| @@ -6,31 +6,33 @@ import ( | |||||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/patriceckhart/zot/packages/agent/subagents" | 11 | "github.com/patriceckhart/zot/packages/agent/subagents" | ||
| 11 | - | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 12 | "github.com/patriceckhart/zot/packages/provider" | 12 | "github.com/patriceckhart/zot/packages/provider" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | type noopSwarmRunner struct{} | 15 | + | type noopSupervisorRunner struct{} |
| 16 | 16 | ||||
| 17 | - | func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil } | 17 | + | func (noopSupervisorRunner) Run(context.Context, subagents.Sink) error { return nil } |
| 18 | 18 | ||||
| 19 | - | func newTestSwarm(t *testing.T) *swarm.Swarm { | 19 | + | func newTestSupervisor(t *testing.T) *subagents.Supervisor { |
| 20 | t.Helper() | 20 | t.Helper() | ||
| 21 | root := t.TempDir() | 21 | root := t.TempDir() | ||
| 22 | - | return swarm.New(swarm.Config{ | 22 | + | manager := subagents.New(subagents.Config{ |
| 23 | - | Root: filepath.Join(root, "swarm"), | 23 | + | Root: filepath.Join(root, "subagents"), |
| 24 | RepoRoot: root, | 24 | RepoRoot: root, | ||
| 25 | - | NewRunner: func(*swarm.Agent) swarm.Runner { | 25 | + | NewRunner: func(*subagents.Agent) subagents.Runner { |
| 26 | - | return noopSwarmRunner{} | 26 | + | return noopSupervisorRunner{} |
| 27 | }, | 27 | }, | ||
| 28 | }) | 28 | }) | ||
| 29 | + | t.Cleanup(manager.StopAll) | |||
| 30 | + | return manager | |||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| 31 | - | func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | 33 | + | func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { |
| 32 | - | tool := &SwarmSpawnTool{ | 34 | + | tool := &SubagentSpawnTool{ |
| 33 | - | Swarm: newTestSwarm(t), | 35 | + | Supervisor: newTestSupervisor(t), |
| 34 | Enabled: func() bool { return true }, | 36 | Enabled: func() bool { return true }, | ||
| 35 | DefaultModel: func() string { return "gpt-5" }, | 37 | DefaultModel: func() string { return "gpt-5" }, | ||
| 36 | DefaultProvider: func() string { return "openai-codex" }, | 38 | DefaultProvider: func() string { return "openai-codex" }, | ||
| @@ -62,19 +64,78 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||||
| 62 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | 64 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | ||
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 65 | - | agents := tool.Swarm.List() | 67 | + | agents := tool.Supervisor.List() |
| 66 | if len(agents) != 1 { | 68 | if len(agents) != 1 { | ||
| 67 | t.Fatalf("spawned agents = %d, want 1", len(agents)) | 69 | t.Fatalf("spawned agents = %d, want 1", len(agents)) | ||
| 68 | } | 70 | } | ||
| 69 | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { | 71 | 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) | 72 | 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 | } | 73 | } | ||
| 74 | + | if agents[0].MaxTurns != 3 { | |||
| 75 | + | t.Fatalf("omitted max_turns = %d, want default 3", agents[0].MaxTurns) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) { | |||
| 80 | + | root := t.TempDir() | |||
| 81 | + | manager := subagents.New(subagents.Config{ | |||
| 82 | + | Root: filepath.Join(root, "subagents"), | |||
| 83 | + | RepoRoot: root, | |||
| 84 | + | Policy: subagents.SubagentPolicy{ | |||
| 85 | + | DefaultTimeout: 37 * time.Minute, | |||
| 86 | + | MaxTurns: 7, | |||
| 87 | + | }, | |||
| 88 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 89 | + | return noopSupervisorRunner{} | |||
| 90 | + | }, | |||
| 91 | + | }) | |||
| 92 | + | t.Cleanup(manager.StopAll) | |||
| 93 | + | tool := &SubagentSpawnTool{ | |||
| 94 | + | Supervisor: manager, | |||
| 95 | + | Enabled: func() bool { return true }, | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | |||
| 99 | + | if err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | if res.IsError { | |||
| 103 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 104 | + | } | |||
| 105 | + | details, ok := res.Details.(map[string]any) | |||
| 106 | + | if !ok { | |||
| 107 | + | t.Fatalf("details type = %T, want map[string]any", res.Details) | |||
| 108 | + | } | |||
| 109 | + | if got := details["timeout"]; got != (37 * time.Minute).String() { | |||
| 110 | + | t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String()) | |||
| 111 | + | } | |||
| 112 | + | if got := details["max_turns"]; got != 7 { | |||
| 113 | + | t.Fatalf("max_turns detail = %v, want 7", got) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) { | |||
| 118 | + | tool := &SubagentSpawnTool{ | |||
| 119 | + | Supervisor: newTestSupervisor(t), | |||
| 120 | + | Enabled: func() bool { return true }, | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":0}`), nil) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | if !res.IsError || !strings.Contains(textResult(res.Content), "max_turns must be positive") { | |||
| 128 | + | t.Fatalf("result = %#v, want max_turns validation error", res) | |||
| 129 | + | } | |||
| 130 | + | if got := len(tool.Supervisor.List()); got != 0 { | |||
| 131 | + | t.Fatalf("spawned agents = %d, want 0", got) | |||
| 132 | + | } | |||
| 72 | } | 133 | } | ||
| 73 | 134 | ||||
| 74 | - | func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | 135 | + | func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) { |
| 75 | - | tool := &SwarmSpawnTool{ | 136 | + | tool := &SubagentSpawnTool{ |
| 76 | - | Swarm: newTestSwarm(t), | 137 | + | Supervisor: newTestSupervisor(t), |
| 77 | - | Enabled: func() bool { return true }, | 138 | + | Enabled: func() bool { return true }, |
| 78 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | 139 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | ||
| 79 | if name != "reviewer" { | 140 | if name != "reviewer" { | ||
| 80 | return nil, nil | 141 | return nil, nil | ||
| @@ -100,7 +161,7 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |||||
| 100 | t.Fatalf("result text missing %q:\n%s", want, text) | 161 | t.Fatalf("result text missing %q:\n%s", want, text) | ||
| 101 | } | 162 | } | ||
| 102 | } | 163 | } | ||
| 103 | - | agents := tool.Swarm.List() | 164 | + | agents := tool.Supervisor.List() |
| 104 | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | 165 | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | ||
| 105 | t.Fatalf("agent profile/reasoning = %#v", agents) | 166 | t.Fatalf("agent profile/reasoning = %#v", agents) | ||
| 106 | } | 167 | } | ||
| @@ -109,20 +170,21 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |||||
| 109 | } | 170 | } | ||
| 110 | } | 171 | } | ||
| 111 | 172 | ||||
| 112 | - | func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) { | 173 | + | func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) { |
| 113 | root := t.TempDir() | 174 | root := t.TempDir() | ||
| 114 | profileFastMode := false | 175 | profileFastMode := false | ||
| 115 | - | f := swarm.New(swarm.Config{ | 176 | + | f := subagents.New(subagents.Config{ |
| 116 | - | Root: filepath.Join(root, "swarm"), | 177 | + | Root: filepath.Join(root, "subagents"), |
| 117 | RepoRoot: root, | 178 | RepoRoot: root, | ||
| 118 | FastMode: true, | 179 | FastMode: true, | ||
| 119 | - | NewRunner: func(*swarm.Agent) swarm.Runner { | 180 | + | NewRunner: func(*subagents.Agent) subagents.Runner { |
| 120 | - | return noopSwarmRunner{} | 181 | + | return noopSupervisorRunner{} |
| 121 | }, | 182 | }, | ||
| 122 | }) | 183 | }) | ||
| 123 | - | tool := &SwarmSpawnTool{ | 184 | + | t.Cleanup(f.StopAll) |
| 124 | - | Swarm: f, | 185 | + | tool := &SubagentSpawnTool{ |
| 125 | - | Enabled: func() bool { return true }, | 186 | + | Supervisor: f, |
| 187 | + | Enabled: func() bool { return true }, | |||
| 126 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | 188 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | ||
| 127 | if name != "reviewer" { | 189 | if name != "reviewer" { | ||
| 128 | return nil, nil | 190 | return nil, nil | ||
| @@ -147,10 +209,10 @@ func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |||||
| 147 | } | 209 | } | ||
| 148 | } | 210 | } | ||
| 149 | 211 | ||||
| 150 | - | func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | 212 | + | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { |
| 151 | - | tool := &SwarmSpawnTool{ | 213 | + | tool := &SubagentSpawnTool{ |
| 152 | - | Swarm: newTestSwarm(t), | 214 | + | Supervisor: newTestSupervisor(t), |
| 153 | - | Enabled: func() bool { return true }, | 215 | + | Enabled: func() bool { return true }, |
| 154 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | 216 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | ||
| 155 | if name != "reviewer" { | 217 | if name != "reviewer" { | ||
| 156 | return nil, nil | 218 | return nil, nil | ||
| @@ -170,16 +232,16 @@ func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | |||||
| 170 | if res.IsError { | 232 | if res.IsError { | ||
| 171 | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | 233 | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | ||
| 172 | } | 234 | } | ||
| 173 | - | agents := tool.Swarm.List() | 235 | + | agents := tool.Supervisor.List() |
| 174 | if len(agents) != 1 || agents[0].Reasoning != "low" { | 236 | if len(agents) != 1 || agents[0].Reasoning != "low" { | ||
| 175 | t.Fatalf("agent reasoning = %#v, want low", agents) | 237 | t.Fatalf("agent reasoning = %#v, want low", agents) | ||
| 176 | } | 238 | } | ||
| 177 | } | 239 | } | ||
| 178 | 240 | ||||
| 179 | - | func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) { | 241 | + | func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) { |
| 180 | - | tool := &SwarmSpawnTool{ | 242 | + | tool := &SubagentSpawnTool{ |
| 181 | - | Swarm: newTestSwarm(t), | 243 | + | Supervisor: newTestSupervisor(t), |
| 182 | - | Enabled: func() bool { return true }, | 244 | + | Enabled: func() bool { return true }, |
| 183 | ResolveSubagent: func(string) (*subagents.Profile, error) { | 245 | ResolveSubagent: func(string) (*subagents.Profile, error) { | ||
| 184 | return nil, nil | 246 | return nil, nil | ||
| 185 | }, | 247 | }, | ||
| @@ -191,14 +253,14 @@ func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) { | |||||
| 191 | if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") { | 253 | if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") { | ||
| 192 | t.Fatalf("result = %#v, want unknown profile error", res) | 254 | t.Fatalf("result = %#v, want unknown profile error", res) | ||
| 193 | } | 255 | } | ||
| 194 | - | if got := len(tool.Swarm.List()); got != 0 { | 256 | + | if got := len(tool.Supervisor.List()); got != 0 { |
| 195 | t.Fatalf("spawned agents = %d, want 0", got) | 257 | t.Fatalf("spawned agents = %d, want 0", got) | ||
| 196 | } | 258 | } | ||
| 197 | } | 259 | } | ||
| 198 | 260 | ||||
| 199 | - | func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) { | 261 | + | func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) { |
| 200 | - | tool := &SwarmSpawnTool{ | 262 | + | tool := &SubagentSpawnTool{ |
| 201 | - | Swarm: newTestSwarm(t), | 263 | + | Supervisor: newTestSupervisor(t), |
| 202 | Enabled: func() bool { return true }, | 264 | Enabled: func() bool { return true }, | ||
| 203 | DefaultModel: func() string { return "gpt-5" }, | 265 | DefaultModel: func() string { return "gpt-5" }, | ||
| 204 | DefaultProvider: func() string { return "openai-codex" }, | 266 | DefaultProvider: func() string { return "openai-codex" }, | ||
| @@ -214,7 +276,7 @@ func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) { | |||||
| 214 | if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") { | 276 | if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") { | ||
| 215 | t.Fatalf("error text = %q", got) | 277 | t.Fatalf("error text = %q", got) | ||
| 216 | } | 278 | } | ||
| 217 | - | if got := len(tool.Swarm.List()); got != 0 { | 279 | + | if got := len(tool.Supervisor.List()); got != 0 { |
| 218 | t.Fatalf("spawned agents = %d, want 0", got) | 280 | t.Fatalf("spawned agents = %d, want 0", got) | ||
| 219 | } | 281 | } | ||
| 220 | } | 282 | } | ||
M packages/core/agent.go +44 -9
| @@ -16,9 +16,13 @@ import ( | |||||
| 16 | // Agent is a stateful conversation bound to a provider client, a model, | 16 | // Agent is a stateful conversation bound to a provider client, a model, | ||
| 17 | // and a set of tools. | 17 | // and a set of tools. | ||
| 18 | type Agent struct { | 18 | type Agent struct { | ||
| 19 | - | Client provider.Client | 19 | + | Client provider.Client |
| 20 | - | Model string | 20 | + | Model string |
| 21 | - | System string | 21 | + | // System is mutex-protected; use SetSystemPrompt to write it and |
| 22 | + | // PromptConfig to read it. | |||
| 23 | + | System string | |||
| 24 | + | // Tools is mutex-protected; use SetTools to write it and PromptConfig | |||
| 25 | + | // or ToolsSnapshot to read it. | |||
| 22 | Tools Registry | 26 | Tools Registry | ||
| 23 | MaxSteps int | 27 | MaxSteps int | ||
| 24 | Reasoning string | 28 | Reasoning string | ||
| @@ -264,6 +268,36 @@ func (a *Agent) SetTools(reg Registry) { | |||||
| 264 | a.mu.Unlock() | 268 | a.mu.Unlock() | ||
| 265 | } | 269 | } | ||
| 266 | 270 | ||||
| 271 | + | // SetSystemPrompt replaces the system prompt used for subsequent turns. | |||
| 272 | + | func (a *Agent) SetSystemPrompt(system string) { | |||
| 273 | + | a.mu.Lock() | |||
| 274 | + | a.System = system | |||
| 275 | + | a.mu.Unlock() | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // PromptConfig returns a consistent snapshot of the system prompt and tools | |||
| 279 | + | // used to construct a provider request. | |||
| 280 | + | func (a *Agent) PromptConfig() (string, Registry) { | |||
| 281 | + | a.mu.Lock() | |||
| 282 | + | defer a.mu.Unlock() | |||
| 283 | + | tools := make(Registry, len(a.Tools)) | |||
| 284 | + | for name, tool := range a.Tools { | |||
| 285 | + | tools[name] = tool | |||
| 286 | + | } | |||
| 287 | + | return a.System, tools | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // ToolsSnapshot returns a copy of the current tool registry. | |||
| 291 | + | func (a *Agent) ToolsSnapshot() Registry { | |||
| 292 | + | a.mu.Lock() | |||
| 293 | + | defer a.mu.Unlock() | |||
| 294 | + | tools := make(Registry, len(a.Tools)) | |||
| 295 | + | for name, tool := range a.Tools { | |||
| 296 | + | tools[name] = tool | |||
| 297 | + | } | |||
| 298 | + | return tools | |||
| 299 | + | } | |||
| 300 | + | ||||
| 267 | // SetMessages replaces the transcript (used when resuming a session). | 301 | // SetMessages replaces the transcript (used when resuming a session). | ||
| 268 | func (a *Agent) SetMessages(msgs []provider.Message) { | 302 | func (a *Agent) SetMessages(msgs []provider.Message) { | ||
| 269 | a.mu.Lock() | 303 | a.mu.Lock() | ||
| @@ -594,7 +628,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 594 | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | 628 | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | ||
| 595 | return provider.StopError, provider.Message{}, err | 629 | return provider.StopError, provider.Message{}, err | ||
| 596 | } | 630 | } | ||
| 597 | - | system := a.System | 631 | + | system, tools := a.PromptConfig() |
| 598 | if contextText := boundedTurnContext(turnContext); contextText != "" { | 632 | if contextText := boundedTurnContext(turnContext); contextText != "" { | ||
| 599 | if system != "" { | 633 | if system != "" { | ||
| 600 | system += "\n\n" | 634 | system += "\n\n" | ||
| @@ -613,7 +647,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 613 | // with "tool_use ids were found without tool_result blocks". The | 647 | // with "tool_use ids were found without tool_result blocks". The | ||
| 614 | // repair is pure and a no-op on already-valid transcripts. | 648 | // repair is pure and a no-op on already-valid transcripts. | ||
| 615 | Messages: repairToolUseResultPairs(a.Messages()), | 649 | Messages: repairToolUseResultPairs(a.Messages()), | ||
| 616 | - | Tools: a.Tools.Specs(), | 650 | + | Tools: tools.Specs(), |
| 617 | Reasoning: a.Reasoning, | 651 | Reasoning: a.Reasoning, | ||
| 618 | FastMode: fastMode, | 652 | FastMode: fastMode, | ||
| 619 | MaxTokens: a.MaxTokens, | 653 | MaxTokens: a.MaxTokens, | ||
| @@ -723,13 +757,14 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||||
| 723 | var results []provider.Content | 757 | var results []provider.Content | ||
| 724 | var addedTools []string | 758 | var addedTools []string | ||
| 725 | hadError := false | 759 | hadError := false | ||
| 760 | + | tools := a.ToolsSnapshot() | |||
| 726 | 761 | ||||
| 727 | for _, c := range msg.Content { | 762 | for _, c := range msg.Content { | ||
| 728 | tc, ok := c.(provider.ToolCallBlock) | 763 | tc, ok := c.(provider.ToolCallBlock) | ||
| 729 | if !ok { | 764 | if !ok { | ||
| 730 | continue | 765 | continue | ||
| 731 | } | 766 | } | ||
| 732 | - | res := a.runOneTool(ctx, tc, sink) | 767 | + | res := a.runOneTool(ctx, tc, tools, sink) |
| 733 | if res.IsError { | 768 | if res.IsError { | ||
| 734 | hadError = true | 769 | hadError = true | ||
| 735 | } | 770 | } | ||
| @@ -739,7 +774,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||||
| 739 | IsError: res.IsError, | 774 | IsError: res.IsError, | ||
| 740 | }) | 775 | }) | ||
| 741 | for _, name := range res.ActivateTools { | 776 | for _, name := range res.ActivateTools { | ||
| 742 | - | if _, err := a.Tools.Get(name); err == nil && !containsString(addedTools, name) { | 777 | + | if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) { |
| 743 | addedTools = append(addedTools, name) | 778 | addedTools = append(addedTools, name) | ||
| 744 | } | 779 | } | ||
| 745 | } | 780 | } | ||
| @@ -757,8 +792,8 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||||
| 757 | }, hadError | 792 | }, hadError | ||
| 758 | } | 793 | } | ||
| 759 | 794 | ||||
| 760 | - | func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult { | 795 | + | func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) ToolResult { |
| 761 | - | tool, err := a.Tools.Get(tc.Name) | 796 | + | tool, err := tools.Get(tc.Name) |
| 762 | if err != nil { | 797 | if err != nil { | ||
| 763 | return ToolResult{ | 798 | return ToolResult{ | ||
| 764 | Content: []provider.Content{provider.TextBlock{Text: err.Error()}}, | 799 | Content: []provider.Content{provider.TextBlock{Text: err.Error()}}, | ||
M packages/core/core_test.go +2 -2
| @@ -116,11 +116,11 @@ func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) { | |||||
| 116 | } | 116 | } | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | - | // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's | 119 | + | // TestNewSessionAtPathCreatesAtExplicitPath proves the subagent worker's |
| 120 | // session-persistence fallback works: NewSessionAtPath creates the | 120 | // session-persistence fallback works: NewSessionAtPath creates the | ||
| 121 | // file (and parent dirs) at the exact path the caller chose, | 121 | // file (and parent dirs) at the exact path the caller chose, | ||
| 122 | // independent of SessionsDir(root, cwd). Without this helper, the | 122 | // independent of SessionsDir(root, cwd). Without this helper, the | ||
| 123 | - | // swarm child's --session <path> with a non-existent path would | 123 | + | // subagent worker's --session <path> with a non-existent path would |
| 124 | // fall through to NewSession and write the session under a | 124 | // fall through to NewSession and write the session under a | ||
| 125 | // different (autogenerated) name, leaving the supervisor-chosen | 125 | // different (autogenerated) name, leaving the supervisor-chosen | ||
| 126 | // path empty. | 126 | // path empty. | ||
M packages/core/intercept_test.go +6 -3
| @@ -39,11 +39,12 @@ func TestBeforeToolExecuteModifiesArgs(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | ctx := context.Background() | 41 | ctx := context.Background() | ||
| 42 | + | tools := a.ToolsSnapshot() | |||
| 42 | res := a.runOneTool(ctx, provider.ToolCallBlock{ | 43 | res := a.runOneTool(ctx, provider.ToolCallBlock{ | ||
| 43 | ID: "T1", | 44 | ID: "T1", | ||
| 44 | Name: "echo", | 45 | Name: "echo", | ||
| 45 | Arguments: json.RawMessage(`{"command":"ls"}`), | 46 | Arguments: json.RawMessage(`{"command":"ls"}`), | ||
| 46 | - | }, func(AgentEvent) {}) | 47 | + | }, tools, func(AgentEvent) {}) |
| 47 | if res.IsError { | 48 | if res.IsError { | ||
| 48 | t.Fatalf("unexpected error result: %v", res.Content) | 49 | t.Fatalf("unexpected error result: %v", res.Content) | ||
| 49 | } | 50 | } | ||
| @@ -65,12 +66,13 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) { | |||||
| 65 | } | 66 | } | ||
| 66 | 67 | ||||
| 67 | ctx := context.Background() | 68 | ctx := context.Background() | ||
| 69 | + | tools := a.ToolsSnapshot() | |||
| 68 | orig := json.RawMessage(`{"command":"ls"}`) | 70 | orig := json.RawMessage(`{"command":"ls"}`) | ||
| 69 | a.runOneTool(ctx, provider.ToolCallBlock{ | 71 | a.runOneTool(ctx, provider.ToolCallBlock{ | ||
| 70 | ID: "T1", | 72 | ID: "T1", | ||
| 71 | Name: "echo", | 73 | Name: "echo", | ||
| 72 | Arguments: orig, | 74 | Arguments: orig, | ||
| 73 | - | }, func(AgentEvent) {}) | 75 | + | }, tools, func(AgentEvent) {}) |
| 74 | if string(rec.lastArgs) != string(orig) { | 76 | if string(rec.lastArgs) != string(orig) { | ||
| 75 | t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig)) | 77 | t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig)) | ||
| 76 | } | 78 | } | ||
| @@ -88,11 +90,12 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) { | |||||
| 88 | } | 90 | } | ||
| 89 | 91 | ||||
| 90 | ctx := context.Background() | 92 | ctx := context.Background() | ||
| 93 | + | tools := a.ToolsSnapshot() | |||
| 91 | res := a.runOneTool(ctx, provider.ToolCallBlock{ | 94 | res := a.runOneTool(ctx, provider.ToolCallBlock{ | ||
| 92 | ID: "T1", | 95 | ID: "T1", | ||
| 93 | Name: "echo", | 96 | Name: "echo", | ||
| 94 | Arguments: json.RawMessage(`{"command":"ls"}`), | 97 | Arguments: json.RawMessage(`{"command":"ls"}`), | ||
| 95 | - | }, func(AgentEvent) {}) | 98 | + | }, tools, func(AgentEvent) {}) |
| 96 | if !res.IsError { | 99 | if !res.IsError { | ||
| 97 | t.Fatal("want error result, got success") | 100 | t.Fatal("want error result, got success") | ||
| 98 | } | 101 | } | ||
M packages/core/session.go +1 -1
| @@ -153,7 +153,7 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error | |||||
| 153 | } | 153 | } | ||
| 154 | 154 | ||||
| 155 | // NewSessionAtPath creates a session at an explicit file path. Used | 155 | // NewSessionAtPath creates a session at an explicit file path. Used | ||
| 156 | - | // by callers (notably the swarm-agent child) that need the session | 156 | + | // by callers (notably the subagent worker) that need the session |
| 157 | // file to live at a path chosen by their parent rather than under | 157 | // file to live at a path chosen by their parent rather than under | ||
| 158 | // SessionsDir. Returns an error if the file already exists — use | 158 | // SessionsDir. Returns an error if the file already exists — use | ||
| 159 | // OpenSession for that case. | 159 | // OpenSession for that case. | ||
M packages/tui/render.go +1 -1
| @@ -476,7 +476,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 476 | // alternative — always writing SeqHideCursor + cursor-position + | 476 | // alternative — always writing SeqHideCursor + cursor-position + | ||
| 477 | // SeqShowCursor — resets the terminal's cursor blink timer on | 477 | // SeqShowCursor — resets the terminal's cursor blink timer on | ||
| 478 | // every tick. At our 120ms animation cadence that means the | 478 | // every tick. At our 120ms animation cadence that means the | ||
| 479 | - | // caret in an idle dialog editor (e.g. an open swarm transcript | 479 | + | // caret in an idle dialog editor (e.g. an open subagent transcript |
| 480 | // for an agent that's currently idle) appears as a solid block | 480 | // for an agent that's currently idle) appears as a solid block | ||
| 481 | // that never blinks, because we keep "showing" it before the | 481 | // that never blinks, because we keep "showing" it before the | ||
| 482 | // terminal can blink it off. Bailing out here lets the OS run | 482 | // terminal can blink it off. Bailing out here lets the OS run | ||
M packages/tui/render_test.go +1 -1
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | // used to always emit SeqHideCursor + cursor-position + | 14 | // used to always emit SeqHideCursor + cursor-position + | ||
| 15 | // SeqShowCursor, which resets the terminal's blink timer. Faster | 15 | // SeqShowCursor, which resets the terminal's blink timer. Faster | ||
| 16 | // than the OS blink interval, so an idle dialog editor (e.g. a | 16 | // than the OS blink interval, so an idle dialog editor (e.g. a | ||
| 17 | - | // re-opened swarm transcript whose agent isn't producing output) | 17 | + | // re-opened subagent transcript whose agent isn't producing output) |
| 18 | // rendered the caret as a solid non-blinking block. | 18 | // rendered the caret as a solid non-blinking block. | ||
| 19 | // | 19 | // | ||
| 20 | // With the no-op fast path the renderer leaves the screen alone | 20 | // With the no-op fast path the renderer leaves the screen alone | ||
M packages/tui/statusbar_test.go +18 -0
| @@ -57,6 +57,24 @@ func TestStatusBarNoCWD(t *testing.T) { | |||||
| 57 | } | 57 | } | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | + | func TestStatusBarShowsFastMode(t *testing.T) { | |||
| 61 | + | lines := StatusBar(StatusBarParams{ | |||
| 62 | + | Theme: Dark, | |||
| 63 | + | Provider: "openai-codex", | |||
| 64 | + | Model: "gpt-5.6-luna", | |||
| 65 | + | FastMode: true, | |||
| 66 | + | CWD: "/tmp/x", | |||
| 67 | + | Cols: 200, | |||
| 68 | + | }) | |||
| 69 | + | if len(lines) != 2 { | |||
| 70 | + | t.Fatalf("want 2 lines, got %d: %q", len(lines), lines) | |||
| 71 | + | } | |||
| 72 | + | plain := stripANSI(lines[0]) | |||
| 73 | + | if !strings.Contains(plain, "fast mode") { | |||
| 74 | + | t.Fatalf("fast mode should be visible in status bar: %q", plain) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 60 | func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) { | 78 | func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) { | ||
| 61 | lines := StatusBar(StatusBarParams{ | 79 | lines := StatusBar(StatusBarParams{ | ||
| 62 | Theme: Dark, | 80 | Theme: Dark, | ||
M packages/tui/view.go +34 -10
| @@ -2408,6 +2408,7 @@ type StatusBarParams struct { | |||||
| 2408 | Provider string | 2408 | Provider string | ||
| 2409 | Model string | 2409 | Model string | ||
| 2410 | Reasoning string // "" means thinking off | 2410 | Reasoning string // "" means thinking off | ||
| 2411 | + | FastMode bool // show the provider's opt-in fast tier when enabled | |||
| 2411 | Busy bool | 2412 | Busy bool | ||
| 2412 | BusyPrefix string // spinner + funny line when busy | 2413 | BusyPrefix string // spinner + funny line when busy | ||
| 2413 | CWD string | 2414 | CWD string | ||
| @@ -2509,8 +2510,15 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2509 | if reasoning != "" { | 2510 | if reasoning != "" { | ||
| 2510 | reasoningText = "reasoning: " + reasoning | 2511 | reasoningText = "reasoning: " + reasoning | ||
| 2511 | } | 2512 | } | ||
| 2513 | + | fastText := "" | |||
| 2514 | + | if p.FastMode { | |||
| 2515 | + | fastText = "fast mode" | |||
| 2516 | + | } | |||
| 2512 | statsText := strings.Join(stats, " ") | 2517 | statsText := strings.Join(stats, " ") | ||
| 2513 | - | middleParts := make([]string, 0, 2) | 2518 | + | middleParts := make([]string, 0, 3) |
| 2519 | + | if fastText != "" { | |||
| 2520 | + | middleParts = append(middleParts, fastText) | |||
| 2521 | + | } | |||
| 2514 | if reasoningText != "" { | 2522 | if reasoningText != "" { | ||
| 2515 | middleParts = append(middleParts, reasoningText) | 2523 | middleParts = append(middleParts, reasoningText) | ||
| 2516 | } | 2524 | } | ||
| @@ -2570,7 +2578,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2570 | modelLine := pad + th.FG256(th.Muted, left) | 2578 | modelLine := pad + th.FG256(th.Muted, left) | ||
| 2571 | lines := []string{busyLine} | 2579 | lines := []string{busyLine} | ||
| 2572 | if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols { | 2580 | if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols { | ||
| 2573 | - | lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols) | 2581 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) |
| 2574 | } else { | 2582 | } else { | ||
| 2575 | var infoBuilder strings.Builder | 2583 | var infoBuilder strings.Builder | ||
| 2576 | infoBuilder.WriteString(modelLine) | 2584 | infoBuilder.WriteString(modelLine) | ||
| @@ -2592,7 +2600,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2592 | // into an awkward position on small widths. | 2600 | // into an awkward position on small widths. | ||
| 2593 | if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { | 2601 | if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { | ||
| 2594 | var lines []string | 2602 | var lines []string | ||
| 2595 | - | lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols) | 2603 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) |
| 2596 | if cwd != "" { | 2604 | if cwd != "" { | ||
| 2597 | lines = append(lines, pad+th.FG256(th.Muted, cwd)) | 2605 | lines = append(lines, pad+th.FG256(th.Muted, cwd)) | ||
| 2598 | } | 2606 | } | ||
| @@ -2609,9 +2617,17 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2609 | return []string{primary, cwdRendered} | 2617 | return []string{primary, cwdRendered} | ||
| 2610 | } | 2618 | } | ||
| 2611 | 2619 | ||||
| 2612 | - | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasoningText, statsText string, cols int) []string { | 2620 | + | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string { |
| 2613 | modelLine := pad + th.FG256(th.Muted, modelText) | 2621 | modelLine := pad + th.FG256(th.Muted, modelText) | ||
| 2614 | - | if reasoningText == "" { | 2622 | + | infoParts := make([]string, 0, 2) |
| 2623 | + | if fastText != "" { | |||
| 2624 | + | infoParts = append(infoParts, fastText) | |||
| 2625 | + | } | |||
| 2626 | + | if reasoningText != "" { | |||
| 2627 | + | infoParts = append(infoParts, reasoningText) | |||
| 2628 | + | } | |||
| 2629 | + | infoText := strings.Join(infoParts, pad) | |||
| 2630 | + | if infoText == "" { | |||
| 2615 | lines = append(lines, modelLine) | 2631 | lines = append(lines, modelLine) | ||
| 2616 | if statsText != "" { | 2632 | if statsText != "" { | ||
| 2617 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | 2633 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | ||
| @@ -2619,13 +2635,21 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasonin | |||||
| 2619 | return lines | 2635 | return lines | ||
| 2620 | } | 2636 | } | ||
| 2621 | 2637 | ||||
| 2622 | - | modelReasoningPlain := pad + modelText + pad + reasoningText | 2638 | + | modelInfoPlain := pad + modelText + pad + infoText |
| 2623 | - | reasoningColor := reasoningStatusColor(th, reasoningText) | 2639 | + | infoColor := th.Muted |
| 2624 | - | if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols { | 2640 | + | if reasoningText != "" { |
| 2625 | - | lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+reasoningText)) | 2641 | + | infoColor = reasoningStatusColor(th, reasoningText) |
| 2642 | + | } | |||
| 2643 | + | if cols <= 0 || visibleWidth(modelInfoPlain) <= cols { | |||
| 2644 | + | lines = append(lines, pad+th.FG256(infoColor, modelText+pad+infoText)) | |||
| 2626 | } else { | 2645 | } else { | ||
| 2627 | lines = append(lines, modelLine) | 2646 | lines = append(lines, modelLine) | ||
| 2628 | - | lines = append(lines, pad+th.FG256(reasoningColor, reasoningText)) | 2647 | + | if fastText != "" { |
| 2648 | + | lines = append(lines, pad+th.FG256(th.Muted, fastText)) | |||
| 2649 | + | } | |||
| 2650 | + | if reasoningText != "" { | |||
| 2651 | + | lines = append(lines, pad+th.FG256(reasoningStatusColor(th, reasoningText), reasoningText)) | |||
| 2652 | + | } | |||
| 2629 | } | 2653 | } | ||
| 2630 | if statsText != "" { | 2654 | if statsText != "" { | ||
| 2631 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | 2655 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | ||