detached b16bf8d

Merge pull request #32 from bnema/feat/issue-29-live-subagent-status

feat(agent): expose live subagent status
commit
b16bf8d14b92ed810bef0c94ad72e420c9585058
author
bnema · · 2026-08-05 14:31 UTC
committer
GitHub · · 2026-08-05 14:31 UTC
merge of
a2c78af + fa64ef6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +829 -113
M README.md +2 -2
@@ -395,7 +395,7 @@ Background subagents that run alongside your main session. Each one is a separat
395395
396396 **`/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.
397397
398-**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"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.
398+**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 companion `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial 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"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.
399399
400400 ### Named subagent profiles
401401
@@ -408,7 +408,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
408408 - **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.
409409 - **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.
410410 - **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`.
411-- **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.
411+- **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; 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.
412412 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZOT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zotfile agents.
413413 - **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.
414414 - **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`.
@@ -395,7 +395,7 @@ Background subagents that run alongside your main session. Each one is a separat
395 395
396 **`/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.396 **`/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.
397 397
398-**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"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.398+**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 companion `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial 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"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.
399 399
400 ### Named subagent profiles400 ### Named subagent profiles
401 401
@@ -408,7 +408,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
408 - **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.408 - **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.
409 - **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.409 - **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.
410 - **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`.410 - **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`.
411-- **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.411+- **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; 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.
412 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZOT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zotfile agents.412 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZOT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zotfile agents.
413 - **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.413 - **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.
414 - **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`.414 - **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`.
M docs/subagents.md +2 -0
@@ -103,6 +103,8 @@ subagent://<id>/patch
103103
104104 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.
105105
106+When auto-subagents is enabled, the model also receives the read-only `subagent_status` tool. Call it with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include `subagent_status` when the status tool is needed.
107+
106108 ## Resource policy
107109
108110 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. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. 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.
@@ -103,6 +103,8 @@ subagent://<id>/patch
103 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.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 105
106+When auto-subagents is enabled, the model also receives the read-only `subagent_status` tool. Call it with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include `subagent_status` when the status tool is needed.
107+
106 ## Resource policy108 ## Resource policy
107 109
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. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. 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.110 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. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. 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/build.go +14 -2
@@ -740,7 +740,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
740740 append_ = append(append_, skillAddendum)
741741 }
742742 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
743- if selectedProfile == nil && interactiveMode && autoSubagentsToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
743+ if selectedProfile == nil && interactiveMode && autoSubagentsAnyToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
744744 homeDir, _ := os.UserHomeDir()
745745 profiles, _ := subagents.Discover(args.CWD, homeDir)
746746 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
@@ -1192,6 +1192,18 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
11921192 }
11931193
11941194 func autoSubagentsToolAllowed(args Args) bool {
1195+ return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1196+}
1197+
1198+func autoSubagentsStatusToolAllowed(args Args) bool {
1199+ return autoSubagentsToolAllowedFor(args, "subagent_status")
1200+}
1201+
1202+func autoSubagentsAnyToolAllowed(args Args) bool {
1203+ return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args)
1204+}
1205+
1206+func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
11951207 if args.NoTools || args.PermissionSet != nil {
11961208 return false
11971209 }
@@ -1199,7 +1211,7 @@ func autoSubagentsToolAllowed(args Args) bool {
11991211 return true
12001212 }
12011213 for _, name := range args.Tools {
1202- if name == "subagent_spawn" {
1214+ if name == toolName {
12031215 return true
12041216 }
12051217 }
@@ -740,7 +740,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
740 append_ = append(append_, skillAddendum)740 append_ = append(append_, skillAddendum)
741 }741 }
742 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive742 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
743- if selectedProfile == nil && interactiveMode && autoSubagentsToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {743+ if selectedProfile == nil && interactiveMode && autoSubagentsAnyToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
744 homeDir, _ := os.UserHomeDir()744 homeDir, _ := os.UserHomeDir()
745 profiles, _ := subagents.Discover(args.CWD, homeDir)745 profiles, _ := subagents.Discover(args.CWD, homeDir)
746 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {746 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
@@ -1192,6 +1192,18 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
1192 }1192 }
1193 1193
1194 func autoSubagentsToolAllowed(args Args) bool {1194 func autoSubagentsToolAllowed(args Args) bool {
1195+ return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1196+}
1197+
1198+func autoSubagentsStatusToolAllowed(args Args) bool {
1199+ return autoSubagentsToolAllowedFor(args, "subagent_status")
1200+}
1201+
1202+func autoSubagentsAnyToolAllowed(args Args) bool {
1203+ return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args)
1204+}
1205+
1206+func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1195 if args.NoTools || args.PermissionSet != nil {1207 if args.NoTools || args.PermissionSet != nil {
1196 return false1208 return false
1197 }1209 }
@@ -1199,7 +1211,7 @@ func autoSubagentsToolAllowed(args Args) bool {
1199 return true1211 return true
1200 }1212 }
1201 for _, name := range args.Tools {1213 for _, name := range args.Tools {
1202- if name == "subagent_spawn" {1214+ if name == toolName {
1203 return true1215 return true
1204 }1216 }
1205 }1217 }
M packages/agent/build_test.go +32 -0
@@ -106,6 +106,38 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
106106 }
107107 }
108108
109+func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
110+ for _, tc := range []struct {
111+ name string
112+ tools []string
113+ wantSpawn bool
114+ wantStatus bool
115+ wantAny bool
116+ }{
117+ {name: "default", wantSpawn: true, wantStatus: true, wantAny: true},
118+ {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
119+ {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
120+ {name: "other", tools: []string{"read"}},
121+ {name: "no tools", wantAny: false, tools: nil},
122+ } {
123+ t.Run(tc.name, func(t *testing.T) {
124+ args := Args{Tools: tc.tools}
125+ if tc.name == "no tools" {
126+ args.NoTools = true
127+ }
128+ if got := autoSubagentsToolAllowed(args); got != tc.wantSpawn {
129+ t.Fatalf("spawn allowed = %v, want %v", got, tc.wantSpawn)
130+ }
131+ if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
132+ t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
133+ }
134+ if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
135+ t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
136+ }
137+ })
138+ }
139+}
140+
109141 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
110142 root := t.TempDir()
111143 zotHome := filepath.Join(root, "zot-home")
@@ -106,6 +106,38 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
106 }106 }
107 }107 }
108 108
109+func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
110+ for _, tc := range []struct {
111+ name string
112+ tools []string
113+ wantSpawn bool
114+ wantStatus bool
115+ wantAny bool
116+ }{
117+ {name: "default", wantSpawn: true, wantStatus: true, wantAny: true},
118+ {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
119+ {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
120+ {name: "other", tools: []string{"read"}},
121+ {name: "no tools", wantAny: false, tools: nil},
122+ } {
123+ t.Run(tc.name, func(t *testing.T) {
124+ args := Args{Tools: tc.tools}
125+ if tc.name == "no tools" {
126+ args.NoTools = true
127+ }
128+ if got := autoSubagentsToolAllowed(args); got != tc.wantSpawn {
129+ t.Fatalf("spawn allowed = %v, want %v", got, tc.wantSpawn)
130+ }
131+ if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
132+ t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
133+ }
134+ if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
135+ t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
136+ }
137+ })
138+ }
139+}
140+
109 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {141 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
110 root := t.TempDir()142 root := t.TempDir()
111 zotHome := filepath.Join(root, "zot-home")143 zotHome := filepath.Join(root, "zot-home")
M packages/agent/cli.go +93 -82
@@ -1396,40 +1396,49 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13961396 return findSubagentProfile(r.CWD, name)
13971397 }
13981398
1399- // Inject the subagent_spawn auto-subagents tool only when /settings ->
1400- // auto-subagents is currently enabled. Registering it unconditionally
1401- // leaves the model trying to call it (and getting a polite error)
1402- // even when the user has switched the feature off. The /settings
1403- // toggle live-mutates the running agent's registry separately so
1404- // flipping the flag mid-session takes effect on the next turn.
1405- injectSubagentSpawn := func(reg core.Registry) core.Registry {
1406- if reg == nil || subagentsMgr == nil || !autoSubagentsToolAllowed(args) {
1399+ // Inject the auto-subagent tools only when /settings -> auto-subagents
1400+ // is currently enabled. Registering them unconditionally leaves the model
1401+ // trying to call them (and getting a polite error) even when the user has
1402+ // switched the feature off. The /settings toggle live-mutates the running
1403+ // agent's registry separately so flipping the flag mid-session takes effect
1404+ // on the next turn.
1405+ injectSubagentTools := func(reg core.Registry) core.Registry {
1406+ if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
14071407 return reg
14081408 }
14091409 if !AutoSubagentsEnabled() {
14101410 return reg
14111411 }
1412- canonical := &tools.SubagentSpawnTool{
1413- Supervisor: subagentsMgr,
1414- Enabled: AutoSubagentsEnabled,
1415- DefaultModel: func() string {
1416- persistMu.Lock()
1417- defer persistMu.Unlock()
1418- return activeModel
1419- },
1420- DefaultProvider: func() string {
1421- persistMu.Lock()
1422- defer persistMu.Unlock()
1423- return activeProvider
1424- },
1425- DefaultReasoning: func() string { return r.Reasoning },
1426- ResolveSubagent: resolveSubagent,
1427- OnSpawned: onSpawnedSupervisor,
1428- }
1429- reg[canonical.Name()] = canonical
1412+ if autoSubagentsToolAllowed(args) {
1413+ canonical := &tools.SubagentSpawnTool{
1414+ Supervisor: subagentsMgr,
1415+ Enabled: AutoSubagentsEnabled,
1416+ DefaultModel: func() string {
1417+ persistMu.Lock()
1418+ defer persistMu.Unlock()
1419+ return activeModel
1420+ },
1421+ DefaultProvider: func() string {
1422+ persistMu.Lock()
1423+ defer persistMu.Unlock()
1424+ return activeProvider
1425+ },
1426+ DefaultReasoning: func() string { return r.Reasoning },
1427+ ResolveSubagent: resolveSubagent,
1428+ OnSpawned: onSpawnedSupervisor,
1429+ }
1430+ reg[canonical.Name()] = canonical
1431+ }
1432+ if autoSubagentsStatusToolAllowed(args) {
1433+ statusTool := &tools.SubagentStatusTool{
1434+ Supervisor: subagentsMgr,
1435+ Enabled: AutoSubagentsEnabled,
1436+ }
1437+ reg[statusTool.Name()] = statusTool
1438+ }
14301439 return reg
14311440 }
1432- injectSubagentSpawn(r.ToolRegistry)
1441+ injectSubagentTools(r.ToolRegistry)
14331442
14341443 // Confirmation gate: when --no-yolo is on, the agent must ask
14351444 // the user before every tool call. In interactive mode the TUI
@@ -1517,7 +1526,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15171526 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
15181527 }
15191528 resolved.MergeExtensionTools(extToolAdapter)
1520- injectSubagentSpawn(resolved.ToolRegistry)
1529+ injectSubagentTools(resolved.ToolRegistry)
15211530 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
15221531 }
15231532
@@ -1540,7 +1549,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15401549 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
15411550 }
15421551 resolved.MergeExtensionTools(extToolAdapter)
1543- injectSubagentSpawn(resolved.ToolRegistry)
1552+ injectSubagentTools(resolved.ToolRegistry)
15441553 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
15451554 }
15461555
@@ -1571,7 +1580,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15711580 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
15721581 }
15731582 resolved.MergeExtensionTools(extToolAdapter)
1574- injectSubagentSpawn(resolved.ToolRegistry)
1583+ injectSubagentTools(resolved.ToolRegistry)
15751584 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
15761585 }
15771586
@@ -1590,7 +1599,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15901599 if current == nil {
15911600 return
15921601 }
1593- if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil && iv != nil {
1602+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv); err != nil && iv != nil {
15941603 iv.ReportError(err)
15951604 }
15961605 })
@@ -2099,56 +2108,58 @@ func runInteractive(ctx context.Context, args Args, version string) error {
20992108 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
21002109 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
21012110 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2111+ autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
21022112
21032113 iv = modes.NewInteractive(modes.InteractiveConfig{
2104- Terminal: term,
2105- Theme: theme,
2106- InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2107- TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2108- TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2109- AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2110- PonytailEnabled: initialCfg.PonytailEnabled,
2111- AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2112- FastMode: &fastMode,
2113- LSPEnabled: initialCfg.LSPEnabled,
2114- SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2115- AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2116- JailByDefault: initialCfg.JailByDefault,
2117- QuickModelShortcuts: quickModelShortcuts,
2118- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2119- RespectGitignore: initialCfg.RespectGitignore,
2120- CompactMode: initialCfg.CompactMode,
2121- TUIInputStyle: initialCfg.TUIInputStyle,
2122- TUIStatusPosition: initialCfg.TUIStatusPosition,
2123- TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2124- ThemeName: initialCfg.Theme,
2125- FlatTools: initialCfg.FlatToolRender(),
2126- CompactUser: initialCfg.CompactUserInput(),
2127- ExtensionThemes: extMgr.ThemeOptions,
2128- AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,
2129- SubagentsSystemAddendum: subagentsAddendum,
2130- SettingsStore: configSettingsStore{},
2131- Model: r.Model,
2132- Provider: r.Provider,
2133- AuthMethod: r.AuthMethod,
2134- BaseURL: r.BaseURL,
2135- Reasoning: r.Reasoning,
2136- SystemPrompt: r.SystemPrompt,
2137- Tools: r.ToolRegistry,
2138- MaxSteps: r.MaxSteps,
2139- CWD: r.CWD,
2140- StartupAgentName: args.AgentName,
2141- StartupContextPaths: instructionContextPaths(r.ContextFiles),
2142- StartupExtensionNames: startupExtensionNames(extMgr.All()),
2143- StartupExtensionErrors: startupExtensionErrors,
2144- StartupSkillNames: startupSkillNames(startupSkills),
2145- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2146- ZotHome: ZotHome(),
2147- SessionsRoot: agentSessionsRoot(ZotHome(), args),
2148- Version: version,
2149- UpdateInfoChan: updateCh,
2150- Sandbox: sharedSandbox,
2151- Agent: ag,
2114+ Terminal: term,
2115+ Theme: theme,
2116+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2117+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2118+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2119+ AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2120+ PonytailEnabled: initialCfg.PonytailEnabled,
2121+ AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2122+ AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2123+ FastMode: &fastMode,
2124+ LSPEnabled: initialCfg.LSPEnabled,
2125+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2126+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2127+ JailByDefault: initialCfg.JailByDefault,
2128+ QuickModelShortcuts: quickModelShortcuts,
2129+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2130+ RespectGitignore: initialCfg.RespectGitignore,
2131+ CompactMode: initialCfg.CompactMode,
2132+ TUIInputStyle: initialCfg.TUIInputStyle,
2133+ TUIStatusPosition: initialCfg.TUIStatusPosition,
2134+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2135+ ThemeName: initialCfg.Theme,
2136+ FlatTools: initialCfg.FlatToolRender(),
2137+ CompactUser: initialCfg.CompactUserInput(),
2138+ ExtensionThemes: extMgr.ThemeOptions,
2139+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,
2140+ SubagentsSystemAddendum: subagentsAddendum,
2141+ SettingsStore: configSettingsStore{},
2142+ Model: r.Model,
2143+ Provider: r.Provider,
2144+ AuthMethod: r.AuthMethod,
2145+ BaseURL: r.BaseURL,
2146+ Reasoning: r.Reasoning,
2147+ SystemPrompt: r.SystemPrompt,
2148+ Tools: r.ToolRegistry,
2149+ MaxSteps: r.MaxSteps,
2150+ CWD: r.CWD,
2151+ StartupAgentName: args.AgentName,
2152+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2153+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2154+ StartupExtensionErrors: startupExtensionErrors,
2155+ StartupSkillNames: startupSkillNames(startupSkills),
2156+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2157+ ZotHome: ZotHome(),
2158+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
2159+ Version: version,
2160+ UpdateInfoChan: updateCh,
2161+ Sandbox: sharedSandbox,
2162+ Agent: ag,
21522163 InitialSessionTitle: func() string {
21532164 if sess == nil || sessionTitlePending {
21542165 return ""
@@ -2166,18 +2177,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21662177 _ = extMgr.Reload(context.Background(), 2*time.Second)
21672178 }
21682179 if current != nil {
2169- if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil {
2180+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv); err != nil {
21702181 iv.ReportError(err)
21712182 }
21722183 }
21732184 },
21742185 RefreshTools: func() error {
21752186 current := liveInteractiveAgent(iv, ag)
2176- return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)
2187+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv)
21772188 },
21782189 RefreshPrompt: func() error {
21792190 current := liveInteractiveAgent(iv, ag)
2180- return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)
2191+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv)
21812192 },
21822193 AuthManager: mgr,
21832194 LlamaCPPConfig: ResolveLlamaCPPConfig,
@@ -1396,40 +1396,49 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1396 return findSubagentProfile(r.CWD, name)1396 return findSubagentProfile(r.CWD, name)
1397 }1397 }
1398 1398
1399- // Inject the subagent_spawn auto-subagents tool only when /settings ->1399+ // Inject the auto-subagent tools only when /settings -> auto-subagents
1400- // auto-subagents is currently enabled. Registering it unconditionally1400+ // is currently enabled. Registering them unconditionally leaves the model
1401- // leaves the model trying to call it (and getting a polite error)1401+ // trying to call them (and getting a polite error) even when the user has
1402- // even when the user has switched the feature off. The /settings1402+ // switched the feature off. The /settings toggle live-mutates the running
1403- // toggle live-mutates the running agent's registry separately so1403+ // agent's registry separately so flipping the flag mid-session takes effect
1404- // flipping the flag mid-session takes effect on the next turn.1404+ // on the next turn.
1405- injectSubagentSpawn := func(reg core.Registry) core.Registry {1405+ injectSubagentTools := func(reg core.Registry) core.Registry {
1406- if reg == nil || subagentsMgr == nil || !autoSubagentsToolAllowed(args) {1406+ if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1407 return reg1407 return reg
1408 }1408 }
1409 if !AutoSubagentsEnabled() {1409 if !AutoSubagentsEnabled() {
1410 return reg1410 return reg
1411 }1411 }
1412- canonical := &tools.SubagentSpawnTool{1412+ if autoSubagentsToolAllowed(args) {
1413- Supervisor: subagentsMgr,1413+ canonical := &tools.SubagentSpawnTool{
1414- Enabled: AutoSubagentsEnabled,1414+ Supervisor: subagentsMgr,
1415- DefaultModel: func() string {1415+ Enabled: AutoSubagentsEnabled,
1416- persistMu.Lock()1416+ DefaultModel: func() string {
1417- defer persistMu.Unlock()1417+ persistMu.Lock()
1418- return activeModel1418+ defer persistMu.Unlock()
1419- },1419+ return activeModel
1420- DefaultProvider: func() string {1420+ },
1421- persistMu.Lock()1421+ DefaultProvider: func() string {
1422- defer persistMu.Unlock()1422+ persistMu.Lock()
1423- return activeProvider1423+ defer persistMu.Unlock()
1424- },1424+ return activeProvider
1425- DefaultReasoning: func() string { return r.Reasoning },1425+ },
1426- ResolveSubagent: resolveSubagent,1426+ DefaultReasoning: func() string { return r.Reasoning },
1427- OnSpawned: onSpawnedSupervisor,1427+ ResolveSubagent: resolveSubagent,
1428- }1428+ OnSpawned: onSpawnedSupervisor,
1429- reg[canonical.Name()] = canonical1429+ }
1430+ reg[canonical.Name()] = canonical
1431+ }
1432+ if autoSubagentsStatusToolAllowed(args) {
1433+ statusTool := &tools.SubagentStatusTool{
1434+ Supervisor: subagentsMgr,
1435+ Enabled: AutoSubagentsEnabled,
1436+ }
1437+ reg[statusTool.Name()] = statusTool
1438+ }
1430 return reg1439 return reg
1431 }1440 }
1432- injectSubagentSpawn(r.ToolRegistry)1441+ injectSubagentTools(r.ToolRegistry)
1433 1442
1434 // Confirmation gate: when --no-yolo is on, the agent must ask1443 // Confirmation gate: when --no-yolo is on, the agent must ask
1435 // the user before every tool call. In interactive mode the TUI1444 // the user before every tool call. In interactive mode the TUI
@@ -1517,7 +1526,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1517 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1526 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1518 }1527 }
1519 resolved.MergeExtensionTools(extToolAdapter)1528 resolved.MergeExtensionTools(extToolAdapter)
1520- injectSubagentSpawn(resolved.ToolRegistry)1529+ injectSubagentTools(resolved.ToolRegistry)
1521 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1530 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1522 }1531 }
1523 1532
@@ -1540,7 +1549,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1540 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1549 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1541 }1550 }
1542 resolved.MergeExtensionTools(extToolAdapter)1551 resolved.MergeExtensionTools(extToolAdapter)
1543- injectSubagentSpawn(resolved.ToolRegistry)1552+ injectSubagentTools(resolved.ToolRegistry)
1544 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1553 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1545 }1554 }
1546 1555
@@ -1571,7 +1580,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1571 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1580 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1572 }1581 }
1573 resolved.MergeExtensionTools(extToolAdapter)1582 resolved.MergeExtensionTools(extToolAdapter)
1574- injectSubagentSpawn(resolved.ToolRegistry)1583+ injectSubagentTools(resolved.ToolRegistry)
1575 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1584 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1576 }1585 }
1577 1586
@@ -1590,7 +1599,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1590 if current == nil {1599 if current == nil {
1591 return1600 return
1592 }1601 }
1593- if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil && iv != nil {1602+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv); err != nil && iv != nil {
1594 iv.ReportError(err)1603 iv.ReportError(err)
1595 }1604 }
1596 })1605 })
@@ -2099,56 +2108,58 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2099 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)2108 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2100 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)2109 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2101 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)2110 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2111+ autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2102 2112
2103 iv = modes.NewInteractive(modes.InteractiveConfig{2113 iv = modes.NewInteractive(modes.InteractiveConfig{
2104- Terminal: term,2114+ Terminal: term,
2105- Theme: theme,2115+ Theme: theme,
2106- InlineImagesEnabled: initialCfg.InlineImagesEnabled,2116+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2107- TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,2117+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2108- TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,2118+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2109- AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,2119+ AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2110- PonytailEnabled: initialCfg.PonytailEnabled,2120+ PonytailEnabled: initialCfg.PonytailEnabled,
2111- AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,2121+ AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2112- FastMode: &fastMode,2122+ AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2113- LSPEnabled: initialCfg.LSPEnabled,2123+ FastMode: &fastMode,
2114- SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,2124+ LSPEnabled: initialCfg.LSPEnabled,
2115- AutoCompactThreshold: initialCfg.AutoCompactThreshold,2125+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2116- JailByDefault: initialCfg.JailByDefault,2126+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2117- QuickModelShortcuts: quickModelShortcuts,2127+ JailByDefault: initialCfg.JailByDefault,
2118- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,2128+ QuickModelShortcuts: quickModelShortcuts,
2119- RespectGitignore: initialCfg.RespectGitignore,2129+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2120- CompactMode: initialCfg.CompactMode,2130+ RespectGitignore: initialCfg.RespectGitignore,
2121- TUIInputStyle: initialCfg.TUIInputStyle,2131+ CompactMode: initialCfg.CompactMode,
2122- TUIStatusPosition: initialCfg.TUIStatusPosition,2132+ TUIInputStyle: initialCfg.TUIInputStyle,
2123- TUIWorkingPosition: initialCfg.TUIWorkingPosition,2133+ TUIStatusPosition: initialCfg.TUIStatusPosition,
2124- ThemeName: initialCfg.Theme,2134+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2125- FlatTools: initialCfg.FlatToolRender(),2135+ ThemeName: initialCfg.Theme,
2126- CompactUser: initialCfg.CompactUserInput(),2136+ FlatTools: initialCfg.FlatToolRender(),
2127- ExtensionThemes: extMgr.ThemeOptions,2137+ CompactUser: initialCfg.CompactUserInput(),
2128- AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,2138+ ExtensionThemes: extMgr.ThemeOptions,
2129- SubagentsSystemAddendum: subagentsAddendum,2139+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,
2130- SettingsStore: configSettingsStore{},2140+ SubagentsSystemAddendum: subagentsAddendum,
2131- Model: r.Model,2141+ SettingsStore: configSettingsStore{},
2132- Provider: r.Provider,2142+ Model: r.Model,
2133- AuthMethod: r.AuthMethod,2143+ Provider: r.Provider,
2134- BaseURL: r.BaseURL,2144+ AuthMethod: r.AuthMethod,
2135- Reasoning: r.Reasoning,2145+ BaseURL: r.BaseURL,
2136- SystemPrompt: r.SystemPrompt,2146+ Reasoning: r.Reasoning,
2137- Tools: r.ToolRegistry,2147+ SystemPrompt: r.SystemPrompt,
2138- MaxSteps: r.MaxSteps,2148+ Tools: r.ToolRegistry,
2139- CWD: r.CWD,2149+ MaxSteps: r.MaxSteps,
2140- StartupAgentName: args.AgentName,2150+ CWD: r.CWD,
2141- StartupContextPaths: instructionContextPaths(r.ContextFiles),2151+ StartupAgentName: args.AgentName,
2142- StartupExtensionNames: startupExtensionNames(extMgr.All()),2152+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2143- StartupExtensionErrors: startupExtensionErrors,2153+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2144- StartupSkillNames: startupSkillNames(startupSkills),2154+ StartupExtensionErrors: startupExtensionErrors,
2145- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,2155+ StartupSkillNames: startupSkillNames(startupSkills),
2146- ZotHome: ZotHome(),2156+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2147- SessionsRoot: agentSessionsRoot(ZotHome(), args),2157+ ZotHome: ZotHome(),
2148- Version: version,2158+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
2149- UpdateInfoChan: updateCh,2159+ Version: version,
2150- Sandbox: sharedSandbox,2160+ UpdateInfoChan: updateCh,
2151- Agent: ag,2161+ Sandbox: sharedSandbox,
2162+ Agent: ag,
2152 InitialSessionTitle: func() string {2163 InitialSessionTitle: func() string {
2153 if sess == nil || sessionTitlePending {2164 if sess == nil || sessionTitlePending {
2154 return ""2165 return ""
@@ -2166,18 +2177,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2166 _ = extMgr.Reload(context.Background(), 2*time.Second)2177 _ = extMgr.Reload(context.Background(), 2*time.Second)
2167 }2178 }
2168 if current != nil {2179 if current != nil {
2169- if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil {2180+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv); err != nil {
2170 iv.ReportError(err)2181 iv.ReportError(err)
2171 }2182 }
2172 }2183 }
2173 },2184 },
2174 RefreshTools: func() error {2185 RefreshTools: func() error {
2175 current := liveInteractiveAgent(iv, ag)2186 current := liveInteractiveAgent(iv, ag)
2176- return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)2187+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv)
2177 },2188 },
2178 RefreshPrompt: func() error {2189 RefreshPrompt: func() error {
2179 current := liveInteractiveAgent(iv, ag)2190 current := liveInteractiveAgent(iv, ag)
2180- return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)2191+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentTools, iv)
2181 },2192 },
2182 AuthManager: mgr,2193 AuthManager: mgr,
2183 LlamaCPPConfig: ResolveLlamaCPPConfig,2194 LlamaCPPConfig: ResolveLlamaCPPConfig,
M packages/agent/config.go +1 -1
@@ -91,7 +91,7 @@ type Config struct {
9191 Subagents SubagentsConfig `json:"subagents,omitempty"`
9292
9393 // AutoSubagentsEnabled lets the main agent spawn background sub-agents
94- // for parallel sub-tasks via a built-in subagent_spawn tool. Off by
94+ // and query their live state via built-in auto-subagent tools. Off by
9595 // default; nil/missing means disabled. Toggle from /settings.
9696 AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`
9797
@@ -91,7 +91,7 @@ type Config struct {
91 Subagents SubagentsConfig `json:"subagents,omitempty"`91 Subagents SubagentsConfig `json:"subagents,omitempty"`
92 92
93 // AutoSubagentsEnabled lets the main agent spawn background sub-agents93 // AutoSubagentsEnabled lets the main agent spawn background sub-agents
94- // for parallel sub-tasks via a built-in subagent_spawn tool. Off by94+ // and query their live state via built-in auto-subagent tools. Off by
95 // default; nil/missing means disabled. Toggle from /settings.95 // default; nil/missing means disabled. Toggle from /settings.
96 AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`96 AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`
97 97
M packages/agent/modes/auto_subagents_policy_test.go +42 -0
@@ -87,6 +87,9 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
8787 if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
8888 t.Fatal("subagent_spawn registered despite launch-time policy")
8989 }
90+ if _, ok := iv.agent.Tools["subagent_status"]; ok {
91+ t.Fatal("subagent_status registered despite launch-time policy")
92+ }
9093 iv.applySettingToggle("auto_subagents_enabled", true)
9194 iv.mu.Lock()
9295 statusErr := iv.statusErr
@@ -100,6 +103,45 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
100103 }
101104 }
102105
106+func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) {
107+ for _, tc := range []struct {
108+ name string
109+ spawnAllowed bool
110+ statusAllowed bool
111+ wantSpawn bool
112+ wantStatus bool
113+ }{
114+ {name: "status only", statusAllowed: true, wantStatus: true},
115+ {name: "spawn only", spawnAllowed: true, wantSpawn: true},
116+ {name: "both", spawnAllowed: true, statusAllowed: true, wantSpawn: true, wantStatus: true},
117+ } {
118+ t.Run(tc.name, func(t *testing.T) {
119+ enabled := true
120+ spawnAllowed := tc.spawnAllowed
121+ statusAllowed := tc.statusAllowed
122+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
123+ t.Cleanup(supervisor.StopAll)
124+ iv := &Interactive{
125+ agent: &core.Agent{Tools: core.Registry{}},
126+ cfg: InteractiveConfig{
127+ AutoSubagentsEnabled: &enabled,
128+ AutoSubagentsToolAllowed: &spawnAllowed,
129+ AutoSubagentsStatusToolAllowed: &statusAllowed,
130+ Supervisor: supervisor,
131+ },
132+ dirty: make(chan struct{}, 1),
133+ }
134+
135+ iv.applyAutoSubagentsTool(true)
136+ _, gotSpawn := iv.agent.Tools["subagent_spawn"]
137+ _, gotStatus := iv.agent.Tools["subagent_status"]
138+ if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus {
139+ t.Fatalf("registered tools spawn=%v status=%v, want spawn=%v status=%v", gotSpawn, gotStatus, tc.wantSpawn, tc.wantStatus)
140+ }
141+ })
142+ }
143+}
144+
103145 type serializedAutoSubagentsClient struct {
104146 started chan struct{}
105147 request chan provider.Request
@@ -87,6 +87,9 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
87 if _, ok := iv.agent.Tools["subagent_spawn"]; ok {87 if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
88 t.Fatal("subagent_spawn registered despite launch-time policy")88 t.Fatal("subagent_spawn registered despite launch-time policy")
89 }89 }
90+ if _, ok := iv.agent.Tools["subagent_status"]; ok {
91+ t.Fatal("subagent_status registered despite launch-time policy")
92+ }
90 iv.applySettingToggle("auto_subagents_enabled", true)93 iv.applySettingToggle("auto_subagents_enabled", true)
91 iv.mu.Lock()94 iv.mu.Lock()
92 statusErr := iv.statusErr95 statusErr := iv.statusErr
@@ -100,6 +103,45 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
100 }103 }
101 }104 }
102 105
106+func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) {
107+ for _, tc := range []struct {
108+ name string
109+ spawnAllowed bool
110+ statusAllowed bool
111+ wantSpawn bool
112+ wantStatus bool
113+ }{
114+ {name: "status only", statusAllowed: true, wantStatus: true},
115+ {name: "spawn only", spawnAllowed: true, wantSpawn: true},
116+ {name: "both", spawnAllowed: true, statusAllowed: true, wantSpawn: true, wantStatus: true},
117+ } {
118+ t.Run(tc.name, func(t *testing.T) {
119+ enabled := true
120+ spawnAllowed := tc.spawnAllowed
121+ statusAllowed := tc.statusAllowed
122+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
123+ t.Cleanup(supervisor.StopAll)
124+ iv := &Interactive{
125+ agent: &core.Agent{Tools: core.Registry{}},
126+ cfg: InteractiveConfig{
127+ AutoSubagentsEnabled: &enabled,
128+ AutoSubagentsToolAllowed: &spawnAllowed,
129+ AutoSubagentsStatusToolAllowed: &statusAllowed,
130+ Supervisor: supervisor,
131+ },
132+ dirty: make(chan struct{}, 1),
133+ }
134+
135+ iv.applyAutoSubagentsTool(true)
136+ _, gotSpawn := iv.agent.Tools["subagent_spawn"]
137+ _, gotStatus := iv.agent.Tools["subagent_status"]
138+ if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus {
139+ t.Fatalf("registered tools spawn=%v status=%v, want spawn=%v status=%v", gotSpawn, gotStatus, tc.wantSpawn, tc.wantStatus)
140+ }
141+ })
142+ }
143+}
144+
103 type serializedAutoSubagentsClient struct {145 type serializedAutoSubagentsClient struct {
104 started chan struct{}146 started chan struct{}
105 request chan provider.Request147 request chan provider.Request
M packages/agent/modes/interactive.go +57 -23
@@ -77,9 +77,13 @@ type InteractiveConfig struct {
7777 PonytailEnabled *bool
7878
7979 // AutoSubagentsToolAllowed is nil for normal sessions and false when the
80- // launch-time --no-tools/--tools policy excludes delegation.
80+ // launch-time --no-tools/--tools policy excludes subagent spawning.
8181 AutoSubagentsToolAllowed *bool
8282
83+ // AutoSubagentsStatusToolAllowed is nil for normal sessions and false when
84+ // the launch-time --no-tools/--tools policy excludes status queries.
85+ AutoSubagentsStatusToolAllowed *bool
86+
8387 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
8488 // nil/missing means disabled. Unsupported providers reject attempts
8589 // to enable it and reject requests when it remains enabled.
@@ -807,7 +811,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
807811 baseURL, _, err := cfg.LlamaCPPConfig()
808812 i.llamaConfigured = err == nil && baseURL != ""
809813 }
810- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && (cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed) {
814+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
811815 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
812816 }
813817 if cfg.Agent != nil {
@@ -4147,7 +4151,7 @@ func (i *Interactive) openSettingsDialog() {
41474151 {
41484152 key: "auto_subagents_enabled",
41494153 label: "auto-subagents",
4150- desc: "let the agent spawn background sub-agents in parallel via the subagent_spawn tool",
4154+ desc: "let the agent spawn background sub-agents and query their live state",
41514155 value: autoSubagents,
41524156 disabled: autoSubagentsDisabled,
41534157 hint: autoSubagentsHint,
@@ -4643,13 +4647,13 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
46434647 return
46444648 }
46454649 }
4646- // Add/remove the subagent_spawn tool on the live agent so the
4650+ // Add/remove the auto-subagent tools on the live agent so the
46474651 // model's tools[] list reflects the toggle on the next turn.
4648- // Without this the tool stays advertised after a disable and
4649- // the model keeps trying to call it.
4652+ // Without this the tools stay advertised after a disable and
4653+ // the model keeps trying to call them.
46504654 i.applyAutoSubagentsTool(value)
46514655 // Also swap the system-prompt addendum in/out so the model
4652- // knows to use the tool proactively (or stops referencing it
4656+ // knows to use the tools proactively (or stops referencing them
46534657 // after a disable).
46544658 i.applyAutoSubagentsSystemPrompt(value)
46554659 i.mu.Lock()
@@ -7704,12 +7708,12 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
77047708 }
77057709
77067710 // applyAutoSubagentsTool registers (active=true) or removes (active=false)
7707-// the subagent_spawn tool on the running agent so the model only sees it
7711+// the auto-subagent tools on the running agent so the model only sees them
77087712 // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
77097713 // snapshot+mutate pattern so extension tools and /reload-ext additions
77107714 // survive a toggle.
77117715 func (i *Interactive) autoSubagentsAvailable() bool {
7712- return i.cfg.Supervisor != nil && (i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed)
7716+ return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())
77137717 }
77147718
77157719 func (i *Interactive) autoSubagentsUnavailableHint() string {
@@ -7717,14 +7721,35 @@ func (i *Interactive) autoSubagentsUnavailableHint() string {
77177721 if i.cfg.Supervisor == nil {
77187722 hints = append(hints, "subagent supervisor not available in this mode")
77197723 }
7720- if !i.autoSubagentsToolAllowed() {
7721- hints = append(hints, "launch-time tool policy excludes subagent_spawn")
7724+ if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {
7725+ hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")
77227726 }
77237727 return strings.Join(hints, "; ")
77247728 }
77257729
7730+func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
7731+ return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
7732+}
7733+
7734+func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
7735+ if cfg.AutoSubagentsStatusToolAllowed != nil {
7736+ return *cfg.AutoSubagentsStatusToolAllowed
7737+ }
7738+ // Preserve the old single-boolean embedding contract: when callers have
7739+ // not supplied the new field, status follows the existing delegation gate.
7740+ return autoSubagentsToolAllowedConfig(cfg)
7741+}
7742+
7743+func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
7744+ return autoSubagentsToolAllowedConfig(cfg) || autoSubagentsStatusToolAllowedConfig(cfg)
7745+}
7746+
77267747 func (i *Interactive) autoSubagentsToolAllowed() bool {
7727- return i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed
7748+ return autoSubagentsToolAllowedConfig(i.cfg)
7749+}
7750+
7751+func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
7752+ return autoSubagentsStatusToolAllowedConfig(i.cfg)
77287753 }
77297754
77307755 func (i *Interactive) autoSubagentsEnabled() bool {
@@ -7748,22 +7773,31 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
77487773 current := i.agent.ToolsSnapshot()
77497774 next := core.Registry{}
77507775 for name, t := range current {
7751- if name == "subagent_spawn" {
7776+ if name == "subagent_spawn" || name == "subagent_status" {
77527777 continue
77537778 }
77547779 next[name] = t
77557780 }
77567781 if active && i.autoSubagentsAvailable() {
7757- canonical := &tools.SubagentSpawnTool{
7758- Supervisor: i.cfg.Supervisor,
7759- Enabled: func() bool { return i.autoSubagentsEnabled() },
7760- DefaultModel: func() string { return i.cfg.Model },
7761- DefaultProvider: func() string { return i.cfg.Provider },
7762- DefaultReasoning: func() string { return i.cfg.Reasoning },
7763- ResolveSubagent: i.cfg.ResolveSubagent,
7764- OnSpawned: i.trackSubagentWorker,
7765- }
7766- next[canonical.Name()] = canonical
7782+ if i.autoSubagentsToolAllowed() {
7783+ canonical := &tools.SubagentSpawnTool{
7784+ Supervisor: i.cfg.Supervisor,
7785+ Enabled: func() bool { return i.autoSubagentsEnabled() },
7786+ DefaultModel: func() string { return i.cfg.Model },
7787+ DefaultProvider: func() string { return i.cfg.Provider },
7788+ DefaultReasoning: func() string { return i.cfg.Reasoning },
7789+ ResolveSubagent: i.cfg.ResolveSubagent,
7790+ OnSpawned: i.trackSubagentWorker,
7791+ }
7792+ next[canonical.Name()] = canonical
7793+ }
7794+ if i.autoSubagentsStatusToolAllowed() {
7795+ statusTool := &tools.SubagentStatusTool{
7796+ Supervisor: i.cfg.Supervisor,
7797+ Enabled: func() bool { return i.autoSubagentsEnabled() },
7798+ }
7799+ next[statusTool.Name()] = statusTool
7800+ }
77677801 }
77687802 i.agent.SetTools(next)
77697803 }
@@ -77,9 +77,13 @@ type InteractiveConfig struct {
77 PonytailEnabled *bool77 PonytailEnabled *bool
78 78
79 // AutoSubagentsToolAllowed is nil for normal sessions and false when the79 // AutoSubagentsToolAllowed is nil for normal sessions and false when the
80- // launch-time --no-tools/--tools policy excludes delegation.80+ // launch-time --no-tools/--tools policy excludes subagent spawning.
81 AutoSubagentsToolAllowed *bool81 AutoSubagentsToolAllowed *bool
82 82
83+ // AutoSubagentsStatusToolAllowed is nil for normal sessions and false when
84+ // the launch-time --no-tools/--tools policy excludes status queries.
85+ AutoSubagentsStatusToolAllowed *bool
86+
83 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.87 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
84 // nil/missing means disabled. Unsupported providers reject attempts88 // nil/missing means disabled. Unsupported providers reject attempts
85 // to enable it and reject requests when it remains enabled.89 // to enable it and reject requests when it remains enabled.
@@ -807,7 +811,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
807 baseURL, _, err := cfg.LlamaCPPConfig()811 baseURL, _, err := cfg.LlamaCPPConfig()
808 i.llamaConfigured = err == nil && baseURL != ""812 i.llamaConfigured = err == nil && baseURL != ""
809 }813 }
810- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && (cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed) {814+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
811 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)815 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
812 }816 }
813 if cfg.Agent != nil {817 if cfg.Agent != nil {
@@ -4147,7 +4151,7 @@ func (i *Interactive) openSettingsDialog() {
4147 {4151 {
4148 key: "auto_subagents_enabled",4152 key: "auto_subagents_enabled",
4149 label: "auto-subagents",4153 label: "auto-subagents",
4150- desc: "let the agent spawn background sub-agents in parallel via the subagent_spawn tool",4154+ desc: "let the agent spawn background sub-agents and query their live state",
4151 value: autoSubagents,4155 value: autoSubagents,
4152 disabled: autoSubagentsDisabled,4156 disabled: autoSubagentsDisabled,
4153 hint: autoSubagentsHint,4157 hint: autoSubagentsHint,
@@ -4643,13 +4647,13 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4643 return4647 return
4644 }4648 }
4645 }4649 }
4646- // Add/remove the subagent_spawn tool on the live agent so the4650+ // Add/remove the auto-subagent tools on the live agent so the
4647 // model's tools[] list reflects the toggle on the next turn.4651 // model's tools[] list reflects the toggle on the next turn.
4648- // Without this the tool stays advertised after a disable and4652+ // Without this the tools stay advertised after a disable and
4649- // the model keeps trying to call it.4653+ // the model keeps trying to call them.
4650 i.applyAutoSubagentsTool(value)4654 i.applyAutoSubagentsTool(value)
4651 // Also swap the system-prompt addendum in/out so the model4655 // Also swap the system-prompt addendum in/out so the model
4652- // knows to use the tool proactively (or stops referencing it4656+ // knows to use the tools proactively (or stops referencing them
4653 // after a disable).4657 // after a disable).
4654 i.applyAutoSubagentsSystemPrompt(value)4658 i.applyAutoSubagentsSystemPrompt(value)
4655 i.mu.Lock()4659 i.mu.Lock()
@@ -7704,12 +7708,12 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7704 }7708 }
7705 7709
7706 // applyAutoSubagentsTool registers (active=true) or removes (active=false)7710 // applyAutoSubagentsTool registers (active=true) or removes (active=false)
7707-// the subagent_spawn tool on the running agent so the model only sees it7711+// the auto-subagent tools on the running agent so the model only sees them
7708 // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'7712 // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
7709 // snapshot+mutate pattern so extension tools and /reload-ext additions7713 // snapshot+mutate pattern so extension tools and /reload-ext additions
7710 // survive a toggle.7714 // survive a toggle.
7711 func (i *Interactive) autoSubagentsAvailable() bool {7715 func (i *Interactive) autoSubagentsAvailable() bool {
7712- return i.cfg.Supervisor != nil && (i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed)7716+ return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())
7713 }7717 }
7714 7718
7715 func (i *Interactive) autoSubagentsUnavailableHint() string {7719 func (i *Interactive) autoSubagentsUnavailableHint() string {
@@ -7717,14 +7721,35 @@ func (i *Interactive) autoSubagentsUnavailableHint() string {
7717 if i.cfg.Supervisor == nil {7721 if i.cfg.Supervisor == nil {
7718 hints = append(hints, "subagent supervisor not available in this mode")7722 hints = append(hints, "subagent supervisor not available in this mode")
7719 }7723 }
7720- if !i.autoSubagentsToolAllowed() {7724+ if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {
7721- hints = append(hints, "launch-time tool policy excludes subagent_spawn")7725+ hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")
7722 }7726 }
7723 return strings.Join(hints, "; ")7727 return strings.Join(hints, "; ")
7724 }7728 }
7725 7729
7730+func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
7731+ return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
7732+}
7733+
7734+func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
7735+ if cfg.AutoSubagentsStatusToolAllowed != nil {
7736+ return *cfg.AutoSubagentsStatusToolAllowed
7737+ }
7738+ // Preserve the old single-boolean embedding contract: when callers have
7739+ // not supplied the new field, status follows the existing delegation gate.
7740+ return autoSubagentsToolAllowedConfig(cfg)
7741+}
7742+
7743+func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
7744+ return autoSubagentsToolAllowedConfig(cfg) || autoSubagentsStatusToolAllowedConfig(cfg)
7745+}
7746+
7726 func (i *Interactive) autoSubagentsToolAllowed() bool {7747 func (i *Interactive) autoSubagentsToolAllowed() bool {
7727- return i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed7748+ return autoSubagentsToolAllowedConfig(i.cfg)
7749+}
7750+
7751+func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
7752+ return autoSubagentsStatusToolAllowedConfig(i.cfg)
7728 }7753 }
7729 7754
7730 func (i *Interactive) autoSubagentsEnabled() bool {7755 func (i *Interactive) autoSubagentsEnabled() bool {
@@ -7748,22 +7773,31 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
7748 current := i.agent.ToolsSnapshot()7773 current := i.agent.ToolsSnapshot()
7749 next := core.Registry{}7774 next := core.Registry{}
7750 for name, t := range current {7775 for name, t := range current {
7751- if name == "subagent_spawn" {7776+ if name == "subagent_spawn" || name == "subagent_status" {
7752 continue7777 continue
7753 }7778 }
7754 next[name] = t7779 next[name] = t
7755 }7780 }
7756 if active && i.autoSubagentsAvailable() {7781 if active && i.autoSubagentsAvailable() {
7757- canonical := &tools.SubagentSpawnTool{7782+ if i.autoSubagentsToolAllowed() {
7758- Supervisor: i.cfg.Supervisor,7783+ canonical := &tools.SubagentSpawnTool{
7759- Enabled: func() bool { return i.autoSubagentsEnabled() },7784+ Supervisor: i.cfg.Supervisor,
7760- DefaultModel: func() string { return i.cfg.Model },7785+ Enabled: func() bool { return i.autoSubagentsEnabled() },
7761- DefaultProvider: func() string { return i.cfg.Provider },7786+ DefaultModel: func() string { return i.cfg.Model },
7762- DefaultReasoning: func() string { return i.cfg.Reasoning },7787+ DefaultProvider: func() string { return i.cfg.Provider },
7763- ResolveSubagent: i.cfg.ResolveSubagent,7788+ DefaultReasoning: func() string { return i.cfg.Reasoning },
7764- OnSpawned: i.trackSubagentWorker,7789+ ResolveSubagent: i.cfg.ResolveSubagent,
7765- }7790+ OnSpawned: i.trackSubagentWorker,
7766- next[canonical.Name()] = canonical7791+ }
7792+ next[canonical.Name()] = canonical
7793+ }
7794+ if i.autoSubagentsStatusToolAllowed() {
7795+ statusTool := &tools.SubagentStatusTool{
7796+ Supervisor: i.cfg.Supervisor,
7797+ Enabled: func() bool { return i.autoSubagentsEnabled() },
7798+ }
7799+ next[statusTool.Name()] = statusTool
7800+ }
7767 }7801 }
7768 i.agent.SetTools(next)7802 i.agent.SetTools(next)
7769 }7803 }
M packages/agent/settings_store.go +3 -3
@@ -228,8 +228,8 @@ func (configSettingsStore) SetTheme(name string) error {
228228
229229 // AutoSubagentsEnabled reads the current auto-subagents flag from config.
230230 // The build/CLI startup path uses it when initially injecting
231-// subagent_spawn into the tool registry; interactive mode mirrors the setting
232-// into the live tool registry when its settings toggle changes.
231+// the auto-subagent tools into the tool registry; interactive mode mirrors
232+// the setting into the live tool registry when its settings toggle changes.
233233 func AutoSubagentsEnabled() bool {
234234 cfg, err := LoadConfig()
235235 if err != nil {
@@ -242,7 +242,7 @@ func AutoSubagentsEnabled() bool {
242242 // auto-subagents is enabled, so the model knows it may delegate to
243243 // background sub-agents without the user having to mention the tool
244244 // by name. Kept short so it doesn't bloat the cached prompt prefix.
245-const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You have a subagent_spawn tool that starts background sub-agents in separate long-lived processes.
245+const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. When available, subagent_spawn starts background sub-agents in separate long-lived processes, and subagent_status reports their live state without waiting for completion.
246246
247247 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.
248248
@@ -228,8 +228,8 @@ func (configSettingsStore) SetTheme(name string) error {
228 228
229 // AutoSubagentsEnabled reads the current auto-subagents flag from config.229 // AutoSubagentsEnabled reads the current auto-subagents flag from config.
230 // The build/CLI startup path uses it when initially injecting230 // The build/CLI startup path uses it when initially injecting
231-// subagent_spawn into the tool registry; interactive mode mirrors the setting231+// the auto-subagent tools into the tool registry; interactive mode mirrors
232-// into the live tool registry when its settings toggle changes.232+// the setting into the live tool registry when its settings toggle changes.
233 func AutoSubagentsEnabled() bool {233 func AutoSubagentsEnabled() bool {
234 cfg, err := LoadConfig()234 cfg, err := LoadConfig()
235 if err != nil {235 if err != nil {
@@ -242,7 +242,7 @@ func AutoSubagentsEnabled() bool {
242 // auto-subagents is enabled, so the model knows it may delegate to242 // auto-subagents is enabled, so the model knows it may delegate to
243 // background sub-agents without the user having to mention the tool243 // background sub-agents without the user having to mention the tool
244 // by name. Kept short so it doesn't bloat the cached prompt prefix.244 // by name. Kept short so it doesn't bloat the cached prompt prefix.
245-const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You have a subagent_spawn tool that starts background sub-agents in separate long-lived processes.245+const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. When available, subagent_spawn starts background sub-agents in separate long-lived processes, and subagent_status reports their live state without waiting for completion.
246 246
247 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.247 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.
248 248
A packages/agent/tools/subagent_status.go +221 -0
@@ -0,0 +1,221 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+ "unicode/utf8"
10+
11+ "github.com/patriceckhart/zot/packages/agent/subagents"
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+// SubagentStatusTool reports live state for background sub-agents without
17+// waiting for a worker to finish. An omitted agent_id lists the workers
18+// visible to the current supervisor session; an agent_id queries one worker.
19+// The result deliberately contains metadata only: it does not expose
20+// transcripts, result output, credentials, provider settings, or filesystem
21+// paths.
22+type SubagentStatusTool struct {
23+ Supervisor *subagents.Supervisor
24+ Enabled func() bool
25+}
26+
27+type subagentStatusArgs struct {
28+ AgentID string `json:"agent_id,omitempty"`
29+}
30+
31+type subagentStatusResponse struct {
32+ Agent *subagentStatusEntry `json:"agent,omitempty"`
33+ Agents []subagentStatusEntry `json:"agents"`
34+}
35+
36+type subagentStatusEntry struct {
37+ ID string `json:"agent_id"`
38+ State string `json:"state"`
39+ ProcessState string `json:"process_state"`
40+ TurnState string `json:"turn_state"`
41+ StartedAt time.Time `json:"started_at"`
42+ UpdatedAt time.Time `json:"updated_at"`
43+ FinishedAt *time.Time `json:"finished_at,omitempty"`
44+ TaskSummary string `json:"task_summary,omitempty"`
45+ Result *subagentStatusResult `json:"result,omitempty"`
46+}
47+
48+type subagentStatusResult struct {
49+ State string `json:"state"`
50+ Available bool `json:"available"`
51+ Ref string `json:"ref,omitempty"`
52+}
53+
54+const subagentStatusSchema = `{
55+ "type": "object",
56+ "properties": {
57+ "agent_id": {
58+ "type": "string",
59+ "description": "Optional worker id or unique id prefix. Omit it to list all visible background sub-agents."
60+ }
61+ }
62+}`
63+
64+func (t *SubagentStatusTool) Name() string { return "subagent_status" }
65+
66+func (t *SubagentStatusTool) Description() string {
67+ return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
68+}
69+
70+func (t *SubagentStatusTool) Schema() json.RawMessage {
71+ return json.RawMessage(subagentStatusSchema)
72+}
73+
74+func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
75+ if ctx != nil {
76+ select {
77+ case <-ctx.Done():
78+ return core.ToolResult{}, ctx.Err()
79+ default:
80+ }
81+ }
82+ prefix := t.Name()
83+ if t.Supervisor == nil {
84+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
85+ }
86+ if t.Enabled == nil || !t.Enabled() {
87+ return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before querying workers.")
88+ }
89+
90+ var args subagentStatusArgs
91+ if err := json.Unmarshal(raw, &args); err != nil {
92+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
93+ }
94+
95+ // SnapshotAll is intentionally used for both list and single-worker
96+ // queries. Besides being non-blocking, it applies the supervisor's active
97+ // session visibility rules so an id lookup cannot bypass list scoping.
98+ snapshots := t.Supervisor.SnapshotAll()
99+ id := strings.TrimSpace(args.AgentID)
100+ if id == "" {
101+ entries := make([]subagentStatusEntry, 0, len(snapshots))
102+ for _, snapshot := range snapshots {
103+ entries = append(entries, publicSubagentStatus(snapshot))
104+ }
105+ return renderSubagentStatus(subagentStatusResponse{Agents: entries})
106+ }
107+
108+ snapshot, ok := findSubagentStatusSnapshot(snapshots, id)
109+ if !ok {
110+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
111+ }
112+ entry := publicSubagentStatus(snapshot)
113+ return renderSubagentStatus(subagentStatusResponse{Agent: &entry})
114+}
115+
116+func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) (subagents.AgentSnapshot, bool) {
117+ for _, snapshot := range snapshots {
118+ if snapshot.ID == id {
119+ return snapshot, true
120+ }
121+ }
122+ var match subagents.AgentSnapshot
123+ hits := 0
124+ for _, snapshot := range snapshots {
125+ if strings.HasPrefix(snapshot.ID, id) {
126+ match = snapshot
127+ hits++
128+ }
129+ }
130+ return match, hits == 1
131+}
132+
133+func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {
134+ entry := subagentStatusEntry{
135+ ID: snapshot.ID,
136+ State: publicSubagentState(snapshot),
137+ ProcessState: string(snapshot.ProcessState),
138+ TurnState: string(snapshot.TurnState),
139+ StartedAt: snapshot.Started,
140+ UpdatedAt: snapshot.UpdatedAt,
141+ TaskSummary: summarizeSubagentTask(snapshot.Task),
142+ }
143+ if !snapshot.Finished.IsZero() {
144+ finished := snapshot.Finished
145+ entry.FinishedAt = &finished
146+ }
147+ if snapshot.Result != nil {
148+ entry.Result = &subagentStatusResult{
149+ State: publicResultState(snapshot.Result.Status),
150+ Available: true,
151+ Ref: snapshot.ResultRef,
152+ }
153+ }
154+ return entry
155+}
156+
157+func publicSubagentState(snapshot subagents.AgentSnapshot) string {
158+ switch snapshot.Status {
159+ case subagents.StatusPending:
160+ return "starting"
161+ case subagents.StatusRunning:
162+ return "running"
163+ case subagents.StatusDone:
164+ return "completed"
165+ case subagents.StatusFailed:
166+ return "failed"
167+ case subagents.StatusKilled:
168+ return "cancelled"
169+ case subagents.StatusDetached:
170+ return "detached"
171+ default:
172+ return "unknown"
173+ }
174+}
175+
176+func publicResultState(status subagents.TurnStatus) string {
177+ switch status {
178+ case subagents.ResultSucceeded:
179+ return "completed"
180+ case subagents.ResultFailed:
181+ return "failed"
182+ case subagents.ResultCanceled:
183+ return "cancelled"
184+ default:
185+ return "unknown"
186+ }
187+}
188+
189+// summarizeSubagentTask exposes only the first logical line and a small bound.
190+// This keeps the status surface useful for identifying a worker without
191+// returning the full prompt or any following context.
192+func summarizeSubagentTask(task string) string {
193+ firstLine := strings.TrimSpace(strings.SplitN(task, "\n", 2)[0])
194+ firstLine = strings.Join(strings.Fields(firstLine), " ")
195+ return truncateSubagentStatus(firstLine, 200)
196+}
197+
198+func truncateSubagentStatus(value string, maxRunes int) string {
199+ if maxRunes <= 0 {
200+ return ""
201+ }
202+ if utf8.RuneCountInString(value) <= maxRunes {
203+ return value
204+ }
205+ runes := []rune(value)
206+ if maxRunes <= 3 {
207+ return string(runes[:maxRunes])
208+ }
209+ return string(runes[:maxRunes-3]) + "..."
210+}
211+
212+func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
213+ data, err := json.Marshal(response)
214+ if err != nil {
215+ return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)
216+ }
217+ return core.ToolResult{
218+ Content: []provider.Content{provider.TextBlock{Text: string(data)}},
219+ Details: response,
220+ }, nil
221+}
@@ -0,0 +1,221 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+ "unicode/utf8"
10+
11+ "github.com/patriceckhart/zot/packages/agent/subagents"
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+// SubagentStatusTool reports live state for background sub-agents without
17+// waiting for a worker to finish. An omitted agent_id lists the workers
18+// visible to the current supervisor session; an agent_id queries one worker.
19+// The result deliberately contains metadata only: it does not expose
20+// transcripts, result output, credentials, provider settings, or filesystem
21+// paths.
22+type SubagentStatusTool struct {
23+ Supervisor *subagents.Supervisor
24+ Enabled func() bool
25+}
26+
27+type subagentStatusArgs struct {
28+ AgentID string `json:"agent_id,omitempty"`
29+}
30+
31+type subagentStatusResponse struct {
32+ Agent *subagentStatusEntry `json:"agent,omitempty"`
33+ Agents []subagentStatusEntry `json:"agents"`
34+}
35+
36+type subagentStatusEntry struct {
37+ ID string `json:"agent_id"`
38+ State string `json:"state"`
39+ ProcessState string `json:"process_state"`
40+ TurnState string `json:"turn_state"`
41+ StartedAt time.Time `json:"started_at"`
42+ UpdatedAt time.Time `json:"updated_at"`
43+ FinishedAt *time.Time `json:"finished_at,omitempty"`
44+ TaskSummary string `json:"task_summary,omitempty"`
45+ Result *subagentStatusResult `json:"result,omitempty"`
46+}
47+
48+type subagentStatusResult struct {
49+ State string `json:"state"`
50+ Available bool `json:"available"`
51+ Ref string `json:"ref,omitempty"`
52+}
53+
54+const subagentStatusSchema = `{
55+ "type": "object",
56+ "properties": {
57+ "agent_id": {
58+ "type": "string",
59+ "description": "Optional worker id or unique id prefix. Omit it to list all visible background sub-agents."
60+ }
61+ }
62+}`
63+
64+func (t *SubagentStatusTool) Name() string { return "subagent_status" }
65+
66+func (t *SubagentStatusTool) Description() string {
67+ return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
68+}
69+
70+func (t *SubagentStatusTool) Schema() json.RawMessage {
71+ return json.RawMessage(subagentStatusSchema)
72+}
73+
74+func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
75+ if ctx != nil {
76+ select {
77+ case <-ctx.Done():
78+ return core.ToolResult{}, ctx.Err()
79+ default:
80+ }
81+ }
82+ prefix := t.Name()
83+ if t.Supervisor == nil {
84+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
85+ }
86+ if t.Enabled == nil || !t.Enabled() {
87+ return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before querying workers.")
88+ }
89+
90+ var args subagentStatusArgs
91+ if err := json.Unmarshal(raw, &args); err != nil {
92+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
93+ }
94+
95+ // SnapshotAll is intentionally used for both list and single-worker
96+ // queries. Besides being non-blocking, it applies the supervisor's active
97+ // session visibility rules so an id lookup cannot bypass list scoping.
98+ snapshots := t.Supervisor.SnapshotAll()
99+ id := strings.TrimSpace(args.AgentID)
100+ if id == "" {
101+ entries := make([]subagentStatusEntry, 0, len(snapshots))
102+ for _, snapshot := range snapshots {
103+ entries = append(entries, publicSubagentStatus(snapshot))
104+ }
105+ return renderSubagentStatus(subagentStatusResponse{Agents: entries})
106+ }
107+
108+ snapshot, ok := findSubagentStatusSnapshot(snapshots, id)
109+ if !ok {
110+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
111+ }
112+ entry := publicSubagentStatus(snapshot)
113+ return renderSubagentStatus(subagentStatusResponse{Agent: &entry})
114+}
115+
116+func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) (subagents.AgentSnapshot, bool) {
117+ for _, snapshot := range snapshots {
118+ if snapshot.ID == id {
119+ return snapshot, true
120+ }
121+ }
122+ var match subagents.AgentSnapshot
123+ hits := 0
124+ for _, snapshot := range snapshots {
125+ if strings.HasPrefix(snapshot.ID, id) {
126+ match = snapshot
127+ hits++
128+ }
129+ }
130+ return match, hits == 1
131+}
132+
133+func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {
134+ entry := subagentStatusEntry{
135+ ID: snapshot.ID,
136+ State: publicSubagentState(snapshot),
137+ ProcessState: string(snapshot.ProcessState),
138+ TurnState: string(snapshot.TurnState),
139+ StartedAt: snapshot.Started,
140+ UpdatedAt: snapshot.UpdatedAt,
141+ TaskSummary: summarizeSubagentTask(snapshot.Task),
142+ }
143+ if !snapshot.Finished.IsZero() {
144+ finished := snapshot.Finished
145+ entry.FinishedAt = &finished
146+ }
147+ if snapshot.Result != nil {
148+ entry.Result = &subagentStatusResult{
149+ State: publicResultState(snapshot.Result.Status),
150+ Available: true,
151+ Ref: snapshot.ResultRef,
152+ }
153+ }
154+ return entry
155+}
156+
157+func publicSubagentState(snapshot subagents.AgentSnapshot) string {
158+ switch snapshot.Status {
159+ case subagents.StatusPending:
160+ return "starting"
161+ case subagents.StatusRunning:
162+ return "running"
163+ case subagents.StatusDone:
164+ return "completed"
165+ case subagents.StatusFailed:
166+ return "failed"
167+ case subagents.StatusKilled:
168+ return "cancelled"
169+ case subagents.StatusDetached:
170+ return "detached"
171+ default:
172+ return "unknown"
173+ }
174+}
175+
176+func publicResultState(status subagents.TurnStatus) string {
177+ switch status {
178+ case subagents.ResultSucceeded:
179+ return "completed"
180+ case subagents.ResultFailed:
181+ return "failed"
182+ case subagents.ResultCanceled:
183+ return "cancelled"
184+ default:
185+ return "unknown"
186+ }
187+}
188+
189+// summarizeSubagentTask exposes only the first logical line and a small bound.
190+// This keeps the status surface useful for identifying a worker without
191+// returning the full prompt or any following context.
192+func summarizeSubagentTask(task string) string {
193+ firstLine := strings.TrimSpace(strings.SplitN(task, "\n", 2)[0])
194+ firstLine = strings.Join(strings.Fields(firstLine), " ")
195+ return truncateSubagentStatus(firstLine, 200)
196+}
197+
198+func truncateSubagentStatus(value string, maxRunes int) string {
199+ if maxRunes <= 0 {
200+ return ""
201+ }
202+ if utf8.RuneCountInString(value) <= maxRunes {
203+ return value
204+ }
205+ runes := []rune(value)
206+ if maxRunes <= 3 {
207+ return string(runes[:maxRunes])
208+ }
209+ return string(runes[:maxRunes-3]) + "..."
210+}
211+
212+func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
213+ data, err := json.Marshal(response)
214+ if err != nil {
215+ return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)
216+ }
217+ return core.ToolResult{
218+ Content: []provider.Content{provider.TextBlock{Text: string(data)}},
219+ Details: response,
220+ }, nil
221+}
A packages/agent/tools/subagent_status_test.go +362 -0
@@ -0,0 +1,362 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/agent/subagents"
13+)
14+
15+type statusTestRunner struct {
16+ started chan<- struct{}
17+ release <-chan struct{}
18+ err error
19+}
20+
21+func (r statusTestRunner) Run(ctx context.Context, sink subagents.Sink) error {
22+ if sink != nil {
23+ sink.Transcript("PRIVATE_TRANSCRIPT_MARKER\n/private/worktree api_key=PRIVATE_KEY")
24+ }
25+ if r.started != nil {
26+ r.started <- struct{}{}
27+ }
28+ if r.err != nil {
29+ return r.err
30+ }
31+ if r.release == nil {
32+ return nil
33+ }
34+ select {
35+ case <-r.release:
36+ return nil
37+ case <-ctx.Done():
38+ return ctx.Err()
39+ }
40+}
41+
42+func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
43+ var schema struct {
44+ Type string `json:"type"`
45+ Properties map[string]struct {
46+ Type string `json:"type"`
47+ } `json:"properties"`
48+ Required []string `json:"required"`
49+ }
50+ if err := json.Unmarshal((&SubagentStatusTool{}).Schema(), &schema); err != nil {
51+ t.Fatal(err)
52+ }
53+ if schema.Type != "object" {
54+ t.Fatalf("schema type = %q, want object", schema.Type)
55+ }
56+ if schema.Properties["agent_id"].Type != "string" {
57+ t.Fatalf("agent_id schema = %#v, want string", schema.Properties["agent_id"])
58+ }
59+ if len(schema.Required) != 0 {
60+ t.Fatalf("required = %v, want agent_id optional", schema.Required)
61+ }
62+}
63+
64+func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
65+ root := t.TempDir()
66+ started := make(chan struct{}, 1)
67+ release := make(chan struct{})
68+ manager := subagents.New(subagents.Config{
69+ Root: filepath.Join(root, "subagents"),
70+ RepoRoot: root,
71+ NewRunner: func(*subagents.Agent) subagents.Runner {
72+ return statusTestRunner{started: started, release: release}
73+ },
74+ })
75+ t.Cleanup(manager.StopAll)
76+
77+ agent, err := manager.Spawn(context.Background(), "review architecture\nPRIVATE_PROMPT_MARKER")
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ select {
82+ case <-started:
83+ case <-time.After(time.Second):
84+ t.Fatal("subagent did not start")
85+ }
86+
87+ tool := &SubagentStatusTool{
88+ Supervisor: manager,
89+ Enabled: func() bool { return true },
90+ }
91+ res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if res.IsError {
96+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
97+ }
98+
99+ var got struct {
100+ Agents []struct {
101+ ID string `json:"agent_id"`
102+ State string `json:"state"`
103+ StartedAt string `json:"started_at"`
104+ UpdatedAt string `json:"updated_at"`
105+ TaskSummary string `json:"task_summary"`
106+ } `json:"agents"`
107+ }
108+ text := textResult(res.Content)
109+ if err := json.Unmarshal([]byte(text), &got); err != nil {
110+ t.Fatalf("status JSON: %v\n%s", err, text)
111+ }
112+ if len(got.Agents) != 1 {
113+ t.Fatalf("listed agents = %d, want 1", len(got.Agents))
114+ }
115+ if got.Agents[0].ID != agent.ID {
116+ t.Fatalf("agent id = %q, want %q", got.Agents[0].ID, agent.ID)
117+ }
118+ if got.Agents[0].State != "running" {
119+ t.Fatalf("state = %q, want running", got.Agents[0].State)
120+ }
121+ if got.Agents[0].StartedAt == "" || got.Agents[0].UpdatedAt == "" {
122+ t.Fatalf("timestamps = started %q updated %q, want both present", got.Agents[0].StartedAt, got.Agents[0].UpdatedAt)
123+ }
124+ if got.Agents[0].TaskSummary != "review architecture" {
125+ t.Fatalf("task summary = %q, want first task line", got.Agents[0].TaskSummary)
126+ }
127+ for _, forbidden := range []string{
128+ "PRIVATE_PROMPT_MARKER",
129+ "PRIVATE_TRANSCRIPT_MARKER",
130+ "/private/worktree",
131+ "api_key",
132+ "provider",
133+ "transcript",
134+ } {
135+ if strings.Contains(strings.ToLower(text), strings.ToLower(forbidden)) {
136+ t.Fatalf("status output contains forbidden %q:\n%s", forbidden, text)
137+ }
138+ }
139+
140+ close(release)
141+ agent.Wait()
142+}
143+
144+func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) {
145+ root := t.TempDir()
146+ started := make(chan struct{}, 2)
147+ release := make(chan struct{})
148+ manager := subagents.New(subagents.Config{
149+ Root: filepath.Join(root, "subagents"),
150+ RepoRoot: root,
151+ Policy: subagents.SubagentPolicy{
152+ MaxConcurrent: 1,
153+ },
154+ NewRunner: func(*subagents.Agent) subagents.Runner {
155+ return statusTestRunner{started: started, release: release}
156+ },
157+ })
158+ t.Cleanup(manager.StopAll)
159+
160+ active, err := manager.Spawn(context.Background(), "active worker")
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ queued, err := manager.Spawn(context.Background(), "queued worker")
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ select {
169+ case <-started:
170+ case <-time.After(time.Second):
171+ t.Fatal("active subagent did not start")
172+ }
173+
174+ tool := &SubagentStatusTool{
175+ Supervisor: manager,
176+ Enabled: func() bool { return true },
177+ }
178+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+queued.ID+`"}`), nil)
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ var got struct {
183+ Agent struct {
184+ State string `json:"state"`
185+ } `json:"agent"`
186+ }
187+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
188+ t.Fatal(err)
189+ }
190+ if got.Agent.State != "starting" {
191+ t.Fatalf("queued state = %q, want starting", got.Agent.State)
192+ }
193+
194+ close(release)
195+ active.Wait()
196+ queued.Wait()
197+}
198+
199+func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testing.T) {
200+ root := t.TempDir()
201+ manager := subagents.New(subagents.Config{
202+ Root: filepath.Join(root, "subagents"),
203+ RepoRoot: root,
204+ NewRunner: func(*subagents.Agent) subagents.Runner {
205+ return statusTestRunner{}
206+ },
207+ })
208+ t.Cleanup(manager.StopAll)
209+
210+ agent, err := manager.Spawn(context.Background(), "finish the parser")
211+ if err != nil {
212+ t.Fatal(err)
213+ }
214+ agent.Wait()
215+
216+ tool := &SubagentStatusTool{
217+ Supervisor: manager,
218+ Enabled: func() bool { return true },
219+ }
220+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ if res.IsError {
225+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
226+ }
227+
228+ var got struct {
229+ Agent struct {
230+ ID string `json:"agent_id"`
231+ State string `json:"state"`
232+ Finished string `json:"finished_at"`
233+ Result *struct {
234+ State string `json:"state"`
235+ Available bool `json:"available"`
236+ } `json:"result"`
237+ } `json:"agent"`
238+ }
239+ text := textResult(res.Content)
240+ if err := json.Unmarshal([]byte(text), &got); err != nil {
241+ t.Fatalf("status JSON: %v\n%s", err, text)
242+ }
243+ if got.Agent.ID != agent.ID {
244+ t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
245+ }
246+ if got.Agent.State != "completed" {
247+ t.Fatalf("state = %q, want completed", got.Agent.State)
248+ }
249+ if got.Agent.Finished == "" {
250+ t.Fatal("finished_at is empty for completed worker")
251+ }
252+ if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" {
253+ t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result)
254+ }
255+}
256+
257+func TestSubagentStatusListsEmptySupervisor(t *testing.T) {
258+ tool := &SubagentStatusTool{
259+ Supervisor: newTestSupervisor(t),
260+ Enabled: func() bool { return true },
261+ }
262+
263+ res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
264+ if err != nil {
265+ t.Fatal(err)
266+ }
267+ if res.IsError {
268+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
269+ }
270+ var got struct {
271+ Agents []json.RawMessage `json:"agents"`
272+ }
273+ text := textResult(res.Content)
274+ if err := json.Unmarshal([]byte(text), &got); err != nil {
275+ t.Fatal(err)
276+ }
277+ if got.Agents == nil || len(got.Agents) != 0 {
278+ t.Fatalf("agents = %#v, want present empty list", got.Agents)
279+ }
280+ if text != `{"agents":[]}` {
281+ t.Fatalf("empty status JSON = %s, want {\"agents\":[]}", text)
282+ }
283+}
284+
285+func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
286+ tool := &SubagentStatusTool{
287+ Supervisor: newTestSupervisor(t),
288+ Enabled: func() bool { return true },
289+ }
290+
291+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"missing"}`), nil)
292+ if err != nil {
293+ t.Fatal(err)
294+ }
295+ if !res.IsError || !strings.Contains(textResult(res.Content), "no such agent") {
296+ t.Fatalf("result = %#v, want unknown-agent tool error", res)
297+ }
298+}
299+
300+func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) {
301+ for _, tc := range []struct {
302+ name string
303+ runnerErr error
304+ stop bool
305+ wantState string
306+ }{
307+ {name: "failed", runnerErr: errors.New("runner failed"), wantState: "failed"},
308+ {name: "cancelled", stop: true, wantState: "cancelled"},
309+ } {
310+ t.Run(tc.name, func(t *testing.T) {
311+ root := t.TempDir()
312+ started := make(chan struct{}, 1)
313+ release := make(chan struct{})
314+ manager := subagents.New(subagents.Config{
315+ Root: filepath.Join(root, "subagents"),
316+ RepoRoot: root,
317+ NewRunner: func(*subagents.Agent) subagents.Runner {
318+ return statusTestRunner{started: started, release: release, err: tc.runnerErr}
319+ },
320+ })
321+ t.Cleanup(manager.StopAll)
322+
323+ agent, err := manager.Spawn(context.Background(), tc.name)
324+ if err != nil {
325+ t.Fatal(err)
326+ }
327+ select {
328+ case <-started:
329+ case <-time.After(time.Second):
330+ t.Fatal("subagent did not start")
331+ }
332+ if tc.stop {
333+ if err := manager.Stop(agent.ID); err != nil {
334+ t.Fatal(err)
335+ }
336+ } else {
337+ agent.Wait()
338+ }
339+ agent.Wait()
340+
341+ tool := &SubagentStatusTool{
342+ Supervisor: manager,
343+ Enabled: func() bool { return true },
344+ }
345+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
346+ if err != nil {
347+ t.Fatal(err)
348+ }
349+ var got struct {
350+ Agent struct {
351+ State string `json:"state"`
352+ } `json:"agent"`
353+ }
354+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
355+ t.Fatal(err)
356+ }
357+ if got.Agent.State != tc.wantState {
358+ t.Fatalf("state = %q, want %q", got.Agent.State, tc.wantState)
359+ }
360+ })
361+ }
362+}
@@ -0,0 +1,362 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/agent/subagents"
13+)
14+
15+type statusTestRunner struct {
16+ started chan<- struct{}
17+ release <-chan struct{}
18+ err error
19+}
20+
21+func (r statusTestRunner) Run(ctx context.Context, sink subagents.Sink) error {
22+ if sink != nil {
23+ sink.Transcript("PRIVATE_TRANSCRIPT_MARKER\n/private/worktree api_key=PRIVATE_KEY")
24+ }
25+ if r.started != nil {
26+ r.started <- struct{}{}
27+ }
28+ if r.err != nil {
29+ return r.err
30+ }
31+ if r.release == nil {
32+ return nil
33+ }
34+ select {
35+ case <-r.release:
36+ return nil
37+ case <-ctx.Done():
38+ return ctx.Err()
39+ }
40+}
41+
42+func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
43+ var schema struct {
44+ Type string `json:"type"`
45+ Properties map[string]struct {
46+ Type string `json:"type"`
47+ } `json:"properties"`
48+ Required []string `json:"required"`
49+ }
50+ if err := json.Unmarshal((&SubagentStatusTool{}).Schema(), &schema); err != nil {
51+ t.Fatal(err)
52+ }
53+ if schema.Type != "object" {
54+ t.Fatalf("schema type = %q, want object", schema.Type)
55+ }
56+ if schema.Properties["agent_id"].Type != "string" {
57+ t.Fatalf("agent_id schema = %#v, want string", schema.Properties["agent_id"])
58+ }
59+ if len(schema.Required) != 0 {
60+ t.Fatalf("required = %v, want agent_id optional", schema.Required)
61+ }
62+}
63+
64+func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
65+ root := t.TempDir()
66+ started := make(chan struct{}, 1)
67+ release := make(chan struct{})
68+ manager := subagents.New(subagents.Config{
69+ Root: filepath.Join(root, "subagents"),
70+ RepoRoot: root,
71+ NewRunner: func(*subagents.Agent) subagents.Runner {
72+ return statusTestRunner{started: started, release: release}
73+ },
74+ })
75+ t.Cleanup(manager.StopAll)
76+
77+ agent, err := manager.Spawn(context.Background(), "review architecture\nPRIVATE_PROMPT_MARKER")
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ select {
82+ case <-started:
83+ case <-time.After(time.Second):
84+ t.Fatal("subagent did not start")
85+ }
86+
87+ tool := &SubagentStatusTool{
88+ Supervisor: manager,
89+ Enabled: func() bool { return true },
90+ }
91+ res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if res.IsError {
96+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
97+ }
98+
99+ var got struct {
100+ Agents []struct {
101+ ID string `json:"agent_id"`
102+ State string `json:"state"`
103+ StartedAt string `json:"started_at"`
104+ UpdatedAt string `json:"updated_at"`
105+ TaskSummary string `json:"task_summary"`
106+ } `json:"agents"`
107+ }
108+ text := textResult(res.Content)
109+ if err := json.Unmarshal([]byte(text), &got); err != nil {
110+ t.Fatalf("status JSON: %v\n%s", err, text)
111+ }
112+ if len(got.Agents) != 1 {
113+ t.Fatalf("listed agents = %d, want 1", len(got.Agents))
114+ }
115+ if got.Agents[0].ID != agent.ID {
116+ t.Fatalf("agent id = %q, want %q", got.Agents[0].ID, agent.ID)
117+ }
118+ if got.Agents[0].State != "running" {
119+ t.Fatalf("state = %q, want running", got.Agents[0].State)
120+ }
121+ if got.Agents[0].StartedAt == "" || got.Agents[0].UpdatedAt == "" {
122+ t.Fatalf("timestamps = started %q updated %q, want both present", got.Agents[0].StartedAt, got.Agents[0].UpdatedAt)
123+ }
124+ if got.Agents[0].TaskSummary != "review architecture" {
125+ t.Fatalf("task summary = %q, want first task line", got.Agents[0].TaskSummary)
126+ }
127+ for _, forbidden := range []string{
128+ "PRIVATE_PROMPT_MARKER",
129+ "PRIVATE_TRANSCRIPT_MARKER",
130+ "/private/worktree",
131+ "api_key",
132+ "provider",
133+ "transcript",
134+ } {
135+ if strings.Contains(strings.ToLower(text), strings.ToLower(forbidden)) {
136+ t.Fatalf("status output contains forbidden %q:\n%s", forbidden, text)
137+ }
138+ }
139+
140+ close(release)
141+ agent.Wait()
142+}
143+
144+func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) {
145+ root := t.TempDir()
146+ started := make(chan struct{}, 2)
147+ release := make(chan struct{})
148+ manager := subagents.New(subagents.Config{
149+ Root: filepath.Join(root, "subagents"),
150+ RepoRoot: root,
151+ Policy: subagents.SubagentPolicy{
152+ MaxConcurrent: 1,
153+ },
154+ NewRunner: func(*subagents.Agent) subagents.Runner {
155+ return statusTestRunner{started: started, release: release}
156+ },
157+ })
158+ t.Cleanup(manager.StopAll)
159+
160+ active, err := manager.Spawn(context.Background(), "active worker")
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ queued, err := manager.Spawn(context.Background(), "queued worker")
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ select {
169+ case <-started:
170+ case <-time.After(time.Second):
171+ t.Fatal("active subagent did not start")
172+ }
173+
174+ tool := &SubagentStatusTool{
175+ Supervisor: manager,
176+ Enabled: func() bool { return true },
177+ }
178+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+queued.ID+`"}`), nil)
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ var got struct {
183+ Agent struct {
184+ State string `json:"state"`
185+ } `json:"agent"`
186+ }
187+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
188+ t.Fatal(err)
189+ }
190+ if got.Agent.State != "starting" {
191+ t.Fatalf("queued state = %q, want starting", got.Agent.State)
192+ }
193+
194+ close(release)
195+ active.Wait()
196+ queued.Wait()
197+}
198+
199+func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testing.T) {
200+ root := t.TempDir()
201+ manager := subagents.New(subagents.Config{
202+ Root: filepath.Join(root, "subagents"),
203+ RepoRoot: root,
204+ NewRunner: func(*subagents.Agent) subagents.Runner {
205+ return statusTestRunner{}
206+ },
207+ })
208+ t.Cleanup(manager.StopAll)
209+
210+ agent, err := manager.Spawn(context.Background(), "finish the parser")
211+ if err != nil {
212+ t.Fatal(err)
213+ }
214+ agent.Wait()
215+
216+ tool := &SubagentStatusTool{
217+ Supervisor: manager,
218+ Enabled: func() bool { return true },
219+ }
220+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ if res.IsError {
225+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
226+ }
227+
228+ var got struct {
229+ Agent struct {
230+ ID string `json:"agent_id"`
231+ State string `json:"state"`
232+ Finished string `json:"finished_at"`
233+ Result *struct {
234+ State string `json:"state"`
235+ Available bool `json:"available"`
236+ } `json:"result"`
237+ } `json:"agent"`
238+ }
239+ text := textResult(res.Content)
240+ if err := json.Unmarshal([]byte(text), &got); err != nil {
241+ t.Fatalf("status JSON: %v\n%s", err, text)
242+ }
243+ if got.Agent.ID != agent.ID {
244+ t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
245+ }
246+ if got.Agent.State != "completed" {
247+ t.Fatalf("state = %q, want completed", got.Agent.State)
248+ }
249+ if got.Agent.Finished == "" {
250+ t.Fatal("finished_at is empty for completed worker")
251+ }
252+ if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" {
253+ t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result)
254+ }
255+}
256+
257+func TestSubagentStatusListsEmptySupervisor(t *testing.T) {
258+ tool := &SubagentStatusTool{
259+ Supervisor: newTestSupervisor(t),
260+ Enabled: func() bool { return true },
261+ }
262+
263+ res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
264+ if err != nil {
265+ t.Fatal(err)
266+ }
267+ if res.IsError {
268+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
269+ }
270+ var got struct {
271+ Agents []json.RawMessage `json:"agents"`
272+ }
273+ text := textResult(res.Content)
274+ if err := json.Unmarshal([]byte(text), &got); err != nil {
275+ t.Fatal(err)
276+ }
277+ if got.Agents == nil || len(got.Agents) != 0 {
278+ t.Fatalf("agents = %#v, want present empty list", got.Agents)
279+ }
280+ if text != `{"agents":[]}` {
281+ t.Fatalf("empty status JSON = %s, want {\"agents\":[]}", text)
282+ }
283+}
284+
285+func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
286+ tool := &SubagentStatusTool{
287+ Supervisor: newTestSupervisor(t),
288+ Enabled: func() bool { return true },
289+ }
290+
291+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"missing"}`), nil)
292+ if err != nil {
293+ t.Fatal(err)
294+ }
295+ if !res.IsError || !strings.Contains(textResult(res.Content), "no such agent") {
296+ t.Fatalf("result = %#v, want unknown-agent tool error", res)
297+ }
298+}
299+
300+func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) {
301+ for _, tc := range []struct {
302+ name string
303+ runnerErr error
304+ stop bool
305+ wantState string
306+ }{
307+ {name: "failed", runnerErr: errors.New("runner failed"), wantState: "failed"},
308+ {name: "cancelled", stop: true, wantState: "cancelled"},
309+ } {
310+ t.Run(tc.name, func(t *testing.T) {
311+ root := t.TempDir()
312+ started := make(chan struct{}, 1)
313+ release := make(chan struct{})
314+ manager := subagents.New(subagents.Config{
315+ Root: filepath.Join(root, "subagents"),
316+ RepoRoot: root,
317+ NewRunner: func(*subagents.Agent) subagents.Runner {
318+ return statusTestRunner{started: started, release: release, err: tc.runnerErr}
319+ },
320+ })
321+ t.Cleanup(manager.StopAll)
322+
323+ agent, err := manager.Spawn(context.Background(), tc.name)
324+ if err != nil {
325+ t.Fatal(err)
326+ }
327+ select {
328+ case <-started:
329+ case <-time.After(time.Second):
330+ t.Fatal("subagent did not start")
331+ }
332+ if tc.stop {
333+ if err := manager.Stop(agent.ID); err != nil {
334+ t.Fatal(err)
335+ }
336+ } else {
337+ agent.Wait()
338+ }
339+ agent.Wait()
340+
341+ tool := &SubagentStatusTool{
342+ Supervisor: manager,
343+ Enabled: func() bool { return true },
344+ }
345+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
346+ if err != nil {
347+ t.Fatal(err)
348+ }
349+ var got struct {
350+ Agent struct {
351+ State string `json:"state"`
352+ } `json:"agent"`
353+ }
354+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
355+ t.Fatal(err)
356+ }
357+ if got.Agent.State != tc.wantState {
358+ t.Fatalf("state = %q, want %q", got.Agent.State, tc.wantState)
359+ }
360+ })
361+ }
362+}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close