detached 0c65e7a

feat(agent): add headless subagent orchestration

commit
0c65e7ae80667c1a13a185f37ab11011e25ea3de
author
brice · · 2026-08-08 18:49 UTC
parent
66e9275
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 zut -p "list all go files" # print final text, exit192 zut -p "list all go files" # print final text, exit
193 zut -p --stats stats.json "task" # print final text and write generation stats193 zut -p --stats stats.json "task" # print final text and write generation stats
194 zut --json "refactor main.go" # newline-delimited json events, exit194 zut --json "refactor main.go" # newline-delimited json events, exit
195+zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
195 zut --continue # resume the most recent session for this cwd196 zut --continue # resume the most recent session for this cwd
196 zut --resume # pick a session to resume197 zut --resume # pick a session to resume
197 zut --resume <UUID> # resume a persisted session by UUID198 zut --resume <UUID> # resume a persisted session by UUID
@@ -213,7 +214,8 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
213 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZUT_HOME/SYSTEM.md`). |214 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZUT_HOME/SYSTEM.md`). |
214 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |215 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
215 | `--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 | `--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 | `-c`, `--continue` | Resume the latest session for this cwd. |219 | `-c`, `--continue` | Resume the latest session for this cwd. |
218 | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |220 | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |
219 | `--session <path>` | Resume a specific session file. |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 - **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).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 - **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.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 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.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 ## zutfile agents263 ## zutfile agents
M docs/subagents.md +14 -0
@@ -52,6 +52,20 @@ Supported metadata:
52 52
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.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 ## Selecting a profile69 ## Selecting a profile
56 70
57 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.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 // Args holds parsed command-line options.35 // Args holds parsed command-line options.
36 type Args struct {36 type Args struct {
37- Mode Mode37+ Mode Mode
38- Provider string38+ Orchestrate bool
39- Model string39+ Provider string
40- APIKey string40+ Model string
41+ APIKey string
41 42
42 // inheritedCredential is populated only by subagent-worker mode from43 // inheritedCredential is populated only by subagent-worker mode from
43 // the supervisor's stdin. It is never accepted as a CLI argument.44 // the supervisor's stdin. It is never accepted as a CLI argument.
@@ -200,6 +201,8 @@ func ParseArgs(in []string) (Args, error) {
200 a.Mode = ModeStream201 a.Mode = ModeStream
201 case "--json":202 case "--json":
202 a.Mode = ModeJSON203 a.Mode = ModeJSON
204+ case "--orchestrate":
205+ a.Orchestrate = true
203 case "--rpc":206 case "--rpc":
204 a.Mode = ModeRPC207 a.Mode = ModeRPC
205 case "-c", "--continue":208 case "-c", "--continue":
@@ -518,6 +521,7 @@ func PrintHelp(version string) {
518 row{"zut", "interactive tui"},521 row{"zut", "interactive tui"},
519 row{"zut \"prompt\"", "interactive, pre-filled prompt"},522 row{"zut \"prompt\"", "interactive, pre-filled prompt"},
520 row{"zut -p \"prompt\"", "print final text, exit"},523 row{"zut -p \"prompt\"", "print final text, exit"},
524+ row{"zut -p --orchestrate \"prompt\"", "delegate independent work, print final synthesis, exit"},
521 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},525 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},
522 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},526 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},
523 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},527 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},
@@ -553,6 +557,7 @@ func PrintHelp(version string) {
553 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},557 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
554 )558 )
555 section("prompt and session flags",559 section("prompt and session flags",
560+ row{"--orchestrate", "headless delegation and final synthesis (print, stream, or json modes)"},
556 row{"--stats PATH", "write print-mode generation stats as json"},561 row{"--stats PATH", "write print-mode generation stats as json"},
557 row{"--system-prompt TEXT", "replace the default system prompt"},562 row{"--system-prompt TEXT", "replace the default system prompt"},
558 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},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 package agent1 package agent
2 2
3 import (3 import (
4+ "os"
5+ "strings"
4 "testing"6 "testing"
5 "time"7 "time"
6 8
7 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/agent/tools"
8 "github.com/google/uuid"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 func TestParseArgsSubagentAndReasoning(t *testing.T) {70 func TestParseArgsSubagentAndReasoning(t *testing.T) {
12 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})71 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})
13 if err != nil {72 if err != nil {
M packages/agent/build.go +18 -2
@@ -644,7 +644,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
644 if !CredentialAvailable(other) {644 if !CredentialAvailable(other) {
645 continue645 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 provName = other650 provName = other
649 cred, method, accountID, credErr = c, m, a, err651 cred, method, accountID, credErr = c, m, a, err
650 break652 break
@@ -857,7 +859,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
857 append_ = append(append_, skillAddendum)859 append_ = append(append_, skillAddendum)
858 }860 }
859 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive861 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 if autoSubagentsToolAllowed(args) {877 if autoSubagentsToolAllowed(args) {
862 homeDir, _ := os.UserHomeDir()878 homeDir, _ := os.UserHomeDir()
863 profiles, _ := subagents.Discover(args.CWD, homeDir)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 func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {382 func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
343 root := t.TempDir()383 root := t.TempDir()
344 home := filepath.Join(root, "home")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 return candidate, nil532 return candidate, nil
533 }533 }
534 534
535-// buildNonInteractiveSessionAgent constructs a provider/model-specific agent535+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) {
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) {
539 next := args536 next := args
540 next.Provider = providerName537 next.Provider = providerName
541 next.Model = model538 next.Model = model
@@ -547,6 +544,9 @@ func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolv
547 if extMgr != nil {544 if extMgr != nil {
548 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})545 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
549 }546 }
547+ if prepareRegistry != nil {
548+ prepareRegistry(resolved.ToolRegistry)
549+ }
550 ag := resolved.NewAgent()550 ag := resolved.NewAgent()
551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
552 return ag, resolved.Provider, resolved.Model, nil552 return ag, resolved.Provider, resolved.Model, nil
@@ -598,9 +598,44 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
598 // contract used by interactive session switching to non-interactive startup598 // contract used by interactive session switching to non-interactive startup
599 // modes. The returned session owns the replacement append handle.599 // modes. The returned session owns the replacement append handle.
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) {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 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,605 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
602 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {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 if err != nil {640 if err != nil {
606 return nil, ag, "", "", err641 return nil, ag, "", "", err
@@ -749,6 +784,11 @@ func runWithArgsRaw(rawArgs []string, version string) error {
749 fmt.Println("zut", version)784 fmt.Println("zut", version)
750 return nil785 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 // Model catalog: load any cached discovery data before we inspect792 // Model catalog: load any cached discovery data before we inspect
753 // the model list (list-models, print/json, interactive).793 // the model list (list-models, print/json, interactive).
754 prepareRuntimeCatalog()794 prepareRuntimeCatalog()
@@ -762,6 +802,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
762 }802 }
763 803
764 func runWithArgs(args Args, version string) error {804 func runWithArgs(args Args, version string) error {
805+ if err := validateOrchestrationArgs(args); err != nil {
806+ return err
807+ }
765 ctx := context.Background()808 ctx := context.Background()
766 switch args.Mode {809 switch args.Mode {
767 case ModePrint:810 case ModePrint:
@@ -891,6 +934,9 @@ func writePrintStats(path, providerID, model string, usage provider.Usage, elaps
891 }934 }
892 935
893 func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {936 func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
937+ if args.Orchestrate {
938+ return runOrchestratedPrintMode(ctx, args, version)
939+ }
894 if args.NoYolo {940 if args.NoYolo {
895 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")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 func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {1017 func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1018+ if args.Orchestrate {
1019+ return runOrchestratedStreamMode(ctx, args, version)
1020+ }
972 if args.NoYolo {1021 if args.NoYolo {
973 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")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 func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {1137 func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1138+ if args.Orchestrate {
1139+ return runOrchestratedJSONMode(ctx, args, version)
1140+ }
1089 if args.NoYolo {1141 if args.NoYolo {
1090 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")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 // refreshes the agent tool registry + system prompt so skills/extensions1352 // refreshes the agent tool registry + system prompt so skills/extensions
1301 // installed by entry.pre are visible to the following turn.1353 // installed by entry.pre are visible to the following turn.
1302 func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {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 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {1360 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1304- return nil1361+ return subagents.WebSearchDeny, nil
1305 }1362 }
1306 adapter := &extToolAdapter{mgr: extMgr}1363 adapter := &extToolAdapter{mgr: extMgr}
1307 if extMgr != nil {1364 if extMgr != nil {
1308 _ = extMgr.Reload(ctx, 2*time.Second)1365 _ = extMgr.Reload(ctx, 2*time.Second)
1309 }1366 }
1310- _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)1367+ return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
1311- return err
1312 }1368 }
1313 1369
1314 // ---- interactive mode: opens the TUI even without credentials ----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 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {1372 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1345 initialCfg, _ := LoadConfig()1373 initialCfg, _ := LoadConfig()
1346 // Resolve WITHOUT requiring credentials.1374 // Resolve WITHOUT requiring credentials.
@@ -1405,78 +1433,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1405 r.MergeExtensionTools(extToolAdapter)1433 r.MergeExtensionTools(extToolAdapter)
1406 webSearchGuard := &webSearchSessionGuard{}1434 webSearchGuard := &webSearchSessionGuard{}
1407 1435
1408- // Build the subagent supervisor BEFORE the agent so the auto-subagents1436+ // Keep the shared subagent runtime in the outer scope: session reloads,
1409- // tool can reference it during tool-registry construction. State1437+ // dashboard updates, agent rebuilds, and shutdown all use the same owner.
1410- // lives under ZutHome/subagents so per-agent meta/events survive1438+ var runtime *subagentRuntime
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.
1417 var subagentsMgr *subagents.Supervisor1439 var subagentsMgr *subagents.Supervisor
1418- subagentsMgr = subagents.New(subagents.Config{1440+ // These callbacks deliberately resolve iv lazily because the Interactive
1419- Context: ctx,1441+ // instance is constructed after the agent and its registry.
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.
1480 onSpawnedSupervisor := func(a *subagents.Agent, task string) {1442 onSpawnedSupervisor := func(a *subagents.Agent, task string) {
1481 if iv != nil {1443 if iv != nil {
1482 iv.TrackSubagentWorker(a, task)1444 iv.TrackSubagentWorker(a, task)
@@ -1487,86 +1449,66 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1487 iv.TrackResumedSubagentWorker(a, prompt)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 onStopRequestedSupervisor := func(a *subagents.Agent) {1458 onStopRequestedSupervisor := func(a *subagents.Agent) {
1491 if iv != nil {1459 if iv != nil {
1492 iv.TrackStoppedSubagentWorker(a)1460 iv.TrackStoppedSubagentWorker(a)
1493 }1461 }
1494 }1462 }
1495- resolveSubagent := func(name string) (*subagents.Profile, error) {1463+ activeProviderForSubagents := func() string {
1496- return findSubagentProfile(r.CWD, name)1464+ persistMu.Lock()
1497- }1465+ defer persistMu.Unlock()
1498-1466+ return activeProvider
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
1550 }1467 }
1551- prepareInteractiveRegistry := func(reg core.Registry) core.Registry {1468+ activeModelForSubagents := func() string {
1552- reg = injectSubagentTools(reg)1469+ persistMu.Lock()
1553- return webSearchGuard.wrapRegistry(reg)1470+ defer persistMu.Unlock()
1471+ return activeModel
1554 }1472 }
1555- prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {1473+
1556- // The resolved policy is a ceiling. In particular, an extension named1474+ runtime = newSubagentRuntime(subagentRuntimeConfig{
1557- // web_search must not reintroduce the capability when --no-tools,1475+ Context: ctx,
1558- // --tools, a packaged PermissionSet, or persisted settings deny it.1476+ Args: args,
1559- if policy != subagents.WebSearchAllow {1477+ Root: filepath.Join(ZutHome(), "subagents"),
1560- delete(reg, "web_search")1478+ RepoRoot: r.CWD,
1561- }1479+ Provider: r.Provider,
1562- return prepareInteractiveRegistry(reg)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 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)1507 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1565 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)1508 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1566 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)1509 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1567- if subagentsMgr != nil {1510+ runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1568- subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy)1511+ resolveSubagent := runtime.resolveSubagent
1569- }
1570 1512
1571 // Confirmation gate: when --no-yolo is on, the agent must ask1513 // Confirmation gate: when --no-yolo is on, the agent must ask
1572 // the user before every tool call. In interactive mode the TUI1514 // 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 return nil, "", "", err1592 return nil, "", "", err
1651 }1593 }
1652 resolved.UseSandbox(sharedSandbox)1594 resolved.UseSandbox(sharedSandbox)
1653- if subagentsMgr != nil {1595+ runtime.SetProvider(resolved.Provider)
1654- subagentsMgr.SetProvider(resolved.Provider)1596+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1655- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1597+ runtime.SetFastMode(resolved.FastMode)
1656- }
1657 resolved.MergeExtensionTools(extToolAdapter)1598 resolved.MergeExtensionTools(extToolAdapter)
1658 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1599 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1659 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1600 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 return nil, "", "", err1614 return nil, "", "", err
1674 }1615 }
1675 resolved.UseSandbox(sharedSandbox)1616 resolved.UseSandbox(sharedSandbox)
1676- if subagentsMgr != nil {1617+ runtime.SetProvider(resolved.Provider)
1677- subagentsMgr.SetProvider(resolved.Provider)1618+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1678- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1619+ runtime.SetFastMode(resolved.FastMode)
1679- }
1680 resolved.MergeExtensionTools(extToolAdapter)1620 resolved.MergeExtensionTools(extToolAdapter)
1681 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1621 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1682 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1622 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 return nil, "", "", err1644 return nil, "", "", err
1705 }1645 }
1706 resolved.UseSandbox(sharedSandbox)1646 resolved.UseSandbox(sharedSandbox)
1707- if subagentsMgr != nil {1647+ runtime.SetProvider(resolved.Provider)
1708- subagentsMgr.SetProvider(resolved.Provider)1648+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1709- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1649+ runtime.SetFastMode(resolved.FastMode)
1710- }
1711 resolved.MergeExtensionTools(extToolAdapter)1650 resolved.MergeExtensionTools(extToolAdapter)
1712 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1651 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1713 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1652 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 persistMu.Unlock()1866 persistMu.Unlock()
1928 1867
1929 // A candidate build must not publish its provider/model as the live1868 // A candidate build must not publish its provider/model as the live
1930- // selection before the candidate commits.1869+ // selection before the candidate commits. The builder also stages
1931- candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)1870+ // provider settings on the shared runtime, so restore those settings on
1932- if err != nil {1871+ // every failed path, including errors returned during preparation.
1933- // prepareSessionResume closes its append handle on failure;1872+ runtimeConfig := runtime.snapshotConfiguration()
1934- // no live agent or session has been changed.
1935- return err
1936- }
1937 committed := false1873 committed := false
1874+ var candidate sessionResumeCandidate
1938 defer func() {1875 defer func() {
1939 if !committed {1876 if !committed {
1877+ runtime.restoreConfiguration(runtimeConfig)
1940 loadErr = joinSessionCloseError(loadErr, candidate.session)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 // Wire the private candidate before it can become visible. This is1888 // Wire the private candidate before it can become visible. This is
1945 // also required for legacy same-provider resumes, where the current1889 // 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 if iv != nil {1939 if iv != nil {
1996 iv.SetSubagentSessionScope(candidate.session.ID)1940 iv.SetSubagentSessionScope(candidate.session.ID)
1997 } else {1941 } else {
1998- subagentsMgr.SetActiveSession(candidate.session.ID)1942+ runtime.SetActiveSession(candidate.session.ID)
1999 }1943 }
2000 }1944 }
2001 committed = true1945 committed = true
@@ -2073,10 +2017,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2073 oldActiveProvider := activeProvider2017 oldActiveProvider := activeProvider
2074 oldActiveModel := activeModel2018 oldActiveModel := activeModel
2075 persistMu.Unlock()2019 persistMu.Unlock()
2076- oldSupervisorRoot := ""2020+ runtimeConfig := runtime.snapshotConfiguration()
2077- if subagentsMgr != nil {
2078- oldSupervisorRoot = subagentsMgr.RepoRoot()
2079- }
2080 oldSandboxRoot := ""2021 oldSandboxRoot := ""
2081 oldSandboxLocked := false2022 oldSandboxLocked := false
2082 if sharedSandbox != nil {2023 if sharedSandbox != nil {
@@ -2091,9 +2032,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2091 activeProvider = oldActiveProvider2032 activeProvider = oldActiveProvider
2092 activeModel = oldActiveModel2033 activeModel = oldActiveModel
2093 persistMu.Unlock()2034 persistMu.Unlock()
2094- if subagentsMgr != nil {2035+ runtime.restoreConfiguration(runtimeConfig)
2095- subagentsMgr.SetRepoRoot(oldSupervisorRoot)
2096- }
2097 if sharedSandbox != nil {2036 if sharedSandbox != nil {
2098 sharedSandbox.Root = oldSandboxRoot2037 sharedSandbox.Root = oldSandboxRoot
2099 if oldSandboxLocked {2038 if oldSandboxLocked {
@@ -2106,9 +2045,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2106 2045
2107 args.CWD = absPath2046 args.CWD = absPath
2108 r.CWD = absPath2047 r.CWD = absPath
2109- if subagentsMgr != nil {2048+ runtime.SetRepoRoot(absPath)
2110- subagentsMgr.SetRepoRoot(absPath)
2111- }
2112 if args.PermissionSet != nil {2049 if args.PermissionSet != nil {
2113 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)2050 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2114 args.PermissionSet = &expanded2051 args.PermissionSet = &expanded
@@ -2189,11 +2126,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2189 }2126 }
2190 2127
2191 // Re-scope the subagent dashboard to the new session.2128 // Re-scope the subagent dashboard to the new session.
2192- if subagentsMgr != nil && newSess != nil {2129+ if newSess != nil {
2193 if iv != nil {2130 if iv != nil {
2194 iv.SetSubagentSessionScope(newSess.ID)2131 iv.SetSubagentSessionScope(newSess.ID)
2195 } else {2132 } else {
2196- subagentsMgr.SetActiveSession(newSess.ID)2133+ runtime.SetActiveSession(newSess.ID)
2197 }2134 }
2198 }2135 }
2199 if newSess != nil {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 agent2212+ // Scope the dashboard to the active host session. The runtime owns
2276- // build, so the auto-subagents tool could capture it). Here we just2213+ // shutdown separately and closes the supervisor exactly once.
2277- // scope the dashboard to the active host session so /subagents only2214+ if sess != nil {
2278- // shows agents this session spawned (and any pre-upgrade unscoped2215+ runtime.SetActiveSession(sess.ID)
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- }()
2294 }2216 }
2295 2217
2296 var startupSkills []*skills.Skill2218 var startupSkills []*skills.Skill
@@ -2315,7 +2237,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2315 if available {2237 if available {
2316 policy = subagents.WebSearchAllow2238 policy = subagents.WebSearchAllow
2317 }2239 }
2318- subagentsMgr.SetWebSearchPolicy(policy)2240+ runtime.SetWebSearchPolicy(policy)
2319 }2241 }
2320 2242
2321 iv = modes.NewInteractive(modes.InteractiveConfig{2243 iv = modes.NewInteractive(modes.InteractiveConfig{
@@ -2624,12 +2546,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2624 closeAgentLSP(finalAg)2546 closeAgentLSP(finalAg)
2625 closeResolvedLSP(r)2547 closeResolvedLSP(r)
2626 // Stop supervised workers before exiting. os.Exit skips deferred2548 // Stop supervised workers before exiting. os.Exit skips deferred
2627- // cleanup, so relying only on the normal runInteractive defer would2549+ // cleanup, so close the shared runtime explicitly on a TERM or HUP.
2628- // leave child daemons (and their descendants) alive after a TERM or2550+ _ = runtime.Close(context.Background())
2629- // HUP delivered to the host.
2630- if subagentsMgr != nil {
2631- subagentsMgr.StopAll()
2632- }
2633 // Exit cleanly. Re-raising the signal would skip os.Exit's2551 // Exit cleanly. Re-raising the signal would skip os.Exit's
2634 // at-exit hooks; explicit exit is fine because we've already2552 // at-exit hooks; explicit exit is fine because we've already
2635 // flushed the only at-risk state (the session file) and stopped2553 // flushed the only at-risk state (the session file) and stopped
@@ -2825,7 +2743,11 @@ func readAllStdin() (string, error) {
2825 if (fi.Mode() & os.ModeCharDevice) != 0 {2743 if (fi.Mode() & os.ModeCharDevice) != 0 {
2826 return "", nil2744 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 return string(b), err2751 return string(b), err
2830 }2752 }
2831 2753
M packages/agent/cli_session_test.go +52 -0
@@ -18,6 +18,16 @@ import (
18 "github.com/google/uuid"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 func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {31 func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
22 root := t.TempDir()32 root := t.TempDir()
23 cwd := t.TempDir()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 func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {467 func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
416 old := core.NewAgent(nil, "old-model", "", nil)468 old := core.NewAgent(nil, "old-model", "", nil)
417 old.SetMessages([]provider.Message{{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 if a.OnTurnEnd == nil {188 if a.OnTurnEnd == nil {
189 t.Fatal("resumed worker has no completion watcher")189 t.Fatal("resumed worker has no completion watcher")
190 }190 }
191- a.OnTurnEnd(2, "")191+ a.OnTurnEnd(1, "")
192 192
193 update := waitForQueuedPrompt(t, iv)193 update := waitForQueuedPrompt(t, iv)
194 if !strings.Contains(update, "task: "+followUp) {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 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {293 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
203 started := make(chan struct{})294 started := make(chan struct{})
204 mgr := subagents.New(subagents.Config{295 mgr := subagents.New(subagents.Config{
@@ -223,10 +314,11 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
223 })314 })
224 315
225 iv := newQueuedAutoSubagentsInteractive()316 iv := newQueuedAutoSubagentsInteractive()
226- entry := &subagentWatchEntry{agent: a, task: a.Task}317+ iv.TrackSubagentWorker(a, a.Task)
227- iv.subagentWatch = []*subagentWatchEntry{entry}318+ if a.OnTurnEnd == nil {
228- iv.completeSupervisorWatchEntry(entry, "completed", "")319+ t.Fatal("worker has no completion watcher")
229- iv.completeSupervisorWatchEntry(entry, "failed", "late error")320+ }
321+ a.OnTurnEnd(1, "")
230 322
231 update := waitForQueuedPrompt(t, iv)323 update := waitForQueuedPrompt(t, iv)
232 if !strings.Contains(update, "status: completed") {324 if !strings.Contains(update, "status: completed") {
@@ -274,10 +366,9 @@ func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
274 return366 return
275 }367 }
276 once.Do(func() {368 once.Do(func() {
277- interactive.flushSupervisorSummary([]*subagentWatchEntry{{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."
278- agent: &subagents.Agent{},370+ update := subagents.FormatCompletionUpdate([]subagents.Completion{{Task: "smoke test"}}, instruction)
279- task: "smoke test",371+ interactive.SubmitOrQueue(update, nil)
280- }})
281 })372 })
282 }373 }
283 374
M packages/agent/modes/interactive.go +126 -137
@@ -774,14 +774,14 @@ type Interactive struct {
774 extPanel *extPanelDialog774 extPanel *extPanelDialog
775 llamaConfigured bool775 llamaConfigured bool
776 776
777- // subagentWatch tracks auto-subagents sub-agents the main agent spawned777+ // completionTracker observes auto-subagent turn and process completion.
778- // via subagent_spawn. Each entry holds the agent + the task text;778+ // Delivery is kept separate so every active set is injected as one
779- // a per-entry goroutine waits on the agent's terminal state. When779+ // summary turn, preserving the existing queue behavior.
780- // every tracked entry has finished, the watcher flushes a single780+ completionTracker *subagents.CompletionTracker
781- // summary turn into the main chat (queued if the main agent is781+ completionDeliveryMu sync.Mutex
782- // busy, run immediately if idle).782+ completionDeliveryRunning bool
783- subagentWatchMu sync.Mutex783+ completionDeliveryRequest bool
784- subagentWatch []*subagentWatchEntry784+ completionDeliveryHolds int
785 785
786 // subagentsWaitWatcherDone is an optional test hook invoked when a786 // subagentsWaitWatcherDone is an optional test hook invoked when a
787 // /subagents wait watcher exits.787 // /subagents wait watcher exits.
@@ -920,7 +920,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
920 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)920 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
921 }921 }
922 i := &Interactive{922 i := &Interactive{
923- cfg: cfg,923+ cfg: cfg,
924+ completionTracker: subagents.NewCompletionTracker(),
924 view: &tui.View{925 view: &tui.View{
925 Theme: cfg.Theme,926 Theme: cfg.Theme,
926 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),927 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
@@ -1026,7 +1027,9 @@ func (i *Interactive) markCtrlCExit() {
1026 1027
1027 // Run blocks until the user quits.1028 // Run blocks until the user quits.
1028 func (i *Interactive) Run(ctx context.Context) error {1029 func (i *Interactive) Run(ctx context.Context) error {
1030+ i.mu.Lock()
1029 i.runCtx = ctx1031 i.runCtx = ctx
1032+ i.mu.Unlock()
1030 term := i.cfg.Terminal1033 term := i.cfg.Terminal
1031 restore, err := term.EnterRaw()1034 restore, err := term.EnterRaw()
1032 if err != nil {1035 if err != nil {
@@ -7672,7 +7675,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7672 i.handleEventForPresentation(ev)7675 i.handleEventForPresentation(ev)
7673 }7676 }
7674 7677
7678+ releaseCompletionHold := i.beginCompletionDeliveryHold()
7675 go func() {7679 go func() {
7680+ defer releaseCompletionHold()
7676 var err error7681 var err error
7677 if continueExisting {7682 if continueExisting {
7678 err = i.agent.Continue(ctx, sink)7683 err = i.agent.Continue(ctx, sink)
@@ -8694,166 +8699,150 @@ func (a telegramSenderAdapter) Active() bool {
8694 return a.bridge != nil && a.bridge.Active()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 // TrackSubagentWorker is the exported entry point used by the cli to8702 // 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 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {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 // TrackResumedSubagentWorker watches a resumed follow-up independently of the8708 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
8715 // worker's original task. A long-lived daemon can complete several manager8709 // worker's original task. A long-lived daemon can complete several manager
8716 // turns, each of which must produce its own automatic delivery.8710 // turns, each of which must produce its own automatic delivery.
8717 func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {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 // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active8723 // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8722-// task watcher already owns the worker, that watcher reports the terminal8724+// task watcher already owns the worker, the shared tracker leaves that watcher
8723-// outcome; otherwise this creates a single exit-only watcher, including for a8725+// responsible for the terminal outcome.
8724-// worker restored by Reload.
8725 func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {8726 func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
8726 if i == nil || a == nil {8727 if i == nil || a == nil {
8727 return8728 return
8728 }8729 }
8729- i.subagentWatchMu.Lock()8730+ tracker := i.ensureCompletionTracker()
8730- for _, existing := range i.subagentWatch {8731+ tracker.TrackExit(a, a.Task)
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()
8739 i.invalidate()8732 i.invalidate()
8740- i.watchSubagentExit(entry)8733+ i.requestCompletionDelivery()
8741 }8734 }
8742 8735
8743-// trackSubagentWorker records an auto-subagents turn and subscribes to both8736+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp, future bool) func() {
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) {
8749 if i == nil || a == nil {8737 if i == nil || a == nil {
8750- return8738+ 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 i.invalidate()8747 i.invalidate()
8748+ i.requestCompletionDelivery()
8749+ return cancel
8750+}
8757 8751
8758- a.SetOnTurnEnd(func(step int, errMsg string) {8752+func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8759- outcome := "completed"8753+ i.completionDeliveryMu.Lock()
8760- if errMsg != "" {8754+ defer i.completionDeliveryMu.Unlock()
8761- outcome = "failed"8755+ if i.completionTracker == nil {
8762- }8756+ i.completionTracker = subagents.NewCompletionTracker()
8763- i.completeSupervisorWatchEntry(entry, outcome, errMsg)8757+ }
8764- })8758+ return i.completionTracker
8765- i.watchSubagentExit(entry)
8766 }8759 }
8767 8760
8768-func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) {8761+// requestCompletionDelivery starts at most one waiter for the current active
8769- go func() {8762+// set. A request arriving while that waiter is formatting or submitting is
8770- entry.agent.Wait()8763+// picked up before the waiter exits, so a later worker cannot be lost in the
8771- snap := entry.agent.Snapshot()8764+// handoff between batches.
8772- outcome := string(snap.Status)8765+func (i *Interactive) requestCompletionDelivery() {
8773- if snap.Status == subagents.StatusDone {8766+ if i == nil {
8774- outcome = "completed"8767+ return
8775- }8768+ }
8776- i.completeSupervisorWatchEntry(entry, outcome, snap.Err)8769+ i.completionDeliveryMu.Lock()
8777- }()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) {8795+func (i *Interactive) releaseCompletionDeliveryHold() {
8781- i.subagentWatchMu.Lock()8796+ if i == nil {
8782- if entry.done {
8783- i.subagentWatchMu.Unlock()
8784 return8797 return
8785 }8798 }
8786- entry.done = true8799+ start := false
8787- entry.outcome = outcome8800+ i.completionDeliveryMu.Lock()
8788- entry.err = errMsg8801+ if i.completionDeliveryHolds > 0 {
8789- allDone := true8802+ i.completionDeliveryHolds--
8790- for _, e := range i.subagentWatch {
8791- if !e.done {
8792- allDone = false
8793- break
8794- }
8795 }8803 }
8796- var batch []*subagentWatchEntry8804+ if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
8797- if allDone {8805+ i.completionDeliveryRunning = true
8798- batch = i.subagentWatch8806+ start = true
8799- i.subagentWatch = nil
8800 }8807 }
8801- i.subagentWatchMu.Unlock()8808+ i.completionDeliveryMu.Unlock()
8802- if len(batch) != 0 {8809+ if start {
8803- i.flushSupervisorSummary(batch)8810+ go i.deliverCompletionUpdates()
8804 }8811 }
8805 }8812 }
8806 8813
8807-// flushSupervisorSummary composes a synthetic user turn describing every8814+func (i *Interactive) completionWaitContext() context.Context {
8808-// sub-agent's outcome and injects it via SubmitOrQueue so the main8815+ i.mu.Lock()
8809-// agent picks it up at the next safe boundary. The summary is8816+ ctx := i.runCtx
8810-// phrased as a system update ("Auto-subagents finished: ...") so the8817+ i.mu.Unlock()
8811-// model treats it as observed state, not as a fresh user request.8818+ if ctx == nil {
8812-func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {8819+ return context.Background()
8813- if len(batch) == 0 {
8814- return
8815 }8820 }
8816- var sb strings.Builder8821+ return ctx
8817- fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))8822+}
8818- for idx, e := range batch {8823+
8819- snap := e.agent.Snapshot()8824+func (i *Interactive) deliverCompletionUpdates() {
8820- status := e.outcome8825+ for {
8821- if status == "" {8826+ i.completionDeliveryMu.Lock()
8822- status = string(snap.Status)8827+ i.completionDeliveryRequest = false
8823- }8828+ tracker := i.completionTracker
8824- task := e.task8829+ i.completionDeliveryMu.Unlock()
8825- if task == "" || !e.followUp {8830+
8826- task = snap.Task8831+ batch, err := tracker.WaitIdle(i.completionWaitContext())
8827- }8832+ if err == nil && len(batch) != 0 {
8828- if task == "" {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."
8829- task = e.task8834+ i.SubmitOrQueue(subagents.FormatCompletionUpdate(batch, instruction), nil)
8830- }8835+ }
8831- fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)8836+
8832- fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))8837+ i.completionDeliveryMu.Lock()
8833- if snap.Err != "" {8838+ if i.completionDeliveryRequest {
8834- fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))8839+ i.completionDeliveryMu.Unlock()
8835- } else if e.err != "" {8840+ continue
8836- fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))8841+ }
8837- }8842+ i.completionDeliveryRunning = false
8838- if snap.LastAssistant != "" {8843+ i.completionDeliveryMu.Unlock()
8839- sb.WriteString(" final response:\n")8844+ return
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
8855 }8845 }
8856- return s[:n-3] + "..."
8857 }8846 }
8858 8847
8859 // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents8848 // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
@@ -9055,9 +9044,9 @@ func (i *Interactive) applyAutoSubagentsTool() {
9055 }9044 }
9056 if i.autoSubagentsResumeToolAllowed() {9045 if i.autoSubagentsResumeToolAllowed() {
9057 resumeTool := &tools.SubagentResumeTool{9046 resumeTool := &tools.SubagentResumeTool{
9058- Supervisor: i.cfg.Supervisor,9047+ Supervisor: i.cfg.Supervisor,
9059- Enabled: func() bool { return true },9048+ Enabled: func() bool { return true },
9060- OnResumed: i.TrackResumedSubagentWorker,9049+ BeforeResumed: i.PrepareResumedSubagentWorker,
9061 }9050 }
9062 next[resumeTool.Name()] = resumeTool9051 next[resumeTool.Name()] = resumeTool
9063 }9052 }
M packages/agent/modes/json.go +7 -9
@@ -3,7 +3,7 @@ package modes
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6- "fmt"6+ "errors"
7 "io"7 "io"
8 8
9 "github.com/bnema/zut/packages/core"9 "github.com/bnema/zut/packages/core"
@@ -24,8 +24,11 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
24 // a successful checkpoint.24 // a successful checkpoint.
25 func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {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 enc := json.NewEncoder(out)26 enc := json.NewEncoder(out)
27+ var writeErr error
27 write := func(v any) {28 write := func(v any) {
28- _ = enc.Encode(v)29+ if writeErr == nil {
30+ writeErr = enc.Encode(v)
31+ }
29 }32 }
30 33
31 var runErr error34 var runErr error
@@ -42,9 +45,9 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri
42 }45 }
43 46
44 if runErr != nil {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, runErr50+ return recovery, errors.Join(runErr, writeErr)
48 }51 }
49 52
50 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire53 // 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 return usage.ReasoningTokens159 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 package modes1 package modes
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "strings"
4 "testing"9 "testing"
5 "time"10 "time"
6 11
7 "github.com/bnema/zut/packages/core"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 func TestEventToJSONActivityLifecycle(t *testing.T) {72 func TestEventToJSONActivityLifecycle(t *testing.T) {
11 request := EventToJSON(core.EvRequestStarted{73 request := EventToJSON(core.EvRequestStarted{
12 Provider: "anthropic",74 Provider: "anthropic",
M packages/agent/modes/stream_test.go +4 -0
@@ -11,11 +11,15 @@ import (
11 11
12 type streamTestClient struct {12 type streamTestClient struct {
13 events []provider.Event13 events []provider.Event
14+ err error
14 }15 }
15 16
16 func (c *streamTestClient) Name() string { return "stream-test" }17 func (c *streamTestClient) Name() string { return "stream-test" }
17 18
18 func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {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 out := make(chan provider.Event, len(c.events))23 out := make(chan provider.Event, len(c.events))
20 go func() {24 go func() {
21 defer close(out)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 // terminal worker is restarted with the follow-up as its initial prompt,805 // terminal worker is restarted with the follow-up as its initial prompt,
806 // avoiding a race with inbox listener startup.806 // avoiding a race with inbox listener startup.
807 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {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 if ctx == nil {817 if ctx == nil {
809 ctx = context.Background()818 ctx = context.Background()
810 }819 }
@@ -843,10 +852,17 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
843 // Active turns cannot accept a command through the worker inbox yet.852 // Active turns cannot accept a command through the worker inbox yet.
844 // Keep the prompt durable and let the idle transition deliver it in853 // Keep the prompt durable and let the idle transition deliver it in
845 // order after the current message turn finishes.854 // order after the current message turn finishes.
855+ var undo func()
856+ if before != nil {
857+ undo = before(existing, prompt)
858+ }
846 acceptedAt := f.cfg.Now()859 acceptedAt := f.cfg.Now()
847 commandID := existing.queueResumePrompt(prompt, acceptedAt)860 commandID := existing.queueResumePrompt(prompt, acceptedAt)
848 if err := f.persistAgent(existing); err != nil {861 if err := f.persistAgent(existing); err != nil {
849 existing.removeQueuedResumePrompt(commandID)862 existing.removeQueuedResumePrompt(commandID)
863+ if undo != nil {
864+ undo()
865+ }
850 f.operationMu.Unlock()866 f.operationMu.Unlock()
851 return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)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 turnID := existing.CurrentTurnID()876 turnID := existing.CurrentTurnID()
861 previousRunTurns := existing.resetCurrentRunTurns()877 previousRunTurns := existing.resetCurrentRunTurns()
878+ var undo func()
879+ if before != nil {
880+ undo = before(existing, prompt)
881+ }
862 // Reserve the worker before writing to its inbox. A second manager call882 // Reserve the worker before writing to its inbox. A second manager call
863 // must not enqueue another turn in the small interval before the worker883 // must not enqueue another turn in the small interval before the worker
864 // reports its turn.start event. Persist the prompt first so a host exit884 // 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 existing.setTurnState(TurnIdle, turnID)892 existing.setTurnState(TurnIdle, turnID)
873 existing.setCurrentRunTurns(previousRunTurns)893 existing.setCurrentRunTurns(previousRunTurns)
874 existing.restoreResumePrompt(previousPrompt)894 existing.restoreResumePrompt(previousPrompt)
895+ if undo != nil {
896+ undo()
897+ }
875 f.operationMu.Unlock()898 f.operationMu.Unlock()
876 return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)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 retryUnknown = true911 retryUnknown = true
889 } else {912 } else {
890 existing.restoreResumePrompt(previousPrompt)913 existing.restoreResumePrompt(previousPrompt)
914+ if undo != nil {
915+ undo()
916+ }
891 }917 }
892 if persistErr := f.persistAgent(existing); persistErr != nil {918 if persistErr := f.persistAgent(existing); persistErr != nil {
893 err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)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 f.operationMu.Unlock()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 // dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a937 // 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 func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {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 f.operationMu.Lock()1019 f.operationMu.Lock()
990 defer f.operationMu.Unlock()1020 defer f.operationMu.Unlock()
991 if ctx == nil {1021 if ctx == nil {
@@ -1254,7 +1284,11 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
1254 1284
1255 // Refreshing metadata happened before queue admission, so a later1285 // Refreshing metadata happened before queue admission, so a later
1256 // supervisor can reconstruct this resumed attempt even if the runner1286 // 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 f.armQueueTimeout(a)1292 f.armQueueTimeout(a)
1259 f.schedule()1293 f.schedule()
1260 return a, nil1294 return a, nil
M packages/agent/subagents/resume_prompt_test.go +24 -3
@@ -71,9 +71,20 @@ func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
71 firstFollowUp = "Check the parser behavior."71 firstFollowUp = "Check the parser behavior."
72 secondFollowUp = "Then check the error handling."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 t.Fatalf("queue first active follow-up: %v", err)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 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {88 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {
78 t.Fatalf("queue second active follow-up: %v", err)89 t.Fatalf("queue second active follow-up: %v", err)
79 }90 }
@@ -202,7 +213,10 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
202 a.setTurnCounts(4, 2)213 a.setTurnCounts(4, 2)
203 214
204 const followUp = "I applied your review. What do you think now?"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 if err != nil {220 if err != nil {
207 t.Fatalf("continue idle worker: %v", err)221 t.Fatalf("continue idle worker: %v", err)
208 }222 }
@@ -236,10 +250,17 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
236 if !payload.NewRun {250 if !payload.NewRun {
237 t.Fatal("idle follow-up did not request a fresh run budget")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 case <-time.After(time.Second):257 case <-time.After(time.Second):
240 t.Fatal("idle worker did not receive the follow-up")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 persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))264 persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
244 if err != nil {265 if err != nil {
245 t.Fatal(err)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 func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {165 func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
82 root := t.TempDir()166 root := t.TempDir()
83 started := make(chan *subagents.Agent, 2)167 started := make(chan *subagents.Agent, 2)
M packages/agent/tools/subagent_resume.go +10 -5
@@ -20,10 +20,15 @@ type SubagentResumeTool struct {
20 Supervisor *subagents.Supervisor20 Supervisor *subagents.Supervisor
21 Enabled func() bool21 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 // OnResumed receives the worker and follow-up after a successful resume.29 // OnResumed receives the worker and follow-up after a successful resume.
24- // It runs synchronously, so callbacks must only register non-blocking30+ // It remains available for callers that only need post-success
25- // tracking work. The interactive host uses it to watch this new delegated31+ // notification; tracking callers should prefer BeforeResumed.
26- // turn and deliver its outcome back to the manager automatically.
27 OnResumed func(agent *subagents.Agent, prompt string)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 if !ok {93 if !ok {
89 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))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 if err != nil {97 if err != nil {
93 return protocolToolError(prefix + ": " + err.Error())98 return protocolToolError(prefix + ": " + err.Error())
94 }99 }
95- if t.OnResumed != nil {100+ if t.BeforeResumed == nil && t.OnResumed != nil {
96 t.OnResumed(resumed, args.Prompt)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 if yes {114 if yes {
115 args.Yes = true115 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 return true, runLocalZutfile(ref, args, version)123 return true, runLocalZutfile(ref, args, version)
118 default:124 default:
119 return false, nil125 return false, nil
@@ -132,6 +138,13 @@ func runLocalZutfile(ref string, args Args, version string) error {
132 if err := checkZutfileRequirements(zf, version); err != nil {138 if err := checkZutfileRequirements(zf, version); err != nil {
133 return err139 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 agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")148 agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
136 if err := os.MkdirAll(agentData, 0o755); err != nil {149 if err := os.MkdirAll(agentData, 0o755); err != nil {
137 return err150 return err
M packages/agent/zutfile_test.go +23 -0
@@ -18,6 +18,29 @@ import (
18 "github.com/bnema/zut/packages/tui"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 func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {44 func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
22 tests := []struct {45 tests := []struct {
23 name string46 name string

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close