feat: auto-swarm summary, system-prompt nudge, README/docs
- Sub-agents are long-lived daemons that keep running on the inbox after the initial task, so agent.Wait() never unblocks for them. Replaced the Wait-based watcher with a per-turn OnTurnEnd callback: Agent.SetOnTurnEnd installs it under the agent mutex, the runner's stdout decoder fires it on every turn_end event from the child. - trackSwarmAgent now subscribes via SetOnTurnEnd. First turn_end per tracked sub-agent marks it done; when every entry is done, zot flushes one [auto-swarm update] turn via SubmitOrQueue summarising each agent's status / task / transcript tail (and the turn error if any) so the main agent can recap the collective outcome in chat. - System addendum extended to tell the model to expect that update message and treat it as observed state, not a new user request. - README: /settings row in the slash-commands table, new /settings subsection covering both toggles, auto-swarm paragraph appended to the /swarm subsection, /settings listed as a read-only mid-turn command.
- commit
- 80c0ac97a5cc1464ac13bc97c64b31e4ced08265
- author
- patriceckhart · · 2026-05-25 16:47 UTC
- parent
- 6320769
- signature
- unsigned
- refs
- tag: v0.1.97
7 files · +186 -6
M README.md +11 -1
| @@ -202,6 +202,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||
| 202 | 202 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 203 | 203 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 204 | 204 | | `/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`. | | |
| 205 | + | | `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |
| 205 | 206 | | `/clear` | Clear the chat transcript. | | |
| 206 | 207 | | `/exit` | Exit zot. | | |
| 207 | 208 | ||
| @@ -285,6 +286,15 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 285 | 286 | ||
| 286 | 287 | **`/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. | |
| 287 | 288 | ||
| 289 | + | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and transcript tail; the main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`. | |
| 290 | + | ||
| 291 | + | ### `/settings` | |
| 292 | + | ||
| 293 | + | Opens a dialog with every persistent toggle. `up`/`down` to navigate, `enter` or `space` to flip the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current toggles: | |
| 294 | + | ||
| 295 | + | - **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. | |
| 296 | + | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |
| 297 | + | ||
| 288 | 298 | ### `/skills` | |
| 289 | 299 | ||
| 290 | 300 | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. | |
| @@ -521,7 +531,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q | |||
| 521 | 531 | ||
| 522 | 532 | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | |
| 523 | 533 | ||
| 524 | - | Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | |
| 534 | + | Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | |
| 525 | 535 | ||
| 526 | 536 | ||
| 527 | 537 | ## Keys (interactive mode) | |
| @@ -202,6 +202,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||||
| 202 | | `/unjail` | Allow tools to touch paths outside again. | | 202 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 203 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 203 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 204 | | `/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`. | | 204 | | `/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`. | | ||
| 205 | + | | `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |||
| 205 | | `/clear` | Clear the chat transcript. | | 206 | | `/clear` | Clear the chat transcript. | | ||
| 206 | | `/exit` | Exit zot. | | 207 | | `/exit` | Exit zot. | | ||
| 207 | 208 | ||||
| @@ -285,6 +286,15 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 285 | 286 | ||||
| 286 | **`/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. | 287 | **`/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. | ||
| 287 | 288 | ||||
| 289 | + | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and transcript tail; the main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`. | |||
| 290 | + | ||||
| 291 | + | ### `/settings` | |||
| 292 | + | ||||
| 293 | + | Opens a dialog with every persistent toggle. `up`/`down` to navigate, `enter` or `space` to flip the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current toggles: | |||
| 294 | + | ||||
| 295 | + | - **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. | |||
| 296 | + | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |||
| 297 | + | ||||
| 288 | ### `/skills` | 298 | ### `/skills` | ||
| 289 | 299 | ||||
| 290 | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. | 300 | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. | ||
| @@ -521,7 +531,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q | |||||
| 521 | 531 | ||||
| 522 | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | 532 | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | ||
| 523 | 533 | ||||
| 524 | - | Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | 534 | + | Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. |
| 525 | 535 | ||||
| 526 | 536 | ||||
| 527 | ## Keys (interactive mode) | 537 | ## Keys (interactive mode) | ||
M internal/agent/cli.go +14 -2
| @@ -441,6 +441,17 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 441 | 441 | // shows them as detached and the user can resume / remove them. | |
| 442 | 442 | _, _ = swarmMgr.Reload() | |
| 443 | 443 | ||
| 444 | + | // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool | |
| 445 | + | // fires after every successful spawn. It hands the agent off to | |
| 446 | + | // the running Interactive so the watcher can flush a summary back | |
| 447 | + | // into chat when all sub-agents finish. Reads `iv` lazily because | |
| 448 | + | // the Interactive is constructed after the agent. | |
| 449 | + | onSpawnedSwarm := func(a *swarm.Agent, task string) { | |
| 450 | + | if iv != nil { | |
| 451 | + | iv.TrackSwarmAgent(a, task) | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 444 | 455 | // Inject the swarm_spawn auto-swarm tool only when /settings -> | |
| 445 | 456 | // auto-swarm is currently enabled. Registering it unconditionally | |
| 446 | 457 | // leaves the model trying to call it (and getting a polite error) | |
| @@ -455,8 +466,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 455 | 466 | return reg | |
| 456 | 467 | } | |
| 457 | 468 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 458 | - | Swarm: swarmMgr, | |
| 459 | - | Enabled: AutoSwarmEnabled, | |
| 469 | + | Swarm: swarmMgr, | |
| 470 | + | Enabled: AutoSwarmEnabled, | |
| 471 | + | OnSpawned: onSpawnedSwarm, | |
| 460 | 472 | } | |
| 461 | 473 | return reg | |
| 462 | 474 | } | |
| @@ -441,6 +441,17 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 441 | // shows them as detached and the user can resume / remove them. | 441 | // shows them as detached and the user can resume / remove them. | ||
| 442 | _, _ = swarmMgr.Reload() | 442 | _, _ = swarmMgr.Reload() | ||
| 443 | 443 | ||||
| 444 | + | // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool | |||
| 445 | + | // fires after every successful spawn. It hands the agent off to | |||
| 446 | + | // the running Interactive so the watcher can flush a summary back | |||
| 447 | + | // into chat when all sub-agents finish. Reads `iv` lazily because | |||
| 448 | + | // the Interactive is constructed after the agent. | |||
| 449 | + | onSpawnedSwarm := func(a *swarm.Agent, task string) { | |||
| 450 | + | if iv != nil { | |||
| 451 | + | iv.TrackSwarmAgent(a, task) | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 444 | // Inject the swarm_spawn auto-swarm tool only when /settings -> | 455 | // Inject the swarm_spawn auto-swarm tool only when /settings -> | ||
| 445 | // auto-swarm is currently enabled. Registering it unconditionally | 456 | // auto-swarm is currently enabled. Registering it unconditionally | ||
| 446 | // leaves the model trying to call it (and getting a polite error) | 457 | // leaves the model trying to call it (and getting a polite error) | ||
| @@ -455,8 +466,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 455 | return reg | 466 | return reg | ||
| 456 | } | 467 | } | ||
| 457 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | 468 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | ||
| 458 | - | Swarm: swarmMgr, | 469 | + | Swarm: swarmMgr, |
| 459 | - | Enabled: AutoSwarmEnabled, | 470 | + | Enabled: AutoSwarmEnabled, |
| 471 | + | OnSpawned: onSpawnedSwarm, | |||
| 460 | } | 472 | } | ||
| 461 | return reg | 473 | return reg | ||
| 462 | } | 474 | } | ||
M internal/agent/modes/interactive.go +116 -2
| @@ -325,6 +325,15 @@ type Interactive struct { | |||
| 325 | 325 | sessionTreeDialog *sessionTreeDialog | |
| 326 | 326 | extPanel *extPanelDialog | |
| 327 | 327 | ||
| 328 | + | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | |
| 329 | + | // via swarm_spawn. Each entry holds the agent + the task text; | |
| 330 | + | // a per-entry goroutine waits on the agent's terminal state. When | |
| 331 | + | // every tracked entry has finished, the watcher flushes a single | |
| 332 | + | // summary turn into the main chat (queued if the main agent is | |
| 333 | + | // busy, run immediately if idle). | |
| 334 | + | swarmWatchMu sync.Mutex | |
| 335 | + | swarmWatch []*swarmWatchEntry | |
| 336 | + | ||
| 328 | 337 | // pendingFork is true when the user ran /session fork: the next | |
| 329 | 338 | // jump-picker selection should branch off that message instead | |
| 330 | 339 | // of scrolling. Flag resets after the action fires or the dialog | |
| @@ -4418,6 +4427,110 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 4418 | 4427 | return a.bridge != nil && a.bridge.Active() | |
| 4419 | 4428 | } | |
| 4420 | 4429 | ||
| 4430 | + | // swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at | |
| 4431 | + | // spawn time and finalised in trackSwarmAgent's waiter goroutine. | |
| 4432 | + | type swarmWatchEntry struct { | |
| 4433 | + | agent *swarm.Agent | |
| 4434 | + | task string | |
| 4435 | + | done bool | |
| 4436 | + | err string | |
| 4437 | + | } | |
| 4438 | + | ||
| 4439 | + | // TrackSwarmAgent is the exported entry point used by the cli to | |
| 4440 | + | // hand a freshly-spawned auto-swarm agent off to the watcher. | |
| 4441 | + | func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) { | |
| 4442 | + | i.trackSwarmAgent(a, task) | |
| 4443 | + | } | |
| 4444 | + | ||
| 4445 | + | // trackSwarmAgent records a freshly-spawned auto-swarm agent and | |
| 4446 | + | // subscribes to its turn_end events. Sub-agents are long-lived | |
| 4447 | + | // daemons that keep running on the inbox after the initial task, | |
| 4448 | + | // so we can't wait on agent.Wait() — it never returns until the | |
| 4449 | + | // whole daemon dies. Instead we mark each entry done on its first | |
| 4450 | + | // turn_end (the initial task finishing), and when every tracked | |
| 4451 | + | // entry has reported in, flush a single summary into the main chat. | |
| 4452 | + | // | |
| 4453 | + | // Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto- | |
| 4454 | + | // swarm is enabled, so this is a no-op when the feature is off. | |
| 4455 | + | func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) { | |
| 4456 | + | if i == nil || a == nil { | |
| 4457 | + | return | |
| 4458 | + | } | |
| 4459 | + | entry := &swarmWatchEntry{agent: a, task: task} | |
| 4460 | + | i.swarmWatchMu.Lock() | |
| 4461 | + | i.swarmWatch = append(i.swarmWatch, entry) | |
| 4462 | + | i.swarmWatchMu.Unlock() | |
| 4463 | + | ||
| 4464 | + | a.SetOnTurnEnd(func(step int, errMsg string) { | |
| 4465 | + | i.swarmWatchMu.Lock() | |
| 4466 | + | if entry.done { | |
| 4467 | + | i.swarmWatchMu.Unlock() | |
| 4468 | + | return | |
| 4469 | + | } | |
| 4470 | + | entry.done = true | |
| 4471 | + | entry.err = errMsg | |
| 4472 | + | allDone := true | |
| 4473 | + | for _, e := range i.swarmWatch { | |
| 4474 | + | if !e.done { | |
| 4475 | + | allDone = false | |
| 4476 | + | break | |
| 4477 | + | } | |
| 4478 | + | } | |
| 4479 | + | var batch []*swarmWatchEntry | |
| 4480 | + | if allDone { | |
| 4481 | + | batch = i.swarmWatch | |
| 4482 | + | i.swarmWatch = nil | |
| 4483 | + | } | |
| 4484 | + | i.swarmWatchMu.Unlock() | |
| 4485 | + | if len(batch) == 0 { | |
| 4486 | + | return | |
| 4487 | + | } | |
| 4488 | + | i.flushSwarmSummary(batch) | |
| 4489 | + | }) | |
| 4490 | + | } | |
| 4491 | + | ||
| 4492 | + | // flushSwarmSummary composes a synthetic user turn describing every | |
| 4493 | + | // sub-agent's outcome and injects it via SubmitOrQueue so the main | |
| 4494 | + | // agent picks it up at the next safe boundary. The summary is | |
| 4495 | + | // phrased as a system update ("Auto-swarm finished: ...") so the | |
| 4496 | + | // model treats it as observed state, not as a fresh user request. | |
| 4497 | + | func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) { | |
| 4498 | + | if len(batch) == 0 { | |
| 4499 | + | return | |
| 4500 | + | } | |
| 4501 | + | var sb strings.Builder | |
| 4502 | + | fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch)) | |
| 4503 | + | for idx, e := range batch { | |
| 4504 | + | snap := e.agent.Snapshot() | |
| 4505 | + | status := string(snap.Status) | |
| 4506 | + | task := snap.Task | |
| 4507 | + | if task == "" { | |
| 4508 | + | task = e.task | |
| 4509 | + | } | |
| 4510 | + | fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status) | |
| 4511 | + | fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240)) | |
| 4512 | + | if snap.Err != "" { | |
| 4513 | + | fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240)) | |
| 4514 | + | } else if e.err != "" { | |
| 4515 | + | fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240)) | |
| 4516 | + | } | |
| 4517 | + | if tail := strings.TrimSpace(snap.Tail); tail != "" { | |
| 4518 | + | fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600)) | |
| 4519 | + | } | |
| 4520 | + | sb.WriteString("\n") | |
| 4521 | + | } | |
| 4522 | + | sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.") | |
| 4523 | + | i.SubmitOrQueue(sb.String(), nil) | |
| 4524 | + | } | |
| 4525 | + | ||
| 4526 | + | func truncateForSummary(s string, n int) string { | |
| 4527 | + | s = strings.TrimSpace(s) | |
| 4528 | + | if len(s) <= n { | |
| 4529 | + | return s | |
| 4530 | + | } | |
| 4531 | + | return s[:n-3] + "..." | |
| 4532 | + | } | |
| 4533 | + | ||
| 4421 | 4534 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | |
| 4422 | 4535 | // (active=false) the auto-swarm system-prompt block on the running | |
| 4423 | 4536 | // agent so the model proactively considers swarm_spawn when the user | |
| @@ -4463,8 +4576,9 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||
| 4463 | 4576 | } | |
| 4464 | 4577 | if active && i.cfg.Swarm != nil { | |
| 4465 | 4578 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 4466 | - | Swarm: i.cfg.Swarm, | |
| 4467 | - | Enabled: func() bool { return true }, | |
| 4579 | + | Swarm: i.cfg.Swarm, | |
| 4580 | + | Enabled: func() bool { return true }, | |
| 4581 | + | OnSpawned: i.trackSwarmAgent, | |
| 4468 | 4582 | } | |
| 4469 | 4583 | } | |
| 4470 | 4584 | i.agent.SetTools(next) | |
| @@ -325,6 +325,15 @@ type Interactive struct { | |||||
| 325 | sessionTreeDialog *sessionTreeDialog | 325 | sessionTreeDialog *sessionTreeDialog | ||
| 326 | extPanel *extPanelDialog | 326 | extPanel *extPanelDialog | ||
| 327 | 327 | ||||
| 328 | + | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | |||
| 329 | + | // via swarm_spawn. Each entry holds the agent + the task text; | |||
| 330 | + | // a per-entry goroutine waits on the agent's terminal state. When | |||
| 331 | + | // every tracked entry has finished, the watcher flushes a single | |||
| 332 | + | // summary turn into the main chat (queued if the main agent is | |||
| 333 | + | // busy, run immediately if idle). | |||
| 334 | + | swarmWatchMu sync.Mutex | |||
| 335 | + | swarmWatch []*swarmWatchEntry | |||
| 336 | + | ||||
| 328 | // pendingFork is true when the user ran /session fork: the next | 337 | // pendingFork is true when the user ran /session fork: the next | ||
| 329 | // jump-picker selection should branch off that message instead | 338 | // jump-picker selection should branch off that message instead | ||
| 330 | // of scrolling. Flag resets after the action fires or the dialog | 339 | // of scrolling. Flag resets after the action fires or the dialog | ||
| @@ -4418,6 +4427,110 @@ func (a telegramSenderAdapter) Active() bool { | |||||
| 4418 | return a.bridge != nil && a.bridge.Active() | 4427 | return a.bridge != nil && a.bridge.Active() | ||
| 4419 | } | 4428 | } | ||
| 4420 | 4429 | ||||
| 4430 | + | // swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at | |||
| 4431 | + | // spawn time and finalised in trackSwarmAgent's waiter goroutine. | |||
| 4432 | + | type swarmWatchEntry struct { | |||
| 4433 | + | agent *swarm.Agent | |||
| 4434 | + | task string | |||
| 4435 | + | done bool | |||
| 4436 | + | err string | |||
| 4437 | + | } | |||
| 4438 | + | ||||
| 4439 | + | // TrackSwarmAgent is the exported entry point used by the cli to | |||
| 4440 | + | // hand a freshly-spawned auto-swarm agent off to the watcher. | |||
| 4441 | + | func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) { | |||
| 4442 | + | i.trackSwarmAgent(a, task) | |||
| 4443 | + | } | |||
| 4444 | + | ||||
| 4445 | + | // trackSwarmAgent records a freshly-spawned auto-swarm agent and | |||
| 4446 | + | // subscribes to its turn_end events. Sub-agents are long-lived | |||
| 4447 | + | // daemons that keep running on the inbox after the initial task, | |||
| 4448 | + | // so we can't wait on agent.Wait() — it never returns until the | |||
| 4449 | + | // whole daemon dies. Instead we mark each entry done on its first | |||
| 4450 | + | // turn_end (the initial task finishing), and when every tracked | |||
| 4451 | + | // entry has reported in, flush a single summary into the main chat. | |||
| 4452 | + | // | |||
| 4453 | + | // Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto- | |||
| 4454 | + | // swarm is enabled, so this is a no-op when the feature is off. | |||
| 4455 | + | func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) { | |||
| 4456 | + | if i == nil || a == nil { | |||
| 4457 | + | return | |||
| 4458 | + | } | |||
| 4459 | + | entry := &swarmWatchEntry{agent: a, task: task} | |||
| 4460 | + | i.swarmWatchMu.Lock() | |||
| 4461 | + | i.swarmWatch = append(i.swarmWatch, entry) | |||
| 4462 | + | i.swarmWatchMu.Unlock() | |||
| 4463 | + | ||||
| 4464 | + | a.SetOnTurnEnd(func(step int, errMsg string) { | |||
| 4465 | + | i.swarmWatchMu.Lock() | |||
| 4466 | + | if entry.done { | |||
| 4467 | + | i.swarmWatchMu.Unlock() | |||
| 4468 | + | return | |||
| 4469 | + | } | |||
| 4470 | + | entry.done = true | |||
| 4471 | + | entry.err = errMsg | |||
| 4472 | + | allDone := true | |||
| 4473 | + | for _, e := range i.swarmWatch { | |||
| 4474 | + | if !e.done { | |||
| 4475 | + | allDone = false | |||
| 4476 | + | break | |||
| 4477 | + | } | |||
| 4478 | + | } | |||
| 4479 | + | var batch []*swarmWatchEntry | |||
| 4480 | + | if allDone { | |||
| 4481 | + | batch = i.swarmWatch | |||
| 4482 | + | i.swarmWatch = nil | |||
| 4483 | + | } | |||
| 4484 | + | i.swarmWatchMu.Unlock() | |||
| 4485 | + | if len(batch) == 0 { | |||
| 4486 | + | return | |||
| 4487 | + | } | |||
| 4488 | + | i.flushSwarmSummary(batch) | |||
| 4489 | + | }) | |||
| 4490 | + | } | |||
| 4491 | + | ||||
| 4492 | + | // flushSwarmSummary composes a synthetic user turn describing every | |||
| 4493 | + | // sub-agent's outcome and injects it via SubmitOrQueue so the main | |||
| 4494 | + | // agent picks it up at the next safe boundary. The summary is | |||
| 4495 | + | // phrased as a system update ("Auto-swarm finished: ...") so the | |||
| 4496 | + | // model treats it as observed state, not as a fresh user request. | |||
| 4497 | + | func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) { | |||
| 4498 | + | if len(batch) == 0 { | |||
| 4499 | + | return | |||
| 4500 | + | } | |||
| 4501 | + | var sb strings.Builder | |||
| 4502 | + | fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch)) | |||
| 4503 | + | for idx, e := range batch { | |||
| 4504 | + | snap := e.agent.Snapshot() | |||
| 4505 | + | status := string(snap.Status) | |||
| 4506 | + | task := snap.Task | |||
| 4507 | + | if task == "" { | |||
| 4508 | + | task = e.task | |||
| 4509 | + | } | |||
| 4510 | + | fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status) | |||
| 4511 | + | fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240)) | |||
| 4512 | + | if snap.Err != "" { | |||
| 4513 | + | fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240)) | |||
| 4514 | + | } else if e.err != "" { | |||
| 4515 | + | fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240)) | |||
| 4516 | + | } | |||
| 4517 | + | if tail := strings.TrimSpace(snap.Tail); tail != "" { | |||
| 4518 | + | fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600)) | |||
| 4519 | + | } | |||
| 4520 | + | sb.WriteString("\n") | |||
| 4521 | + | } | |||
| 4522 | + | sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.") | |||
| 4523 | + | i.SubmitOrQueue(sb.String(), nil) | |||
| 4524 | + | } | |||
| 4525 | + | ||||
| 4526 | + | func truncateForSummary(s string, n int) string { | |||
| 4527 | + | s = strings.TrimSpace(s) | |||
| 4528 | + | if len(s) <= n { | |||
| 4529 | + | return s | |||
| 4530 | + | } | |||
| 4531 | + | return s[:n-3] + "..." | |||
| 4532 | + | } | |||
| 4533 | + | ||||
| 4421 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | 4534 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | ||
| 4422 | // (active=false) the auto-swarm system-prompt block on the running | 4535 | // (active=false) the auto-swarm system-prompt block on the running | ||
| 4423 | // agent so the model proactively considers swarm_spawn when the user | 4536 | // agent so the model proactively considers swarm_spawn when the user | ||
| @@ -4463,8 +4576,9 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||||
| 4463 | } | 4576 | } | ||
| 4464 | if active && i.cfg.Swarm != nil { | 4577 | if active && i.cfg.Swarm != nil { | ||
| 4465 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | 4578 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | ||
| 4466 | - | Swarm: i.cfg.Swarm, | 4579 | + | Swarm: i.cfg.Swarm, |
| 4467 | - | Enabled: func() bool { return true }, | 4580 | + | Enabled: func() bool { return true }, |
| 4581 | + | OnSpawned: i.trackSwarmAgent, | |||
| 4468 | } | 4582 | } | ||
| 4469 | } | 4583 | } | ||
| 4470 | i.agent.SetTools(next) | 4584 | i.agent.SetTools(next) | ||
M internal/agent/settings_store.go +3 -1
| @@ -38,4 +38,6 @@ const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn t | |||
| 38 | 38 | ||
| 39 | 39 | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | |
| 40 | 40 | ||
| 41 | - | 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.` | |
| 41 | + | 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. | |
| 42 | + | ||
| 43 | + | 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.` | |
| @@ -38,4 +38,6 @@ const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn t | |||||
| 38 | 38 | ||||
| 39 | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | 39 | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | ||
| 40 | 40 | ||||
| 41 | - | 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.` | 41 | + | 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. |
| 42 | + | ||||
| 43 | + | 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.` | |||
M internal/agent/tools/swarm_spawn.go +9 -0
| @@ -30,6 +30,12 @@ type SwarmSpawnTool struct { | |||
| 30 | 30 | // /settings without rebuilding the agent. When nil, the tool | |
| 31 | 31 | // is treated as disabled. | |
| 32 | 32 | Enabled func() bool | |
| 33 | + | ||
| 34 | + | // OnSpawned, if set, is called after every successful spawn with | |
| 35 | + | // the new agent + the task it was started with. Used by the | |
| 36 | + | // interactive host to track agents and surface a summary back | |
| 37 | + | // in the main chat once they all finish. | |
| 38 | + | OnSpawned func(agent *swarm.Agent, task string) | |
| 33 | 39 | } | |
| 34 | 40 | ||
| 35 | 41 | type swarmSpawnArgs struct { | |
| @@ -87,6 +93,9 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 87 | 93 | if err != nil { | |
| 88 | 94 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | |
| 89 | 95 | } | |
| 96 | + | if t.OnSpawned != nil { | |
| 97 | + | t.OnSpawned(agent, task) | |
| 98 | + | } | |
| 90 | 99 | ||
| 91 | 100 | var sb strings.Builder | |
| 92 | 101 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | |
| @@ -30,6 +30,12 @@ type SwarmSpawnTool struct { | |||||
| 30 | // /settings without rebuilding the agent. When nil, the tool | 30 | // /settings without rebuilding the agent. When nil, the tool | ||
| 31 | // is treated as disabled. | 31 | // is treated as disabled. | ||
| 32 | Enabled func() bool | 32 | Enabled func() bool | ||
| 33 | + | ||||
| 34 | + | // OnSpawned, if set, is called after every successful spawn with | |||
| 35 | + | // the new agent + the task it was started with. Used by the | |||
| 36 | + | // interactive host to track agents and surface a summary back | |||
| 37 | + | // in the main chat once they all finish. | |||
| 38 | + | OnSpawned func(agent *swarm.Agent, task string) | |||
| 33 | } | 39 | } | ||
| 34 | 40 | ||||
| 35 | type swarmSpawnArgs struct { | 41 | type swarmSpawnArgs struct { | ||
| @@ -87,6 +93,9 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 87 | if err != nil { | 93 | if err != nil { | ||
| 88 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | 94 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | ||
| 89 | } | 95 | } | ||
| 96 | + | if t.OnSpawned != nil { | |||
| 97 | + | t.OnSpawned(agent, task) | |||
| 98 | + | } | |||
| 90 | 99 | ||||
| 91 | var sb strings.Builder | 100 | var sb strings.Builder | ||
| 92 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | 101 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | ||
M internal/swarm/agent.go +18 -0
| @@ -71,6 +71,14 @@ type Agent struct { | |||
| 71 | 71 | finished time.Time | |
| 72 | 72 | lastErr error | |
| 73 | 73 | ||
| 74 | + | // OnTurnEnd, if set, fires once per turn_end event the runner | |
| 75 | + | // observes from the child daemon. Used by auto-swarm watchers | |
| 76 | + | // to detect that a sub-agent's first (or n-th) task has | |
| 77 | + | // finished without waiting for the long-lived daemon itself to | |
| 78 | + | // exit — sub-agents keep running on the inbox even after the | |
| 79 | + | // initial task completes, so Wait() never unblocks for them. | |
| 80 | + | OnTurnEnd func(step int, errMsg string) | |
| 81 | + | ||
| 74 | 82 | ctx context.Context | |
| 75 | 83 | cancel context.CancelFunc | |
| 76 | 84 | runner Runner | |
| @@ -120,6 +128,16 @@ func (a *Agent) Err() error { | |||
| 120 | 128 | // and by /swarm wait <id>. | |
| 121 | 129 | func (a *Agent) Wait() { <-a.done } | |
| 122 | 130 | ||
| 131 | + | // SetOnTurnEnd installs (or clears, with nil) the per-turn callback | |
| 132 | + | // fired from the runner when the child daemon emits a turn_end | |
| 133 | + | // event. Safe to call from any goroutine: the runner reads the | |
| 134 | + | // callback under the same mutex. | |
| 135 | + | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | |
| 136 | + | a.mu.Lock() | |
| 137 | + | a.OnTurnEnd = fn | |
| 138 | + | a.mu.Unlock() | |
| 139 | + | } | |
| 140 | + | ||
| 123 | 141 | func (a *Agent) setStatus(s Status) { | |
| 124 | 142 | a.mu.Lock() | |
| 125 | 143 | a.status = s | |
| @@ -71,6 +71,14 @@ type Agent struct { | |||||
| 71 | finished time.Time | 71 | finished time.Time | ||
| 72 | lastErr error | 72 | lastErr error | ||
| 73 | 73 | ||||
| 74 | + | // OnTurnEnd, if set, fires once per turn_end event the runner | |||
| 75 | + | // observes from the child daemon. Used by auto-swarm watchers | |||
| 76 | + | // to detect that a sub-agent's first (or n-th) task has | |||
| 77 | + | // finished without waiting for the long-lived daemon itself to | |||
| 78 | + | // exit — sub-agents keep running on the inbox even after the | |||
| 79 | + | // initial task completes, so Wait() never unblocks for them. | |||
| 80 | + | OnTurnEnd func(step int, errMsg string) | |||
| 81 | + | ||||
| 74 | ctx context.Context | 82 | ctx context.Context | ||
| 75 | cancel context.CancelFunc | 83 | cancel context.CancelFunc | ||
| 76 | runner Runner | 84 | runner Runner | ||
| @@ -120,6 +128,16 @@ func (a *Agent) Err() error { | |||||
| 120 | // and by /swarm wait <id>. | 128 | // and by /swarm wait <id>. | ||
| 121 | func (a *Agent) Wait() { <-a.done } | 129 | func (a *Agent) Wait() { <-a.done } | ||
| 122 | 130 | ||||
| 131 | + | // SetOnTurnEnd installs (or clears, with nil) the per-turn callback | |||
| 132 | + | // fired from the runner when the child daemon emits a turn_end | |||
| 133 | + | // event. Safe to call from any goroutine: the runner reads the | |||
| 134 | + | // callback under the same mutex. | |||
| 135 | + | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | |||
| 136 | + | a.mu.Lock() | |||
| 137 | + | a.OnTurnEnd = fn | |||
| 138 | + | a.mu.Unlock() | |||
| 139 | + | } | |||
| 140 | + | ||||
| 123 | func (a *Agent) setStatus(s Status) { | 141 | func (a *Agent) setStatus(s Status) { | ||
| 124 | a.mu.Lock() | 142 | a.mu.Lock() | ||
| 125 | a.status = s | 143 | a.status = s | ||
M internal/swarm/runner.go +15 -0
| @@ -209,6 +209,21 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 209 | 209 | if ev, ok := parseEventLine(trimmed); ok { | |
| 210 | 210 | _ = log.Append(ev) | |
| 211 | 211 | applyEventToSink(ev, sink) | |
| 212 | + | // Fan turn_end up to any subscriber on the | |
| 213 | + | // supervised Agent. Daemons stay alive across | |
| 214 | + | // many turns, so Wait()-style hooks would | |
| 215 | + | // never fire; per-turn callbacks let auto- | |
| 216 | + | // swarm summarise as each task completes. | |
| 217 | + | if ev.Type == "turn_end" && r.agent != nil { | |
| 218 | + | r.agent.mu.Lock() | |
| 219 | + | fn := r.agent.OnTurnEnd | |
| 220 | + | r.agent.mu.Unlock() | |
| 221 | + | if fn != nil { | |
| 222 | + | step, _ := ev.Data["step"].(float64) | |
| 223 | + | errMsg, _ := ev.Data["error"].(string) | |
| 224 | + | go fn(int(step), errMsg) | |
| 225 | + | } | |
| 226 | + | } | |
| 212 | 227 | } else { | |
| 213 | 228 | // Non-JSON output. Keep it as transcript so an | |
| 214 | 229 | // accidental fmt.Println in the child still | |
| @@ -209,6 +209,21 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 209 | if ev, ok := parseEventLine(trimmed); ok { | 209 | if ev, ok := parseEventLine(trimmed); ok { | ||
| 210 | _ = log.Append(ev) | 210 | _ = log.Append(ev) | ||
| 211 | applyEventToSink(ev, sink) | 211 | applyEventToSink(ev, sink) | ||
| 212 | + | // Fan turn_end up to any subscriber on the | |||
| 213 | + | // supervised Agent. Daemons stay alive across | |||
| 214 | + | // many turns, so Wait()-style hooks would | |||
| 215 | + | // never fire; per-turn callbacks let auto- | |||
| 216 | + | // swarm summarise as each task completes. | |||
| 217 | + | if ev.Type == "turn_end" && r.agent != nil { | |||
| 218 | + | r.agent.mu.Lock() | |||
| 219 | + | fn := r.agent.OnTurnEnd | |||
| 220 | + | r.agent.mu.Unlock() | |||
| 221 | + | if fn != nil { | |||
| 222 | + | step, _ := ev.Data["step"].(float64) | |||
| 223 | + | errMsg, _ := ev.Data["error"].(string) | |||
| 224 | + | go fn(int(step), errMsg) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 212 | } else { | 227 | } else { | ||
| 213 | // Non-JSON output. Keep it as transcript so an | 228 | // Non-JSON output. Keep it as transcript so an | ||
| 214 | // accidental fmt.Println in the child still | 229 | // accidental fmt.Println in the child still | ||