feat(agent): add headless subagent orchestration
- commit
- 0c65e7ae80667c1a13a185f37ab11011e25ea3de
- author
- brice · · 2026-08-08 18:49 UTC
- parent
- 66e9275
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
32 files · +3832 -407
M README.md +11 -1
| @@ -192,6 +192,7 @@ zut "fix the failing test" # tui, pre-filled prompt | |||
| 192 | 192 | zut -p "list all go files" # print final text, exit | |
| 193 | 193 | zut -p --stats stats.json "task" # print final text and write generation stats | |
| 194 | 194 | zut --json "refactor main.go" # newline-delimited json events, exit | |
| 195 | + | zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize" | |
| 195 | 196 | zut --continue # resume the most recent session for this cwd | |
| 196 | 197 | zut --resume # pick a session to resume | |
| 197 | 198 | zut --resume <UUID> # resume a persisted session by UUID | |
| @@ -213,7 +214,8 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 213 | 214 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZUT_HOME/SYSTEM.md`). | | |
| 214 | 215 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | |
| 215 | 216 | | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. | | |
| 216 | - | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | |
| 217 | + | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON; incompatible with `--orchestrate`. | | |
| 218 | + | | `--orchestrate` | In print, stream, or JSON mode, enable bounded headless delegation and final synthesis. It is not implicit. | | |
| 217 | 219 | | `-c`, `--continue` | Resume the latest session for this cwd. | | |
| 218 | 220 | | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. | | |
| 219 | 221 | | `--session <path>` | Resume a specific session file. | | |
| @@ -248,6 +250,14 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||
| 248 | 250 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | |
| 249 | 251 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |
| 250 | 252 | ||
| 253 | + | ### Headless orchestration | |
| 254 | + | ||
| 255 | + | `--orchestrate` explicitly enables bounded delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged-agent and profile-worker modes cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it. | |
| 256 | + | ||
| 257 | + | Workers inherit the parent CLI provider, model, and reasoning selection. A named profile from [docs/subagents.md](docs/subagents.md) may explicitly override its own provider/model/reasoning; profile metadata and the profile body are loaded only after the parent selects that profile. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. | |
| 258 | + | ||
| 259 | + | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. | |
| 260 | + | ||
| 251 | 261 | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | |
| 252 | 262 | ||
| 253 | 263 | ## zutfile agents | |
M docs/subagents.md +14 -0
| @@ -52,6 +52,20 @@ Supported metadata: | |||
| 52 | 52 | ||
| 53 | 53 | Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | |
| 54 | 54 | ||
| 55 | + | ## Headless orchestration | |
| 56 | + | ||
| 57 | + | Headless orchestration is explicit: pass `--orchestrate` with `-p`/`--print`, `--stream`, or `--json`. There is no implicit activation. The parent must be allowed to use `subagent_spawn`; the default tool set allows it, while an explicit allowlist must include it (for example, `--tools subagent_spawn`), and `--no-tools` disables it. A packaged agent's `PermissionSet` has no `subagent_spawn` capability, so orchestration is rejected for packaged agents and profile-worker mode. `--orchestrate` is incompatible with `--stats` and unsupported in interactive and RPC modes. Workers are non-recursive: a worker cannot spawn another worker. | |
| 58 | + | ||
| 59 | + | The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited by unnamed workers. A named profile can explicitly override provider, model, and reasoning in its frontmatter; the profile's body and other metadata are applied only after the parent selects it. For example: | |
| 60 | + | ||
| 61 | + | ```bash | |
| 62 | + | zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result" | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | The parent runs completion-driven waves rather than polling. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, maximum turns/output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it. | |
| 66 | + | ||
| 67 | + | Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log. | |
| 68 | + | ||
| 55 | 69 | ## Selecting a profile | |
| 56 | 70 | ||
| 57 | 71 | When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results. | |
M packages/agent/args.go +9 -4
| @@ -34,10 +34,11 @@ const ( | |||
| 34 | 34 | ||
| 35 | 35 | // Args holds parsed command-line options. | |
| 36 | 36 | type Args struct { | |
| 37 | - | Mode Mode | |
| 38 | - | Provider string | |
| 39 | - | Model string | |
| 40 | - | APIKey string | |
| 37 | + | Mode Mode | |
| 38 | + | Orchestrate bool | |
| 39 | + | Provider string | |
| 40 | + | Model string | |
| 41 | + | APIKey string | |
| 41 | 42 | ||
| 42 | 43 | // inheritedCredential is populated only by subagent-worker mode from | |
| 43 | 44 | // the supervisor's stdin. It is never accepted as a CLI argument. | |
| @@ -200,6 +201,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 200 | 201 | a.Mode = ModeStream | |
| 201 | 202 | case "--json": | |
| 202 | 203 | a.Mode = ModeJSON | |
| 204 | + | case "--orchestrate": | |
| 205 | + | a.Orchestrate = true | |
| 203 | 206 | case "--rpc": | |
| 204 | 207 | a.Mode = ModeRPC | |
| 205 | 208 | case "-c", "--continue": | |
| @@ -518,6 +521,7 @@ func PrintHelp(version string) { | |||
| 518 | 521 | row{"zut", "interactive tui"}, | |
| 519 | 522 | row{"zut \"prompt\"", "interactive, pre-filled prompt"}, | |
| 520 | 523 | row{"zut -p \"prompt\"", "print final text, exit"}, | |
| 524 | + | row{"zut -p --orchestrate \"prompt\"", "delegate independent work, print final synthesis, exit"}, | |
| 521 | 525 | row{"zut --stream \"prompt\"", "stream assistant text live, exit"}, | |
| 522 | 526 | row{"zut --json \"prompt\"", "newline-delimited json events, exit"}, | |
| 523 | 527 | row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"}, | |
| @@ -553,6 +557,7 @@ func PrintHelp(version string) { | |||
| 553 | 557 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | |
| 554 | 558 | ) | |
| 555 | 559 | section("prompt and session flags", | |
| 560 | + | row{"--orchestrate", "headless delegation and final synthesis (print, stream, or json modes)"}, | |
| 556 | 561 | row{"--stats PATH", "write print-mode generation stats as json"}, | |
| 557 | 562 | row{"--system-prompt TEXT", "replace the default system prompt"}, | |
| 558 | 563 | row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"}, | |
M packages/agent/args_test.go +59 -0
| @@ -1,13 +1,72 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 4 | 6 | "testing" | |
| 5 | 7 | "time" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 8 | 11 | "github.com/google/uuid" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| 14 | + | func TestParseArgsOrchestrate(t *testing.T) { | |
| 15 | + | for _, mode := range []string{"--print", "--stream", "--json"} { | |
| 16 | + | args, err := ParseArgs([]string{mode, "--orchestrate", "task"}) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatalf("ParseArgs(%q): %v", mode, err) | |
| 19 | + | } | |
| 20 | + | if !args.Orchestrate { | |
| 21 | + | t.Fatalf("ParseArgs(%q): Orchestrate = false", mode) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestValidateOrchestrationArgs(t *testing.T) { | |
| 27 | + | valid := Args{Mode: ModePrint, Orchestrate: true} | |
| 28 | + | if err := validateOrchestrationArgs(valid); err != nil { | |
| 29 | + | t.Fatalf("valid orchestration args: %v", err) | |
| 30 | + | } | |
| 31 | + | for _, tc := range []struct { | |
| 32 | + | name string | |
| 33 | + | args Args | |
| 34 | + | want string | |
| 35 | + | }{ | |
| 36 | + | {name: "interactive", args: Args{Mode: ModeInteractive, Orchestrate: true}, want: "print, stream, or JSON"}, | |
| 37 | + | {name: "rpc", args: Args{Mode: ModeRPC, Orchestrate: true}, want: "RPC"}, | |
| 38 | + | {name: "worker", args: Args{Mode: ModeSubagentWorker, Orchestrate: true}, want: "subagent-worker"}, | |
| 39 | + | {name: "profile", args: Args{Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, want: "--subagent profiles"}, | |
| 40 | + | {name: "stats", args: Args{Mode: ModePrint, Orchestrate: true, StatsPath: "stats.json"}, want: "--stats"}, | |
| 41 | + | {name: "packaged", args: Args{Mode: ModePrint, Orchestrate: true, PermissionSet: &tools.PermissionSet{}}, want: "packaged"}, | |
| 42 | + | {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"}, | |
| 43 | + | } { | |
| 44 | + | t.Run(tc.name, func(t *testing.T) { | |
| 45 | + | err := validateOrchestrationArgs(tc.args) | |
| 46 | + | if err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 47 | + | t.Fatalf("error = %v, want substring %q", err, tc.want) | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestInvalidOrchestrationRejectsBeforeRuntimeCatalogSideEffects(t *testing.T) { | |
| 54 | + | home := t.TempDir() | |
| 55 | + | t.Setenv("ZUT_HOME", home) | |
| 56 | + | ||
| 57 | + | err := runWithArgsRaw([]string{"--orchestrate", "task"}, "test") | |
| 58 | + | if err == nil || !strings.Contains(err.Error(), "requires print, stream, or JSON") { | |
| 59 | + | t.Fatalf("run error = %v, want mode validation error", err) | |
| 60 | + | } | |
| 61 | + | entries, readErr := os.ReadDir(home) | |
| 62 | + | if readErr != nil { | |
| 63 | + | t.Fatal(readErr) | |
| 64 | + | } | |
| 65 | + | if len(entries) != 0 { | |
| 66 | + | t.Fatalf("invalid orchestration created runtime state: %v", entries) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 11 | 70 | func TestParseArgsSubagentAndReasoning(t *testing.T) { | |
| 12 | 71 | args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"}) | |
| 13 | 72 | if err != nil { | |
M packages/agent/build.go +18 -2
| @@ -644,7 +644,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 644 | 644 | if !CredentialAvailable(other) { | |
| 645 | 645 | continue | |
| 646 | 646 | } | |
| 647 | - | c, m, a, err := ResolveCredentialFull(other, args.APIKey) | |
| 647 | + | // An explicit launch key belongs to the initially selected provider; | |
| 648 | + | // automatic fallback must never reuse it for another provider. | |
| 649 | + | c, m, a, err := ResolveCredentialFull(other, "") | |
| 648 | 650 | provName = other | |
| 649 | 651 | cred, method, accountID, credErr = c, m, a, err | |
| 650 | 652 | break | |
| @@ -857,7 +859,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 857 | 859 | append_ = append(append_, skillAddendum) | |
| 858 | 860 | } | |
| 859 | 861 | interactiveMode := args.Mode == "" || args.Mode == ModeInteractive | |
| 860 | - | if selectedProfile == nil && interactiveMode && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { | |
| 862 | + | if selectedProfile == nil && args.Orchestrate { | |
| 863 | + | // Headless orchestration owns its prompt contract rather than inheriting | |
| 864 | + | // the interactive setting. Discover and append the compact manifest once; | |
| 865 | + | // child profile bodies remain private to the selected child. | |
| 866 | + | homeDir, _ := os.UserHomeDir() | |
| 867 | + | profiles, _ := subagents.Discover(args.CWD, homeDir) | |
| 868 | + | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | |
| 869 | + | append_ = append(append_, subagentsAddendum) | |
| 870 | + | } | |
| 871 | + | append_ = append(append_, AutoSubagentsSystemAddendumFor( | |
| 872 | + | autoSubagentsToolAllowed(args), | |
| 873 | + | autoSubagentsStopToolAllowed(args), | |
| 874 | + | autoSubagentsResumeToolAllowed(args), | |
| 875 | + | )) | |
| 876 | + | } else if selectedProfile == nil && interactiveMode && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { | |
| 861 | 877 | if autoSubagentsToolAllowed(args) { | |
| 862 | 878 | homeDir, _ := os.UserHomeDir() | |
| 863 | 879 | profiles, _ := subagents.Discover(args.CWD, homeDir) | |
M packages/agent/build_test.go +40 -0
| @@ -339,6 +339,46 @@ func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) { | |||
| 339 | 339 | } | |
| 340 | 340 | } | |
| 341 | 341 | ||
| 342 | + | func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) { | |
| 343 | + | root := t.TempDir() | |
| 344 | + | home := filepath.Join(root, "home") | |
| 345 | + | project := filepath.Join(root, "repo") | |
| 346 | + | if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil { | |
| 347 | + | t.Fatal(err) | |
| 348 | + | } | |
| 349 | + | if err := os.MkdirAll(project, 0o755); err != nil { | |
| 350 | + | t.Fatal(err) | |
| 351 | + | } | |
| 352 | + | t.Setenv("HOME", home) | |
| 353 | + | t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home")) | |
| 354 | + | t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents")) | |
| 355 | + | if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte("---\nname: reviewer\ndescription: Read-only reviewer\n---\nReview only.\n"), 0o600); err != nil { | |
| 356 | + | t.Fatal(err) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | parent, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true}, false) | |
| 360 | + | if err != nil { | |
| 361 | + | t.Fatal(err) | |
| 362 | + | } | |
| 363 | + | if got := strings.Count(parent.SystemPrompt, "[subagents_list]"); got != 1 { | |
| 364 | + | t.Fatalf("parent profile manifest count = %d, want 1\\n%s", got, parent.SystemPrompt) | |
| 365 | + | } | |
| 366 | + | if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") { | |
| 367 | + | t.Fatalf("parent prompt omitted strict orchestrator contract:\\n%s", parent.SystemPrompt) | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false) | |
| 371 | + | if err != nil { | |
| 372 | + | t.Fatal(err) | |
| 373 | + | } | |
| 374 | + | if strings.Contains(child.SystemPrompt, "primary-agent orchestrator") || strings.Contains(child.SystemPrompt, "[subagents_list]") { | |
| 375 | + | t.Fatalf("selected child inherited parent orchestration instructions:\\n%s", child.SystemPrompt) | |
| 376 | + | } | |
| 377 | + | if !strings.Contains(child.SystemPrompt, "Review only.") { | |
| 378 | + | t.Fatalf("selected child profile prompt missing:\\n%s", child.SystemPrompt) | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 342 | 382 | func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) { | |
| 343 | 383 | root := t.TempDir() | |
| 344 | 384 | home := filepath.Join(root, "home") | |
M packages/agent/cli.go +157 -235
| @@ -532,10 +532,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr | |||
| 532 | 532 | return candidate, nil | |
| 533 | 533 | } | |
| 534 | 534 | ||
| 535 | - | // buildNonInteractiveSessionAgent constructs a provider/model-specific agent | |
| 536 | - | // for startup resume while preserving the cwd sandbox and extension tools that | |
| 537 | - | // were attached to the initial resolution. | |
| 538 | - | func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) { | |
| 535 | + | func buildNonInteractiveSessionAgentWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string, prepareRegistry func(core.Registry) core.Registry) (*core.Agent, string, string, error) { | |
| 539 | 536 | next := args | |
| 540 | 537 | next.Provider = providerName | |
| 541 | 538 | next.Model = model | |
| @@ -547,6 +544,9 @@ func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolv | |||
| 547 | 544 | if extMgr != nil { | |
| 548 | 545 | resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 549 | 546 | } | |
| 547 | + | if prepareRegistry != nil { | |
| 548 | + | prepareRegistry(resolved.ToolRegistry) | |
| 549 | + | } | |
| 550 | 550 | ag := resolved.NewAgent() | |
| 551 | 551 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 552 | 552 | return ag, resolved.Provider, resolved.Model, nil | |
| @@ -598,9 +598,44 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur | |||
| 598 | 598 | // contract used by interactive session switching to non-interactive startup | |
| 599 | 599 | // modes. The returned session owns the replacement append handle. | |
| 600 | 600 | func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) { | |
| 601 | + | return applyInitialSessionResumeWithRegistry(ctx, args, base, extMgr, sess, ag, nil) | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | func applyInitialSessionResumeWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, prepareRegistry func(core.Registry) core.Registry) (*core.Session, *core.Agent, string, string, error) { | |
| 601 | 605 | candidate, err := applySessionResume(sess, ag, base.Provider, base.Model, | |
| 602 | 606 | strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) { | |
| 603 | - | return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model) | |
| 607 | + | return buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry) | |
| 608 | + | }) | |
| 609 | + | if err != nil { | |
| 610 | + | return nil, ag, "", "", err | |
| 611 | + | } | |
| 612 | + | return candidate.session, candidate.agent, candidate.provider, candidate.model, nil | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, runtime *subagentRuntime) (*core.Session, *core.Agent, string, string, error) { | |
| 616 | + | candidate, err := applySessionResume(sess, ag, base.Provider, base.Model, | |
| 617 | + | strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) { | |
| 618 | + | next := args | |
| 619 | + | next.Provider = providerName | |
| 620 | + | next.Model = model | |
| 621 | + | resolved, resolveErr := Resolve(next, true) | |
| 622 | + | if resolveErr != nil { | |
| 623 | + | return nil, "", "", resolveErr | |
| 624 | + | } | |
| 625 | + | resolved.UseSandbox(base.Sandbox) | |
| 626 | + | if extMgr != nil { | |
| 627 | + | resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 628 | + | } | |
| 629 | + | if runtime != nil { | |
| 630 | + | runtime.SetProvider(resolved.Provider) | |
| 631 | + | runtime.SetModel(resolved.Model) | |
| 632 | + | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 633 | + | runtime.SetFastMode(resolved.FastMode) | |
| 634 | + | runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| 635 | + | } | |
| 636 | + | rebuilt := resolved.NewAgent() | |
| 637 | + | wireNonInteractiveAgentExtHooks(ctx, rebuilt, extMgr) | |
| 638 | + | return rebuilt, resolved.Provider, resolved.Model, nil | |
| 604 | 639 | }) | |
| 605 | 640 | if err != nil { | |
| 606 | 641 | return nil, ag, "", "", err | |
| @@ -749,6 +784,11 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||
| 749 | 784 | fmt.Println("zut", version) | |
| 750 | 785 | return nil | |
| 751 | 786 | } | |
| 787 | + | // Reject invalid orchestration combinations before catalog repair or model | |
| 788 | + | // refresh can mutate local state or launch background work. | |
| 789 | + | if err := validateOrchestrationArgs(args); err != nil { | |
| 790 | + | return err | |
| 791 | + | } | |
| 752 | 792 | // Model catalog: load any cached discovery data before we inspect | |
| 753 | 793 | // the model list (list-models, print/json, interactive). | |
| 754 | 794 | prepareRuntimeCatalog() | |
| @@ -762,6 +802,9 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||
| 762 | 802 | } | |
| 763 | 803 | ||
| 764 | 804 | func runWithArgs(args Args, version string) error { | |
| 805 | + | if err := validateOrchestrationArgs(args); err != nil { | |
| 806 | + | return err | |
| 807 | + | } | |
| 765 | 808 | ctx := context.Background() | |
| 766 | 809 | switch args.Mode { | |
| 767 | 810 | case ModePrint: | |
| @@ -891,6 +934,9 @@ func writePrintStats(path, providerID, model string, usage provider.Usage, elaps | |||
| 891 | 934 | } | |
| 892 | 935 | ||
| 893 | 936 | func runPrintMode(ctx context.Context, args Args, version string) (runErr error) { | |
| 937 | + | if args.Orchestrate { | |
| 938 | + | return runOrchestratedPrintMode(ctx, args, version) | |
| 939 | + | } | |
| 894 | 940 | if args.NoYolo { | |
| 895 | 941 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | |
| 896 | 942 | } | |
| @@ -969,6 +1015,9 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 969 | 1015 | } | |
| 970 | 1016 | ||
| 971 | 1017 | func runStreamMode(ctx context.Context, args Args, version string) (runErr error) { | |
| 1018 | + | if args.Orchestrate { | |
| 1019 | + | return runOrchestratedStreamMode(ctx, args, version) | |
| 1020 | + | } | |
| 972 | 1021 | if args.NoYolo { | |
| 973 | 1022 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation") | |
| 974 | 1023 | } | |
| @@ -1086,6 +1135,9 @@ func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) { | |||
| 1086 | 1135 | } | |
| 1087 | 1136 | ||
| 1088 | 1137 | func runJSONMode(ctx context.Context, args Args, version string) (runErr error) { | |
| 1138 | + | if args.Orchestrate { | |
| 1139 | + | return runOrchestratedJSONMode(ctx, args, version) | |
| 1140 | + | } | |
| 1089 | 1141 | if args.NoYolo { | |
| 1090 | 1142 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation") | |
| 1091 | 1143 | } | |
| @@ -1300,47 +1352,23 @@ func webSearchExplicitlyEnabledForSession(args Args) bool { | |||
| 1300 | 1352 | // refreshes the agent tool registry + system prompt so skills/extensions | |
| 1301 | 1353 | // installed by entry.pre are visible to the following turn. | |
| 1302 | 1354 | func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error { | |
| 1355 | + | _, err := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, sharedSandbox, ag, nil) | |
| 1356 | + | return err | |
| 1357 | + | } | |
| 1358 | + | ||
| 1359 | + | func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) (subagents.WebSearchPolicy, error) { | |
| 1303 | 1360 | if strings.TrimSpace(args.StartupPre) == "" || ag == nil { | |
| 1304 | - | return nil | |
| 1361 | + | return subagents.WebSearchDeny, nil | |
| 1305 | 1362 | } | |
| 1306 | 1363 | adapter := &extToolAdapter{mgr: extMgr} | |
| 1307 | 1364 | if extMgr != nil { | |
| 1308 | 1365 | _ = extMgr.Reload(ctx, 2*time.Second) | |
| 1309 | 1366 | } | |
| 1310 | - | _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil) | |
| 1311 | - | return err | |
| 1367 | + | return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil) | |
| 1312 | 1368 | } | |
| 1313 | 1369 | ||
| 1314 | 1370 | // ---- interactive mode: opens the TUI even without credentials ---- | |
| 1315 | 1371 | ||
| 1316 | - | func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |
| 1317 | - | parseDuration := func(value string) time.Duration { | |
| 1318 | - | if strings.TrimSpace(value) == "" { | |
| 1319 | - | return 0 | |
| 1320 | - | } | |
| 1321 | - | parsed, err := time.ParseDuration(strings.TrimSpace(value)) | |
| 1322 | - | if err != nil || parsed <= 0 { | |
| 1323 | - | return 0 | |
| 1324 | - | } | |
| 1325 | - | return parsed | |
| 1326 | - | } | |
| 1327 | - | return subagents.SubagentPolicy{ | |
| 1328 | - | MaxConcurrent: cfg.MaxConcurrent, | |
| 1329 | - | MaxConcurrentPerParent: cfg.MaxConcurrentPerParent, | |
| 1330 | - | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 1331 | - | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | |
| 1332 | - | MaxTurns: cfg.MaxTurns, | |
| 1333 | - | MaxOutputBytes: cfg.MaxOutputBytes, | |
| 1334 | - | MaxOutputLines: cfg.MaxOutputLines, | |
| 1335 | - | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| 1336 | - | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |
| 1337 | - | HeartbeatInterval: parseDuration(cfg.HeartbeatInterval), | |
| 1338 | - | IdleTimeout: parseDuration(cfg.IdleTimeout), | |
| 1339 | - | ReconnectTimeout: parseDuration(cfg.ReconnectTimeout), | |
| 1340 | - | CancelGracePeriod: parseDuration(cfg.CancelGracePeriod), | |
| 1341 | - | } | |
| 1342 | - | } | |
| 1343 | - | ||
| 1344 | 1372 | func runInteractive(ctx context.Context, args Args, version string) (runErr error) { | |
| 1345 | 1373 | initialCfg, _ := LoadConfig() | |
| 1346 | 1374 | // Resolve WITHOUT requiring credentials. | |
| @@ -1405,78 +1433,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1405 | 1433 | r.MergeExtensionTools(extToolAdapter) | |
| 1406 | 1434 | webSearchGuard := &webSearchSessionGuard{} | |
| 1407 | 1435 | ||
| 1408 | - | // Build the subagent supervisor BEFORE the agent so the auto-subagents | |
| 1409 | - | // tool can reference it during tool-registry construction. State | |
| 1410 | - | // lives under ZutHome/subagents so per-agent meta/events survive | |
| 1411 | - | // restarts; the user can hunt orphaned agents down with | |
| 1412 | - | // `git worktree list` if anything misbehaves. | |
| 1413 | - | // | |
| 1414 | - | // subagentsMgr is also captured by loadSession / changeCWD closures | |
| 1415 | - | // further down the function, which is why we keep the variable | |
| 1416 | - | // in this outer scope rather than scoping it tighter. | |
| 1436 | + | // Keep the shared subagent runtime in the outer scope: session reloads, | |
| 1437 | + | // dashboard updates, agent rebuilds, and shutdown all use the same owner. | |
| 1438 | + | var runtime *subagentRuntime | |
| 1417 | 1439 | var subagentsMgr *subagents.Supervisor | |
| 1418 | - | subagentsMgr = subagents.New(subagents.Config{ | |
| 1419 | - | Context: ctx, | |
| 1420 | - | Root: filepath.Join(ZutHome(), "subagents"), | |
| 1421 | - | RepoRoot: r.CWD, | |
| 1422 | - | Provider: r.Provider, | |
| 1423 | - | FastMode: r.FastMode, | |
| 1424 | - | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | |
| 1425 | - | BaseURL: r.BaseURL, | |
| 1426 | - | InsecureTLS: r.InsecureTLS, | |
| 1427 | - | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | |
| 1428 | - | // Keep an explicit launch key in this callback only. Supervisor persists | |
| 1429 | - | // connection settings, but never stores credentials in Agent metadata | |
| 1430 | - | // or puts them in the worker's argv/environment. | |
| 1431 | - | ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) { | |
| 1432 | - | childProvider := canonicalProvider(providerID) | |
| 1433 | - | persistMu.Lock() | |
| 1434 | - | hostProvider := canonicalProvider(activeProvider) | |
| 1435 | - | persistMu.Unlock() | |
| 1436 | - | if childProvider == "" { | |
| 1437 | - | childProvider = hostProvider | |
| 1438 | - | } | |
| 1439 | - | explicit := "" | |
| 1440 | - | // A launch-time API key is scoped to the provider selected at | |
| 1441 | - | // startup. Never send it to an explicitly different child | |
| 1442 | - | // provider; resolve that provider's own credential instead. | |
| 1443 | - | if args.APIKey != "" && childProvider == hostProvider { | |
| 1444 | - | explicit = args.APIKey | |
| 1445 | - | } | |
| 1446 | - | if childProvider == "ollama" && explicit == "" { | |
| 1447 | - | return subagents.Credential{Value: "ollama", Method: "apikey"}, nil | |
| 1448 | - | } | |
| 1449 | - | credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit) | |
| 1450 | - | if err != nil { | |
| 1451 | - | // Providers backed only by a local endpoint do not need a key. | |
| 1452 | - | // Leave stdin untouched and let the child resolve that endpoint. | |
| 1453 | - | if !CredentialAvailable(childProvider) { | |
| 1454 | - | return subagents.Credential{}, nil | |
| 1455 | - | } | |
| 1456 | - | return subagents.Credential{}, err | |
| 1457 | - | } | |
| 1458 | - | return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil | |
| 1459 | - | }, | |
| 1460 | - | }) | |
| 1461 | - | if args.PermissionSet != nil { | |
| 1462 | - | // Packaged zutfile agents have an explicit capability ceiling. A | |
| 1463 | - | // child process would otherwise rebuild a fresh unrestricted sandbox, | |
| 1464 | - | // so do not expose either slash or tool-based delegation from this | |
| 1465 | - | // restricted host until worker capability propagation exists. | |
| 1466 | - | subagentsMgr = nil | |
| 1467 | - | } else { | |
| 1468 | - | // Replaying every historical event log can take seconds for users with | |
| 1469 | - | // many completed subagents. The manager is safe for concurrent snapshots, | |
| 1470 | - | // so populate the detached-agent dashboard in the background instead of | |
| 1471 | - | // blocking the first interactive paint. | |
| 1472 | - | go func() { _, _ = subagentsMgr.Reload() }() | |
| 1473 | - | } | |
| 1474 | - | ||
| 1475 | - | // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool | |
| 1476 | - | // fires after every successful spawn. It hands the agent off to | |
| 1477 | - | // the running Interactive so the watcher can flush a summary back | |
| 1478 | - | // into chat when all sub-agents finish. Reads `iv` lazily because | |
| 1479 | - | // the Interactive is constructed after the agent. | |
| 1440 | + | // These callbacks deliberately resolve iv lazily because the Interactive | |
| 1441 | + | // instance is constructed after the agent and its registry. | |
| 1480 | 1442 | onSpawnedSupervisor := func(a *subagents.Agent, task string) { | |
| 1481 | 1443 | if iv != nil { | |
| 1482 | 1444 | iv.TrackSubagentWorker(a, task) | |
| @@ -1487,86 +1449,66 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1487 | 1449 | iv.TrackResumedSubagentWorker(a, prompt) | |
| 1488 | 1450 | } | |
| 1489 | 1451 | } | |
| 1452 | + | onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string) func() { | |
| 1453 | + | if iv != nil { | |
| 1454 | + | return iv.PrepareResumedSubagentWorker(a, prompt) | |
| 1455 | + | } | |
| 1456 | + | return nil | |
| 1457 | + | } | |
| 1490 | 1458 | onStopRequestedSupervisor := func(a *subagents.Agent) { | |
| 1491 | 1459 | if iv != nil { | |
| 1492 | 1460 | iv.TrackStoppedSubagentWorker(a) | |
| 1493 | 1461 | } | |
| 1494 | 1462 | } | |
| 1495 | - | resolveSubagent := func(name string) (*subagents.Profile, error) { | |
| 1496 | - | return findSubagentProfile(r.CWD, name) | |
| 1497 | - | } | |
| 1498 | - | ||
| 1499 | - | // Register the canonical subagent tools whenever launch-time policy permits | |
| 1500 | - | // them. The auto-subagents setting controls strict orchestration guidance, | |
| 1501 | - | // not explicit user-requested delegation. | |
| 1502 | - | injectSubagentTools := func(reg core.Registry) core.Registry { | |
| 1503 | - | if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) { | |
| 1504 | - | return reg | |
| 1505 | - | } | |
| 1506 | - | if autoSubagentsToolAllowed(args) { | |
| 1507 | - | canonical := &tools.SubagentSpawnTool{ | |
| 1508 | - | Supervisor: subagentsMgr, | |
| 1509 | - | Enabled: func() bool { return true }, | |
| 1510 | - | DefaultModel: func() string { | |
| 1511 | - | persistMu.Lock() | |
| 1512 | - | defer persistMu.Unlock() | |
| 1513 | - | return activeModel | |
| 1514 | - | }, | |
| 1515 | - | DefaultProvider: func() string { | |
| 1516 | - | persistMu.Lock() | |
| 1517 | - | defer persistMu.Unlock() | |
| 1518 | - | return activeProvider | |
| 1519 | - | }, | |
| 1520 | - | DefaultReasoning: func() string { return r.Reasoning }, | |
| 1521 | - | ResolveSubagent: resolveSubagent, | |
| 1522 | - | OnSpawned: onSpawnedSupervisor, | |
| 1523 | - | } | |
| 1524 | - | reg[canonical.Name()] = canonical | |
| 1525 | - | } | |
| 1526 | - | if autoSubagentsStatusToolAllowed(args) { | |
| 1527 | - | statusTool := &tools.SubagentStatusTool{ | |
| 1528 | - | Supervisor: subagentsMgr, | |
| 1529 | - | Enabled: func() bool { return true }, | |
| 1530 | - | } | |
| 1531 | - | reg[statusTool.Name()] = statusTool | |
| 1532 | - | } | |
| 1533 | - | if autoSubagentsStopToolAllowed(args) { | |
| 1534 | - | stopTool := &tools.SubagentStopTool{ | |
| 1535 | - | Supervisor: subagentsMgr, | |
| 1536 | - | Enabled: func() bool { return true }, | |
| 1537 | - | OnStopRequested: onStopRequestedSupervisor, | |
| 1538 | - | } | |
| 1539 | - | reg[stopTool.Name()] = stopTool | |
| 1540 | - | } | |
| 1541 | - | if autoSubagentsResumeToolAllowed(args) { | |
| 1542 | - | resumeTool := &tools.SubagentResumeTool{ | |
| 1543 | - | Supervisor: subagentsMgr, | |
| 1544 | - | Enabled: func() bool { return true }, | |
| 1545 | - | OnResumed: onResumedSupervisor, | |
| 1546 | - | } | |
| 1547 | - | reg[resumeTool.Name()] = resumeTool | |
| 1548 | - | } | |
| 1549 | - | return reg | |
| 1463 | + | activeProviderForSubagents := func() string { | |
| 1464 | + | persistMu.Lock() | |
| 1465 | + | defer persistMu.Unlock() | |
| 1466 | + | return activeProvider | |
| 1550 | 1467 | } | |
| 1551 | - | prepareInteractiveRegistry := func(reg core.Registry) core.Registry { | |
| 1552 | - | reg = injectSubagentTools(reg) | |
| 1553 | - | return webSearchGuard.wrapRegistry(reg) | |
| 1468 | + | activeModelForSubagents := func() string { | |
| 1469 | + | persistMu.Lock() | |
| 1470 | + | defer persistMu.Unlock() | |
| 1471 | + | return activeModel | |
| 1554 | 1472 | } | |
| 1555 | - | prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry { | |
| 1556 | - | // The resolved policy is a ceiling. In particular, an extension named | |
| 1557 | - | // web_search must not reintroduce the capability when --no-tools, | |
| 1558 | - | // --tools, a packaged PermissionSet, or persisted settings deny it. | |
| 1559 | - | if policy != subagents.WebSearchAllow { | |
| 1560 | - | delete(reg, "web_search") | |
| 1561 | - | } | |
| 1562 | - | return prepareInteractiveRegistry(reg) | |
| 1473 | + | ||
| 1474 | + | runtime = newSubagentRuntime(subagentRuntimeConfig{ | |
| 1475 | + | Context: ctx, | |
| 1476 | + | Args: args, | |
| 1477 | + | Root: filepath.Join(ZutHome(), "subagents"), | |
| 1478 | + | RepoRoot: r.CWD, | |
| 1479 | + | Provider: r.Provider, | |
| 1480 | + | Model: r.Model, | |
| 1481 | + | Reasoning: r.Reasoning, | |
| 1482 | + | BaseURL: r.BaseURL, | |
| 1483 | + | InsecureTLS: r.InsecureTLS, | |
| 1484 | + | FastMode: r.FastMode, | |
| 1485 | + | APIKey: args.APIKey, | |
| 1486 | + | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | |
| 1487 | + | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | |
| 1488 | + | WebSearchGuard: webSearchGuard, | |
| 1489 | + | ActiveProvider: activeProviderForSubagents, | |
| 1490 | + | ActiveModel: activeModelForSubagents, | |
| 1491 | + | ActiveReasoning: func() string { return r.Reasoning }, | |
| 1492 | + | OnSpawned: onSpawnedSupervisor, | |
| 1493 | + | OnResumed: onResumedSupervisor, | |
| 1494 | + | BeforeResumed: onBeforeResumedSupervisor, | |
| 1495 | + | OnStopRequested: onStopRequestedSupervisor, | |
| 1496 | + | }) | |
| 1497 | + | subagentsMgr = runtime.Supervisor() | |
| 1498 | + | if subagentsMgr != nil { | |
| 1499 | + | // Replaying historical event logs can take seconds. Populate the | |
| 1500 | + | // detached-agent dashboard without blocking the first interactive paint. | |
| 1501 | + | go func() { _, _ = subagentsMgr.Reload() }() | |
| 1563 | 1502 | } | |
| 1503 | + | defer func() { _ = runtime.Close(context.Background()) }() | |
| 1504 | + | ||
| 1505 | + | prepareInteractiveRegistry := runtime.PrepareRegistry | |
| 1506 | + | prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry | |
| 1564 | 1507 | prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy) | |
| 1565 | 1508 | initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry) | |
| 1566 | 1509 | webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow) | |
| 1567 | - | if subagentsMgr != nil { | |
| 1568 | - | subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy) | |
| 1569 | - | } | |
| 1510 | + | runtime.SetWebSearchPolicy(initialWebSearchPolicy) | |
| 1511 | + | resolveSubagent := runtime.resolveSubagent | |
| 1570 | 1512 | ||
| 1571 | 1513 | // Confirmation gate: when --no-yolo is on, the agent must ask | |
| 1572 | 1514 | // the user before every tool call. In interactive mode the TUI | |
| @@ -1650,10 +1592,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1650 | 1592 | return nil, "", "", err | |
| 1651 | 1593 | } | |
| 1652 | 1594 | resolved.UseSandbox(sharedSandbox) | |
| 1653 | - | if subagentsMgr != nil { | |
| 1654 | - | subagentsMgr.SetProvider(resolved.Provider) | |
| 1655 | - | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1656 | - | } | |
| 1595 | + | runtime.SetProvider(resolved.Provider) | |
| 1596 | + | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1597 | + | runtime.SetFastMode(resolved.FastMode) | |
| 1657 | 1598 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1658 | 1599 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| 1659 | 1600 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| @@ -1673,10 +1614,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1673 | 1614 | return nil, "", "", err | |
| 1674 | 1615 | } | |
| 1675 | 1616 | resolved.UseSandbox(sharedSandbox) | |
| 1676 | - | if subagentsMgr != nil { | |
| 1677 | - | subagentsMgr.SetProvider(resolved.Provider) | |
| 1678 | - | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1679 | - | } | |
| 1617 | + | runtime.SetProvider(resolved.Provider) | |
| 1618 | + | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1619 | + | runtime.SetFastMode(resolved.FastMode) | |
| 1680 | 1620 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1681 | 1621 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| 1682 | 1622 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| @@ -1704,10 +1644,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1704 | 1644 | return nil, "", "", err | |
| 1705 | 1645 | } | |
| 1706 | 1646 | resolved.UseSandbox(sharedSandbox) | |
| 1707 | - | if subagentsMgr != nil { | |
| 1708 | - | subagentsMgr.SetProvider(resolved.Provider) | |
| 1709 | - | subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1710 | - | } | |
| 1647 | + | runtime.SetProvider(resolved.Provider) | |
| 1648 | + | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1649 | + | runtime.SetFastMode(resolved.FastMode) | |
| 1711 | 1650 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1712 | 1651 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| 1713 | 1652 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| @@ -1927,19 +1866,24 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1927 | 1866 | persistMu.Unlock() | |
| 1928 | 1867 | ||
| 1929 | 1868 | // A candidate build must not publish its provider/model as the live | |
| 1930 | - | // selection before the candidate commits. | |
| 1931 | - | candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |
| 1932 | - | if err != nil { | |
| 1933 | - | // prepareSessionResume closes its append handle on failure; | |
| 1934 | - | // no live agent or session has been changed. | |
| 1935 | - | return err | |
| 1936 | - | } | |
| 1869 | + | // selection before the candidate commits. The builder also stages | |
| 1870 | + | // provider settings on the shared runtime, so restore those settings on | |
| 1871 | + | // every failed path, including errors returned during preparation. | |
| 1872 | + | runtimeConfig := runtime.snapshotConfiguration() | |
| 1937 | 1873 | committed := false | |
| 1874 | + | var candidate sessionResumeCandidate | |
| 1938 | 1875 | defer func() { | |
| 1939 | 1876 | if !committed { | |
| 1877 | + | runtime.restoreConfiguration(runtimeConfig) | |
| 1940 | 1878 | loadErr = joinSessionCloseError(loadErr, candidate.session) | |
| 1941 | 1879 | } | |
| 1942 | 1880 | }() | |
| 1881 | + | candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |
| 1882 | + | if err != nil { | |
| 1883 | + | // prepareSessionResume closes its append handle on failure; | |
| 1884 | + | // no live agent or session has been changed. | |
| 1885 | + | return err | |
| 1886 | + | } | |
| 1943 | 1887 | ||
| 1944 | 1888 | // Wire the private candidate before it can become visible. This is | |
| 1945 | 1889 | // also required for legacy same-provider resumes, where the current | |
| @@ -1995,7 +1939,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1995 | 1939 | if iv != nil { | |
| 1996 | 1940 | iv.SetSubagentSessionScope(candidate.session.ID) | |
| 1997 | 1941 | } else { | |
| 1998 | - | subagentsMgr.SetActiveSession(candidate.session.ID) | |
| 1942 | + | runtime.SetActiveSession(candidate.session.ID) | |
| 1999 | 1943 | } | |
| 2000 | 1944 | } | |
| 2001 | 1945 | committed = true | |
| @@ -2073,10 +2017,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2073 | 2017 | oldActiveProvider := activeProvider | |
| 2074 | 2018 | oldActiveModel := activeModel | |
| 2075 | 2019 | persistMu.Unlock() | |
| 2076 | - | oldSupervisorRoot := "" | |
| 2077 | - | if subagentsMgr != nil { | |
| 2078 | - | oldSupervisorRoot = subagentsMgr.RepoRoot() | |
| 2079 | - | } | |
| 2020 | + | runtimeConfig := runtime.snapshotConfiguration() | |
| 2080 | 2021 | oldSandboxRoot := "" | |
| 2081 | 2022 | oldSandboxLocked := false | |
| 2082 | 2023 | if sharedSandbox != nil { | |
| @@ -2091,9 +2032,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2091 | 2032 | activeProvider = oldActiveProvider | |
| 2092 | 2033 | activeModel = oldActiveModel | |
| 2093 | 2034 | persistMu.Unlock() | |
| 2094 | - | if subagentsMgr != nil { | |
| 2095 | - | subagentsMgr.SetRepoRoot(oldSupervisorRoot) | |
| 2096 | - | } | |
| 2035 | + | runtime.restoreConfiguration(runtimeConfig) | |
| 2097 | 2036 | if sharedSandbox != nil { | |
| 2098 | 2037 | sharedSandbox.Root = oldSandboxRoot | |
| 2099 | 2038 | if oldSandboxLocked { | |
| @@ -2106,9 +2045,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2106 | 2045 | ||
| 2107 | 2046 | args.CWD = absPath | |
| 2108 | 2047 | r.CWD = absPath | |
| 2109 | - | if subagentsMgr != nil { | |
| 2110 | - | subagentsMgr.SetRepoRoot(absPath) | |
| 2111 | - | } | |
| 2048 | + | runtime.SetRepoRoot(absPath) | |
| 2112 | 2049 | if args.PermissionSet != nil { | |
| 2113 | 2050 | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | |
| 2114 | 2051 | args.PermissionSet = &expanded | |
| @@ -2189,11 +2126,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2189 | 2126 | } | |
| 2190 | 2127 | ||
| 2191 | 2128 | // Re-scope the subagent dashboard to the new session. | |
| 2192 | - | if subagentsMgr != nil && newSess != nil { | |
| 2129 | + | if newSess != nil { | |
| 2193 | 2130 | if iv != nil { | |
| 2194 | 2131 | iv.SetSubagentSessionScope(newSess.ID) | |
| 2195 | 2132 | } else { | |
| 2196 | - | subagentsMgr.SetActiveSession(newSess.ID) | |
| 2133 | + | runtime.SetActiveSession(newSess.ID) | |
| 2197 | 2134 | } | |
| 2198 | 2135 | } | |
| 2199 | 2136 | if newSess != nil { | |
| @@ -2272,25 +2209,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2272 | 2209 | } | |
| 2273 | 2210 | } | |
| 2274 | 2211 | ||
| 2275 | - | // subagentsMgr was constructed and reloaded earlier (before the agent | |
| 2276 | - | // build, so the auto-subagents tool could capture it). Here we just | |
| 2277 | - | // scope the dashboard to the active host session so /subagents only | |
| 2278 | - | // shows agents this session spawned (and any pre-upgrade unscoped | |
| 2279 | - | // agents — see SnapshotAll docs). Updated again whenever the | |
| 2280 | - | // user swaps sessions via loadSession below. | |
| 2281 | - | if sess != nil && subagentsMgr != nil { | |
| 2282 | - | subagentsMgr.SetActiveSession(sess.ID) | |
| 2283 | - | } | |
| 2284 | - | // Best-effort shutdown on normal interactive exit: stop only workers | |
| 2285 | - | // owned by the active host session. Other session scopes can share this | |
| 2286 | - | // supervisor and must remain alive. | |
| 2287 | - | if subagentsMgr != nil { | |
| 2288 | - | defer func() { | |
| 2289 | - | rootSessionID := subagentsMgr.ActiveSession() | |
| 2290 | - | if rootSessionID != "" { | |
| 2291 | - | _ = subagentsMgr.StopByRootSession(rootSessionID) | |
| 2292 | - | } | |
| 2293 | - | }() | |
| 2212 | + | // Scope the dashboard to the active host session. The runtime owns | |
| 2213 | + | // shutdown separately and closes the supervisor exactly once. | |
| 2214 | + | if sess != nil { | |
| 2215 | + | runtime.SetActiveSession(sess.ID) | |
| 2294 | 2216 | } | |
| 2295 | 2217 | ||
| 2296 | 2218 | var startupSkills []*skills.Skill | |
| @@ -2315,7 +2237,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2315 | 2237 | if available { | |
| 2316 | 2238 | policy = subagents.WebSearchAllow | |
| 2317 | 2239 | } | |
| 2318 | - | subagentsMgr.SetWebSearchPolicy(policy) | |
| 2240 | + | runtime.SetWebSearchPolicy(policy) | |
| 2319 | 2241 | } | |
| 2320 | 2242 | ||
| 2321 | 2243 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| @@ -2624,12 +2546,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2624 | 2546 | closeAgentLSP(finalAg) | |
| 2625 | 2547 | closeResolvedLSP(r) | |
| 2626 | 2548 | // Stop supervised workers before exiting. os.Exit skips deferred | |
| 2627 | - | // cleanup, so relying only on the normal runInteractive defer would | |
| 2628 | - | // leave child daemons (and their descendants) alive after a TERM or | |
| 2629 | - | // HUP delivered to the host. | |
| 2630 | - | if subagentsMgr != nil { | |
| 2631 | - | subagentsMgr.StopAll() | |
| 2632 | - | } | |
| 2549 | + | // cleanup, so close the shared runtime explicitly on a TERM or HUP. | |
| 2550 | + | _ = runtime.Close(context.Background()) | |
| 2633 | 2551 | // Exit cleanly. Re-raising the signal would skip os.Exit's | |
| 2634 | 2552 | // at-exit hooks; explicit exit is fine because we've already | |
| 2635 | 2553 | // flushed the only at-risk state (the session file) and stopped | |
| @@ -2825,7 +2743,11 @@ func readAllStdin() (string, error) { | |||
| 2825 | 2743 | if (fi.Mode() & os.ModeCharDevice) != 0 { | |
| 2826 | 2744 | return "", nil | |
| 2827 | 2745 | } | |
| 2828 | - | b, err := io.ReadAll(os.Stdin) | |
| 2746 | + | return readAllStdinFrom(os.Stdin) | |
| 2747 | + | } | |
| 2748 | + | ||
| 2749 | + | func readAllStdinFrom(r io.Reader) (string, error) { | |
| 2750 | + | b, err := io.ReadAll(r) | |
| 2829 | 2751 | return string(b), err | |
| 2830 | 2752 | } | |
| 2831 | 2753 | ||
M packages/agent/cli_session_test.go +52 -0
| @@ -18,6 +18,16 @@ import ( | |||
| 18 | 18 | "github.com/google/uuid" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | + | type failingStdinReader struct{} | |
| 22 | + | ||
| 23 | + | func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") } | |
| 24 | + | ||
| 25 | + | func TestReadAllStdinFromPropagatesReadErrors(t *testing.T) { | |
| 26 | + | if _, err := readAllStdinFrom(failingStdinReader{}); err == nil || !strings.Contains(err.Error(), "stdin failed") { | |
| 27 | + | t.Fatalf("readAllStdinFrom error = %v, want stdin failure", err) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 21 | 31 | func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) { | |
| 22 | 32 | root := t.TempDir() | |
| 23 | 33 | cwd := t.TempDir() | |
| @@ -412,6 +422,48 @@ func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) { | |||
| 412 | 422 | } | |
| 413 | 423 | } | |
| 414 | 424 | ||
| 425 | + | func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *testing.T) { | |
| 426 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 427 | + | if err := SaveConfig(Config{Provider: "ollama", Model: "current-local-model"}); err != nil { | |
| 428 | + | t.Fatal(err) | |
| 429 | + | } | |
| 430 | + | path := syntheticSession(t, "ollama", "stored-local-model", provider.Usage{}) | |
| 431 | + | args := Args{Mode: ModePrint, Orchestrate: true, CWD: t.TempDir()} | |
| 432 | + | base, err := Resolve(args, true) | |
| 433 | + | if err != nil { | |
| 434 | + | t.Fatal(err) | |
| 435 | + | } | |
| 436 | + | current := base.NewAgent() | |
| 437 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 438 | + | Context: context.Background(), | |
| 439 | + | Args: args, | |
| 440 | + | Root: t.TempDir(), | |
| 441 | + | RepoRoot: args.CWD, | |
| 442 | + | Provider: base.Provider, | |
| 443 | + | Model: base.Model, | |
| 444 | + | }) | |
| 445 | + | defer func() { _ = runtime.Close(context.Background()) }() | |
| 446 | + | ||
| 447 | + | sess, _, err := core.OpenSession(path) | |
| 448 | + | if err != nil { | |
| 449 | + | t.Fatal(err) | |
| 450 | + | } | |
| 451 | + | gotSess, gotAgent, providerName, model, err := applyInitialSessionResumeWithRuntime(context.Background(), args, base, nil, sess, current, runtime) | |
| 452 | + | if err != nil { | |
| 453 | + | t.Fatal(err) | |
| 454 | + | } | |
| 455 | + | defer gotSess.Close() | |
| 456 | + | if gotAgent == current || providerName != "ollama" || model != "stored-local-model" { | |
| 457 | + | t.Fatalf("resume candidate = rebuilt=%v provider/model=%q/%q", gotAgent == current, providerName, model) | |
| 458 | + | } | |
| 459 | + | if got := runtime.currentModel(); got != "stored-local-model" { | |
| 460 | + | t.Fatalf("runtime inherited model = %q, want stored-local-model", got) | |
| 461 | + | } | |
| 462 | + | if _, ok := gotAgent.ToolsSnapshot()["subagent_spawn"]; !ok { | |
| 463 | + | t.Fatal("resumed parent lost canonical subagent_spawn manager tool") | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | ||
| 415 | 467 | func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) { | |
| 416 | 468 | old := core.NewAgent(nil, "old-model", "", nil) | |
| 417 | 469 | old.SetMessages([]provider.Message{{ | |
M packages/agent/modes/auto_subagents_test.go +100 -9
| @@ -188,7 +188,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) { | |||
| 188 | 188 | if a.OnTurnEnd == nil { | |
| 189 | 189 | t.Fatal("resumed worker has no completion watcher") | |
| 190 | 190 | } | |
| 191 | - | a.OnTurnEnd(2, "") | |
| 191 | + | a.OnTurnEnd(1, "") | |
| 192 | 192 | ||
| 193 | 193 | update := waitForQueuedPrompt(t, iv) | |
| 194 | 194 | if !strings.Contains(update, "task: "+followUp) { | |
| @@ -199,6 +199,97 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) { | |||
| 199 | 199 | } | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | + | func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T) { | |
| 203 | + | started := make(chan struct{}) | |
| 204 | + | mgr := subagents.New(subagents.Config{ | |
| 205 | + | Root: t.TempDir(), | |
| 206 | + | RepoRoot: t.TempDir(), | |
| 207 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 208 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 209 | + | select { | |
| 210 | + | case started <- struct{}{}: | |
| 211 | + | default: | |
| 212 | + | } | |
| 213 | + | <-ctx.Done() | |
| 214 | + | return ctx.Err() | |
| 215 | + | }) | |
| 216 | + | }, | |
| 217 | + | }) | |
| 218 | + | first, err := mgr.Spawn(context.Background(), "original task") | |
| 219 | + | if err != nil { | |
| 220 | + | t.Fatal(err) | |
| 221 | + | } | |
| 222 | + | <-started | |
| 223 | + | t.Cleanup(func() { | |
| 224 | + | _ = mgr.Stop(first.ID) | |
| 225 | + | first.Wait() | |
| 226 | + | }) | |
| 227 | + | ||
| 228 | + | iv := newQueuedAutoSubagentsInteractive() | |
| 229 | + | iv.TrackSubagentWorker(first, first.Task) | |
| 230 | + | const followUp = "queued follow-up" | |
| 231 | + | cancel := iv.PrepareResumedSubagentWorker(first, followUp) | |
| 232 | + | if cancel == nil { | |
| 233 | + | t.Fatal("resume registration returned nil cleanup") | |
| 234 | + | } | |
| 235 | + | if first.OnTurnEnd == nil { | |
| 236 | + | t.Fatal("worker has no completion watcher") | |
| 237 | + | } | |
| 238 | + | first.OnTurnEnd(1, "") | |
| 239 | + | first.OnTurnEnd(2, "") | |
| 240 | + | ||
| 241 | + | update := waitForQueuedPrompt(t, iv) | |
| 242 | + | if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") { | |
| 243 | + | t.Fatalf("active follow-up was not batched with the original turn: %q", update) | |
| 244 | + | } | |
| 245 | + | if !strings.Contains(update, "task: "+followUp) { | |
| 246 | + | t.Fatalf("active follow-up task missing: %q", update) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) { | |
| 251 | + | mgr := subagents.New(subagents.Config{ | |
| 252 | + | Root: t.TempDir(), | |
| 253 | + | RepoRoot: t.TempDir(), | |
| 254 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 255 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 256 | + | <-ctx.Done() | |
| 257 | + | return ctx.Err() | |
| 258 | + | }) | |
| 259 | + | }, | |
| 260 | + | }) | |
| 261 | + | first, err := mgr.Spawn(context.Background(), "first task") | |
| 262 | + | if err != nil { | |
| 263 | + | t.Fatal(err) | |
| 264 | + | } | |
| 265 | + | second, err := mgr.Spawn(context.Background(), "second task") | |
| 266 | + | if err != nil { | |
| 267 | + | t.Fatal(err) | |
| 268 | + | } | |
| 269 | + | t.Cleanup(func() { | |
| 270 | + | _ = mgr.Stop(first.ID) | |
| 271 | + | _ = mgr.Stop(second.ID) | |
| 272 | + | first.Wait() | |
| 273 | + | second.Wait() | |
| 274 | + | }) | |
| 275 | + | ||
| 276 | + | iv := newQueuedAutoSubagentsInteractive() | |
| 277 | + | release := iv.beginCompletionDeliveryHold() | |
| 278 | + | iv.TrackSubagentWorker(first, first.Task) | |
| 279 | + | first.OnTurnEnd(1, "") | |
| 280 | + | iv.TrackSubagentWorker(second, second.Task) | |
| 281 | + | second.OnTurnEnd(1, "") | |
| 282 | + | release() | |
| 283 | + | ||
| 284 | + | update := waitForQueuedPrompt(t, iv) | |
| 285 | + | if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") { | |
| 286 | + | t.Fatalf("fast and late registrations were not batched: %q", update) | |
| 287 | + | } | |
| 288 | + | if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") { | |
| 289 | + | t.Fatalf("batched update omitted one task: %q", update) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 202 | 293 | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |
| 203 | 294 | started := make(chan struct{}) | |
| 204 | 295 | mgr := subagents.New(subagents.Config{ | |
| @@ -223,10 +314,11 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |||
| 223 | 314 | }) | |
| 224 | 315 | ||
| 225 | 316 | iv := newQueuedAutoSubagentsInteractive() | |
| 226 | - | entry := &subagentWatchEntry{agent: a, task: a.Task} | |
| 227 | - | iv.subagentWatch = []*subagentWatchEntry{entry} | |
| 228 | - | iv.completeSupervisorWatchEntry(entry, "completed", "") | |
| 229 | - | iv.completeSupervisorWatchEntry(entry, "failed", "late error") | |
| 317 | + | iv.TrackSubagentWorker(a, a.Task) | |
| 318 | + | if a.OnTurnEnd == nil { | |
| 319 | + | t.Fatal("worker has no completion watcher") | |
| 320 | + | } | |
| 321 | + | a.OnTurnEnd(1, "") | |
| 230 | 322 | ||
| 231 | 323 | update := waitForQueuedPrompt(t, iv) | |
| 232 | 324 | if !strings.Contains(update, "status: completed") { | |
| @@ -274,10 +366,9 @@ func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) { | |||
| 274 | 366 | return | |
| 275 | 367 | } | |
| 276 | 368 | once.Do(func() { | |
| 277 | - | interactive.flushSupervisorSummary([]*subagentWatchEntry{{ | |
| 278 | - | agent: &subagents.Agent{}, | |
| 279 | - | task: "smoke test", | |
| 280 | - | }}) | |
| 369 | + | instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks." | |
| 370 | + | update := subagents.FormatCompletionUpdate([]subagents.Completion{{Task: "smoke test"}}, instruction) | |
| 371 | + | interactive.SubmitOrQueue(update, nil) | |
| 281 | 372 | }) | |
| 282 | 373 | } | |
| 283 | 374 | ||
M packages/agent/modes/interactive.go +126 -137
| @@ -774,14 +774,14 @@ type Interactive struct { | |||
| 774 | 774 | extPanel *extPanelDialog | |
| 775 | 775 | llamaConfigured bool | |
| 776 | 776 | ||
| 777 | - | // subagentWatch tracks auto-subagents sub-agents the main agent spawned | |
| 778 | - | // via subagent_spawn. Each entry holds the agent + the task text; | |
| 779 | - | // a per-entry goroutine waits on the agent's terminal state. When | |
| 780 | - | // every tracked entry has finished, the watcher flushes a single | |
| 781 | - | // summary turn into the main chat (queued if the main agent is | |
| 782 | - | // busy, run immediately if idle). | |
| 783 | - | subagentWatchMu sync.Mutex | |
| 784 | - | subagentWatch []*subagentWatchEntry | |
| 777 | + | // completionTracker observes auto-subagent turn and process completion. | |
| 778 | + | // Delivery is kept separate so every active set is injected as one | |
| 779 | + | // summary turn, preserving the existing queue behavior. | |
| 780 | + | completionTracker *subagents.CompletionTracker | |
| 781 | + | completionDeliveryMu sync.Mutex | |
| 782 | + | completionDeliveryRunning bool | |
| 783 | + | completionDeliveryRequest bool | |
| 784 | + | completionDeliveryHolds int | |
| 785 | 785 | ||
| 786 | 786 | // subagentsWaitWatcherDone is an optional test hook invoked when a | |
| 787 | 787 | // /subagents wait watcher exits. | |
| @@ -920,7 +920,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 920 | 920 | startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...) | |
| 921 | 921 | } | |
| 922 | 922 | i := &Interactive{ | |
| 923 | - | cfg: cfg, | |
| 923 | + | cfg: cfg, | |
| 924 | + | completionTracker: subagents.NewCompletionTracker(), | |
| 924 | 925 | view: &tui.View{ | |
| 925 | 926 | Theme: cfg.Theme, | |
| 926 | 927 | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| @@ -1026,7 +1027,9 @@ func (i *Interactive) markCtrlCExit() { | |||
| 1026 | 1027 | ||
| 1027 | 1028 | // Run blocks until the user quits. | |
| 1028 | 1029 | func (i *Interactive) Run(ctx context.Context) error { | |
| 1030 | + | i.mu.Lock() | |
| 1029 | 1031 | i.runCtx = ctx | |
| 1032 | + | i.mu.Unlock() | |
| 1030 | 1033 | term := i.cfg.Terminal | |
| 1031 | 1034 | restore, err := term.EnterRaw() | |
| 1032 | 1035 | if err != nil { | |
| @@ -7672,7 +7675,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7672 | 7675 | i.handleEventForPresentation(ev) | |
| 7673 | 7676 | } | |
| 7674 | 7677 | ||
| 7678 | + | releaseCompletionHold := i.beginCompletionDeliveryHold() | |
| 7675 | 7679 | go func() { | |
| 7680 | + | defer releaseCompletionHold() | |
| 7676 | 7681 | var err error | |
| 7677 | 7682 | if continueExisting { | |
| 7678 | 7683 | err = i.agent.Continue(ctx, sink) | |
| @@ -8694,166 +8699,150 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 8694 | 8699 | return a.bridge != nil && a.bridge.Active() | |
| 8695 | 8700 | } | |
| 8696 | 8701 | ||
| 8697 | - | // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the | |
| 8698 | - | // delegated turn's outcome separately from the long-lived daemon status. | |
| 8699 | - | type subagentWatchEntry struct { | |
| 8700 | - | agent *subagents.Agent | |
| 8701 | - | task string | |
| 8702 | - | followUp bool | |
| 8703 | - | done bool | |
| 8704 | - | outcome string | |
| 8705 | - | err string | |
| 8706 | - | } | |
| 8707 | - | ||
| 8708 | 8702 | // TrackSubagentWorker is the exported entry point used by the cli to | |
| 8709 | - | // hand a freshly-spawned auto-subagents agent off to the watcher. | |
| 8703 | + | // hand a freshly-spawned auto-subagents agent off to the shared tracker. | |
| 8710 | 8704 | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) { | |
| 8711 | - | i.trackSubagentWorker(a, task, false) | |
| 8705 | + | i.trackSubagentWorker(a, task, false, false) | |
| 8712 | 8706 | } | |
| 8713 | 8707 | ||
| 8714 | 8708 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | |
| 8715 | 8709 | // worker's original task. A long-lived daemon can complete several manager | |
| 8716 | 8710 | // turns, each of which must produce its own automatic delivery. | |
| 8717 | 8711 | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) { | |
| 8718 | - | i.trackSubagentWorker(a, prompt, true) | |
| 8712 | + | i.trackSubagentWorker(a, prompt, true, false) | |
| 8713 | + | } | |
| 8714 | + | ||
| 8715 | + | // PrepareResumedSubagentWorker registers a resumed turn before its prompt is | |
| 8716 | + | // sent. The returned cleanup removes the registration if the resume operation | |
| 8717 | + | // fails before delivery. It is used by the resume tool's pre-send hook; the | |
| 8718 | + | // existing TrackResumedSubagentWorker signature remains the post-success API. | |
| 8719 | + | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string) func() { | |
| 8720 | + | return i.trackSubagentWorker(a, prompt, true, true) | |
| 8719 | 8721 | } | |
| 8720 | 8722 | ||
| 8721 | 8723 | // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active | |
| 8722 | - | // task watcher already owns the worker, that watcher reports the terminal | |
| 8723 | - | // outcome; otherwise this creates a single exit-only watcher, including for a | |
| 8724 | - | // worker restored by Reload. | |
| 8724 | + | // task watcher already owns the worker, the shared tracker leaves that watcher | |
| 8725 | + | // responsible for the terminal outcome. | |
| 8725 | 8726 | func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) { | |
| 8726 | 8727 | if i == nil || a == nil { | |
| 8727 | 8728 | return | |
| 8728 | 8729 | } | |
| 8729 | - | i.subagentWatchMu.Lock() | |
| 8730 | - | for _, existing := range i.subagentWatch { | |
| 8731 | - | if existing.agent.ID == a.ID && !existing.done { | |
| 8732 | - | i.subagentWatchMu.Unlock() | |
| 8733 | - | return | |
| 8734 | - | } | |
| 8735 | - | } | |
| 8736 | - | entry := &subagentWatchEntry{agent: a, task: a.Task} | |
| 8737 | - | i.subagentWatch = append(i.subagentWatch, entry) | |
| 8738 | - | i.subagentWatchMu.Unlock() | |
| 8730 | + | tracker := i.ensureCompletionTracker() | |
| 8731 | + | tracker.TrackExit(a, a.Task) | |
| 8739 | 8732 | i.invalidate() | |
| 8740 | - | i.watchSubagentExit(entry) | |
| 8733 | + | i.requestCompletionDelivery() | |
| 8741 | 8734 | } | |
| 8742 | 8735 | ||
| 8743 | - | // trackSubagentWorker records an auto-subagents turn and subscribes to both | |
| 8744 | - | // ways it can finish. Successful long-lived agents report a prompt-level | |
| 8745 | - | // turn_end and keep listening; startup failures and unexpected daemon exits | |
| 8746 | - | // only unblock Agent.Wait. Whichever signal arrives first completes the entry | |
| 8747 | - | // exactly once. | |
| 8748 | - | func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) { | |
| 8736 | + | func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp, future bool) func() { | |
| 8749 | 8737 | if i == nil || a == nil { | |
| 8750 | - | return | |
| 8738 | + | return nil | |
| 8739 | + | } | |
| 8740 | + | tracker := i.ensureCompletionTracker() | |
| 8741 | + | var cancel func() | |
| 8742 | + | if future { | |
| 8743 | + | cancel = tracker.TrackFutureTurn(a, task, followUp) | |
| 8744 | + | } else { | |
| 8745 | + | cancel = tracker.TrackTurn(a, task, followUp) | |
| 8751 | 8746 | } | |
| 8752 | - | entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp} | |
| 8753 | - | i.subagentWatchMu.Lock() | |
| 8754 | - | i.subagentWatch = append(i.subagentWatch, entry) | |
| 8755 | - | i.subagentWatchMu.Unlock() | |
| 8756 | 8747 | i.invalidate() | |
| 8748 | + | i.requestCompletionDelivery() | |
| 8749 | + | return cancel | |
| 8750 | + | } | |
| 8757 | 8751 | ||
| 8758 | - | a.SetOnTurnEnd(func(step int, errMsg string) { | |
| 8759 | - | outcome := "completed" | |
| 8760 | - | if errMsg != "" { | |
| 8761 | - | outcome = "failed" | |
| 8762 | - | } | |
| 8763 | - | i.completeSupervisorWatchEntry(entry, outcome, errMsg) | |
| 8764 | - | }) | |
| 8765 | - | i.watchSubagentExit(entry) | |
| 8752 | + | func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker { | |
| 8753 | + | i.completionDeliveryMu.Lock() | |
| 8754 | + | defer i.completionDeliveryMu.Unlock() | |
| 8755 | + | if i.completionTracker == nil { | |
| 8756 | + | i.completionTracker = subagents.NewCompletionTracker() | |
| 8757 | + | } | |
| 8758 | + | return i.completionTracker | |
| 8766 | 8759 | } | |
| 8767 | 8760 | ||
| 8768 | - | func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) { | |
| 8769 | - | go func() { | |
| 8770 | - | entry.agent.Wait() | |
| 8771 | - | snap := entry.agent.Snapshot() | |
| 8772 | - | outcome := string(snap.Status) | |
| 8773 | - | if snap.Status == subagents.StatusDone { | |
| 8774 | - | outcome = "completed" | |
| 8775 | - | } | |
| 8776 | - | i.completeSupervisorWatchEntry(entry, outcome, snap.Err) | |
| 8777 | - | }() | |
| 8761 | + | // requestCompletionDelivery starts at most one waiter for the current active | |
| 8762 | + | // set. A request arriving while that waiter is formatting or submitting is | |
| 8763 | + | // picked up before the waiter exits, so a later worker cannot be lost in the | |
| 8764 | + | // handoff between batches. | |
| 8765 | + | func (i *Interactive) requestCompletionDelivery() { | |
| 8766 | + | if i == nil { | |
| 8767 | + | return | |
| 8768 | + | } | |
| 8769 | + | i.completionDeliveryMu.Lock() | |
| 8770 | + | i.completionDeliveryRequest = true | |
| 8771 | + | if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 { | |
| 8772 | + | i.completionDeliveryMu.Unlock() | |
| 8773 | + | return | |
| 8774 | + | } | |
| 8775 | + | i.completionDeliveryRunning = true | |
| 8776 | + | i.completionDeliveryMu.Unlock() | |
| 8777 | + | ||
| 8778 | + | go i.deliverCompletionUpdates() | |
| 8779 | + | } | |
| 8780 | + | ||
| 8781 | + | // beginCompletionDeliveryHold keeps all completions observed during one | |
| 8782 | + | // parent model turn together. Tool calls are executed sequentially inside the | |
| 8783 | + | // core agent, so the release point is the owning registration boundary for | |
| 8784 | + | // same-parent-turn batching rather than a timing-based debounce. | |
| 8785 | + | func (i *Interactive) beginCompletionDeliveryHold() func() { | |
| 8786 | + | if i == nil { | |
| 8787 | + | return func() {} | |
| 8788 | + | } | |
| 8789 | + | i.completionDeliveryMu.Lock() | |
| 8790 | + | i.completionDeliveryHolds++ | |
| 8791 | + | i.completionDeliveryMu.Unlock() | |
| 8792 | + | return i.releaseCompletionDeliveryHold | |
| 8778 | 8793 | } | |
| 8779 | 8794 | ||
| 8780 | - | func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) { | |
| 8781 | - | i.subagentWatchMu.Lock() | |
| 8782 | - | if entry.done { | |
| 8783 | - | i.subagentWatchMu.Unlock() | |
| 8795 | + | func (i *Interactive) releaseCompletionDeliveryHold() { | |
| 8796 | + | if i == nil { | |
| 8784 | 8797 | return | |
| 8785 | 8798 | } | |
| 8786 | - | entry.done = true | |
| 8787 | - | entry.outcome = outcome | |
| 8788 | - | entry.err = errMsg | |
| 8789 | - | allDone := true | |
| 8790 | - | for _, e := range i.subagentWatch { | |
| 8791 | - | if !e.done { | |
| 8792 | - | allDone = false | |
| 8793 | - | break | |
| 8794 | - | } | |
| 8799 | + | start := false | |
| 8800 | + | i.completionDeliveryMu.Lock() | |
| 8801 | + | if i.completionDeliveryHolds > 0 { | |
| 8802 | + | i.completionDeliveryHolds-- | |
| 8795 | 8803 | } | |
| 8796 | - | var batch []*subagentWatchEntry | |
| 8797 | - | if allDone { | |
| 8798 | - | batch = i.subagentWatch | |
| 8799 | - | i.subagentWatch = nil | |
| 8804 | + | if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning { | |
| 8805 | + | i.completionDeliveryRunning = true | |
| 8806 | + | start = true | |
| 8800 | 8807 | } | |
| 8801 | - | i.subagentWatchMu.Unlock() | |
| 8802 | - | if len(batch) != 0 { | |
| 8803 | - | i.flushSupervisorSummary(batch) | |
| 8808 | + | i.completionDeliveryMu.Unlock() | |
| 8809 | + | if start { | |
| 8810 | + | go i.deliverCompletionUpdates() | |
| 8804 | 8811 | } | |
| 8805 | 8812 | } | |
| 8806 | 8813 | ||
| 8807 | - | // flushSupervisorSummary composes a synthetic user turn describing every | |
| 8808 | - | // sub-agent's outcome and injects it via SubmitOrQueue so the main | |
| 8809 | - | // agent picks it up at the next safe boundary. The summary is | |
| 8810 | - | // phrased as a system update ("Auto-subagents finished: ...") so the | |
| 8811 | - | // model treats it as observed state, not as a fresh user request. | |
| 8812 | - | func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) { | |
| 8813 | - | if len(batch) == 0 { | |
| 8814 | - | return | |
| 8814 | + | func (i *Interactive) completionWaitContext() context.Context { | |
| 8815 | + | i.mu.Lock() | |
| 8816 | + | ctx := i.runCtx | |
| 8817 | + | i.mu.Unlock() | |
| 8818 | + | if ctx == nil { | |
| 8819 | + | return context.Background() | |
| 8815 | 8820 | } | |
| 8816 | - | var sb strings.Builder | |
| 8817 | - | fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch)) | |
| 8818 | - | for idx, e := range batch { | |
| 8819 | - | snap := e.agent.Snapshot() | |
| 8820 | - | status := e.outcome | |
| 8821 | - | if status == "" { | |
| 8822 | - | status = string(snap.Status) | |
| 8823 | - | } | |
| 8824 | - | task := e.task | |
| 8825 | - | if task == "" || !e.followUp { | |
| 8826 | - | task = snap.Task | |
| 8827 | - | } | |
| 8828 | - | if task == "" { | |
| 8829 | - | task = e.task | |
| 8830 | - | } | |
| 8831 | - | fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status) | |
| 8832 | - | fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240)) | |
| 8833 | - | if snap.Err != "" { | |
| 8834 | - | fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240)) | |
| 8835 | - | } else if e.err != "" { | |
| 8836 | - | fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240)) | |
| 8837 | - | } | |
| 8838 | - | if snap.LastAssistant != "" { | |
| 8839 | - | sb.WriteString(" final response:\n") | |
| 8840 | - | sb.WriteString(snap.LastAssistant) | |
| 8841 | - | sb.WriteString("\n") | |
| 8842 | - | } else if tail := strings.TrimSpace(snap.Tail); tail != "" { | |
| 8843 | - | fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600)) | |
| 8844 | - | } | |
| 8845 | - | sb.WriteString("\n") | |
| 8846 | - | } | |
| 8847 | - | sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.") | |
| 8848 | - | i.SubmitOrQueue(sb.String(), nil) | |
| 8849 | - | } | |
| 8850 | - | ||
| 8851 | - | func truncateForSummary(s string, n int) string { | |
| 8852 | - | s = strings.TrimSpace(s) | |
| 8853 | - | if len(s) <= n { | |
| 8854 | - | return s | |
| 8821 | + | return ctx | |
| 8822 | + | } | |
| 8823 | + | ||
| 8824 | + | func (i *Interactive) deliverCompletionUpdates() { | |
| 8825 | + | for { | |
| 8826 | + | i.completionDeliveryMu.Lock() | |
| 8827 | + | i.completionDeliveryRequest = false | |
| 8828 | + | tracker := i.completionTracker | |
| 8829 | + | i.completionDeliveryMu.Unlock() | |
| 8830 | + | ||
| 8831 | + | batch, err := tracker.WaitIdle(i.completionWaitContext()) | |
| 8832 | + | if err == nil && len(batch) != 0 { | |
| 8833 | + | instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks." | |
| 8834 | + | i.SubmitOrQueue(subagents.FormatCompletionUpdate(batch, instruction), nil) | |
| 8835 | + | } | |
| 8836 | + | ||
| 8837 | + | i.completionDeliveryMu.Lock() | |
| 8838 | + | if i.completionDeliveryRequest { | |
| 8839 | + | i.completionDeliveryMu.Unlock() | |
| 8840 | + | continue | |
| 8841 | + | } | |
| 8842 | + | i.completionDeliveryRunning = false | |
| 8843 | + | i.completionDeliveryMu.Unlock() | |
| 8844 | + | return | |
| 8855 | 8845 | } | |
| 8856 | - | return s[:n-3] + "..." | |
| 8857 | 8846 | } | |
| 8858 | 8847 | ||
| 8859 | 8848 | // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents | |
| @@ -9055,9 +9044,9 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 9055 | 9044 | } | |
| 9056 | 9045 | if i.autoSubagentsResumeToolAllowed() { | |
| 9057 | 9046 | resumeTool := &tools.SubagentResumeTool{ | |
| 9058 | - | Supervisor: i.cfg.Supervisor, | |
| 9059 | - | Enabled: func() bool { return true }, | |
| 9060 | - | OnResumed: i.TrackResumedSubagentWorker, | |
| 9047 | + | Supervisor: i.cfg.Supervisor, | |
| 9048 | + | Enabled: func() bool { return true }, | |
| 9049 | + | BeforeResumed: i.PrepareResumedSubagentWorker, | |
| 9061 | 9050 | } | |
| 9062 | 9051 | next[resumeTool.Name()] = resumeTool | |
| 9063 | 9052 | } | |
M packages/agent/modes/json.go +7 -9
| @@ -3,7 +3,7 @@ package modes | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | - | "fmt" | |
| 6 | + | "errors" | |
| 7 | 7 | "io" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zut/packages/core" | |
| @@ -24,8 +24,11 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||
| 24 | 24 | // a successful checkpoint. | |
| 25 | 25 | func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | |
| 26 | 26 | enc := json.NewEncoder(out) | |
| 27 | + | var writeErr error | |
| 27 | 28 | write := func(v any) { | |
| 28 | - | _ = enc.Encode(v) | |
| 29 | + | if writeErr == nil { | |
| 30 | + | writeErr = enc.Encode(v) | |
| 31 | + | } | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | var runErr error | |
| @@ -42,9 +45,9 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri | |||
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | 47 | if runErr != nil { | |
| 45 | - | fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`) | |
| 48 | + | write(map[string]any{"type": "error", "message": runErr.Error()}) | |
| 46 | 49 | } | |
| 47 | - | return recovery, runErr | |
| 50 | + | return recovery, errors.Join(runErr, writeErr) | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 50 | 53 | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | |
| @@ -155,8 +158,3 @@ func nullableReasoningTokens(usage provider.Usage) any { | |||
| 155 | 158 | } | |
| 156 | 159 | return usage.ReasoningTokens | |
| 157 | 160 | } | |
| 158 | - | ||
| 159 | - | func jsonString(s string) string { | |
| 160 | - | b, _ := json.Marshal(s) | |
| 161 | - | return string(b) | |
| 162 | - | } | |
M packages/agent/modes/json_activity_test.go +62 -0
| @@ -1,12 +1,74 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "strings" | |
| 4 | 9 | "testing" | |
| 5 | 10 | "time" | |
| 6 | 11 | ||
| 7 | 12 | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | 14 | ) | |
| 9 | 15 | ||
| 16 | + | func TestRunJSONCompletionUserMessageIsValidJSONL(t *testing.T) { | |
| 17 | + | message := core.EvUserMessage{Message: provider.Message{ | |
| 18 | + | Role: provider.RoleUser, | |
| 19 | + | Content: []provider.Content{provider.TextBlock{Text: "[auto-subagents update] worker finished"}}, | |
| 20 | + | }} | |
| 21 | + | var line bytes.Buffer | |
| 22 | + | if err := json.NewEncoder(&line).Encode(EventToJSON(message)); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | var decoded map[string]any | |
| 26 | + | if err := json.Unmarshal(bytes.TrimSpace(line.Bytes()), &decoded); err != nil { | |
| 27 | + | t.Fatalf("completion event is not JSON: %v", err) | |
| 28 | + | } | |
| 29 | + | if decoded["type"] != "user_message" { | |
| 30 | + | t.Fatalf("event type = %#v, want user_message", decoded["type"]) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type failingJSONWriter struct{ err error } | |
| 35 | + | ||
| 36 | + | func (w failingJSONWriter) Write([]byte) (int, error) { return 0, w.err } | |
| 37 | + | ||
| 38 | + | func TestRunJSONPropagatesOutputErrors(t *testing.T) { | |
| 39 | + | providerErr := errors.New("provider unavailable") | |
| 40 | + | writerErr := errors.New("stdout unavailable") | |
| 41 | + | ag := core.NewAgent(&streamTestClient{err: providerErr}, "test-model", "", nil) | |
| 42 | + | _, err := RunJSONWithContextRecovery(context.Background(), ag, "hello", nil, failingJSONWriter{err: writerErr}, nil) | |
| 43 | + | if !errors.Is(err, providerErr) || !errors.Is(err, writerErr) { | |
| 44 | + | t.Fatalf("RunJSONWithContextRecovery error = %v, want provider and output errors", err) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestRunJSONFailureEmitsOneErrorObject(t *testing.T) { | |
| 49 | + | client := &streamTestClient{err: errors.New("provider unavailable")} | |
| 50 | + | ag := core.NewAgent(client, "test-model", "", nil) | |
| 51 | + | var out bytes.Buffer | |
| 52 | + | _, err := RunJSONWithContextRecovery(context.Background(), ag, "hello", nil, &out, nil) | |
| 53 | + | if err == nil { | |
| 54 | + | t.Fatal("RunJSONWithContextRecovery unexpectedly succeeded") | |
| 55 | + | } | |
| 56 | + | lines := strings.Split(strings.TrimSpace(out.String()), "\n") | |
| 57 | + | errorObjects := 0 | |
| 58 | + | for _, line := range lines { | |
| 59 | + | var decoded map[string]any | |
| 60 | + | if err := json.Unmarshal([]byte(line), &decoded); err != nil { | |
| 61 | + | t.Fatalf("invalid JSONL line %q: %v", line, err) | |
| 62 | + | } | |
| 63 | + | if decoded["type"] == "error" { | |
| 64 | + | errorObjects++ | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | if errorObjects != 1 { | |
| 68 | + | t.Fatalf("error objects = %d, want one; output=%q", errorObjects, out.String()) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 10 | 72 | func TestEventToJSONActivityLifecycle(t *testing.T) { | |
| 11 | 73 | request := EventToJSON(core.EvRequestStarted{ | |
| 12 | 74 | Provider: "anthropic", | |
M packages/agent/modes/stream_test.go +4 -0
| @@ -11,11 +11,15 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | type streamTestClient struct { | |
| 13 | 13 | events []provider.Event | |
| 14 | + | err error | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | func (c *streamTestClient) Name() string { return "stream-test" } | |
| 17 | 18 | ||
| 18 | 19 | func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 20 | + | if c.err != nil { | |
| 21 | + | return nil, c.err | |
| 22 | + | } | |
| 19 | 23 | out := make(chan provider.Event, len(c.events)) | |
| 20 | 24 | go func() { | |
| 21 | 25 | defer close(out) | |
A packages/agent/orchestration.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // validateOrchestrationArgs is the single launch-time gate for headless | |
| 9 | + | // orchestration. Callers should run it before any mode-specific setup and | |
| 10 | + | // again after attaching packaged-agent metadata when using `zut run`. | |
| 11 | + | func validateOrchestrationArgs(args Args) error { | |
| 12 | + | if !args.Orchestrate { | |
| 13 | + | return nil | |
| 14 | + | } | |
| 15 | + | switch args.Mode { | |
| 16 | + | case ModePrint, ModeStream, ModeJSON: | |
| 17 | + | // These are the non-interactive output modes. Print is the first | |
| 18 | + | // execution path; stream and JSON retain the same eligibility contract | |
| 19 | + | // for callers that select those output protocols. | |
| 20 | + | case ModeRPC: | |
| 21 | + | return fmt.Errorf("--orchestrate is not supported with RPC mode") | |
| 22 | + | case ModeSubagentWorker: | |
| 23 | + | return fmt.Errorf("--orchestrate is not supported in subagent-worker mode") | |
| 24 | + | default: | |
| 25 | + | return fmt.Errorf("--orchestrate requires print, stream, or JSON mode") | |
| 26 | + | } | |
| 27 | + | if strings.TrimSpace(args.Subagent) != "" { | |
| 28 | + | return fmt.Errorf("--orchestrate is not supported with --subagent profiles") | |
| 29 | + | } | |
| 30 | + | if args.PermissionSet != nil || strings.TrimSpace(args.AgentName) != "" || strings.TrimSpace(args.AgentDataDir) != "" { | |
| 31 | + | return fmt.Errorf("--orchestrate is not supported for packaged agents") | |
| 32 | + | } | |
| 33 | + | if args.StatsPath != "" { | |
| 34 | + | return fmt.Errorf("--orchestrate cannot be combined with --stats") | |
| 35 | + | } | |
| 36 | + | if !autoSubagentsToolAllowed(args) { | |
| 37 | + | return fmt.Errorf("--orchestrate requires the subagent_spawn tool") | |
| 38 | + | } | |
| 39 | + | return nil | |
| 40 | + | } | |
A packages/agent/orchestration_cli_e2e_test.go +405 -0
| @@ -0,0 +1,405 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path/filepath" | |
| 13 | + | "runtime" | |
| 14 | + | "strings" | |
| 15 | + | "sync" | |
| 16 | + | "syscall" | |
| 17 | + | "testing" | |
| 18 | + | "time" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) { | |
| 22 | + | if runtime.GOOS == "windows" { | |
| 23 | + | t.Skip("signal and subprocess semantics differ on Windows") | |
| 24 | + | } | |
| 25 | + | if testing.Short() { | |
| 26 | + | t.Skip("skip CLI orchestration end-to-end test in -short mode") | |
| 27 | + | } | |
| 28 | + | binary := buildOrchestrationCLI(t) | |
| 29 | + | for _, mode := range []string{"print", "stream", "json"} { | |
| 30 | + | t.Run(mode, func(t *testing.T) { | |
| 31 | + | server := newTwoWaveProvider(t) | |
| 32 | + | defer server.Close() | |
| 33 | + | home := t.TempDir() | |
| 34 | + | args := []string{"--orchestrate", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL(), "--api-key", "test-key"} | |
| 35 | + | switch mode { | |
| 36 | + | case "print": | |
| 37 | + | args = append(args, "--print") | |
| 38 | + | case "stream": | |
| 39 | + | args = append(args, "--stream") | |
| 40 | + | case "json": | |
| 41 | + | args = append(args, "--json") | |
| 42 | + | } | |
| 43 | + | args = append(args, "delegate two waves") | |
| 44 | + | cmd := exec.Command(binary, args...) | |
| 45 | + | cmd.Env = orchestrationCLIEnv(binary, home) | |
| 46 | + | var stdout, stderr bytes.Buffer | |
| 47 | + | cmd.Stdout = &stdout | |
| 48 | + | cmd.Stderr = &stderr | |
| 49 | + | err := cmd.Run() | |
| 50 | + | output := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...) | |
| 51 | + | if err != nil { | |
| 52 | + | t.Fatalf("zut %s: %v\n%s", mode, err, output) | |
| 53 | + | } | |
| 54 | + | if !bytes.Contains(output, []byte("final synthesis")) { | |
| 55 | + | t.Fatalf("%s output omitted final synthesis: %s", mode, output) | |
| 56 | + | } | |
| 57 | + | if server.ParentCalls() != 5 || server.WorkerCalls() < 2 { | |
| 58 | + | t.Fatalf("%s calls = parent %d, worker %d; want two delegation waves and two workers", mode, server.ParentCalls(), server.WorkerCalls()) | |
| 59 | + | } | |
| 60 | + | if mode == "json" { | |
| 61 | + | events := assertJSONL(t, stdout.Bytes()) | |
| 62 | + | assertJSONOrchestrationWaves(t, events) | |
| 63 | + | } | |
| 64 | + | if mode == "stream" && !bytes.Contains(stdout.Bytes(), []byte("wave one synthesis")) { | |
| 65 | + | t.Fatalf("stream output omitted first parent turn: %s", output) | |
| 66 | + | } | |
| 67 | + | if mode == "stream" && bytes.Contains(stdout.Bytes(), []byte("[auto-subagents update]")) { | |
| 68 | + | t.Fatalf("stream stdout leaked internal completion prompt: %s", stdout.Bytes()) | |
| 69 | + | } | |
| 70 | + | if mode == "print" && bytes.Contains(stdout.Bytes(), []byte("wave one synthesis")) { | |
| 71 | + | t.Fatalf("print output leaked intermediate parent turn: %s", output) | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestOrchestratedCLICancellation(t *testing.T) { | |
| 78 | + | if runtime.GOOS == "windows" { | |
| 79 | + | t.Skip("signal and subprocess semantics differ on Windows") | |
| 80 | + | } | |
| 81 | + | if testing.Short() { | |
| 82 | + | t.Skip("skip CLI orchestration end-to-end test in -short mode") | |
| 83 | + | } | |
| 84 | + | binary := buildOrchestrationCLI(t) | |
| 85 | + | server, workerReady, releaseWorker := newCancellationProvider(t) | |
| 86 | + | defer server.Close() | |
| 87 | + | defer close(releaseWorker) | |
| 88 | + | ||
| 89 | + | cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "cancel") | |
| 90 | + | cmd.Env = orchestrationCLIEnv(binary, t.TempDir()) | |
| 91 | + | var stdout, stderr bytes.Buffer | |
| 92 | + | cmd.Stdout = &stdout | |
| 93 | + | cmd.Stderr = &stderr | |
| 94 | + | if err := cmd.Start(); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | waitForWorker := time.NewTimer(10 * time.Second) | |
| 98 | + | select { | |
| 99 | + | case <-workerReady: | |
| 100 | + | if !waitForWorker.Stop() { | |
| 101 | + | <-waitForWorker.C | |
| 102 | + | } | |
| 103 | + | case <-waitForWorker.C: | |
| 104 | + | _ = cmd.Process.Kill() | |
| 105 | + | _ = cmd.Wait() | |
| 106 | + | t.Fatal("worker did not reach the blocking provider request") | |
| 107 | + | } | |
| 108 | + | if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { | |
| 109 | + | t.Fatalf("send cancellation: %v", err) | |
| 110 | + | } | |
| 111 | + | wait := make(chan error, 1) | |
| 112 | + | go func() { wait <- cmd.Wait() }() | |
| 113 | + | select { | |
| 114 | + | case err := <-wait: | |
| 115 | + | if err == nil { | |
| 116 | + | t.Fatal("canceled orchestration unexpectedly succeeded") | |
| 117 | + | } | |
| 118 | + | case <-time.After(10 * time.Second): | |
| 119 | + | _ = cmd.Process.Kill() | |
| 120 | + | t.Fatal("canceled orchestration did not exit") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | events := assertJSONL(t, stdout.Bytes()) | |
| 124 | + | errorCount := 0 | |
| 125 | + | for _, event := range events { | |
| 126 | + | if event["type"] == "error" { | |
| 127 | + | errorCount++ | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | if errorCount != 1 { | |
| 131 | + | t.Fatalf("cancellation error objects = %d, want one; stdout=%s stderr=%s", errorCount, stdout.Bytes(), stderr.Bytes()) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestOrchestratedCLIParentFailure(t *testing.T) { | |
| 136 | + | if runtime.GOOS == "windows" { | |
| 137 | + | t.Skip("signal and subprocess semantics differ on Windows") | |
| 138 | + | } | |
| 139 | + | if testing.Short() { | |
| 140 | + | t.Skip("skip CLI orchestration end-to-end test in -short mode") | |
| 141 | + | } | |
| 142 | + | binary := buildOrchestrationCLI(t) | |
| 143 | + | ||
| 144 | + | t.Run("failure-jsonl", func(t *testing.T) { | |
| 145 | + | server := newFailingProvider(t) | |
| 146 | + | defer server.Close() | |
| 147 | + | cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail") | |
| 148 | + | cmd.Env = orchestrationCLIEnv(binary, t.TempDir()) | |
| 149 | + | var stdout, stderr bytes.Buffer | |
| 150 | + | cmd.Stdout = &stdout | |
| 151 | + | cmd.Stderr = &stderr | |
| 152 | + | err := cmd.Run() | |
| 153 | + | if err == nil { | |
| 154 | + | t.Fatal("failing orchestration unexpectedly succeeded") | |
| 155 | + | } | |
| 156 | + | events := assertJSONL(t, stdout.Bytes()) | |
| 157 | + | errorCount := 0 | |
| 158 | + | for _, event := range events { | |
| 159 | + | if event["type"] == "error" { | |
| 160 | + | errorCount++ | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | if errorCount != 1 { | |
| 164 | + | output := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...) | |
| 165 | + | t.Fatalf("error objects = %d, want one: %s", errorCount, output) | |
| 166 | + | } | |
| 167 | + | }) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | type twoWaveProvider struct { | |
| 171 | + | t *testing.T | |
| 172 | + | server *httptest.Server | |
| 173 | + | mu sync.Mutex | |
| 174 | + | parentCalls int | |
| 175 | + | workerCalls int | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func newTwoWaveProvider(t *testing.T) *twoWaveProvider { | |
| 179 | + | p := &twoWaveProvider{t: t} | |
| 180 | + | p.server = httptest.NewServer(http.HandlerFunc(p.serveHTTP)) | |
| 181 | + | return p | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (p *twoWaveProvider) URL() string { return p.server.URL } | |
| 185 | + | func (p *twoWaveProvider) Close() { p.server.Close() } | |
| 186 | + | ||
| 187 | + | func (p *twoWaveProvider) ParentCalls() int { | |
| 188 | + | p.mu.Lock() | |
| 189 | + | defer p.mu.Unlock() | |
| 190 | + | return p.parentCalls | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (p *twoWaveProvider) WorkerCalls() int { | |
| 194 | + | p.mu.Lock() | |
| 195 | + | defer p.mu.Unlock() | |
| 196 | + | return p.workerCalls | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (p *twoWaveProvider) serveHTTP(w http.ResponseWriter, r *http.Request) { | |
| 200 | + | body, err := io.ReadAll(r.Body) | |
| 201 | + | if err != nil { | |
| 202 | + | p.t.Errorf("read provider request: %v", err) | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | p.mu.Lock() | |
| 206 | + | isParent := requestIsParent(body) | |
| 207 | + | if isParent { | |
| 208 | + | p.parentCalls++ | |
| 209 | + | call := p.parentCalls | |
| 210 | + | p.mu.Unlock() | |
| 211 | + | switch call { | |
| 212 | + | case 1: | |
| 213 | + | writeToolCall(w, "wave-one", "wave one") | |
| 214 | + | case 2: | |
| 215 | + | writeText(w, "wave one synthesis") | |
| 216 | + | case 3: | |
| 217 | + | writeToolCall(w, "wave-two", "wave two") | |
| 218 | + | default: | |
| 219 | + | writeText(w, "final synthesis") | |
| 220 | + | } | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | p.workerCalls++ | |
| 224 | + | worker := p.workerCalls | |
| 225 | + | p.mu.Unlock() | |
| 226 | + | writeText(w, fmt.Sprintf("worker result %d", worker)) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, chan<- struct{}) { | |
| 230 | + | workerReady := make(chan struct{}) | |
| 231 | + | releaseWorker := make(chan struct{}) | |
| 232 | + | var once sync.Once | |
| 233 | + | var mu sync.Mutex | |
| 234 | + | requests := 0 | |
| 235 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 236 | + | mu.Lock() | |
| 237 | + | requests++ | |
| 238 | + | parent := requests == 1 | |
| 239 | + | mu.Unlock() | |
| 240 | + | if parent { | |
| 241 | + | writeToolCall(w, "cancel-worker", "long running worker") | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | once.Do(func() { close(workerReady) }) | |
| 245 | + | select { | |
| 246 | + | case <-r.Context().Done(): | |
| 247 | + | case <-releaseWorker: | |
| 248 | + | } | |
| 249 | + | })) | |
| 250 | + | return server, workerReady, releaseWorker | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func newFailingProvider(t *testing.T) *httptest.Server { | |
| 254 | + | var mu sync.Mutex | |
| 255 | + | parentCalls := 0 | |
| 256 | + | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 257 | + | body, _ := io.ReadAll(r.Body) | |
| 258 | + | if requestIsParent(body) { | |
| 259 | + | mu.Lock() | |
| 260 | + | parentCalls++ | |
| 261 | + | call := parentCalls | |
| 262 | + | mu.Unlock() | |
| 263 | + | if call == 1 { | |
| 264 | + | writeToolCall(w, "failure-worker", "failure wave") | |
| 265 | + | return | |
| 266 | + | } | |
| 267 | + | w.WriteHeader(http.StatusBadGateway) | |
| 268 | + | _, _ = io.WriteString(w, `{"error":{"message":"synthetic provider failure","type":"server_error"}}`) | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | writeText(w, "worker completed before parent failure") | |
| 272 | + | })) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func requestIsParent(body []byte) bool { | |
| 276 | + | var request struct { | |
| 277 | + | Messages []struct { | |
| 278 | + | Role string `json:"role"` | |
| 279 | + | Content json.RawMessage `json:"content"` | |
| 280 | + | } `json:"messages"` | |
| 281 | + | } | |
| 282 | + | if json.Unmarshal(body, &request) != nil { | |
| 283 | + | return false | |
| 284 | + | } | |
| 285 | + | for _, message := range request.Messages { | |
| 286 | + | if message.Role != "user" { | |
| 287 | + | continue | |
| 288 | + | } | |
| 289 | + | var text string | |
| 290 | + | if json.Unmarshal(message.Content, &text) == nil { | |
| 291 | + | if text == "delegate two waves" || text == "fail" || text == "cancel" { | |
| 292 | + | return true | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | if bytes.Contains(message.Content, []byte(`delegate two waves`)) || bytes.Contains(message.Content, []byte(`"text":"fail"`)) || bytes.Contains(message.Content, []byte(`"text":"cancel"`)) { | |
| 296 | + | return true | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | return false | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func writeToolCall(w http.ResponseWriter, id, task string) { | |
| 303 | + | w.Header().Set("content-type", "text/event-stream") | |
| 304 | + | arguments, _ := json.Marshal(map[string]string{"task": task}) | |
| 305 | + | chunk := map[string]any{ | |
| 306 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]string{"name": "subagent_spawn", "arguments": string(arguments)}}}}, "finish_reason": "tool_calls"}}, | |
| 307 | + | "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2}, | |
| 308 | + | } | |
| 309 | + | data, _ := json.Marshal(chunk) | |
| 310 | + | _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func writeText(w http.ResponseWriter, text string) { | |
| 314 | + | w.Header().Set("content-type", "text/event-stream") | |
| 315 | + | chunk := map[string]any{ | |
| 316 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": "stop"}}, | |
| 317 | + | "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2}, | |
| 318 | + | } | |
| 319 | + | data, _ := json.Marshal(chunk) | |
| 320 | + | _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func assertJSONL(t *testing.T, output []byte) []map[string]any { | |
| 324 | + | t.Helper() | |
| 325 | + | var events []map[string]any | |
| 326 | + | for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { | |
| 327 | + | if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "zut:") { | |
| 328 | + | continue | |
| 329 | + | } | |
| 330 | + | var event map[string]any | |
| 331 | + | if err := json.Unmarshal([]byte(line), &event); err != nil { | |
| 332 | + | t.Fatalf("invalid JSONL line %q: %v", line, err) | |
| 333 | + | } | |
| 334 | + | events = append(events, event) | |
| 335 | + | } | |
| 336 | + | return events | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func assertJSONOrchestrationWaves(t *testing.T, events []map[string]any) { | |
| 340 | + | t.Helper() | |
| 341 | + | var userMessages []string | |
| 342 | + | seenTypes := make(map[string]bool) | |
| 343 | + | for _, event := range events { | |
| 344 | + | if typ, ok := event["type"].(string); ok { | |
| 345 | + | seenTypes[typ] = true | |
| 346 | + | } | |
| 347 | + | if event["type"] != "user_message" { | |
| 348 | + | continue | |
| 349 | + | } | |
| 350 | + | content, ok := event["content"].([]any) | |
| 351 | + | if !ok { | |
| 352 | + | t.Fatalf("user_message content = %T, want JSON array", event["content"]) | |
| 353 | + | } | |
| 354 | + | var text strings.Builder | |
| 355 | + | for _, block := range content { | |
| 356 | + | object, ok := block.(map[string]any) | |
| 357 | + | if !ok || object["type"] != "text" { | |
| 358 | + | continue | |
| 359 | + | } | |
| 360 | + | if value, ok := object["text"].(string); ok { | |
| 361 | + | text.WriteString(value) | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | userMessages = append(userMessages, text.String()) | |
| 365 | + | } | |
| 366 | + | if len(userMessages) < 3 { | |
| 367 | + | t.Fatalf("user messages = %d, want initial prompt and two parent completion turns", len(userMessages)) | |
| 368 | + | } | |
| 369 | + | completionMessages := 0 | |
| 370 | + | for _, message := range userMessages { | |
| 371 | + | if strings.HasPrefix(message, "[auto-subagents update]") { | |
| 372 | + | completionMessages++ | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | if completionMessages != 2 { | |
| 376 | + | t.Fatalf("completion user messages = %d, want two: %#v", completionMessages, userMessages) | |
| 377 | + | } | |
| 378 | + | for _, typ := range []string{"assistant_message", "tool_call", "tool_execution_started", "tool_result", "usage", "turn_end"} { | |
| 379 | + | if !seenTypes[typ] { | |
| 380 | + | t.Fatalf("orchestrated JSONL omitted %s event; seen types: %v", typ, seenTypes) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | for _, event := range events { | |
| 384 | + | if event["type"] == "error" { | |
| 385 | + | t.Fatalf("successful two-wave orchestration emitted an error event: %#v", event) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func orchestrationCLIEnv(binary, home string) []string { | |
| 391 | + | return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary)) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func buildOrchestrationCLI(t *testing.T) string { | |
| 395 | + | t.Helper() | |
| 396 | + | out := filepath.Join(t.TempDir(), "zut") | |
| 397 | + | root := filepath.Join("..", "..") | |
| 398 | + | cmd := exec.Command("go", "build", "-o", out, "./cmd/zut") | |
| 399 | + | cmd.Dir = root | |
| 400 | + | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") | |
| 401 | + | if output, err := cmd.CombinedOutput(); err != nil { | |
| 402 | + | t.Fatalf("build zut: %v\n%s", err, output) | |
| 403 | + | } | |
| 404 | + | return out | |
| 405 | + | } | |
A packages/agent/orchestration_loop.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type completionBatchWaiter interface { | |
| 10 | + | WaitIdle(context.Context) ([]subagents.Completion, error) | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // headlessCompletionWaitError marks an error raised while waiting for worker | |
| 14 | + | // completions. A parent turn's renderer already owns errors raised by its | |
| 15 | + | // prompt, so callers should only render this wrapper outside that turn. | |
| 16 | + | type headlessCompletionWaitError struct { | |
| 17 | + | err error | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (e *headlessCompletionWaitError) Error() string { | |
| 21 | + | if e == nil || e.err == nil { | |
| 22 | + | return "completion wait failed" | |
| 23 | + | } | |
| 24 | + | return e.err.Error() | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (e *headlessCompletionWaitError) Unwrap() error { | |
| 28 | + | if e == nil { | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | return e.err | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | const headlessCompletionInstruction = `Treat the completion reports below as evidence, including failures. Do not retry failed workers automatically. Decide whether another independent dependency wave is required; if so, use the manager tools and end or yield the turn again. Otherwise synthesize the final answer for the user. Do not mention this orchestration protocol unless it is relevant to the answer.` | |
| 35 | + | ||
| 36 | + | // runHeadlessContinuation waits for one completion batch and gives the parent | |
| 37 | + | // one follow-up turn. A follow-up may register another batch while it runs, | |
| 38 | + | // which is how dependency waves continue without polling. The initial parent | |
| 39 | + | // turn is supplied by finalText; no worker path therefore returns immediately. | |
| 40 | + | func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) { | |
| 41 | + | if tracker == nil { | |
| 42 | + | return finalText, nil | |
| 43 | + | } | |
| 44 | + | for { | |
| 45 | + | batch, err := tracker.WaitIdle(ctx) | |
| 46 | + | if err != nil { | |
| 47 | + | return finalText, &headlessCompletionWaitError{err: err} | |
| 48 | + | } | |
| 49 | + | if len(batch) == 0 { | |
| 50 | + | return finalText, nil | |
| 51 | + | } | |
| 52 | + | if runParent == nil { | |
| 53 | + | return finalText, nil | |
| 54 | + | } | |
| 55 | + | update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction) | |
| 56 | + | finalText, err = runParent(ctx, update) | |
| 57 | + | if err != nil { | |
| 58 | + | return finalText, err | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
A packages/agent/orchestration_loop_test.go +99 -0
| @@ -0,0 +1,99 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type fakeCompletionBatches struct { | |
| 13 | + | batches [][]subagents.Completion | |
| 14 | + | errors []error | |
| 15 | + | waits int | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) { | |
| 19 | + | index := f.waits | |
| 20 | + | f.waits++ | |
| 21 | + | if index < len(f.errors) && f.errors[index] != nil { | |
| 22 | + | return nil, f.errors[index] | |
| 23 | + | } | |
| 24 | + | if index >= len(f.batches) { | |
| 25 | + | return nil, nil | |
| 26 | + | } | |
| 27 | + | return f.batches[index], nil | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestRunHeadlessContinuationNoWorkerPath(t *testing.T) { | |
| 31 | + | tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{}}} | |
| 32 | + | called := false | |
| 33 | + | got, err := runHeadlessContinuation(context.Background(), tracker, "initial synthesis", func(context.Context, string) (string, error) { | |
| 34 | + | called = true | |
| 35 | + | return "unexpected", nil | |
| 36 | + | }) | |
| 37 | + | if err != nil { | |
| 38 | + | t.Fatal(err) | |
| 39 | + | } | |
| 40 | + | if called || got != "initial synthesis" { | |
| 41 | + | t.Fatalf("no-worker result = %q, called=%v", got, called) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) { | |
| 46 | + | tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{ | |
| 47 | + | {{AgentID: "worker-a", Status: "completed", Task: "inspect"}, {AgentID: "worker-b", Status: "failed", Task: "test", Error: "provider failed"}}, | |
| 48 | + | {{AgentID: "worker-c", Status: "completed", Task: "dependent"}}, | |
| 49 | + | {}, | |
| 50 | + | }} | |
| 51 | + | var prompts []string | |
| 52 | + | got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(_ context.Context, prompt string) (string, error) { | |
| 53 | + | prompts = append(prompts, prompt) | |
| 54 | + | return "synthesis " + string(rune('0'+len(prompts))), nil | |
| 55 | + | }) | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if got != "synthesis 2" || len(prompts) != 2 { | |
| 60 | + | t.Fatalf("result=%q prompts=%d, want second-wave synthesis", got, len(prompts)) | |
| 61 | + | } | |
| 62 | + | if !strings.Contains(prompts[0], "worker-b") || !strings.Contains(prompts[0], "provider failed") { | |
| 63 | + | t.Fatalf("first completion batch omitted failed-worker evidence: %q", prompts[0]) | |
| 64 | + | } | |
| 65 | + | if !strings.Contains(prompts[1], "worker-c") || strings.Contains(prompts[1], "worker-a") { | |
| 66 | + | t.Fatalf("second completion batch was not isolated: %q", prompts[1]) | |
| 67 | + | } | |
| 68 | + | if tracker.waits != 3 { | |
| 69 | + | t.Fatalf("wait count=%d, want completion batches followed by empty terminal batch", tracker.waits) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestRunHeadlessContinuationCancellation(t *testing.T) { | |
| 74 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 75 | + | cancel() | |
| 76 | + | want := errors.New("canceled") | |
| 77 | + | tracker := &fakeCompletionBatches{errors: []error{want}} | |
| 78 | + | got, err := runHeadlessContinuation(ctx, tracker, "initial", func(context.Context, string) (string, error) { | |
| 79 | + | t.Fatal("parent should not run after cancellation") | |
| 80 | + | return "", nil | |
| 81 | + | }) | |
| 82 | + | var waitErr *headlessCompletionWaitError | |
| 83 | + | if !errors.Is(err, want) || !errors.As(err, &waitErr) || got != "initial" { | |
| 84 | + | t.Fatalf("result=(%q,%v), wait error=%v, want initial and cancellation wait error", got, err, waitErr) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestRunHeadlessContinuationParentErrorDoesNotRetry(t *testing.T) { | |
| 89 | + | tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{{AgentID: "worker", Status: "completed"}}}} | |
| 90 | + | want := errors.New("parent failed") | |
| 91 | + | calls := 0 | |
| 92 | + | got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) { | |
| 93 | + | calls++ | |
| 94 | + | return "partial", want | |
| 95 | + | }) | |
| 96 | + | if !errors.Is(err, want) || got != "partial" || calls != 1 { | |
| 97 | + | t.Fatalf("result=(%q,%v), calls=%d, want one non-retried parent failure", got, err, calls) | |
| 98 | + | } | |
| 99 | + | } | |
A packages/agent/orchestration_modes.go +263 -0
| @@ -0,0 +1,263 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/signal" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "syscall" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/agent/modes" | |
| 15 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 16 | + | "github.com/bnema/zut/packages/core" | |
| 17 | + | "github.com/bnema/zut/packages/provider" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type orchestratedModeHooks struct { | |
| 21 | + | promptLabel string | |
| 22 | + | ||
| 23 | + | // startupSink is used for entry.pre agent turns. The completion updates | |
| 24 | + | // themselves go through runPrimary and therefore use the same renderer as | |
| 25 | + | // the initial parent turn. | |
| 26 | + | startupSink func() (func(core.AgentEvent), func()) | |
| 27 | + | runPrimary func(context.Context, *core.Agent, string, func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) | |
| 28 | + | emitError func(error) | |
| 29 | + | finish func(string) error | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // runOrchestratedMode owns the mode-neutral headless lifecycle. Modes provide | |
| 33 | + | // only their existing renderer and wire format; the tracker, worker runtime, | |
| 34 | + | // startup hooks, transcript persistence, and completion-wave loop are shared. | |
| 35 | + | func runOrchestratedMode(parentCtx context.Context, args Args, version string, hooks orchestratedModeHooks) (runErr error) { | |
| 36 | + | ctx, stopSignal := signal.NotifyContext(parentCtx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) | |
| 37 | + | defer stopSignal() | |
| 38 | + | ||
| 39 | + | if args.NoYolo { | |
| 40 | + | fmt.Fprintf(os.Stderr, "warning: --no-yolo has no effect in %s mode (no interactive prompt available); tools will run without confirmation\n", hooks.promptLabel) | |
| 41 | + | } | |
| 42 | + | r, err := Resolve(args, true) | |
| 43 | + | if err != nil { | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | initialCfg, err := LoadConfig() | |
| 47 | + | if err != nil { | |
| 48 | + | return fmt.Errorf("load config for orchestrated %s: %w", hooks.promptLabel, err) | |
| 49 | + | } | |
| 50 | + | prompt := args.Prompt | |
| 51 | + | if prompt == "" { | |
| 52 | + | piped, readErr := readAllStdin() | |
| 53 | + | if readErr != nil { | |
| 54 | + | return fmt.Errorf("read %s prompt from stdin: %w", hooks.promptLabel, readErr) | |
| 55 | + | } | |
| 56 | + | prompt = strings.TrimSpace(piped) | |
| 57 | + | } | |
| 58 | + | if prompt == "" { | |
| 59 | + | return fmt.Errorf("%s mode requires a prompt (arg or stdin)", hooks.promptLabel) | |
| 60 | + | } | |
| 61 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |
| 62 | + | defer stopExt() | |
| 63 | + | ||
| 64 | + | tracker := subagents.NewCompletionTracker() | |
| 65 | + | runtime := newOrchestratedRuntime(ctx, args, r, initialCfg, tracker) | |
| 66 | + | defer func() { | |
| 67 | + | if closeErr := closeSubagentRuntimeFresh(runtime); closeErr != nil { | |
| 68 | + | runErr = errors.Join(runErr, closeErr) | |
| 69 | + | } | |
| 70 | + | }() | |
| 71 | + | ||
| 72 | + | prepareRegistry := func(reg core.Registry) core.Registry { | |
| 73 | + | return runtime.PrepareRegistry(reg) | |
| 74 | + | } | |
| 75 | + | runtime.SetProvider(r.Provider) | |
| 76 | + | runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS) | |
| 77 | + | runtime.SetFastMode(r.FastMode) | |
| 78 | + | runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy) | |
| 79 | + | ag := r.NewAgent() | |
| 80 | + | initialAg := ag | |
| 81 | + | defer func() { | |
| 82 | + | closeAgentLSP(ag) | |
| 83 | + | if ag != initialAg { | |
| 84 | + | closeAgentLSP(initialAg) | |
| 85 | + | } | |
| 86 | + | }() | |
| 87 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 88 | + | ||
| 89 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 90 | + | if err != nil { | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | if sess != nil { | |
| 94 | + | defer func() { runErr = joinSessionCloseError(runErr, sess) }() | |
| 95 | + | var providerName, model string | |
| 96 | + | sess, ag, providerName, model, err = applyInitialSessionResumeWithRuntime(ctx, args, r, extMgr, sess, ag, runtime) | |
| 97 | + | if err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | r.Provider, r.Model = providerName, model | |
| 101 | + | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |
| 102 | + | runtime.SetActiveSession(sess.ID) | |
| 103 | + | } | |
| 104 | + | announceSession(extMgr, sess) | |
| 105 | + | ||
| 106 | + | start := len(ag.Messages()) | |
| 107 | + | preSink, finishPre := hooks.startupSink() | |
| 108 | + | preErr := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr) | |
| 109 | + | finishPre() | |
| 110 | + | if preErr != nil { | |
| 111 | + | if hooks.emitError != nil { | |
| 112 | + | hooks.emitError(preErr) | |
| 113 | + | } | |
| 114 | + | return preErr | |
| 115 | + | } | |
| 116 | + | if strings.TrimSpace(args.StartupPre) != "" { | |
| 117 | + | refreshedPolicy, refreshErr := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, r.Sandbox, ag, prepareRegistry) | |
| 118 | + | if refreshErr != nil { | |
| 119 | + | if hooks.emitError != nil { | |
| 120 | + | hooks.emitError(refreshErr) | |
| 121 | + | } | |
| 122 | + | return refreshErr | |
| 123 | + | } | |
| 124 | + | // entry.pre may have changed the effective permission set. Keep the | |
| 125 | + | // worker supervisor in step with the refreshed parent registry. | |
| 126 | + | runtime.SetWebSearchPolicy(refreshedPolicy) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | var usagePersistErr error | |
| 130 | + | if sess != nil { | |
| 131 | + | ag.OnUsage = func(cumulative provider.Usage) { | |
| 132 | + | if usagePersistErr == nil { | |
| 133 | + | usagePersistErr = sess.AppendUsage(ag.LastTurnUsage(), cumulative) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | var persistCompaction func([]provider.Message) error | |
| 138 | + | if sess != nil { | |
| 139 | + | persistCompaction = func(messages []provider.Message) error { | |
| 140 | + | return sess.AppendCompactionWithUsage(messages, ag.Cost()) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | transcriptStart := start | |
| 145 | + | runParent := func(turnCtx context.Context, text string) (string, error) { | |
| 146 | + | output, recovery, turnErr := hooks.runPrimary(turnCtx, ag, text, persistCompaction) | |
| 147 | + | if recovery.Compacted { | |
| 148 | + | transcriptStart = recovery.OutputStart | |
| 149 | + | } | |
| 150 | + | if usagePersistErr != nil { | |
| 151 | + | // Usage persistence happens after the mode renderer has completed. | |
| 152 | + | // JSON therefore needs an explicit terminal error for this failure; | |
| 153 | + | // unlike provider errors, no renderer event has reported it yet. | |
| 154 | + | if turnErr == nil && hooks.emitError != nil { | |
| 155 | + | hooks.emitError(usagePersistErr) | |
| 156 | + | } | |
| 157 | + | return output, errors.Join(turnErr, usagePersistErr) | |
| 158 | + | } | |
| 159 | + | return output, turnErr | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | finalText, err := runParent(ctx, prompt) | |
| 163 | + | if err == nil { | |
| 164 | + | finalText, err = runHeadlessContinuation(ctx, tracker, finalText, runParent) | |
| 165 | + | if err != nil && hooks.emitError != nil { | |
| 166 | + | // A completion wait has no renderer call in which to report its | |
| 167 | + | // failure, so JSON must add its single terminal error object here. | |
| 168 | + | // Errors from the follow-up parent turn were already rendered by | |
| 169 | + | // that mode and must not be emitted a second time. | |
| 170 | + | var waitErr *headlessCompletionWaitError | |
| 171 | + | if errors.As(err, &waitErr) { | |
| 172 | + | hooks.emitError(waitErr) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil { | |
| 177 | + | if err == nil && hooks.emitError != nil { | |
| 178 | + | hooks.emitError(persistErr) | |
| 179 | + | } | |
| 180 | + | err = errors.Join(err, persistErr) | |
| 181 | + | } | |
| 182 | + | if err != nil { | |
| 183 | + | return err | |
| 184 | + | } | |
| 185 | + | return hooks.finish(finalText) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime { | |
| 189 | + | onSpawned := func(a *subagents.Agent, task string) { | |
| 190 | + | tracker.TrackTurn(a, task, false) | |
| 191 | + | } | |
| 192 | + | onResumed := func(a *subagents.Agent, prompt string) { | |
| 193 | + | tracker.TrackTurn(a, prompt, true) | |
| 194 | + | } | |
| 195 | + | onBeforeResumed := func(a *subagents.Agent, prompt string) func() { | |
| 196 | + | return tracker.TrackFutureTurn(a, prompt, true) | |
| 197 | + | } | |
| 198 | + | onStopRequested := func(a *subagents.Agent) { | |
| 199 | + | tracker.TrackExit(a, "stopped") | |
| 200 | + | } | |
| 201 | + | return newSubagentRuntime(subagentRuntimeConfig{ | |
| 202 | + | Context: ctx, | |
| 203 | + | Args: args, | |
| 204 | + | Root: filepath.Join(ZutHome(), "subagents"), | |
| 205 | + | RepoRoot: r.CWD, | |
| 206 | + | Provider: r.Provider, | |
| 207 | + | Model: r.Model, | |
| 208 | + | Reasoning: r.Reasoning, | |
| 209 | + | BaseURL: r.BaseURL, | |
| 210 | + | InsecureTLS: r.InsecureTLS, | |
| 211 | + | FastMode: r.FastMode, | |
| 212 | + | APIKey: args.APIKey, | |
| 213 | + | Policy: subagentPolicyFromConfig(cfg.Subagents), | |
| 214 | + | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | |
| 215 | + | OnSpawned: onSpawned, | |
| 216 | + | OnResumed: onResumed, | |
| 217 | + | BeforeResumed: onBeforeResumed, | |
| 218 | + | OnStopRequested: onStopRequested, | |
| 219 | + | }) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func runOrchestratedStreamMode(ctx context.Context, args Args, version string) error { | |
| 223 | + | return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{ | |
| 224 | + | promptLabel: "stream", | |
| 225 | + | startupSink: func() (func(core.AgentEvent), func()) { | |
| 226 | + | return newStreamTextSink(os.Stdout) | |
| 227 | + | }, | |
| 228 | + | runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) { | |
| 229 | + | recovery, err := modes.RunStreamWithContextRecovery(turnCtx, ag, prompt, nil, os.Stdout, os.Stderr, persist) | |
| 230 | + | return "", recovery, err | |
| 231 | + | }, | |
| 232 | + | finish: func(string) error { return nil }, | |
| 233 | + | }) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func runOrchestratedJSONMode(ctx context.Context, args Args, version string) error { | |
| 237 | + | enc := json.NewEncoder(os.Stdout) | |
| 238 | + | var outputErr error | |
| 239 | + | encode := func(value any) { | |
| 240 | + | if outputErr == nil { | |
| 241 | + | outputErr = enc.Encode(value) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | runErr := runOrchestratedMode(ctx, args, version, orchestratedModeHooks{ | |
| 245 | + | promptLabel: "json", | |
| 246 | + | startupSink: func() (func(core.AgentEvent), func()) { | |
| 247 | + | return func(ev core.AgentEvent) { | |
| 248 | + | encode(modes.EventToJSON(ev)) | |
| 249 | + | }, func() {} | |
| 250 | + | }, | |
| 251 | + | runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) { | |
| 252 | + | recovery, err := modes.RunJSONWithContextRecovery(turnCtx, ag, prompt, nil, os.Stdout, persist) | |
| 253 | + | return "", recovery, err | |
| 254 | + | }, | |
| 255 | + | emitError: func(err error) { | |
| 256 | + | if err != nil { | |
| 257 | + | encode(map[string]any{"type": "error", "message": err.Error()}) | |
| 258 | + | } | |
| 259 | + | }, | |
| 260 | + | finish: func(string) error { return nil }, | |
| 261 | + | }) | |
| 262 | + | return errors.Join(runErr, outputErr) | |
| 263 | + | } | |
A packages/agent/orchestration_modes_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "os" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRunOrchestratedStreamUsesStreamRenderer(t *testing.T) { | |
| 16 | + | home := t.TempDir() | |
| 17 | + | t.Setenv("ZUT_HOME", home) | |
| 18 | + | server := orchestratedModeTestServer("stream answer") | |
| 19 | + | defer server.Close() | |
| 20 | + | ||
| 21 | + | oldStdout := os.Stdout | |
| 22 | + | reader, writer, err := os.Pipe() | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | os.Stdout = writer | |
| 27 | + | runErr := runOrchestratedStreamMode(context.Background(), Args{ | |
| 28 | + | Mode: ModeStream, | |
| 29 | + | Orchestrate: true, | |
| 30 | + | Provider: "openai", | |
| 31 | + | Model: "gpt-5", | |
| 32 | + | BaseURL: server.URL, | |
| 33 | + | APIKey: "test-key", | |
| 34 | + | Prompt: "say hello", | |
| 35 | + | }, "test") | |
| 36 | + | _ = writer.Close() | |
| 37 | + | os.Stdout = oldStdout | |
| 38 | + | output, readErr := io.ReadAll(reader) | |
| 39 | + | _ = reader.Close() | |
| 40 | + | if runErr != nil { | |
| 41 | + | t.Fatalf("runOrchestratedStreamMode error: %v", runErr) | |
| 42 | + | } | |
| 43 | + | if readErr != nil { | |
| 44 | + | t.Fatal(readErr) | |
| 45 | + | } | |
| 46 | + | if got := string(output); got != "stream answer\n" { | |
| 47 | + | t.Fatalf("stdout = %q, want one rendered answer newline", got) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) { | |
| 52 | + | home := t.TempDir() | |
| 53 | + | t.Setenv("ZUT_HOME", home) | |
| 54 | + | server := orchestratedModeTestServer("json answer") | |
| 55 | + | defer server.Close() | |
| 56 | + | ||
| 57 | + | oldStdout := os.Stdout | |
| 58 | + | reader, writer, err := os.Pipe() | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | os.Stdout = writer | |
| 63 | + | runErr := runOrchestratedJSONMode(context.Background(), Args{ | |
| 64 | + | Mode: ModeJSON, | |
| 65 | + | Orchestrate: true, | |
| 66 | + | Provider: "openai", | |
| 67 | + | Model: "gpt-5", | |
| 68 | + | BaseURL: server.URL, | |
| 69 | + | APIKey: "test-key", | |
| 70 | + | Prompt: "say hello", | |
| 71 | + | }, "test") | |
| 72 | + | _ = writer.Close() | |
| 73 | + | os.Stdout = oldStdout | |
| 74 | + | output, readErr := io.ReadAll(reader) | |
| 75 | + | _ = reader.Close() | |
| 76 | + | if runErr != nil { | |
| 77 | + | t.Fatalf("runOrchestratedJSONMode error: %v", runErr) | |
| 78 | + | } | |
| 79 | + | if readErr != nil { | |
| 80 | + | t.Fatal(readErr) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | seen := map[string]bool{} | |
| 84 | + | scanner := bufio.NewScanner(strings.NewReader(string(output))) | |
| 85 | + | for scanner.Scan() { | |
| 86 | + | var event map[string]any | |
| 87 | + | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | |
| 88 | + | t.Fatalf("invalid JSONL line %q: %v", scanner.Text(), err) | |
| 89 | + | } | |
| 90 | + | if typ, ok := event["type"].(string); ok { | |
| 91 | + | seen[typ] = true | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | if err := scanner.Err(); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | for _, typ := range []string{"user_message", "assistant_message", "turn_end"} { | |
| 98 | + | if !seen[typ] { | |
| 99 | + | t.Fatalf("JSONL omitted %s event: %s", typ, output) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func orchestratedModeTestServer(answer string) *httptest.Server { | |
| 105 | + | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 106 | + | w.Header().Set("content-type", "text/event-stream") | |
| 107 | + | _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\""+answer+"\"},\"finish_reason\":\"stop\"}]}\n\n") | |
| 108 | + | _, _ = io.WriteString(w, "data: [DONE]\n\n") | |
| 109 | + | })) | |
| 110 | + | } | |
A packages/agent/orchestration_print.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/signal" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "syscall" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/agent/modes" | |
| 15 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 16 | + | "github.com/bnema/zut/packages/core" | |
| 17 | + | "github.com/bnema/zut/packages/provider" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // runOrchestratedPrintMode is deliberately separate from ordinary print mode: | |
| 21 | + | // ordinary print continues to stream its existing one-turn implementation, | |
| 22 | + | // while this path keeps every parent turn in memory until terminal synthesis. | |
| 23 | + | func runOrchestratedPrintMode(parentCtx context.Context, args Args, version string) (runErr error) { | |
| 24 | + | ctx, stopSignal := signal.NotifyContext(parentCtx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) | |
| 25 | + | defer stopSignal() | |
| 26 | + | ||
| 27 | + | if args.NoYolo { | |
| 28 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | |
| 29 | + | } | |
| 30 | + | r, err := Resolve(args, true) | |
| 31 | + | if err != nil { | |
| 32 | + | return err | |
| 33 | + | } | |
| 34 | + | initialCfg, err := LoadConfig() | |
| 35 | + | if err != nil { | |
| 36 | + | return fmt.Errorf("load config for orchestrated print: %w", err) | |
| 37 | + | } | |
| 38 | + | prompt := args.Prompt | |
| 39 | + | if prompt == "" { | |
| 40 | + | piped, readErr := readAllStdin() | |
| 41 | + | if readErr != nil { | |
| 42 | + | return fmt.Errorf("read print prompt from stdin: %w", readErr) | |
| 43 | + | } | |
| 44 | + | prompt = strings.TrimSpace(piped) | |
| 45 | + | } | |
| 46 | + | if prompt == "" { | |
| 47 | + | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | |
| 48 | + | } | |
| 49 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |
| 50 | + | defer stopExt() | |
| 51 | + | ||
| 52 | + | tracker := subagents.NewCompletionTracker() | |
| 53 | + | onSpawned := func(a *subagents.Agent, task string) { | |
| 54 | + | tracker.TrackTurn(a, task, false) | |
| 55 | + | } | |
| 56 | + | onResumed := func(a *subagents.Agent, prompt string) { | |
| 57 | + | // BeforeResumed owns the strict future-turn registration. Keep this | |
| 58 | + | // post-delivery callback for the same lifecycle semantics as the | |
| 59 | + | // interactive flow; SubagentResumeTool suppresses it when the pre-hook | |
| 60 | + | // is present so it cannot register a duplicate completion. | |
| 61 | + | tracker.TrackTurn(a, prompt, true) | |
| 62 | + | } | |
| 63 | + | onBeforeResumed := func(a *subagents.Agent, prompt string) func() { | |
| 64 | + | return tracker.TrackFutureTurn(a, prompt, true) | |
| 65 | + | } | |
| 66 | + | onStopRequested := func(a *subagents.Agent) { | |
| 67 | + | tracker.TrackExit(a, "stopped") | |
| 68 | + | } | |
| 69 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 70 | + | Context: ctx, | |
| 71 | + | Args: args, | |
| 72 | + | Root: filepath.Join(ZutHome(), "subagents"), | |
| 73 | + | RepoRoot: r.CWD, | |
| 74 | + | Provider: r.Provider, | |
| 75 | + | Model: r.Model, | |
| 76 | + | Reasoning: r.Reasoning, | |
| 77 | + | BaseURL: r.BaseURL, | |
| 78 | + | InsecureTLS: r.InsecureTLS, | |
| 79 | + | FastMode: r.FastMode, | |
| 80 | + | APIKey: args.APIKey, | |
| 81 | + | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | |
| 82 | + | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | |
| 83 | + | OnSpawned: onSpawned, | |
| 84 | + | OnResumed: onResumed, | |
| 85 | + | BeforeResumed: onBeforeResumed, | |
| 86 | + | OnStopRequested: onStopRequested, | |
| 87 | + | }) | |
| 88 | + | defer func() { | |
| 89 | + | if closeErr := closeSubagentRuntimeFresh(runtime); closeErr != nil { | |
| 90 | + | runErr = errors.Join(runErr, closeErr) | |
| 91 | + | } | |
| 92 | + | }() | |
| 93 | + | ||
| 94 | + | prepareRegistry := func(reg core.Registry) core.Registry { | |
| 95 | + | return runtime.PrepareRegistry(reg) | |
| 96 | + | } | |
| 97 | + | runtime.SetProvider(r.Provider) | |
| 98 | + | runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS) | |
| 99 | + | runtime.SetFastMode(r.FastMode) | |
| 100 | + | runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy) | |
| 101 | + | ag := r.NewAgent() | |
| 102 | + | initialAg := ag | |
| 103 | + | defer func() { | |
| 104 | + | closeAgentLSP(ag) | |
| 105 | + | if ag != initialAg { | |
| 106 | + | closeAgentLSP(initialAg) | |
| 107 | + | } | |
| 108 | + | }() | |
| 109 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 110 | + | ||
| 111 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 112 | + | if err != nil { | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | if sess != nil { | |
| 116 | + | defer func() { runErr = joinSessionCloseError(runErr, sess) }() | |
| 117 | + | var providerName, model string | |
| 118 | + | sess, ag, providerName, model, err = applyInitialSessionResumeWithRuntime(ctx, args, r, extMgr, sess, ag, runtime) | |
| 119 | + | if err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | r.Provider, r.Model = providerName, model | |
| 123 | + | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |
| 124 | + | runtime.SetActiveSession(sess.ID) | |
| 125 | + | } | |
| 126 | + | announceSession(extMgr, sess) | |
| 127 | + | ||
| 128 | + | start := len(ag.Messages()) | |
| 129 | + | if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil { | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | if strings.TrimSpace(args.StartupPre) != "" { | |
| 133 | + | refreshedPolicy, refreshErr := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, r.Sandbox, ag, prepareRegistry) | |
| 134 | + | if refreshErr != nil { | |
| 135 | + | return refreshErr | |
| 136 | + | } | |
| 137 | + | // entry.pre can change the resolved PermissionSet. Apply that effective | |
| 138 | + | // policy before any child launch, not only to the parent's registry. | |
| 139 | + | runtime.SetWebSearchPolicy(refreshedPolicy) | |
| 140 | + | } | |
| 141 | + | var usagePersistErr error | |
| 142 | + | if sess != nil { | |
| 143 | + | ag.OnUsage = func(cumulative provider.Usage) { | |
| 144 | + | if usagePersistErr == nil { | |
| 145 | + | usagePersistErr = sess.AppendUsage(ag.LastTurnUsage(), cumulative) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | var persistCompaction func([]provider.Message) error | |
| 150 | + | if sess != nil { | |
| 151 | + | persistCompaction = func(messages []provider.Message) error { | |
| 152 | + | return sess.AppendCompactionWithUsage(messages, ag.Cost()) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | var totalUsage provider.Usage | |
| 157 | + | transcriptStart := start | |
| 158 | + | runParent := func(turnCtx context.Context, text string) (string, error) { | |
| 159 | + | var captured bytes.Buffer | |
| 160 | + | usage, recovery, turnErr := modes.RunPrintWithContextRecovery(turnCtx, ag, text, nil, &captured, persistCompaction) | |
| 161 | + | totalUsage = totalUsage.Add(usage) | |
| 162 | + | if recovery.Compacted { | |
| 163 | + | transcriptStart = recovery.OutputStart | |
| 164 | + | } | |
| 165 | + | if usagePersistErr != nil { | |
| 166 | + | return captured.String(), usagePersistErr | |
| 167 | + | } | |
| 168 | + | if turnErr != nil { | |
| 169 | + | return captured.String(), turnErr | |
| 170 | + | } | |
| 171 | + | return strings.TrimSuffix(captured.String(), "\n"), nil | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | finalText, err := runParent(ctx, prompt) | |
| 175 | + | if err == nil { | |
| 176 | + | finalText, err = runHeadlessContinuation(ctx, tracker, finalText, runParent) | |
| 177 | + | } | |
| 178 | + | if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil { | |
| 179 | + | err = errors.Join(err, persistErr) | |
| 180 | + | } | |
| 181 | + | if err != nil { | |
| 182 | + | return err | |
| 183 | + | } | |
| 184 | + | if _, err := fmt.Fprintln(os.Stdout, finalText); err != nil { | |
| 185 | + | return err | |
| 186 | + | } | |
| 187 | + | return nil | |
| 188 | + | } | |
A packages/agent/orchestration_print_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) { | |
| 14 | + | home := t.TempDir() | |
| 15 | + | t.Setenv("ZUT_HOME", home) | |
| 16 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 17 | + | w.Header().Set("content-type", "text/event-stream") | |
| 18 | + | _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"final answer\"},\"finish_reason\":\"stop\"}]}\n\n") | |
| 19 | + | _, _ = io.WriteString(w, "data: [DONE]\n\n") | |
| 20 | + | })) | |
| 21 | + | defer server.Close() | |
| 22 | + | ||
| 23 | + | oldStdout := os.Stdout | |
| 24 | + | reader, writer, err := os.Pipe() | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | os.Stdout = writer | |
| 29 | + | runErr := runOrchestratedPrintMode(context.Background(), Args{ | |
| 30 | + | Mode: ModePrint, | |
| 31 | + | Orchestrate: true, | |
| 32 | + | Provider: "openai", | |
| 33 | + | Model: "gpt-5", | |
| 34 | + | BaseURL: server.URL, | |
| 35 | + | APIKey: "test-key", | |
| 36 | + | Prompt: "say hello", | |
| 37 | + | }, "test") | |
| 38 | + | _ = writer.Close() | |
| 39 | + | os.Stdout = oldStdout | |
| 40 | + | output, readErr := io.ReadAll(reader) | |
| 41 | + | _ = reader.Close() | |
| 42 | + | if runErr != nil { | |
| 43 | + | t.Fatalf("runOrchestratedPrintMode error: %v", runErr) | |
| 44 | + | } | |
| 45 | + | if readErr != nil { | |
| 46 | + | t.Fatal(readErr) | |
| 47 | + | } | |
| 48 | + | if got := string(output); got != "final answer\n" { | |
| 49 | + | t.Fatalf("stdout = %q, want final answer only", got) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestRunOrchestratedPrintPropagatesConfigLoadError(t *testing.T) { | |
| 54 | + | home := t.TempDir() | |
| 55 | + | t.Setenv("ZUT_HOME", home) | |
| 56 | + | if err := os.WriteFile(ConfigPath(), []byte("{"), 0o600); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | err := runOrchestratedPrintMode(context.Background(), Args{ | |
| 61 | + | Mode: ModePrint, | |
| 62 | + | Orchestrate: true, | |
| 63 | + | Provider: "ollama", | |
| 64 | + | Model: "llama3", | |
| 65 | + | Prompt: "test", | |
| 66 | + | }, "test") | |
| 67 | + | if err == nil || !strings.Contains(err.Error(), "load config for orchestrated print") { | |
| 68 | + | t.Fatalf("runOrchestratedPrintMode error = %v, want config load error", err) | |
| 69 | + | } | |
| 70 | + | } | |
A packages/agent/orchestration_shutdown.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const headlessShutdownTimeout = 5 * time.Second | |
| 9 | + | ||
| 10 | + | func closeSubagentRuntimeFresh(rt *subagentRuntime) error { | |
| 11 | + | if rt == nil { | |
| 12 | + | return nil | |
| 13 | + | } | |
| 14 | + | shutdownCtx, cancel := context.WithTimeout(context.Background(), headlessShutdownTimeout) | |
| 15 | + | defer cancel() | |
| 16 | + | return rt.Close(shutdownCtx) | |
| 17 | + | } | |
A packages/agent/subagent_runtime.go +429 -0
| @@ -0,0 +1,429 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // subagentRuntime owns the manager-facing runtime assembled around a host | |
| 16 | + | // session. Keeping this state together is important: every injected manager | |
| 17 | + | // tool must use the same supervisor, model/provider selection, profile root, | |
| 18 | + | // policy, callbacks, and shutdown lifecycle. | |
| 19 | + | type subagentRuntime struct { | |
| 20 | + | supervisor *subagents.Supervisor | |
| 21 | + | args Args | |
| 22 | + | root string | |
| 23 | + | ||
| 24 | + | provider string | |
| 25 | + | model string | |
| 26 | + | reasoning string | |
| 27 | + | baseURL string | |
| 28 | + | insecureTLS bool | |
| 29 | + | fastMode bool | |
| 30 | + | repoRoot string | |
| 31 | + | ||
| 32 | + | activeProvider func() string | |
| 33 | + | activeModel func() string | |
| 34 | + | activeReasoning func() string | |
| 35 | + | ||
| 36 | + | apiKey string | |
| 37 | + | apiKeyProvider string | |
| 38 | + | webSearchPolicy subagents.WebSearchPolicy | |
| 39 | + | webSearchGuard *webSearchSessionGuard | |
| 40 | + | ||
| 41 | + | onSpawned func(*subagents.Agent, string) | |
| 42 | + | onResumed func(*subagents.Agent, string) | |
| 43 | + | beforeResumed func(*subagents.Agent, string) func() | |
| 44 | + | onStopRequested func(*subagents.Agent) | |
| 45 | + | ||
| 46 | + | closeMu sync.Mutex | |
| 47 | + | closed bool | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type subagentRuntimeConfiguration struct { | |
| 51 | + | provider string | |
| 52 | + | model string | |
| 53 | + | baseURL string | |
| 54 | + | insecureTLS bool | |
| 55 | + | fastMode bool | |
| 56 | + | repoRoot string | |
| 57 | + | webSearchPolicy subagents.WebSearchPolicy | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // subagentRuntimeConfig supplies the host-owned settings and seams needed to | |
| 61 | + | // assemble a subagentRuntime. The active selection callbacks are optional; | |
| 62 | + | // when omitted, the initial Provider/Model/Reasoning values are used. | |
| 63 | + | type subagentRuntimeConfig struct { | |
| 64 | + | Context context.Context | |
| 65 | + | Args Args | |
| 66 | + | Root string | |
| 67 | + | RepoRoot string | |
| 68 | + | ||
| 69 | + | Provider string | |
| 70 | + | Model string | |
| 71 | + | Reasoning string | |
| 72 | + | BaseURL string | |
| 73 | + | InsecureTLS bool | |
| 74 | + | FastMode bool | |
| 75 | + | ||
| 76 | + | APIKey string | |
| 77 | + | Policy subagents.SubagentPolicy | |
| 78 | + | WebSearchPolicy subagents.WebSearchPolicy | |
| 79 | + | WebSearchGuard *webSearchSessionGuard | |
| 80 | + | ActiveProvider func() string | |
| 81 | + | ActiveModel func() string | |
| 82 | + | ActiveReasoning func() string | |
| 83 | + | NewRunner func(*subagents.Agent) subagents.Runner | |
| 84 | + | ||
| 85 | + | OnSpawned func(*subagents.Agent, string) | |
| 86 | + | OnResumed func(*subagents.Agent, string) | |
| 87 | + | BeforeResumed func(*subagents.Agent, string) func() | |
| 88 | + | OnStopRequested func(*subagents.Agent) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |
| 92 | + | if cfg.Context == nil { | |
| 93 | + | cfg.Context = context.Background() | |
| 94 | + | } | |
| 95 | + | rt := &subagentRuntime{ | |
| 96 | + | args: cfg.Args, | |
| 97 | + | root: cfg.Root, | |
| 98 | + | provider: strings.TrimSpace(cfg.Provider), | |
| 99 | + | model: strings.TrimSpace(cfg.Model), | |
| 100 | + | reasoning: strings.TrimSpace(cfg.Reasoning), | |
| 101 | + | baseURL: strings.TrimSpace(cfg.BaseURL), | |
| 102 | + | insecureTLS: cfg.InsecureTLS, | |
| 103 | + | fastMode: cfg.FastMode, | |
| 104 | + | repoRoot: cfg.RepoRoot, | |
| 105 | + | activeProvider: cfg.ActiveProvider, | |
| 106 | + | activeModel: cfg.ActiveModel, | |
| 107 | + | activeReasoning: cfg.ActiveReasoning, | |
| 108 | + | apiKey: cfg.APIKey, | |
| 109 | + | webSearchPolicy: cfg.WebSearchPolicy, | |
| 110 | + | webSearchGuard: cfg.WebSearchGuard, | |
| 111 | + | onSpawned: cfg.OnSpawned, | |
| 112 | + | onResumed: cfg.OnResumed, | |
| 113 | + | beforeResumed: cfg.BeforeResumed, | |
| 114 | + | onStopRequested: cfg.OnStopRequested, | |
| 115 | + | } | |
| 116 | + | if rt.activeProvider == nil { | |
| 117 | + | rt.activeProvider = func() string { return rt.provider } | |
| 118 | + | } | |
| 119 | + | if rt.activeModel == nil { | |
| 120 | + | rt.activeModel = func() string { return rt.model } | |
| 121 | + | } | |
| 122 | + | if rt.activeReasoning == nil { | |
| 123 | + | rt.activeReasoning = func() string { return rt.reasoning } | |
| 124 | + | } | |
| 125 | + | if rt.apiKey != "" { | |
| 126 | + | // A launch-time API key belongs to the provider selected at startup. It | |
| 127 | + | // must not follow the live provider callback through a later transition. | |
| 128 | + | rt.apiKeyProvider = canonicalProvider(rt.provider) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Packaged agents have an explicit capability ceiling. Until worker | |
| 132 | + | // capability propagation exists, they cannot expose delegation tools or a | |
| 133 | + | // fresh unrestricted supervisor. | |
| 134 | + | if cfg.Args.PermissionSet == nil { | |
| 135 | + | root := rt.root | |
| 136 | + | if strings.TrimSpace(root) == "" { | |
| 137 | + | root = filepath.Join(ZutHome(), "subagents") | |
| 138 | + | rt.root = root | |
| 139 | + | } | |
| 140 | + | rt.supervisor = subagents.New(subagents.Config{ | |
| 141 | + | Context: cfg.Context, | |
| 142 | + | Root: root, | |
| 143 | + | RepoRoot: cfg.RepoRoot, | |
| 144 | + | Provider: rt.currentProvider(), | |
| 145 | + | FastMode: cfg.FastMode, | |
| 146 | + | WebSearchPolicy: cfg.WebSearchPolicy, | |
| 147 | + | BaseURL: cfg.BaseURL, | |
| 148 | + | InsecureTLS: cfg.InsecureTLS, | |
| 149 | + | NewRunner: cfg.NewRunner, | |
| 150 | + | Policy: cfg.Policy, | |
| 151 | + | ResolveCredential: rt.resolveCredential, | |
| 152 | + | }) | |
| 153 | + | } | |
| 154 | + | return rt | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (rt *subagentRuntime) Supervisor() *subagents.Supervisor { | |
| 158 | + | if rt == nil { | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | return rt.supervisor | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (rt *subagentRuntime) currentProvider() string { | |
| 165 | + | if rt == nil || rt.activeProvider == nil { | |
| 166 | + | return "" | |
| 167 | + | } | |
| 168 | + | return strings.TrimSpace(rt.activeProvider()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (rt *subagentRuntime) currentModel() string { | |
| 172 | + | if rt == nil || rt.activeModel == nil { | |
| 173 | + | return "" | |
| 174 | + | } | |
| 175 | + | return strings.TrimSpace(rt.activeModel()) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (rt *subagentRuntime) SetModel(model string) { | |
| 179 | + | if rt == nil { | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | rt.model = strings.TrimSpace(model) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (rt *subagentRuntime) currentReasoning() string { | |
| 186 | + | if rt == nil || rt.activeReasoning == nil { | |
| 187 | + | return "" | |
| 188 | + | } | |
| 189 | + | return strings.TrimSpace(rt.activeReasoning()) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (rt *subagentRuntime) SetProvider(providerID string) { | |
| 193 | + | if rt == nil { | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | rt.provider = strings.TrimSpace(providerID) | |
| 197 | + | if rt.supervisor != nil { | |
| 198 | + | rt.supervisor.SetProvider(rt.provider) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) { | |
| 203 | + | if rt == nil { | |
| 204 | + | return | |
| 205 | + | } | |
| 206 | + | rt.baseURL = strings.TrimSpace(baseURL) | |
| 207 | + | rt.insecureTLS = insecureTLS | |
| 208 | + | if rt.supervisor != nil { | |
| 209 | + | rt.supervisor.SetProviderSettings(rt.baseURL, rt.insecureTLS) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (rt *subagentRuntime) SetFastMode(fastMode bool) { | |
| 214 | + | if rt == nil { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | rt.fastMode = fastMode | |
| 218 | + | if rt.supervisor != nil { | |
| 219 | + | rt.supervisor.SetFastMode(fastMode) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func (rt *subagentRuntime) SetRepoRoot(repoRoot string) { | |
| 224 | + | if rt == nil { | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | rt.repoRoot = repoRoot | |
| 228 | + | if rt.supervisor != nil { | |
| 229 | + | rt.supervisor.SetRepoRoot(repoRoot) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // snapshotConfiguration captures the runtime settings changed while an agent | |
| 234 | + | // candidate is being built. Transition callers restore it when candidate | |
| 235 | + | // preparation fails before the new agent is committed. | |
| 236 | + | func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration { | |
| 237 | + | if rt == nil { | |
| 238 | + | return subagentRuntimeConfiguration{} | |
| 239 | + | } | |
| 240 | + | return subagentRuntimeConfiguration{ | |
| 241 | + | provider: rt.provider, | |
| 242 | + | model: rt.model, | |
| 243 | + | baseURL: rt.baseURL, | |
| 244 | + | insecureTLS: rt.insecureTLS, | |
| 245 | + | fastMode: rt.fastMode, | |
| 246 | + | repoRoot: rt.repoRoot, | |
| 247 | + | webSearchPolicy: rt.webSearchPolicy, | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) { | |
| 252 | + | if rt == nil { | |
| 253 | + | return | |
| 254 | + | } | |
| 255 | + | rt.SetProvider(snapshot.provider) | |
| 256 | + | rt.SetModel(snapshot.model) | |
| 257 | + | rt.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS) | |
| 258 | + | rt.SetFastMode(snapshot.fastMode) | |
| 259 | + | rt.SetRepoRoot(snapshot.repoRoot) | |
| 260 | + | rt.SetWebSearchPolicy(snapshot.webSearchPolicy) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (rt *subagentRuntime) SetActiveSession(sessionID string) { | |
| 264 | + | if rt != nil && rt.supervisor != nil { | |
| 265 | + | rt.supervisor.SetActiveSession(sessionID) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) { | |
| 270 | + | if rt == nil { | |
| 271 | + | return | |
| 272 | + | } | |
| 273 | + | rt.webSearchPolicy = policy | |
| 274 | + | if rt.supervisor != nil { | |
| 275 | + | rt.supervisor.SetWebSearchPolicy(policy) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func (rt *subagentRuntime) resolveCredential(ctx context.Context, providerID string) (subagents.Credential, error) { | |
| 280 | + | childProvider := canonicalProvider(providerID) | |
| 281 | + | if childProvider == "" { | |
| 282 | + | childProvider = canonicalProvider(rt.currentProvider()) | |
| 283 | + | } | |
| 284 | + | explicit := "" | |
| 285 | + | // A launch-time API key is scoped to the provider selected at startup. | |
| 286 | + | // Never send it to an explicitly different child provider; resolve that | |
| 287 | + | // provider's own credential instead. | |
| 288 | + | if rt.apiKey != "" && childProvider == rt.apiKeyProvider { | |
| 289 | + | explicit = rt.apiKey | |
| 290 | + | } | |
| 291 | + | if childProvider == "ollama" && explicit == "" { | |
| 292 | + | return subagents.Credential{Value: "ollama", Method: "apikey"}, nil | |
| 293 | + | } | |
| 294 | + | credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit) | |
| 295 | + | if err != nil { | |
| 296 | + | // Providers backed only by a local endpoint do not need a key. Leave | |
| 297 | + | // stdin untouched and let the child resolve that endpoint. | |
| 298 | + | if !CredentialAvailable(childProvider) { | |
| 299 | + | return subagents.Credential{}, nil | |
| 300 | + | } | |
| 301 | + | return subagents.Credential{}, err | |
| 302 | + | } | |
| 303 | + | return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) { | |
| 307 | + | if rt == nil { | |
| 308 | + | return nil, nil | |
| 309 | + | } | |
| 310 | + | return findSubagentProfile(rt.repoRoot, name) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // InjectTools adds the canonical manager tools allowed by the host launch | |
| 314 | + | // policy. It deliberately mutates the supplied registry, matching the normal | |
| 315 | + | // interactive registry assembly path. | |
| 316 | + | func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry { | |
| 317 | + | if reg == nil || rt == nil || rt.supervisor == nil || !autoSubagentsAnyToolAllowed(rt.args) { | |
| 318 | + | return reg | |
| 319 | + | } | |
| 320 | + | if autoSubagentsToolAllowed(rt.args) { | |
| 321 | + | spawn := &tools.SubagentSpawnTool{ | |
| 322 | + | Supervisor: rt.supervisor, | |
| 323 | + | Enabled: func() bool { return true }, | |
| 324 | + | DefaultModel: func() string { | |
| 325 | + | return rt.currentModel() | |
| 326 | + | }, | |
| 327 | + | DefaultProvider: func() string { | |
| 328 | + | return rt.currentProvider() | |
| 329 | + | }, | |
| 330 | + | DefaultReasoning: func() string { | |
| 331 | + | return rt.currentReasoning() | |
| 332 | + | }, | |
| 333 | + | ResolveSubagent: rt.resolveSubagent, | |
| 334 | + | OnSpawned: rt.onSpawned, | |
| 335 | + | } | |
| 336 | + | reg[spawn.Name()] = spawn | |
| 337 | + | } | |
| 338 | + | if autoSubagentsStatusToolAllowed(rt.args) { | |
| 339 | + | status := &tools.SubagentStatusTool{ | |
| 340 | + | Supervisor: rt.supervisor, | |
| 341 | + | Enabled: func() bool { return true }, | |
| 342 | + | } | |
| 343 | + | reg[status.Name()] = status | |
| 344 | + | } | |
| 345 | + | if autoSubagentsStopToolAllowed(rt.args) { | |
| 346 | + | stop := &tools.SubagentStopTool{ | |
| 347 | + | Supervisor: rt.supervisor, | |
| 348 | + | Enabled: func() bool { return true }, | |
| 349 | + | OnStopRequested: rt.onStopRequested, | |
| 350 | + | } | |
| 351 | + | reg[stop.Name()] = stop | |
| 352 | + | } | |
| 353 | + | if autoSubagentsResumeToolAllowed(rt.args) { | |
| 354 | + | resume := &tools.SubagentResumeTool{ | |
| 355 | + | Supervisor: rt.supervisor, | |
| 356 | + | Enabled: func() bool { return true }, | |
| 357 | + | BeforeResumed: rt.beforeResumed, | |
| 358 | + | OnResumed: rt.onResumed, | |
| 359 | + | } | |
| 360 | + | reg[resume.Name()] = resume | |
| 361 | + | } | |
| 362 | + | return reg | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry { | |
| 366 | + | reg = rt.InjectTools(reg) | |
| 367 | + | if rt != nil && rt.webSearchGuard != nil { | |
| 368 | + | return rt.webSearchGuard.wrapRegistry(reg) | |
| 369 | + | } | |
| 370 | + | return reg | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry { | |
| 374 | + | // Keep the supervisor's capability policy in lockstep with the resolved | |
| 375 | + | // parent registry. This is especially important after entry.pre refreshes | |
| 376 | + | // permissions: workers launched after the refresh must not retain the old | |
| 377 | + | // parent allow decision. | |
| 378 | + | if rt != nil { | |
| 379 | + | rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg)) | |
| 380 | + | } | |
| 381 | + | if policy != subagents.WebSearchAllow { | |
| 382 | + | delete(reg, "web_search") | |
| 383 | + | } | |
| 384 | + | return rt.PrepareRegistry(reg) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func (rt *subagentRuntime) Close(ctx context.Context) error { | |
| 388 | + | if rt == nil || rt.supervisor == nil { | |
| 389 | + | return nil | |
| 390 | + | } | |
| 391 | + | rt.closeMu.Lock() | |
| 392 | + | defer rt.closeMu.Unlock() | |
| 393 | + | if rt.closed { | |
| 394 | + | return nil | |
| 395 | + | } | |
| 396 | + | if err := rt.supervisor.StopAllContext(ctx); err != nil { | |
| 397 | + | return err | |
| 398 | + | } | |
| 399 | + | rt.closed = true | |
| 400 | + | return nil | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |
| 404 | + | parseDuration := func(value string) time.Duration { | |
| 405 | + | if strings.TrimSpace(value) == "" { | |
| 406 | + | return 0 | |
| 407 | + | } | |
| 408 | + | parsed, err := time.ParseDuration(strings.TrimSpace(value)) | |
| 409 | + | if err != nil || parsed <= 0 { | |
| 410 | + | return 0 | |
| 411 | + | } | |
| 412 | + | return parsed | |
| 413 | + | } | |
| 414 | + | return subagents.SubagentPolicy{ | |
| 415 | + | MaxConcurrent: cfg.MaxConcurrent, | |
| 416 | + | MaxConcurrentPerParent: cfg.MaxConcurrentPerParent, | |
| 417 | + | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 418 | + | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | |
| 419 | + | MaxTurns: cfg.MaxTurns, | |
| 420 | + | MaxOutputBytes: cfg.MaxOutputBytes, | |
| 421 | + | MaxOutputLines: cfg.MaxOutputLines, | |
| 422 | + | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| 423 | + | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |
| 424 | + | HeartbeatInterval: parseDuration(cfg.HeartbeatInterval), | |
| 425 | + | IdleTimeout: parseDuration(cfg.IdleTimeout), | |
| 426 | + | ReconnectTimeout: parseDuration(cfg.ReconnectTimeout), | |
| 427 | + | CancelGracePeriod: parseDuration(cfg.CancelGracePeriod), | |
| 428 | + | } | |
| 429 | + | } | |
A packages/agent/subagent_runtime_test.go +385 -0
| @@ -0,0 +1,385 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 15 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 16 | + | "github.com/bnema/zut/packages/core" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type runtimeTestRunner struct { | |
| 20 | + | started chan struct{} | |
| 21 | + | startOne sync.Once | |
| 22 | + | canceled atomic.Int32 | |
| 23 | + | block bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type stubbornRuntimeTestRunner struct { | |
| 27 | + | started chan struct{} | |
| 28 | + | release chan struct{} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (r *runtimeTestRunner) Run(ctx context.Context, _ subagents.Sink) error { | |
| 32 | + | if r.started != nil { | |
| 33 | + | r.startOne.Do(func() { close(r.started) }) | |
| 34 | + | } | |
| 35 | + | if !r.block { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | <-ctx.Done() | |
| 39 | + | r.canceled.Add(1) | |
| 40 | + | return ctx.Err() | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (r *stubbornRuntimeTestRunner) Run(context.Context, subagents.Sink) error { | |
| 44 | + | close(r.started) | |
| 45 | + | <-r.release | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func newRuntimeTestConfig(root, repoRoot string, runner func(*subagents.Agent) subagents.Runner) subagentRuntimeConfig { | |
| 50 | + | return subagentRuntimeConfig{ | |
| 51 | + | Context: context.Background(), | |
| 52 | + | Root: root, | |
| 53 | + | RepoRoot: repoRoot, | |
| 54 | + | Provider: "parent-provider", | |
| 55 | + | Model: "parent-model", | |
| 56 | + | Reasoning: "high", | |
| 57 | + | BaseURL: "https://parent.example/v1", | |
| 58 | + | InsecureTLS: true, | |
| 59 | + | FastMode: true, | |
| 60 | + | Policy: subagents.SubagentPolicy{ | |
| 61 | + | MaxConcurrent: 2, | |
| 62 | + | MaxTurns: 4, | |
| 63 | + | DefaultTimeout: time.Minute, | |
| 64 | + | CancelGracePeriod: time.Second, | |
| 65 | + | }, | |
| 66 | + | NewRunner: runner, | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestSubagentRuntimeLaunchCredentialStaysWithLaunchProvider(t *testing.T) { | |
| 71 | + | activeProvider := "parent-provider" | |
| 72 | + | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | |
| 73 | + | return &runtimeTestRunner{} | |
| 74 | + | }) | |
| 75 | + | cfg.APIKey = "launch-key" | |
| 76 | + | cfg.ActiveProvider = func() string { return activeProvider } | |
| 77 | + | rt := newSubagentRuntime(cfg) | |
| 78 | + | defer func() { _ = rt.Close(context.Background()) }() | |
| 79 | + | ||
| 80 | + | credential, err := rt.resolveCredential(context.Background(), "parent-provider") | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatalf("launch provider credential: %v", err) | |
| 83 | + | } | |
| 84 | + | if credential.Value != "launch-key" { | |
| 85 | + | t.Fatalf("launch provider credential = %q, want launch key", credential.Value) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | activeProvider = "later-provider" | |
| 89 | + | for _, providerID := range []string{"", "later-provider"} { | |
| 90 | + | credential, err = rt.resolveCredential(context.Background(), providerID) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatalf("later provider credential (%q): %v", providerID, err) | |
| 93 | + | } | |
| 94 | + | if credential.Value == "launch-key" { | |
| 95 | + | t.Fatalf("launch API key leaked to provider selected after construction (%q)", providerID) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) { | |
| 101 | + | root := t.TempDir() | |
| 102 | + | repoRoot := t.TempDir() | |
| 103 | + | var captured *subagents.Agent | |
| 104 | + | runner := func(a *subagents.Agent) subagents.Runner { | |
| 105 | + | captured = a | |
| 106 | + | return &runtimeTestRunner{} | |
| 107 | + | } | |
| 108 | + | rt := newSubagentRuntime(newRuntimeTestConfig(root, repoRoot, runner)) | |
| 109 | + | if rt == nil || rt.Supervisor() == nil { | |
| 110 | + | t.Fatal("runtime did not create a supervisor") | |
| 111 | + | } | |
| 112 | + | defer func() { _ = rt.Close(context.Background()) }() | |
| 113 | + | ||
| 114 | + | registry := rt.InjectTools(core.NewRegistry()) | |
| 115 | + | tool, ok := registry["subagent_spawn"] | |
| 116 | + | if !ok { | |
| 117 | + | t.Fatal("runtime did not inject canonical spawn tool") | |
| 118 | + | } | |
| 119 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"configure child"}`), nil); err != nil { | |
| 120 | + | t.Fatalf("spawn tool: %v", err) | |
| 121 | + | } | |
| 122 | + | if captured == nil { | |
| 123 | + | t.Fatal("runner did not receive an agent") | |
| 124 | + | } | |
| 125 | + | if captured.Provider != "parent-provider" || captured.Model != "parent-model" || captured.Reasoning != "high" { | |
| 126 | + | t.Fatalf("child model settings = provider=%q model=%q reasoning=%q", captured.Provider, captured.Model, captured.Reasoning) | |
| 127 | + | } | |
| 128 | + | if captured.BaseURL != "https://parent.example/v1" || !captured.InsecureTLS || !captured.FastMode { | |
| 129 | + | t.Fatalf("child connection/settings = base_url=%q insecure_tls=%v fast_mode=%v", captured.BaseURL, captured.InsecureTLS, captured.FastMode) | |
| 130 | + | } | |
| 131 | + | if captured.Dir != repoRoot { | |
| 132 | + | t.Fatalf("child repo root = %q, want %q", captured.Dir, repoRoot) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) { | |
| 137 | + | var captured []*subagents.Agent | |
| 138 | + | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner { | |
| 139 | + | captured = append(captured, a) | |
| 140 | + | return &runtimeTestRunner{} | |
| 141 | + | }) | |
| 142 | + | cfg.WebSearchPolicy = subagents.WebSearchAllow | |
| 143 | + | rt := newSubagentRuntime(cfg) | |
| 144 | + | defer func() { _ = rt.Close(context.Background()) }() | |
| 145 | + | ||
| 146 | + | allowedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}} | |
| 147 | + | rt.PrepareResolvedRegistry(allowedRegistry, subagents.WebSearchAllow) | |
| 148 | + | if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchAllow { | |
| 149 | + | t.Fatal("resolved allow policy was not propagated to supervisor") | |
| 150 | + | } | |
| 151 | + | allowed, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{ | |
| 152 | + | Task: "allowed child", | |
| 153 | + | Tools: []string{"web_search"}, | |
| 154 | + | }) | |
| 155 | + | if err != nil { | |
| 156 | + | t.Fatalf("spawn allowed child: %v", err) | |
| 157 | + | } | |
| 158 | + | if allowed.WebSearchPolicy != subagents.WebSearchAllow { | |
| 159 | + | t.Fatalf("allowed child policy = %v, want allow", allowed.WebSearchPolicy) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | deniedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}} | |
| 163 | + | rt.PrepareResolvedRegistry(deniedRegistry, subagents.WebSearchDeny) | |
| 164 | + | if _, ok := deniedRegistry["web_search"]; ok { | |
| 165 | + | t.Fatal("resolved deny policy left web_search in parent registry") | |
| 166 | + | } | |
| 167 | + | if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchDeny { | |
| 168 | + | t.Fatal("resolved deny policy was not propagated to supervisor") | |
| 169 | + | } | |
| 170 | + | denied, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{ | |
| 171 | + | Task: "denied child", | |
| 172 | + | Tools: []string{"web_search"}, | |
| 173 | + | }) | |
| 174 | + | if err != nil { | |
| 175 | + | t.Fatalf("spawn denied child: %v", err) | |
| 176 | + | } | |
| 177 | + | if denied.WebSearchPolicy != subagents.WebSearchDeny { | |
| 178 | + | t.Fatalf("denied child policy = %v, want deny", denied.WebSearchPolicy) | |
| 179 | + | } | |
| 180 | + | if len(captured) != 2 { | |
| 181 | + | t.Fatalf("captured children = %d, want 2", len(captured)) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestSubagentRuntimeInjectsCanonicalToolsAccordingToPolicy(t *testing.T) { | |
| 186 | + | cases := []struct { | |
| 187 | + | name string | |
| 188 | + | args Args | |
| 189 | + | want []string | |
| 190 | + | }{ | |
| 191 | + | {name: "default", want: []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}}, | |
| 192 | + | {name: "explicit spawn and status", args: Args{ToolsSet: true, Tools: []string{"subagent_spawn", "subagent_status"}}, want: []string{"subagent_spawn", "subagent_status"}}, | |
| 193 | + | {name: "no tools", args: Args{NoTools: true}}, | |
| 194 | + | {name: "permission set", args: Args{PermissionSet: &tools.PermissionSet{}}}, | |
| 195 | + | } | |
| 196 | + | for _, tc := range cases { | |
| 197 | + | t.Run(tc.name, func(t *testing.T) { | |
| 198 | + | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | |
| 199 | + | return &runtimeTestRunner{} | |
| 200 | + | }) | |
| 201 | + | cfg.Args = tc.args | |
| 202 | + | rt := newSubagentRuntime(cfg) | |
| 203 | + | defer func() { _ = rt.Close(context.Background()) }() | |
| 204 | + | got := rt.InjectTools(core.NewRegistry()) | |
| 205 | + | for _, name := range tc.want { | |
| 206 | + | if _, ok := got[name]; !ok { | |
| 207 | + | t.Errorf("missing injected tool %q in %#v", name, got) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | if len(got) != len(tc.want) { | |
| 211 | + | t.Errorf("injected tools = %#v, want exactly %v", got, tc.want) | |
| 212 | + | } | |
| 213 | + | }) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestSubagentRuntimeFailedTransitionRestoresConfiguration(t *testing.T) { | |
| 218 | + | rt := newSubagentRuntime(newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | |
| 219 | + | return &runtimeTestRunner{} | |
| 220 | + | })) | |
| 221 | + | defer func() { _ = rt.Close(context.Background()) }() | |
| 222 | + | before := rt.snapshotConfiguration() | |
| 223 | + | ||
| 224 | + | if err := func() error { | |
| 225 | + | rt.SetProvider("candidate-provider") | |
| 226 | + | rt.SetProviderSettings("https://candidate.example/v1", false) | |
| 227 | + | rt.SetFastMode(false) | |
| 228 | + | rt.SetRepoRoot(t.TempDir()) | |
| 229 | + | rt.SetWebSearchPolicy(subagents.WebSearchDeny) | |
| 230 | + | return errors.New("candidate transition failed") | |
| 231 | + | }(); err != nil { | |
| 232 | + | rt.restoreConfiguration(before) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | if got := rt.snapshotConfiguration(); got != before { | |
| 236 | + | t.Fatalf("runtime configuration after rollback = %+v, want %+v", got, before) | |
| 237 | + | } | |
| 238 | + | if got := rt.Supervisor().WebSearchPolicy(); got != before.webSearchPolicy { | |
| 239 | + | t.Fatalf("supervisor web-search policy after rollback = %v, want %v", got, before.webSearchPolicy) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestSubagentRuntimeFreshShutdownContextCleansCanceledParent(t *testing.T) { | |
| 244 | + | runner := &runtimeTestRunner{started: make(chan struct{}), block: true} | |
| 245 | + | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner }) | |
| 246 | + | rt := newSubagentRuntime(cfg) | |
| 247 | + | spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"] | |
| 248 | + | if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"cancel me"}`), nil); err != nil { | |
| 249 | + | t.Fatal(err) | |
| 250 | + | } | |
| 251 | + | select { | |
| 252 | + | case <-runner.started: | |
| 253 | + | case <-time.After(time.Second): | |
| 254 | + | t.Fatal("runner did not start") | |
| 255 | + | } | |
| 256 | + | if err := closeSubagentRuntimeFresh(rt); err != nil { | |
| 257 | + | t.Fatalf("fresh shutdown: %v", err) | |
| 258 | + | } | |
| 259 | + | if got := runner.canceled.Load(); got != 1 { | |
| 260 | + | t.Fatalf("runner cancellation count = %d, want 1", got) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestSubagentRuntimeCloseRetriesAfterCanceledContext(t *testing.T) { | |
| 265 | + | runner := &stubbornRuntimeTestRunner{started: make(chan struct{}), release: make(chan struct{})} | |
| 266 | + | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner }) | |
| 267 | + | rt := newSubagentRuntime(cfg) | |
| 268 | + | spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"] | |
| 269 | + | if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"retry cleanup"}`), nil); err != nil { | |
| 270 | + | t.Fatal(err) | |
| 271 | + | } | |
| 272 | + | select { | |
| 273 | + | case <-runner.started: | |
| 274 | + | case <-time.After(time.Second): | |
| 275 | + | t.Fatal("runner did not start") | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | canceled, cancel := context.WithCancel(context.Background()) | |
| 279 | + | cancel() | |
| 280 | + | if err := rt.Close(canceled); !errors.Is(err, context.Canceled) { | |
| 281 | + | t.Fatalf("first Close error = %v, want context canceled", err) | |
| 282 | + | } | |
| 283 | + | close(runner.release) | |
| 284 | + | if err := rt.Close(context.Background()); err != nil { | |
| 285 | + | t.Fatalf("retry Close: %v", err) | |
| 286 | + | } | |
| 287 | + | if err := rt.Close(context.Background()); err != nil { | |
| 288 | + | t.Fatalf("idempotent Close: %v", err) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) { | |
| 293 | + | root := t.TempDir() | |
| 294 | + | runner := &runtimeTestRunner{started: make(chan struct{}), block: true} | |
| 295 | + | var spawned atomic.Int32 | |
| 296 | + | var stopped atomic.Int32 | |
| 297 | + | cfg := newRuntimeTestConfig(root, t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner }) | |
| 298 | + | cfg.OnSpawned = func(*subagents.Agent, string) { spawned.Add(1) } | |
| 299 | + | cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) } | |
| 300 | + | rt := newSubagentRuntime(cfg) | |
| 301 | + | registry := rt.InjectTools(core.NewRegistry()) | |
| 302 | + | spawn, ok := registry["subagent_spawn"] | |
| 303 | + | if !ok { | |
| 304 | + | t.Fatal("spawn tool was not injected") | |
| 305 | + | } | |
| 306 | + | if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"callback task"}`), nil); err != nil { | |
| 307 | + | t.Fatalf("spawn tool: %v", err) | |
| 308 | + | } | |
| 309 | + | select { | |
| 310 | + | case <-runner.started: | |
| 311 | + | case <-time.After(time.Second): | |
| 312 | + | t.Fatal("runner did not start") | |
| 313 | + | } | |
| 314 | + | if got := spawned.Load(); got != 1 { | |
| 315 | + | t.Fatalf("OnSpawned calls = %d, want 1", got) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 319 | + | defer cancel() | |
| 320 | + | if err := rt.Close(closeCtx); err != nil { | |
| 321 | + | t.Fatalf("first Close: %v", err) | |
| 322 | + | } | |
| 323 | + | if err := rt.Close(context.Background()); err != nil { | |
| 324 | + | t.Fatalf("second Close: %v", err) | |
| 325 | + | } | |
| 326 | + | if got := runner.canceled.Load(); got != 1 { | |
| 327 | + | t.Fatalf("runner cancellation count = %d, want exactly once", got) | |
| 328 | + | } | |
| 329 | + | if got := stopped.Load(); got != 0 { | |
| 330 | + | t.Fatalf("unexpected stop callback count = %d", got) | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *testing.T) { | |
| 335 | + | cwd := t.TempDir() | |
| 336 | + | profilesDir := filepath.Join(cwd, ".agents") | |
| 337 | + | if err := os.MkdirAll(profilesDir, 0o755); err != nil { | |
| 338 | + | t.Fatal(err) | |
| 339 | + | } | |
| 340 | + | writeRuntimeProfile := func(name, frontmatter string) { | |
| 341 | + | t.Helper() | |
| 342 | + | contents := "---\nname: " + name + "\n" + frontmatter + "---\nDo the work.\n" | |
| 343 | + | if err := os.WriteFile(filepath.Join(profilesDir, name+".md"), []byte(contents), 0o644); err != nil { | |
| 344 | + | t.Fatal(err) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | writeRuntimeProfile("inherited", "") | |
| 348 | + | writeRuntimeProfile("overridden", "model: override-model\nprovider: override-provider\nthinking: low\n") | |
| 349 | + | t.Setenv("ZUT_AGENT_PROFILES", profilesDir) | |
| 350 | + | ||
| 351 | + | var captured []*subagents.Agent | |
| 352 | + | liveReasoning := "high" | |
| 353 | + | cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner { | |
| 354 | + | captured = append(captured, a) | |
| 355 | + | return &runtimeTestRunner{} | |
| 356 | + | }) | |
| 357 | + | cfg.ActiveReasoning = func() string { return liveReasoning } | |
| 358 | + | rt := newSubagentRuntime(cfg) | |
| 359 | + | // Change the host callback after runtime construction. A child without a | |
| 360 | + | // profile override must observe this new live value. | |
| 361 | + | liveReasoning = "medium" | |
| 362 | + | defer func() { _ = rt.Close(context.Background()) }() | |
| 363 | + | spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"] | |
| 364 | + | ||
| 365 | + | for _, name := range []string{"inherited", "overridden"} { | |
| 366 | + | raw := []byte(`{"task":"` + name + ` task","agent":"` + name + `"}`) | |
| 367 | + | result, err := spawn.Execute(context.Background(), raw, nil) | |
| 368 | + | if err != nil { | |
| 369 | + | t.Fatalf("spawn %s: %v", name, err) | |
| 370 | + | } | |
| 371 | + | if result.IsError { | |
| 372 | + | t.Fatalf("spawn %s returned tool error: %#v", name, result) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | if len(captured) != 2 { | |
| 376 | + | t.Fatalf("captured agents = %d, want 2", len(captured)) | |
| 377 | + | } | |
| 378 | + | inherited, overridden := captured[0], captured[1] | |
| 379 | + | if inherited.Provider != "parent-provider" || inherited.Model != "parent-model" || inherited.Reasoning != "medium" { | |
| 380 | + | t.Fatalf("inherited profile settings = provider=%q model=%q reasoning=%q", inherited.Provider, inherited.Model, inherited.Reasoning) | |
| 381 | + | } | |
| 382 | + | if overridden.Provider != "override-provider" || overridden.Model != "override-model" || overridden.Reasoning != "low" { | |
| 383 | + | t.Fatalf("overridden profile settings = provider=%q model=%q reasoning=%q", overridden.Provider, overridden.Model, overridden.Reasoning) | |
| 384 | + | } | |
| 385 | + | } | |
A packages/agent/subagents/completion.go +508 -0
| @@ -0,0 +1,508 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sort" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Completion is the observed outcome of one delegated turn. The values are | |
| 13 | + | // kept unbounded so callers can decide how much evidence to retain; the | |
| 14 | + | // shared formatter applies the bounds used in parent-agent updates. | |
| 15 | + | type Completion struct { | |
| 16 | + | AgentID string | |
| 17 | + | Status string | |
| 18 | + | Task string | |
| 19 | + | Error string | |
| 20 | + | FinalResponse string | |
| 21 | + | Tail string | |
| 22 | + | ||
| 23 | + | // turnError preserves the label used by the interactive update for an | |
| 24 | + | // error reported by a prompt-level turn_end rather than by the worker | |
| 25 | + | // process itself. | |
| 26 | + | turnError bool | |
| 27 | + | ||
| 28 | + | registrationSeq uint64 | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type completionEntry struct { | |
| 32 | + | state *completionAgent | |
| 33 | + | task string | |
| 34 | + | followUp bool | |
| 35 | + | expectedStep int | |
| 36 | + | strictStep bool | |
| 37 | + | registrationSeq uint64 | |
| 38 | + | done bool | |
| 39 | + | completion *Completion | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type completionAgent struct { | |
| 43 | + | agent *Agent | |
| 44 | + | entries []*completionEntry | |
| 45 | + | seenTurnEnds map[int]struct{} | |
| 46 | + | turnMu sync.Mutex | |
| 47 | + | callbackInstalled bool | |
| 48 | + | waitCancel context.CancelFunc | |
| 49 | + | waitingForProcess bool | |
| 50 | + | waitDone chan struct{} | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // CompletionTracker observes delegated turns through Agent's event and | |
| 54 | + | // lifecycle callbacks. Completed values are buffered so a worker can finish | |
| 55 | + | // before the parent begins waiting for the batch. | |
| 56 | + | type CompletionTracker struct { | |
| 57 | + | mu sync.Mutex | |
| 58 | + | active map[*Agent]*completionAgent | |
| 59 | + | ready []Completion | |
| 60 | + | nextRegistration uint64 | |
| 61 | + | changed chan struct{} | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // NewCompletionTracker creates an empty completion tracker. | |
| 65 | + | func NewCompletionTracker() *CompletionTracker { | |
| 66 | + | return &CompletionTracker{ | |
| 67 | + | active: make(map[*Agent]*completionAgent), | |
| 68 | + | changed: make(chan struct{}), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (t *CompletionTracker) initLocked() { | |
| 73 | + | if t.active == nil { | |
| 74 | + | t.active = make(map[*Agent]*completionAgent) | |
| 75 | + | } | |
| 76 | + | if t.changed == nil { | |
| 77 | + | t.changed = make(chan struct{}) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // TrackTurn tracks the worker's current prompt-level turn, or its first turn | |
| 82 | + | // when no turn has started yet. Multiple turns for the same long-lived worker | |
| 83 | + | // are retained and dispatched in registration order. | |
| 84 | + | func (t *CompletionTracker) TrackTurn(a *Agent, task string, followUp bool) func() { | |
| 85 | + | return t.trackTurn(a, task, followUp, false) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // TrackFutureTurn tracks a prompt-level turn before its prompt is delivered. | |
| 89 | + | // The returned function removes the registration without producing a | |
| 90 | + | // completion; callers use it when prompt delivery is rejected. | |
| 91 | + | func (t *CompletionTracker) TrackFutureTurn(a *Agent, task string, followUp bool) func() { | |
| 92 | + | return t.trackTurn(a, task, followUp, true) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (t *CompletionTracker) trackTurn(a *Agent, task string, followUp, future bool) func() { | |
| 96 | + | if t == nil || a == nil { | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | state, entry, installCallback := t.addTurn(a, task, followUp, future) | |
| 101 | + | if state == nil { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | if installCallback { | |
| 105 | + | // SetOnTurnEnd is deliberately installed before the process waiter and | |
| 106 | + | // before the caller sends a follow-up. It also replays notices that | |
| 107 | + | // arrived while no callback was registered. | |
| 108 | + | a.SetOnTurnEnd(func(step int, errMsg string) { | |
| 109 | + | state.turnMu.Lock() | |
| 110 | + | defer state.turnMu.Unlock() | |
| 111 | + | t.completeNextTurn(state, step, errMsg) | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | t.startProcessWaiter(state) | |
| 115 | + | return func() { t.cancelEntry(entry) } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // TrackExit tracks only process termination. It is used for explicit worker | |
| 119 | + | // stops when no prompt-level turn watcher owns the worker. | |
| 120 | + | func (t *CompletionTracker) TrackExit(a *Agent, task string) { | |
| 121 | + | if t == nil || a == nil { | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | state, entry := t.addExit(a, task) | |
| 125 | + | if state == nil || entry == nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | t.startProcessWaiter(state) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (t *CompletionTracker) addTurn(a *Agent, task string, followUp, future bool) (*completionAgent, *completionEntry, bool) { | |
| 132 | + | entry := &completionEntry{task: task, followUp: followUp, expectedStep: expectedTurnStep(a, future), strictStep: future} | |
| 133 | + | t.mu.Lock() | |
| 134 | + | defer t.mu.Unlock() | |
| 135 | + | t.initLocked() | |
| 136 | + | state := t.active[a] | |
| 137 | + | if state == nil { | |
| 138 | + | state = &completionAgent{agent: a, seenTurnEnds: make(map[int]struct{})} | |
| 139 | + | t.active[a] = state | |
| 140 | + | } | |
| 141 | + | if state.seenTurnEnds == nil { | |
| 142 | + | state.seenTurnEnds = make(map[int]struct{}) | |
| 143 | + | } | |
| 144 | + | if count := len(state.entries); count != 0 { | |
| 145 | + | previousStep := state.entries[count-1].expectedStep | |
| 146 | + | if entry.expectedStep <= previousStep { | |
| 147 | + | entry.expectedStep = previousStep + 1 | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | t.nextRegistration++ | |
| 151 | + | entry.registrationSeq = t.nextRegistration | |
| 152 | + | entry.state = state | |
| 153 | + | state.entries = append(state.entries, entry) | |
| 154 | + | installCallback := !state.callbackInstalled | |
| 155 | + | state.callbackInstalled = true | |
| 156 | + | return state, entry, installCallback | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (t *CompletionTracker) addExit(a *Agent, task string) (*completionAgent, *completionEntry) { | |
| 160 | + | t.mu.Lock() | |
| 161 | + | defer t.mu.Unlock() | |
| 162 | + | t.initLocked() | |
| 163 | + | if state := t.active[a]; state != nil && len(state.entries) != 0 { | |
| 164 | + | // A turn watcher already owns this worker. Its process fallback is | |
| 165 | + | // sufficient and must not create a duplicate terminal completion. | |
| 166 | + | return nil, nil | |
| 167 | + | } | |
| 168 | + | state := &completionAgent{agent: a} | |
| 169 | + | t.nextRegistration++ | |
| 170 | + | entry := &completionEntry{state: state, task: task, registrationSeq: t.nextRegistration} | |
| 171 | + | state.entries = append(state.entries, entry) | |
| 172 | + | t.active[a] = state | |
| 173 | + | return state, entry | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (t *CompletionTracker) startProcessWaiter(state *completionAgent) { | |
| 177 | + | if state == nil || state.agent == nil { | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | t.mu.Lock() | |
| 181 | + | if t.active[state.agent] != state || state.waitingForProcess { | |
| 182 | + | t.mu.Unlock() | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | waitCtx, cancel := context.WithCancel(context.Background()) | |
| 186 | + | state.waitingForProcess = true | |
| 187 | + | state.waitCancel = cancel | |
| 188 | + | state.waitDone = make(chan struct{}) | |
| 189 | + | waitDone := state.waitDone | |
| 190 | + | t.mu.Unlock() | |
| 191 | + | ||
| 192 | + | go func() { | |
| 193 | + | defer close(waitDone) | |
| 194 | + | err := state.agent.WaitContext(waitCtx) | |
| 195 | + | if err == nil { | |
| 196 | + | t.completeProcess(state) | |
| 197 | + | } | |
| 198 | + | }() | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func expectedTurnStep(a *Agent, future bool) int { | |
| 202 | + | step := a.LifetimeTurnsValue() | |
| 203 | + | if turnID := strings.TrimPrefix(a.CurrentTurnID(), "turn-"); turnID != "" { | |
| 204 | + | if current, err := strconv.Atoi(turnID); err == nil && current > step { | |
| 205 | + | step = current | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | if future || step == 0 { | |
| 209 | + | step++ | |
| 210 | + | } | |
| 211 | + | return step | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (t *CompletionTracker) completeNextTurn(state *completionAgent, step int, turnErr string) { | |
| 215 | + | if state == nil { | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | t.mu.Lock() | |
| 219 | + | if t.active[state.agent] != state || len(state.entries) == 0 { | |
| 220 | + | t.mu.Unlock() | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | if _, duplicate := state.seenTurnEnds[step]; duplicate { | |
| 224 | + | t.mu.Unlock() | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | state.seenTurnEnds[step] = struct{}{} | |
| 228 | + | var entry *completionEntry | |
| 229 | + | for _, candidate := range state.entries { | |
| 230 | + | if !candidate.done && candidate.expectedStep == step { | |
| 231 | + | entry = candidate | |
| 232 | + | break | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | if entry == nil { | |
| 236 | + | // Post-success callers can register after a resumed worker was queued | |
| 237 | + | // but before its next turn.start updated metadata. Accept the first | |
| 238 | + | // later step for that compatibility path; pre-delivery registrations | |
| 239 | + | // use strict identity and never consume a stale or unrelated notice. | |
| 240 | + | for _, candidate := range state.entries { | |
| 241 | + | if !candidate.done && !candidate.strictStep && candidate.expectedStep < step { | |
| 242 | + | entry = candidate | |
| 243 | + | candidate.expectedStep = step | |
| 244 | + | break | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | t.mu.Unlock() | |
| 249 | + | if entry != nil { | |
| 250 | + | t.completeTurn(entry, turnErr) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (t *CompletionTracker) cancelEntry(entry *completionEntry) { | |
| 255 | + | if entry == nil || entry.state == nil { | |
| 256 | + | return | |
| 257 | + | } | |
| 258 | + | var cancel context.CancelFunc | |
| 259 | + | t.mu.Lock() | |
| 260 | + | state := entry.state | |
| 261 | + | if entry.done || t.active[state.agent] != state { | |
| 262 | + | t.mu.Unlock() | |
| 263 | + | return | |
| 264 | + | } | |
| 265 | + | entry.done = true | |
| 266 | + | t.flushReadyLocked(state) | |
| 267 | + | if len(state.entries) == 0 { | |
| 268 | + | delete(t.active, state.agent) | |
| 269 | + | cancel = state.waitCancel | |
| 270 | + | state.waitCancel = nil | |
| 271 | + | state.waitingForProcess = false | |
| 272 | + | } | |
| 273 | + | t.signalLocked() | |
| 274 | + | t.mu.Unlock() | |
| 275 | + | if cancel != nil { | |
| 276 | + | cancel() | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func (t *CompletionTracker) completeTurn(entry *completionEntry, turnErr string) { | |
| 281 | + | if entry == nil || entry.state == nil || entry.state.agent == nil { | |
| 282 | + | return | |
| 283 | + | } | |
| 284 | + | snap := entry.state.agent.Snapshot() | |
| 285 | + | completion := makeCompletion(entry, snap, turnErr, true) | |
| 286 | + | ||
| 287 | + | var cancel context.CancelFunc | |
| 288 | + | t.mu.Lock() | |
| 289 | + | state := entry.state | |
| 290 | + | if entry.done || t.active[state.agent] != state { | |
| 291 | + | t.mu.Unlock() | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | entry.done = true | |
| 295 | + | entry.completion = &completion | |
| 296 | + | t.flushReadyLocked(state) | |
| 297 | + | if len(state.entries) == 0 { | |
| 298 | + | delete(t.active, state.agent) | |
| 299 | + | cancel = state.waitCancel | |
| 300 | + | state.waitCancel = nil | |
| 301 | + | state.waitingForProcess = false | |
| 302 | + | } | |
| 303 | + | t.signalLocked() | |
| 304 | + | t.mu.Unlock() | |
| 305 | + | if cancel != nil { | |
| 306 | + | cancel() | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (t *CompletionTracker) completeProcess(state *completionAgent) { | |
| 311 | + | if state == nil || state.agent == nil { | |
| 312 | + | return | |
| 313 | + | } | |
| 314 | + | snap := state.agent.Snapshot() | |
| 315 | + | ||
| 316 | + | t.mu.Lock() | |
| 317 | + | if t.active[state.agent] != state { | |
| 318 | + | t.mu.Unlock() | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | for _, entry := range state.entries { | |
| 322 | + | if entry.done { | |
| 323 | + | continue | |
| 324 | + | } | |
| 325 | + | entry.done = true | |
| 326 | + | completion := makeCompletion(entry, snap, "", false) | |
| 327 | + | entry.completion = &completion | |
| 328 | + | } | |
| 329 | + | t.flushReadyLocked(state) | |
| 330 | + | delete(t.active, state.agent) | |
| 331 | + | state.waitCancel = nil | |
| 332 | + | state.waitingForProcess = false | |
| 333 | + | t.signalLocked() | |
| 334 | + | t.mu.Unlock() | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func (t *CompletionTracker) flushReadyLocked(state *completionAgent) { | |
| 338 | + | for len(state.entries) != 0 && state.entries[0].done { | |
| 339 | + | entry := state.entries[0] | |
| 340 | + | state.entries[0] = nil | |
| 341 | + | state.entries = state.entries[1:] | |
| 342 | + | if entry.completion != nil { | |
| 343 | + | t.ready = append(t.ready, *entry.completion) | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func makeCompletion(entry *completionEntry, snap AgentSnapshot, turnErr string, turnEnd bool) Completion { | |
| 349 | + | status := string(snap.Status) | |
| 350 | + | if turnEnd { | |
| 351 | + | status = "completed" | |
| 352 | + | if turnErr != "" { | |
| 353 | + | status = "failed" | |
| 354 | + | } | |
| 355 | + | } else if snap.Status == StatusDone { | |
| 356 | + | status = "completed" | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | task := entry.task | |
| 360 | + | if !entry.followUp && snap.Task != "" { | |
| 361 | + | task = snap.Task | |
| 362 | + | } | |
| 363 | + | if task == "" { | |
| 364 | + | task = snap.Task | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | errText := snap.Err | |
| 368 | + | turnError := false | |
| 369 | + | if errText == "" && turnErr != "" { | |
| 370 | + | errText = turnErr | |
| 371 | + | turnError = true | |
| 372 | + | } | |
| 373 | + | return Completion{ | |
| 374 | + | AgentID: snap.ID, | |
| 375 | + | Status: status, | |
| 376 | + | Task: task, | |
| 377 | + | Error: errText, | |
| 378 | + | FinalResponse: snap.LastAssistant, | |
| 379 | + | Tail: snap.Tail, | |
| 380 | + | turnError: turnError, | |
| 381 | + | registrationSeq: entry.registrationSeq, | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (t *CompletionTracker) signalLocked() { | |
| 386 | + | close(t.changed) | |
| 387 | + | t.changed = make(chan struct{}) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func (t *CompletionTracker) takeReadyLocked() []Completion { | |
| 391 | + | if len(t.ready) == 0 { | |
| 392 | + | return nil | |
| 393 | + | } | |
| 394 | + | batch := append([]Completion(nil), t.ready...) | |
| 395 | + | t.ready = nil | |
| 396 | + | sort.SliceStable(batch, func(i, j int) bool { | |
| 397 | + | return batch[i].registrationSeq < batch[j].registrationSeq | |
| 398 | + | }) | |
| 399 | + | return batch | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // Pending reports the number of tracked entries that have not completed. | |
| 403 | + | func (t *CompletionTracker) Pending() int { | |
| 404 | + | if t == nil { | |
| 405 | + | return 0 | |
| 406 | + | } | |
| 407 | + | t.mu.Lock() | |
| 408 | + | defer t.mu.Unlock() | |
| 409 | + | pending := 0 | |
| 410 | + | for _, state := range t.active { | |
| 411 | + | for _, entry := range state.entries { | |
| 412 | + | if !entry.done { | |
| 413 | + | pending++ | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | return pending | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // WaitIdle returns all completions buffered so far and all completions that | |
| 421 | + | // arrive until no tracked entry remains. A cancelled wait leaves active and | |
| 422 | + | // buffered entries intact for a later caller. | |
| 423 | + | func (t *CompletionTracker) WaitIdle(ctx context.Context) ([]Completion, error) { | |
| 424 | + | if t == nil { | |
| 425 | + | return nil, nil | |
| 426 | + | } | |
| 427 | + | if ctx == nil { | |
| 428 | + | ctx = context.Background() | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | for { | |
| 432 | + | t.mu.Lock() | |
| 433 | + | t.initLocked() | |
| 434 | + | if len(t.active) == 0 { | |
| 435 | + | batch := t.takeReadyLocked() | |
| 436 | + | t.mu.Unlock() | |
| 437 | + | return batch, nil | |
| 438 | + | } | |
| 439 | + | changed := t.changed | |
| 440 | + | t.mu.Unlock() | |
| 441 | + | ||
| 442 | + | select { | |
| 443 | + | case <-changed: | |
| 444 | + | case <-ctx.Done(): | |
| 445 | + | // Prefer an already-finished batch if completion won the race | |
| 446 | + | // with cancellation; otherwise preserve the active work. | |
| 447 | + | t.mu.Lock() | |
| 448 | + | if len(t.active) == 0 { | |
| 449 | + | batch := t.takeReadyLocked() | |
| 450 | + | t.mu.Unlock() | |
| 451 | + | return batch, nil | |
| 452 | + | } | |
| 453 | + | t.mu.Unlock() | |
| 454 | + | return nil, ctx.Err() | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // FormatCompletionUpdate formats the stable evidence payload injected into a | |
| 460 | + | // parent agent. Task and error fields are bounded to 240 characters and tail | |
| 461 | + | // evidence is bounded to 600 characters; final responses retain their full | |
| 462 | + | // inline text as they did in interactive mode. | |
| 463 | + | func FormatCompletionUpdate(batch []Completion, instruction string) string { | |
| 464 | + | if len(batch) == 0 { | |
| 465 | + | return "" | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | var sb strings.Builder | |
| 469 | + | fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch)) | |
| 470 | + | for idx, completion := range batch { | |
| 471 | + | fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, completion.AgentID, completion.Status) | |
| 472 | + | fmt.Fprintf(&sb, " task: %s\n", truncateCompletionField(completion.Task, 240)) | |
| 473 | + | if completion.Error != "" { | |
| 474 | + | label := "error" | |
| 475 | + | if completion.turnError { | |
| 476 | + | label = "turn error" | |
| 477 | + | } | |
| 478 | + | fmt.Fprintf(&sb, " %s: %s\n", label, truncateCompletionField(completion.Error, 240)) | |
| 479 | + | } | |
| 480 | + | if completion.FinalResponse != "" { | |
| 481 | + | sb.WriteString(" final response:\n") | |
| 482 | + | sb.WriteString(completion.FinalResponse) | |
| 483 | + | sb.WriteString("\n") | |
| 484 | + | } else if tail := strings.TrimSpace(completion.Tail); tail != "" { | |
| 485 | + | fmt.Fprintf(&sb, " tail: %s\n", truncateCompletionField(tail, 600)) | |
| 486 | + | } | |
| 487 | + | sb.WriteString("\n") | |
| 488 | + | } | |
| 489 | + | if instruction != "" { | |
| 490 | + | sb.WriteString(instruction) | |
| 491 | + | } | |
| 492 | + | return sb.String() | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func truncateCompletionField(value string, limit int) string { | |
| 496 | + | value = strings.TrimSpace(value) | |
| 497 | + | if limit <= 0 { | |
| 498 | + | return "" | |
| 499 | + | } | |
| 500 | + | runes := []rune(value) | |
| 501 | + | if len(runes) <= limit { | |
| 502 | + | return value | |
| 503 | + | } | |
| 504 | + | if limit <= 3 { | |
| 505 | + | return string(runes[:limit]) | |
| 506 | + | } | |
| 507 | + | return string(runes[:limit-3]) + "..." | |
| 508 | + | } | |
A packages/agent/subagents/completion_test.go +408 -0
| @@ -0,0 +1,408 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func newCompletionTestAgent(id, task string, status Status) *Agent { | |
| 12 | + | return &Agent{ | |
| 13 | + | ID: id, | |
| 14 | + | Task: task, | |
| 15 | + | status: status, | |
| 16 | + | activity: "testing", | |
| 17 | + | done: make(chan struct{}), | |
| 18 | + | transcript: []string{"tail line 1", "tail line 2"}, | |
| 19 | + | lastAssistant: "final response", | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) { | |
| 24 | + | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | |
| 25 | + | tracker := NewCompletionTracker() | |
| 26 | + | tracker.TrackTurn(agent, "ignored task", false) | |
| 27 | + | ||
| 28 | + | if agent.OnTurnEnd == nil { | |
| 29 | + | t.Fatal("TrackTurn did not install an event callback") | |
| 30 | + | } | |
| 31 | + | agent.OnTurnEnd(1, "") | |
| 32 | + | ||
| 33 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if len(batch) != 1 { | |
| 38 | + | t.Fatalf("completion count = %d, want 1", len(batch)) | |
| 39 | + | } | |
| 40 | + | if got := batch[0].Status; got != "completed" { | |
| 41 | + | t.Fatalf("status = %q, want completed", got) | |
| 42 | + | } | |
| 43 | + | if got := batch[0].Task; got != agent.Task { | |
| 44 | + | t.Fatalf("task = %q, want %q", got, agent.Task) | |
| 45 | + | } | |
| 46 | + | if tracker.Pending() != 0 { | |
| 47 | + | t.Fatalf("pending = %d, want 0", tracker.Pending()) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | close(agent.done) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | status Status | |
| 57 | + | err error | |
| 58 | + | want string | |
| 59 | + | }{ | |
| 60 | + | {name: "startup failure", status: StatusFailed, err: errors.New("listener startup failed"), want: "failed"}, | |
| 61 | + | {name: "stopped process", status: StatusKilled, err: context.Canceled, want: "killed"}, | |
| 62 | + | } | |
| 63 | + | for _, tt := range tests { | |
| 64 | + | t.Run(tt.name, func(t *testing.T) { | |
| 65 | + | agent := newCompletionTestAgent("worker-"+tt.name, "task", tt.status) | |
| 66 | + | agent.lastErr = tt.err | |
| 67 | + | tracker := NewCompletionTracker() | |
| 68 | + | tracker.TrackExit(agent, agent.Task) | |
| 69 | + | close(agent.done) | |
| 70 | + | ||
| 71 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 72 | + | if err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | if len(batch) != 1 || batch[0].Status != tt.want { | |
| 76 | + | t.Fatalf("batch = %+v, want one %s completion", batch, tt.want) | |
| 77 | + | } | |
| 78 | + | if batch[0].Error != tt.err.Error() { | |
| 79 | + | t.Fatalf("error = %q, want %q", batch[0].Error, tt.err) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestCompletionTrackerQueuesSequentialTurnsForOneWorker(t *testing.T) { | |
| 86 | + | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | |
| 87 | + | tracker := NewCompletionTracker() | |
| 88 | + | tracker.TrackTurn(agent, agent.Task, false) | |
| 89 | + | tracker.TrackTurn(agent, "follow-up task", true) | |
| 90 | + | tracker.TrackTurn(agent, "follow-up task", true) | |
| 91 | + | ||
| 92 | + | if got := tracker.Pending(); got != 3 { | |
| 93 | + | t.Fatalf("pending after queued turns = %d, want 3", got) | |
| 94 | + | } | |
| 95 | + | // Callback goroutines may be scheduled out of order. Completion still has | |
| 96 | + | // to match the registered turn identity and flush in registration order. | |
| 97 | + | agent.OnTurnEnd(2, "") | |
| 98 | + | if got := tracker.Pending(); got != 2 { | |
| 99 | + | t.Fatalf("pending after second turn completed first = %d, want 2", got) | |
| 100 | + | } | |
| 101 | + | // A duplicate lifecycle event for the same prompt-level turn must not | |
| 102 | + | // consume the next accepted follow-up, even when its prompt is identical. | |
| 103 | + | agent.OnTurnEnd(2, "") | |
| 104 | + | if got := tracker.Pending(); got != 2 { | |
| 105 | + | t.Fatalf("pending after duplicate second turn = %d, want 2", got) | |
| 106 | + | } | |
| 107 | + | agent.OnTurnEnd(1, "") | |
| 108 | + | if got := tracker.Pending(); got != 1 { | |
| 109 | + | t.Fatalf("pending after first turn = %d, want 1", got) | |
| 110 | + | } | |
| 111 | + | agent.OnTurnEnd(3, "") | |
| 112 | + | ||
| 113 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 114 | + | if err != nil || len(batch) != 3 { | |
| 115 | + | t.Fatalf("wait = (%+v, %v), want three turn completions", batch, err) | |
| 116 | + | } | |
| 117 | + | if batch[0].Task != agent.Task || batch[1].Task != "follow-up task" || batch[2].Task != "follow-up task" { | |
| 118 | + | t.Fatalf("queued tasks = %q, %q, %q; want original then both follow-ups", batch[0].Task, batch[1].Task, batch[2].Task) | |
| 119 | + | } | |
| 120 | + | close(agent.done) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestCompletionTrackerTurnEndReleasesProcessWaiterWithoutDuplicate(t *testing.T) { | |
| 124 | + | agent := newCompletionTestAgent("worker-1", "task", StatusRunning) | |
| 125 | + | agent.lastErr = errors.New("late process failure") | |
| 126 | + | tracker := NewCompletionTracker() | |
| 127 | + | tracker.TrackTurn(agent, agent.Task, false) | |
| 128 | + | ||
| 129 | + | tracker.mu.Lock() | |
| 130 | + | state := tracker.active[agent] | |
| 131 | + | waitDone := state.waitDone | |
| 132 | + | tracker.mu.Unlock() | |
| 133 | + | if waitDone == nil { | |
| 134 | + | t.Fatal("turn tracking did not start a process fallback waiter") | |
| 135 | + | } | |
| 136 | + | agent.OnTurnEnd(1, "") | |
| 137 | + | select { | |
| 138 | + | case <-waitDone: | |
| 139 | + | case <-time.After(time.Second): | |
| 140 | + | t.Fatal("process fallback waiter was not released after turn_end") | |
| 141 | + | } | |
| 142 | + | close(agent.done) | |
| 143 | + | ||
| 144 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 145 | + | if err != nil || len(batch) != 1 { | |
| 146 | + | t.Fatalf("wait = (%+v, %v), want one completion", batch, err) | |
| 147 | + | } | |
| 148 | + | if batch[0].Status != "completed" { | |
| 149 | + | t.Fatalf("status = %q, want completed", batch[0].Status) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestCompletionTrackerSingleWinnerDoesNotDuplicate(t *testing.T) { | |
| 154 | + | agent := newCompletionTestAgent("worker-1", "task", StatusRunning) | |
| 155 | + | tracker := NewCompletionTracker() | |
| 156 | + | tracker.TrackTurn(agent, agent.Task, false) | |
| 157 | + | ||
| 158 | + | agent.OnTurnEnd(1, "") | |
| 159 | + | close(agent.done) | |
| 160 | + | ||
| 161 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 162 | + | if err != nil { | |
| 163 | + | t.Fatal(err) | |
| 164 | + | } | |
| 165 | + | if len(batch) != 1 { | |
| 166 | + | t.Fatalf("completion count = %d, want exactly 1", len(batch)) | |
| 167 | + | } | |
| 168 | + | if second, err := tracker.WaitIdle(context.Background()); err != nil || len(second) != 0 { | |
| 169 | + | t.Fatalf("second wait = (%+v, %v), want no duplicate", second, err) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestCompletionTrackerBatchesUntilAllActiveEntriesComplete(t *testing.T) { | |
| 174 | + | first := newCompletionTestAgent("worker-1", "first", StatusRunning) | |
| 175 | + | second := newCompletionTestAgent("worker-2", "second", StatusRunning) | |
| 176 | + | tracker := NewCompletionTracker() | |
| 177 | + | tracker.TrackTurn(first, first.Task, false) | |
| 178 | + | tracker.TrackTurn(second, second.Task, false) | |
| 179 | + | ||
| 180 | + | waited := make(chan []Completion, 1) | |
| 181 | + | go func() { | |
| 182 | + | batch, _ := tracker.WaitIdle(context.Background()) | |
| 183 | + | waited <- batch | |
| 184 | + | }() | |
| 185 | + | // Complete the workers in reverse order. The batch must still follow the | |
| 186 | + | // registration sequence rather than callback arrival order. | |
| 187 | + | second.OnTurnEnd(1, "") | |
| 188 | + | if got := tracker.Pending(); got != 1 { | |
| 189 | + | t.Fatalf("pending after second completion = %d, want 1", got) | |
| 190 | + | } | |
| 191 | + | first.OnTurnEnd(1, "") | |
| 192 | + | ||
| 193 | + | batch := <-waited | |
| 194 | + | if len(batch) != 2 { | |
| 195 | + | t.Fatalf("batch count = %d, want 2", len(batch)) | |
| 196 | + | } | |
| 197 | + | if batch[0].AgentID != first.ID || batch[1].AgentID != second.ID { | |
| 198 | + | t.Fatalf("batch order = %q, %q; want %q, %q", batch[0].AgentID, batch[1].AgentID, first.ID, second.ID) | |
| 199 | + | } | |
| 200 | + | close(first.done) | |
| 201 | + | close(second.done) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestCompletionTrackerReplaysTurnEndReceivedBeforeTracking(t *testing.T) { | |
| 205 | + | agent := newCompletionTestAgent("worker-1", "task", StatusRunning) | |
| 206 | + | notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(1)})) | |
| 207 | + | tracker := NewCompletionTracker() | |
| 208 | + | tracker.TrackTurn(agent, agent.Task, false) | |
| 209 | + | ||
| 210 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 211 | + | if err != nil || len(batch) != 1 { | |
| 212 | + | t.Fatalf("wait = (%+v, %v), want replayed completion", batch, err) | |
| 213 | + | } | |
| 214 | + | if batch[0].Status != "completed" { | |
| 215 | + | t.Fatalf("replayed status = %q, want completed", batch[0].Status) | |
| 216 | + | } | |
| 217 | + | close(agent.done) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestCompletionTrackerFutureTurnIgnoresPriorPendingTurnEnd(t *testing.T) { | |
| 221 | + | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | |
| 222 | + | agent.setTurnCounts(1, 1) | |
| 223 | + | agent.setTurnState(TurnIdle, "turn-1") | |
| 224 | + | notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(1)})) | |
| 225 | + | ||
| 226 | + | tracker := NewCompletionTracker() | |
| 227 | + | tracker.TrackFutureTurn(agent, "follow-up task", true) | |
| 228 | + | if got := tracker.Pending(); got != 1 { | |
| 229 | + | t.Fatalf("pending after stale turn_end replay = %d, want 1", got) | |
| 230 | + | } | |
| 231 | + | agent.OnTurnEnd(2, "") | |
| 232 | + | ||
| 233 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 234 | + | if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" { | |
| 235 | + | t.Fatalf("future turn completion = (%+v, %v), want follow-up turn", batch, err) | |
| 236 | + | } | |
| 237 | + | close(agent.done) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestCompletionTrackerCurrentTurnReplaysFastCompletion(t *testing.T) { | |
| 241 | + | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | |
| 242 | + | agent.setTurnCounts(2, 1) | |
| 243 | + | agent.setTurnState(TurnSucceeded, "turn-2") | |
| 244 | + | notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(2)})) | |
| 245 | + | ||
| 246 | + | tracker := NewCompletionTracker() | |
| 247 | + | tracker.TrackTurn(agent, "follow-up task", true) | |
| 248 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 249 | + | if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" { | |
| 250 | + | t.Fatalf("current turn replay = (%+v, %v), want follow-up turn", batch, err) | |
| 251 | + | } | |
| 252 | + | close(agent.done) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestCompletionTrackerPostSuccessRegistrationAcceptsNextStartedTurn(t *testing.T) { | |
| 256 | + | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | |
| 257 | + | agent.setTurnCounts(1, 1) | |
| 258 | + | agent.setTurnState(TurnQueued, "turn-1") | |
| 259 | + | ||
| 260 | + | tracker := NewCompletionTracker() | |
| 261 | + | tracker.TrackTurn(agent, "follow-up task", true) | |
| 262 | + | agent.OnTurnEnd(2, "") | |
| 263 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 264 | + | if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" { | |
| 265 | + | t.Fatalf("post-success next turn = (%+v, %v), want follow-up turn", batch, err) | |
| 266 | + | } | |
| 267 | + | close(agent.done) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestCompletionTrackerKeepsCompletionsBeforeWaiting(t *testing.T) { | |
| 271 | + | agent := newCompletionTestAgent("worker-1", "task", StatusRunning) | |
| 272 | + | tracker := NewCompletionTracker() | |
| 273 | + | tracker.TrackTurn(agent, agent.Task, false) | |
| 274 | + | agent.OnTurnEnd(1, "") | |
| 275 | + | ||
| 276 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 277 | + | if err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | if len(batch) != 1 || batch[0].AgentID != agent.ID { | |
| 281 | + | t.Fatalf("batch = %+v, want buffered worker completion", batch) | |
| 282 | + | } | |
| 283 | + | close(agent.done) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestCompletionTrackerWaitIdleCancellationLeavesActiveWork(t *testing.T) { | |
| 287 | + | agent := newCompletionTestAgent("worker-1", "task", StatusRunning) | |
| 288 | + | tracker := NewCompletionTracker() | |
| 289 | + | tracker.TrackTurn(agent, agent.Task, false) | |
| 290 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 291 | + | cancel() | |
| 292 | + | ||
| 293 | + | batch, err := tracker.WaitIdle(ctx) | |
| 294 | + | if !errors.Is(err, context.Canceled) { | |
| 295 | + | t.Fatalf("error = %v, want context.Canceled", err) | |
| 296 | + | } | |
| 297 | + | if batch != nil { | |
| 298 | + | t.Fatalf("cancelled batch = %+v, want nil", batch) | |
| 299 | + | } | |
| 300 | + | if tracker.Pending() != 1 { | |
| 301 | + | t.Fatalf("pending after cancellation = %d, want 1", tracker.Pending()) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | agent.OnTurnEnd(1, "") | |
| 305 | + | close(agent.done) | |
| 306 | + | batch, err = tracker.WaitIdle(context.Background()) | |
| 307 | + | if err != nil || len(batch) != 1 { | |
| 308 | + | t.Fatalf("post-cancellation wait = (%+v, %v), want one completion", batch, err) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func TestCompletionTrackerStoresRawFieldsAndResumedTask(t *testing.T) { | |
| 313 | + | longTask := strings.Repeat("task ", 80) | |
| 314 | + | longError := errors.New(strings.Repeat("error ", 80)) | |
| 315 | + | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | |
| 316 | + | agent.Task = longTask | |
| 317 | + | agent.lastErr = longError | |
| 318 | + | agent.lastAssistant = "" | |
| 319 | + | tracker := NewCompletionTracker() | |
| 320 | + | const followUp = "follow-up task" | |
| 321 | + | tracker.TrackTurn(agent, followUp, true) | |
| 322 | + | agent.OnTurnEnd(1, "") | |
| 323 | + | ||
| 324 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 325 | + | if err != nil || len(batch) != 1 { | |
| 326 | + | t.Fatalf("wait = (%+v, %v), want one completion", batch, err) | |
| 327 | + | } | |
| 328 | + | completion := batch[0] | |
| 329 | + | if completion.Task != followUp { | |
| 330 | + | t.Fatalf("resumed task = %q, want %q", completion.Task, followUp) | |
| 331 | + | } | |
| 332 | + | if completion.Error != longError.Error() { | |
| 333 | + | t.Fatalf("stored error was truncated: got %d chars, want %d", len(completion.Error), len(longError.Error())) | |
| 334 | + | } | |
| 335 | + | if completion.Tail == "" { | |
| 336 | + | t.Fatal("stored tail is empty") | |
| 337 | + | } | |
| 338 | + | close(agent.done) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) { | |
| 342 | + | longTask := strings.Repeat("t", 300) | |
| 343 | + | longError := strings.Repeat("e", 300) | |
| 344 | + | longTail := strings.Repeat("x", 700) | |
| 345 | + | instruction := "Summarise the collective outcome without spawning new workers." | |
| 346 | + | update := FormatCompletionUpdate([]Completion{{ | |
| 347 | + | AgentID: "worker-1", Status: "failed", Task: longTask, Error: longError, Tail: longTail, | |
| 348 | + | }}, instruction) | |
| 349 | + | ||
| 350 | + | if !strings.HasPrefix(update, "[auto-subagents update] 1 sub-agent(s) finished:\n\n") { | |
| 351 | + | t.Fatalf("update has unexpected header: %q", update) | |
| 352 | + | } | |
| 353 | + | if !strings.Contains(update, "1. agent worker-1 - status: failed\n task: ") { | |
| 354 | + | t.Fatalf("update omitted stable labels: %q", update) | |
| 355 | + | } | |
| 356 | + | if !strings.Contains(update, " error: ") || !strings.Contains(update, " tail: ") { | |
| 357 | + | t.Fatalf("update omitted error or tail: %q", update) | |
| 358 | + | } | |
| 359 | + | if !strings.HasSuffix(update, instruction) { | |
| 360 | + | t.Fatalf("update omitted instruction: %q", update) | |
| 361 | + | } | |
| 362 | + | if strings.Contains(strings.ToLower(update), "poll") { | |
| 363 | + | t.Fatalf("update instructs the parent to poll: %q", update) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | taskLine := strings.Split(update, "\n")[3] | |
| 367 | + | if got := len([]rune(strings.TrimPrefix(taskLine, " task: "))); got != 240 { | |
| 368 | + | t.Fatalf("formatted task length = %d, want 240", got) | |
| 369 | + | } | |
| 370 | + | errorLine := "" | |
| 371 | + | for _, line := range strings.Split(update, "\n") { | |
| 372 | + | if strings.HasPrefix(line, " error: ") { | |
| 373 | + | errorLine = strings.TrimPrefix(line, " error: ") | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | if got := len([]rune(errorLine)); got != 240 { | |
| 377 | + | t.Fatalf("formatted error length = %d, want 240", got) | |
| 378 | + | } | |
| 379 | + | tailLine := "" | |
| 380 | + | for _, line := range strings.Split(update, "\n") { | |
| 381 | + | if strings.HasPrefix(line, " tail: ") { | |
| 382 | + | tailLine = strings.TrimPrefix(line, " tail: ") | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | if got := len([]rune(tailLine)); got != 600 { | |
| 386 | + | t.Fatalf("formatted tail length = %d, want 600", got) | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestFormatCompletionUpdateUsesFinalResponseBeforeTailFallback(t *testing.T) { | |
| 391 | + | final := "complete final response\nwith multiple lines" | |
| 392 | + | withFinal := FormatCompletionUpdate([]Completion{{ | |
| 393 | + | AgentID: "worker-1", Status: "completed", FinalResponse: final, Tail: "old tail", | |
| 394 | + | }}, "instruction") | |
| 395 | + | if !strings.Contains(withFinal, " final response:\n"+final+"\n") { | |
| 396 | + | t.Fatalf("final response missing: %q", withFinal) | |
| 397 | + | } | |
| 398 | + | if strings.Contains(withFinal, "tail:") { | |
| 399 | + | t.Fatalf("tail should not be used when final response is present: %q", withFinal) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | withTail := FormatCompletionUpdate([]Completion{{ | |
| 403 | + | AgentID: "worker-2", Status: "completed", Tail: " fallback tail ", | |
| 404 | + | }}, "instruction") | |
| 405 | + | if !strings.Contains(withTail, " tail: fallback tail\n") { | |
| 406 | + | t.Fatalf("tail fallback missing or not trimmed: %q", withTail) | |
| 407 | + | } | |
| 408 | + | } | |
M packages/agent/subagents/persist.go +36 -2
| @@ -805,6 +805,15 @@ func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, erro | |||
| 805 | 805 | // terminal worker is restarted with the follow-up as its initial prompt, | |
| 806 | 806 | // avoiding a race with inbox listener startup. | |
| 807 | 807 | func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) { | |
| 808 | + | return f.ResumeWithPromptBefore(ctx, id, prompt, nil) | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The | |
| 812 | + | // hook runs after the target Agent is known but before a queued or direct | |
| 813 | + | // follow-up is made deliverable. For a restarted worker it runs after the new | |
| 814 | + | // Agent is built and before its runner is scheduled. A non-nil cleanup is | |
| 815 | + | // called when the operation is rejected before delivery. | |
| 816 | + | func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) { | |
| 808 | 817 | if ctx == nil { | |
| 809 | 818 | ctx = context.Background() | |
| 810 | 819 | } | |
| @@ -843,10 +852,17 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (* | |||
| 843 | 852 | // Active turns cannot accept a command through the worker inbox yet. | |
| 844 | 853 | // Keep the prompt durable and let the idle transition deliver it in | |
| 845 | 854 | // order after the current message turn finishes. | |
| 855 | + | var undo func() | |
| 856 | + | if before != nil { | |
| 857 | + | undo = before(existing, prompt) | |
| 858 | + | } | |
| 846 | 859 | acceptedAt := f.cfg.Now() | |
| 847 | 860 | commandID := existing.queueResumePrompt(prompt, acceptedAt) | |
| 848 | 861 | if err := f.persistAgent(existing); err != nil { | |
| 849 | 862 | existing.removeQueuedResumePrompt(commandID) | |
| 863 | + | if undo != nil { | |
| 864 | + | undo() | |
| 865 | + | } | |
| 850 | 866 | f.operationMu.Unlock() | |
| 851 | 867 | return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err) | |
| 852 | 868 | } | |
| @@ -859,6 +875,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (* | |||
| 859 | 875 | } | |
| 860 | 876 | turnID := existing.CurrentTurnID() | |
| 861 | 877 | previousRunTurns := existing.resetCurrentRunTurns() | |
| 878 | + | var undo func() | |
| 879 | + | if before != nil { | |
| 880 | + | undo = before(existing, prompt) | |
| 881 | + | } | |
| 862 | 882 | // Reserve the worker before writing to its inbox. A second manager call | |
| 863 | 883 | // must not enqueue another turn in the small interval before the worker | |
| 864 | 884 | // reports its turn.start event. Persist the prompt first so a host exit | |
| @@ -872,6 +892,9 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (* | |||
| 872 | 892 | existing.setTurnState(TurnIdle, turnID) | |
| 873 | 893 | existing.setCurrentRunTurns(previousRunTurns) | |
| 874 | 894 | existing.restoreResumePrompt(previousPrompt) | |
| 895 | + | if undo != nil { | |
| 896 | + | undo() | |
| 897 | + | } | |
| 875 | 898 | f.operationMu.Unlock() | |
| 876 | 899 | return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err) | |
| 877 | 900 | } | |
| @@ -888,6 +911,9 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (* | |||
| 888 | 911 | retryUnknown = true | |
| 889 | 912 | } else { | |
| 890 | 913 | existing.restoreResumePrompt(previousPrompt) | |
| 914 | + | if undo != nil { | |
| 915 | + | undo() | |
| 916 | + | } | |
| 891 | 917 | } | |
| 892 | 918 | if persistErr := f.persistAgent(existing); persistErr != nil { | |
| 893 | 919 | err = fmt.Errorf("%w; persist recovery: %v", err, persistErr) | |
| @@ -905,7 +931,7 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (* | |||
| 905 | 931 | } | |
| 906 | 932 | f.operationMu.Unlock() | |
| 907 | 933 | ||
| 908 | - | return f.resume(ctx, id, true, prompt) | |
| 934 | + | return f.resumeWithHook(ctx, id, true, prompt, before) | |
| 909 | 935 | } | |
| 910 | 936 | ||
| 911 | 937 | // dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a | |
| @@ -986,6 +1012,10 @@ func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) | |||
| 986 | 1012 | } | |
| 987 | 1013 | ||
| 988 | 1014 | func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) { | |
| 1015 | + | return f.resumeWithHook(ctx, id, resuming, resumePrompt, nil) | |
| 1016 | + | } | |
| 1017 | + | ||
| 1018 | + | func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming bool, resumePrompt string, before func(*Agent, string) func()) (*Agent, error) { | |
| 989 | 1019 | f.operationMu.Lock() | |
| 990 | 1020 | defer f.operationMu.Unlock() | |
| 991 | 1021 | if ctx == nil { | |
| @@ -1254,7 +1284,11 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum | |||
| 1254 | 1284 | ||
| 1255 | 1285 | // Refreshing metadata happened before queue admission, so a later | |
| 1256 | 1286 | // supervisor can reconstruct this resumed attempt even if the runner | |
| 1257 | - | // has not reached its first event yet. | |
| 1287 | + | // has not reached its first event yet. Register the turn before the | |
| 1288 | + | // scheduler can start the worker and emit a fast turn_end. | |
| 1289 | + | if before != nil && resumePrompt != "" { | |
| 1290 | + | before(a, resumePrompt) | |
| 1291 | + | } | |
| 1258 | 1292 | f.armQueueTimeout(a) | |
| 1259 | 1293 | f.schedule() | |
| 1260 | 1294 | return a, nil | |
M packages/agent/subagents/resume_prompt_test.go +24 -3
| @@ -71,9 +71,20 @@ func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) { | |||
| 71 | 71 | firstFollowUp = "Check the parser behavior." | |
| 72 | 72 | secondFollowUp = "Then check the error handling." | |
| 73 | 73 | ) | |
| 74 | - | if _, err := f.ResumeWithPrompt(context.Background(), a.ID, firstFollowUp); err != nil { | |
| 74 | + | tracker := NewCompletionTracker() | |
| 75 | + | if _, err := f.ResumeWithPromptBefore(context.Background(), a.ID, firstFollowUp, func(agent *Agent, prompt string) func() { | |
| 76 | + | return tracker.TrackFutureTurn(agent, prompt, true) | |
| 77 | + | }); err != nil { | |
| 75 | 78 | t.Fatalf("queue first active follow-up: %v", err) | |
| 76 | 79 | } | |
| 80 | + | if a.OnTurnEnd == nil { | |
| 81 | + | t.Fatal("active resume did not register its turn callback before queueing") | |
| 82 | + | } | |
| 83 | + | a.OnTurnEnd(2, "") | |
| 84 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 85 | + | if err != nil || len(batch) != 1 || batch[0].Task != firstFollowUp { | |
| 86 | + | t.Fatalf("active resume completion = (%+v, %v), want one %q completion", batch, err, firstFollowUp) | |
| 87 | + | } | |
| 77 | 88 | if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil { | |
| 78 | 89 | t.Fatalf("queue second active follow-up: %v", err) | |
| 79 | 90 | } | |
| @@ -202,7 +213,10 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) { | |||
| 202 | 213 | a.setTurnCounts(4, 2) | |
| 203 | 214 | ||
| 204 | 215 | const followUp = "I applied your review. What do you think now?" | |
| 205 | - | continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp) | |
| 216 | + | tracker := NewCompletionTracker() | |
| 217 | + | continued, err := f.ResumeWithPromptBefore(context.Background(), a.ID, followUp, func(agent *Agent, prompt string) func() { | |
| 218 | + | return tracker.TrackFutureTurn(agent, prompt, true) | |
| 219 | + | }) | |
| 206 | 220 | if err != nil { | |
| 207 | 221 | t.Fatalf("continue idle worker: %v", err) | |
| 208 | 222 | } | |
| @@ -236,10 +250,17 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) { | |||
| 236 | 250 | if !payload.NewRun { | |
| 237 | 251 | t.Fatal("idle follow-up did not request a fresh run budget") | |
| 238 | 252 | } | |
| 253 | + | if a.OnTurnEnd == nil { | |
| 254 | + | t.Fatal("idle resume callback was not installed before prompt delivery") | |
| 255 | + | } | |
| 256 | + | a.OnTurnEnd(5, "") | |
| 239 | 257 | case <-time.After(time.Second): | |
| 240 | 258 | t.Fatal("idle worker did not receive the follow-up") | |
| 241 | 259 | } | |
| 242 | - | ||
| 260 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 261 | + | if err != nil || len(batch) != 1 || batch[0].Task != followUp { | |
| 262 | + | t.Fatalf("idle resume completion = (%+v, %v), want one %q completion", batch, err, followUp) | |
| 263 | + | } | |
| 243 | 264 | persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID)) | |
| 244 | 265 | if err != nil { | |
| 245 | 266 | t.Fatal(err) | |
M packages/agent/tools/subagent_lifecycle_test.go +84 -0
| @@ -78,6 +78,90 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) { | |||
| 78 | 78 | } | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | + | func TestSubagentResumeBeforeHookRunsBeforeRestartedWorkerStarts(t *testing.T) { | |
| 82 | + | root := t.TempDir() | |
| 83 | + | started := make(chan *subagents.Agent, 2) | |
| 84 | + | resumedTurnEnded := make(chan struct{}) | |
| 85 | + | tracker := subagents.NewCompletionTracker() | |
| 86 | + | manager := subagents.New(subagents.Config{ | |
| 87 | + | Root: filepath.Join(root, "subagents"), | |
| 88 | + | RepoRoot: root, | |
| 89 | + | NewRunner: func(agent *subagents.Agent) subagents.Runner { | |
| 90 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 91 | + | started <- agent | |
| 92 | + | if agent.Resuming { | |
| 93 | + | if agent.OnTurnEnd == nil { | |
| 94 | + | return context.Canceled | |
| 95 | + | } | |
| 96 | + | agent.OnTurnEnd(1, "") | |
| 97 | + | close(resumedTurnEnded) | |
| 98 | + | return nil | |
| 99 | + | } | |
| 100 | + | <-ctx.Done() | |
| 101 | + | return ctx.Err() | |
| 102 | + | }) | |
| 103 | + | }, | |
| 104 | + | }) | |
| 105 | + | t.Cleanup(manager.StopAll) | |
| 106 | + | ||
| 107 | + | first, err := manager.Spawn(context.Background(), "review the implementation") | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | select { | |
| 112 | + | case <-started: | |
| 113 | + | case <-time.After(time.Second): | |
| 114 | + | t.Fatal("initial subagent did not start") | |
| 115 | + | } | |
| 116 | + | if err := manager.Stop(first.ID); err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | first.Wait() | |
| 120 | + | ||
| 121 | + | const prompt = "continue the review" | |
| 122 | + | var beforeAgent *subagents.Agent | |
| 123 | + | var beforePrompt string | |
| 124 | + | tool := &SubagentResumeTool{ | |
| 125 | + | Supervisor: manager, | |
| 126 | + | Enabled: func() bool { return true }, | |
| 127 | + | BeforeResumed: func(agent *subagents.Agent, gotPrompt string) func() { | |
| 128 | + | beforeAgent = agent | |
| 129 | + | beforePrompt = gotPrompt | |
| 130 | + | return tracker.TrackFutureTurn(agent, gotPrompt, true) | |
| 131 | + | }, | |
| 132 | + | OnResumed: func(*subagents.Agent, string) { | |
| 133 | + | t.Fatal("post-success callback should not run with a pre-resume hook") | |
| 134 | + | }, | |
| 135 | + | } | |
| 136 | + | args, err := json.Marshal(subagentResumeArgs{AgentID: first.ID, Prompt: prompt}) | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | if res, err := tool.Execute(context.Background(), args, nil); err != nil || res.IsError { | |
| 141 | + | t.Fatalf("resume result = (%+v, %v), want success", res, err) | |
| 142 | + | } | |
| 143 | + | if beforeAgent == nil || beforeAgent == first || beforePrompt != prompt { | |
| 144 | + | t.Fatalf("before hook = (%p, %q), want restarted worker and %q", beforeAgent, beforePrompt, prompt) | |
| 145 | + | } | |
| 146 | + | select { | |
| 147 | + | case resumed := <-started: | |
| 148 | + | if resumed.OnTurnEnd == nil { | |
| 149 | + | t.Fatal("restarted worker started without its pre-registered callback") | |
| 150 | + | } | |
| 151 | + | case <-time.After(time.Second): | |
| 152 | + | t.Fatal("resumed subagent did not start") | |
| 153 | + | } | |
| 154 | + | select { | |
| 155 | + | case <-resumedTurnEnded: | |
| 156 | + | case <-time.After(time.Second): | |
| 157 | + | t.Fatal("resumed turn did not finish") | |
| 158 | + | } | |
| 159 | + | batch, err := tracker.WaitIdle(context.Background()) | |
| 160 | + | if err != nil || len(batch) != 1 || batch[0].Task != prompt { | |
| 161 | + | t.Fatalf("resumed completion = (%+v, %v), want one %q completion", batch, err, prompt) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 81 | 165 | func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) { | |
| 82 | 166 | root := t.TempDir() | |
| 83 | 167 | started := make(chan *subagents.Agent, 2) | |
M packages/agent/tools/subagent_resume.go +10 -5
| @@ -20,10 +20,15 @@ type SubagentResumeTool struct { | |||
| 20 | 20 | Supervisor *subagents.Supervisor | |
| 21 | 21 | Enabled func() bool | |
| 22 | 22 | ||
| 23 | + | // BeforeResumed, when set, registers the follow-up before the supervisor | |
| 24 | + | // sends or queues its prompt. It returns a cleanup for resume failures that | |
| 25 | + | // happen before delivery. The interactive host uses this to avoid losing a | |
| 26 | + | // fast idle or restarted-worker turn. | |
| 27 | + | BeforeResumed func(agent *subagents.Agent, prompt string) func() | |
| 28 | + | ||
| 23 | 29 | // OnResumed receives the worker and follow-up after a successful resume. | |
| 24 | - | // It runs synchronously, so callbacks must only register non-blocking | |
| 25 | - | // tracking work. The interactive host uses it to watch this new delegated | |
| 26 | - | // turn and deliver its outcome back to the manager automatically. | |
| 30 | + | // It remains available for callers that only need post-success | |
| 31 | + | // notification; tracking callers should prefer BeforeResumed. | |
| 27 | 32 | OnResumed func(agent *subagents.Agent, prompt string) | |
| 28 | 33 | } | |
| 29 | 34 | ||
| @@ -88,11 +93,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 88 | 93 | if !ok { | |
| 89 | 94 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |
| 90 | 95 | } | |
| 91 | - | resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt) | |
| 96 | + | resumed, err := t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, t.BeforeResumed) | |
| 92 | 97 | if err != nil { | |
| 93 | 98 | return protocolToolError(prefix + ": " + err.Error()) | |
| 94 | 99 | } | |
| 95 | - | if t.OnResumed != nil { | |
| 100 | + | if t.BeforeResumed == nil && t.OnResumed != nil { | |
| 96 | 101 | t.OnResumed(resumed, args.Prompt) | |
| 97 | 102 | } | |
| 98 | 103 | ||
M packages/agent/zutfile.go +13 -0
| @@ -114,6 +114,12 @@ func runZutfileCommand(rawArgs []string, version string) (bool, error) { | |||
| 114 | 114 | if yes { | |
| 115 | 115 | args.Yes = true | |
| 116 | 116 | } | |
| 117 | + | if args.Orchestrate { | |
| 118 | + | return true, fmt.Errorf("--orchestrate is not supported for packaged agents") | |
| 119 | + | } | |
| 120 | + | if err := validateOrchestrationArgs(args); err != nil { | |
| 121 | + | return true, err | |
| 122 | + | } | |
| 117 | 123 | return true, runLocalZutfile(ref, args, version) | |
| 118 | 124 | default: | |
| 119 | 125 | return false, nil | |
| @@ -132,6 +138,13 @@ func runLocalZutfile(ref string, args Args, version string) error { | |||
| 132 | 138 | if err := checkZutfileRequirements(zf, version); err != nil { | |
| 133 | 139 | return err | |
| 134 | 140 | } | |
| 141 | + | // A packaged agent has not yet had its manifest permissions attached to | |
| 142 | + | // Args. Reject orchestration at this boundary, before creating its data | |
| 143 | + | // directory or asking for permission consent. | |
| 144 | + | if args.Orchestrate { | |
| 145 | + | args.AgentName = zf.Manifest.Name | |
| 146 | + | return validateOrchestrationArgs(args) | |
| 147 | + | } | |
| 135 | 148 | agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data") | |
| 136 | 149 | if err := os.MkdirAll(agentData, 0o755); err != nil { | |
| 137 | 150 | return err | |
M packages/agent/zutfile_test.go +23 -0
| @@ -18,6 +18,29 @@ import ( | |||
| 18 | 18 | "github.com/bnema/zut/packages/tui" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | + | func TestRunZutfileOrchestrationRejectsPackagedAgentBeforeSideEffects(t *testing.T) { | |
| 22 | + | home := t.TempDir() | |
| 23 | + | t.Setenv("ZUT_HOME", home) | |
| 24 | + | // A nonexistent reference proves validation happens before local loading, | |
| 25 | + | // remote fetching, unpacking, data-directory creation, or consent. | |
| 26 | + | missingPackage := filepath.Join(t.TempDir(), "missing.zut") | |
| 27 | + | ||
| 28 | + | handled, err := runZutfileCommand([]string{"run", missingPackage, "--print", "--orchestrate", "task"}, "test") | |
| 29 | + | if !handled { | |
| 30 | + | t.Fatal("run command was not handled") | |
| 31 | + | } | |
| 32 | + | if err == nil || !strings.Contains(err.Error(), "packaged agents") { | |
| 33 | + | t.Fatalf("run error = %v, want packaged-agent validation error", err) | |
| 34 | + | } | |
| 35 | + | entries, readErr := os.ReadDir(home) | |
| 36 | + | if readErr != nil { | |
| 37 | + | t.Fatal(readErr) | |
| 38 | + | } | |
| 39 | + | if len(entries) != 0 { | |
| 40 | + | t.Fatalf("packaged-agent rejection created state: %v", entries) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 21 | 44 | func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | |
| 22 | 45 | tests := []struct { | |
| 23 | 46 | name string | |