Merge pull request #32 from bnema/feat/issue-29-live-subagent-status
feat(agent): expose live subagent status
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 | |||
| 395 | 395 | ||
| 396 | 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 | 400 | ### Named subagent profiles | |
| 401 | 401 | ||
| @@ -408,7 +408,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 408 | 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 | 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 | 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 | 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 | 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 | 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`. | |
| @@ -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 profiles | 400 | ### 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 | |||
| 103 | 103 | ||
| 104 | 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 | 108 | ## Resource policy | |
| 107 | 109 | ||
| 108 | 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. | |
| @@ -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 policy | 108 | ## 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) { | |||
| 740 | 740 | append_ = append(append_, skillAddendum) | |
| 741 | 741 | } | |
| 742 | 742 | 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 | 744 | homeDir, _ := os.UserHomeDir() | |
| 745 | 745 | profiles, _ := subagents.Discover(args.CWD, homeDir) | |
| 746 | 746 | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | |
| @@ -1192,6 +1192,18 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo | |||
| 1192 | 1192 | } | |
| 1193 | 1193 | ||
| 1194 | 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 | 1207 | if args.NoTools || args.PermissionSet != nil { | |
| 1196 | 1208 | return false | |
| 1197 | 1209 | } | |
| @@ -1199,7 +1211,7 @@ func autoSubagentsToolAllowed(args Args) bool { | |||
| 1199 | 1211 | return true | |
| 1200 | 1212 | } | |
| 1201 | 1213 | for _, name := range args.Tools { | |
| 1202 | - | if name == "subagent_spawn" { | |
| 1214 | + | if name == toolName { | |
| 1203 | 1215 | return true | |
| 1204 | 1216 | } | |
| 1205 | 1217 | } | |
| @@ -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 == ModeInteractive | 742 | 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 false | 1208 | return false | ||
| 1197 | } | 1209 | } | ||
| @@ -1199,7 +1211,7 @@ func autoSubagentsToolAllowed(args Args) bool { | |||||
| 1199 | return true | 1211 | 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 true | 1215 | return true | ||
| 1204 | } | 1216 | } | ||
| 1205 | } | 1217 | } | ||
M packages/agent/build_test.go +32 -0
| @@ -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 | 141 | func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |
| 110 | 142 | root := t.TempDir() | |
| 111 | 143 | 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 { | |||
| 1396 | 1396 | return findSubagentProfile(r.CWD, name) | |
| 1397 | 1397 | } | |
| 1398 | 1398 | ||
| 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) { | |
| 1407 | 1407 | return reg | |
| 1408 | 1408 | } | |
| 1409 | 1409 | if !AutoSubagentsEnabled() { | |
| 1410 | 1410 | return reg | |
| 1411 | 1411 | } | |
| 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 | + | } | |
| 1430 | 1439 | return reg | |
| 1431 | 1440 | } | |
| 1432 | - | injectSubagentSpawn(r.ToolRegistry) | |
| 1441 | + | injectSubagentTools(r.ToolRegistry) | |
| 1433 | 1442 | ||
| 1434 | 1443 | // Confirmation gate: when --no-yolo is on, the agent must ask | |
| 1435 | 1444 | // 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 | 1526 | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1518 | 1527 | } | |
| 1519 | 1528 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1520 | - | injectSubagentSpawn(resolved.ToolRegistry) | |
| 1529 | + | injectSubagentTools(resolved.ToolRegistry) | |
| 1521 | 1530 | 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 | 1549 | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1541 | 1550 | } | |
| 1542 | 1551 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1543 | - | injectSubagentSpawn(resolved.ToolRegistry) | |
| 1552 | + | injectSubagentTools(resolved.ToolRegistry) | |
| 1544 | 1553 | 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 | 1580 | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1572 | 1581 | } | |
| 1573 | 1582 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1574 | - | injectSubagentSpawn(resolved.ToolRegistry) | |
| 1583 | + | injectSubagentTools(resolved.ToolRegistry) | |
| 1575 | 1584 | 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 | 1599 | if current == nil { | |
| 1591 | 1600 | 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 | 1603 | iv.ReportError(err) | |
| 1595 | 1604 | } | |
| 1596 | 1605 | }) | |
| @@ -2099,56 +2108,58 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2099 | 2108 | discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir) | |
| 2100 | 2109 | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | |
| 2101 | 2110 | autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args) | |
| 2111 | + | autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args) | |
| 2102 | 2112 | ||
| 2103 | 2113 | 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, | |
| 2152 | 2163 | InitialSessionTitle: func() string { | |
| 2153 | 2164 | if sess == nil || sessionTitlePending { | |
| 2154 | 2165 | return "" | |
| @@ -2166,18 +2177,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2166 | 2177 | _ = extMgr.Reload(context.Background(), 2*time.Second) | |
| 2167 | 2178 | } | |
| 2168 | 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 | 2181 | iv.ReportError(err) | |
| 2171 | 2182 | } | |
| 2172 | 2183 | } | |
| 2173 | 2184 | }, | |
| 2174 | 2185 | RefreshTools: func() error { | |
| 2175 | 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 | 2189 | RefreshPrompt: func() error { | |
| 2179 | 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 | 2193 | AuthManager: mgr, | |
| 2183 | 2194 | 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 unconditionally | 1400 | + | // 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 /settings | 1402 | + | // switched the feature off. The /settings toggle live-mutates the running |
| 1403 | - | // toggle live-mutates the running agent's registry separately so | 1403 | + | // 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 reg | 1407 | return reg | ||
| 1408 | } | 1408 | } | ||
| 1409 | if !AutoSubagentsEnabled() { | 1409 | if !AutoSubagentsEnabled() { | ||
| 1410 | return reg | 1410 | 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 activeModel | 1418 | + | 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 activeProvider | 1423 | + | 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()] = canonical | 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 | + | } | |||
| 1430 | return reg | 1439 | 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 ask | 1443 | // Confirmation gate: when --no-yolo is on, the agent must ask | ||
| 1435 | // the user before every tool call. In interactive mode the TUI | 1444 | // 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, nil | 1530 | 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, nil | 1553 | 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, nil | 1584 | 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 | return | 1600 | 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 { | |||
| 91 | 91 | Subagents SubagentsConfig `json:"subagents,omitempty"` | |
| 92 | 92 | ||
| 93 | 93 | // 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 | |
| 95 | 95 | // default; nil/missing means disabled. Toggle from /settings. | |
| 96 | 96 | AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"` | |
| 97 | 97 | ||
| @@ -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-agents | 93 | // 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 |
| 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) { | |||
| 87 | 87 | if _, ok := iv.agent.Tools["subagent_spawn"]; ok { | |
| 88 | 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 | 93 | iv.applySettingToggle("auto_subagents_enabled", true) | |
| 91 | 94 | iv.mu.Lock() | |
| 92 | 95 | 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 | 145 | type serializedAutoSubagentsClient struct { | |
| 104 | 146 | started chan struct{} | |
| 105 | 147 | 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.statusErr | 95 | 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.Request | 147 | request chan provider.Request | ||
M packages/agent/modes/interactive.go +57 -23
| @@ -77,9 +77,13 @@ type InteractiveConfig struct { | |||
| 77 | 77 | PonytailEnabled *bool | |
| 78 | 78 | ||
| 79 | 79 | // 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 | 81 | 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 | 87 | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | |
| 84 | 88 | // nil/missing means disabled. Unsupported providers reject attempts | |
| 85 | 89 | // to enable it and reject requests when it remains enabled. | |
| @@ -807,7 +811,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 807 | 811 | baseURL, _, err := cfg.LlamaCPPConfig() | |
| 808 | 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 | 815 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg) | |
| 812 | 816 | } | |
| 813 | 817 | if cfg.Agent != nil { | |
| @@ -4147,7 +4151,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4147 | 4151 | { | |
| 4148 | 4152 | key: "auto_subagents_enabled", | |
| 4149 | 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 | 4155 | value: autoSubagents, | |
| 4152 | 4156 | disabled: autoSubagentsDisabled, | |
| 4153 | 4157 | hint: autoSubagentsHint, | |
| @@ -4643,13 +4647,13 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4643 | 4647 | return | |
| 4644 | 4648 | } | |
| 4645 | 4649 | } | |
| 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 | |
| 4647 | 4651 | // 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. | |
| 4650 | 4654 | i.applyAutoSubagentsTool(value) | |
| 4651 | 4655 | // 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 | |
| 4653 | 4657 | // after a disable). | |
| 4654 | 4658 | i.applyAutoSubagentsSystemPrompt(value) | |
| 4655 | 4659 | i.mu.Lock() | |
| @@ -7704,12 +7708,12 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||
| 7704 | 7708 | } | |
| 7705 | 7709 | ||
| 7706 | 7710 | // 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 | |
| 7708 | 7712 | // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools' | |
| 7709 | 7713 | // snapshot+mutate pattern so extension tools and /reload-ext additions | |
| 7710 | 7714 | // survive a toggle. | |
| 7711 | 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 | 7719 | func (i *Interactive) autoSubagentsUnavailableHint() string { | |
| @@ -7717,14 +7721,35 @@ func (i *Interactive) autoSubagentsUnavailableHint() string { | |||
| 7717 | 7721 | if i.cfg.Supervisor == nil { | |
| 7718 | 7722 | hints = append(hints, "subagent supervisor not available in this mode") | |
| 7719 | 7723 | } | |
| 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") | |
| 7722 | 7726 | } | |
| 7723 | 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 | 7747 | 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) | |
| 7728 | 7753 | } | |
| 7729 | 7754 | ||
| 7730 | 7755 | func (i *Interactive) autoSubagentsEnabled() bool { | |
| @@ -7748,22 +7773,31 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||
| 7748 | 7773 | current := i.agent.ToolsSnapshot() | |
| 7749 | 7774 | next := core.Registry{} | |
| 7750 | 7775 | for name, t := range current { | |
| 7751 | - | if name == "subagent_spawn" { | |
| 7776 | + | if name == "subagent_spawn" || name == "subagent_status" { | |
| 7752 | 7777 | continue | |
| 7753 | 7778 | } | |
| 7754 | 7779 | next[name] = t | |
| 7755 | 7780 | } | |
| 7756 | 7781 | 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 | + | } | |
| 7767 | 7801 | } | |
| 7768 | 7802 | i.agent.SetTools(next) | |
| 7769 | 7803 | } | |
| @@ -77,9 +77,13 @@ type InteractiveConfig struct { | |||||
| 77 | PonytailEnabled *bool | 77 | PonytailEnabled *bool | ||
| 78 | 78 | ||||
| 79 | // AutoSubagentsToolAllowed is nil for normal sessions and false when the | 79 | // 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 *bool | 81 | 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 attempts | 88 | // 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 | return | 4647 | return | ||
| 4644 | } | 4648 | } | ||
| 4645 | } | 4649 | } | ||
| 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 |
| 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 and | 4652 | + | // 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 model | 4655 | // 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 |
| 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 it | 7711 | + | // 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 additions | 7713 | // 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.AutoSubagentsToolAllowed | 7748 | + | 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 | continue | 7777 | continue | ||
| 7753 | } | 7778 | } | ||
| 7754 | next[name] = t | 7779 | 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()] = canonical | 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 | + | } | |||
| 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 { | |||
| 228 | 228 | ||
| 229 | 229 | // AutoSubagentsEnabled reads the current auto-subagents flag from config. | |
| 230 | 230 | // 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. | |
| 233 | 233 | func AutoSubagentsEnabled() bool { | |
| 234 | 234 | cfg, err := LoadConfig() | |
| 235 | 235 | if err != nil { | |
| @@ -242,7 +242,7 @@ func AutoSubagentsEnabled() bool { | |||
| 242 | 242 | // auto-subagents is enabled, so the model knows it may delegate to | |
| 243 | 243 | // background sub-agents without the user having to mention the tool | |
| 244 | 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 | 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 | ||
| @@ -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 injecting | 230 | // The build/CLI startup path uses it when initially injecting | ||
| 231 | - | // subagent_spawn into the tool registry; interactive mode mirrors the setting | 231 | + | // 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 to | 242 | // auto-subagents is enabled, so the model knows it may delegate to | ||
| 243 | // background sub-agents without the user having to mention the tool | 243 | // 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 | + | } | |||