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
192192 zut -p "list all go files" # print final text, exit
193193 zut -p --stats stats.json "task" # print final text and write generation stats
194194 zut --json "refactor main.go" # newline-delimited json events, exit
195+zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
195196 zut --continue # resume the most recent session for this cwd
196197 zut --resume # pick a session to resume
197198 zut --resume <UUID> # resume a persisted session by UUID
@@ -213,7 +214,8 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
213214 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZUT_HOME/SYSTEM.md`). |
214215 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
215216 | `--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. |
217219 | `-c`, `--continue` | Resume the latest session for this cwd. |
218220 | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |
219221 | `--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
248250 - **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).
249251 - **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.
250252
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+
251261 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.
252262
253263 ## zutfile agents
M docs/subagents.md +14 -0
@@ -52,6 +52,20 @@ Supported metadata:
5252
5353 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.
5454
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+
5569 ## Selecting a profile
5670
5771 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 (
3434
3535 // Args holds parsed command-line options.
3636 type Args struct {
37- Mode Mode
38- Provider string
39- Model string
40- APIKey string
37+ Mode Mode
38+ Orchestrate bool
39+ Provider string
40+ Model string
41+ APIKey string
4142
4243 // inheritedCredential is populated only by subagent-worker mode from
4344 // the supervisor's stdin. It is never accepted as a CLI argument.
@@ -200,6 +201,8 @@ func ParseArgs(in []string) (Args, error) {
200201 a.Mode = ModeStream
201202 case "--json":
202203 a.Mode = ModeJSON
204+ case "--orchestrate":
205+ a.Orchestrate = true
203206 case "--rpc":
204207 a.Mode = ModeRPC
205208 case "-c", "--continue":
@@ -518,6 +521,7 @@ func PrintHelp(version string) {
518521 row{"zut", "interactive tui"},
519522 row{"zut \"prompt\"", "interactive, pre-filled prompt"},
520523 row{"zut -p \"prompt\"", "print final text, exit"},
524+ row{"zut -p --orchestrate \"prompt\"", "delegate independent work, print final synthesis, exit"},
521525 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},
522526 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},
523527 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},
@@ -553,6 +557,7 @@ func PrintHelp(version string) {
553557 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
554558 )
555559 section("prompt and session flags",
560+ row{"--orchestrate", "headless delegation and final synthesis (print, stream, or json modes)"},
556561 row{"--stats PATH", "write print-mode generation stats as json"},
557562 row{"--system-prompt TEXT", "replace the default system prompt"},
558563 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
M packages/agent/args_test.go +59 -0
@@ -1,13 +1,72 @@
11 package agent
22
33 import (
4+ "os"
5+ "strings"
46 "testing"
57 "time"
68
79 "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/agent/tools"
811 "github.com/google/uuid"
912 )
1013
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+
1170 func TestParseArgsSubagentAndReasoning(t *testing.T) {
1271 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})
1372 if err != nil {
M packages/agent/build.go +18 -2
@@ -644,7 +644,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
644644 if !CredentialAvailable(other) {
645645 continue
646646 }
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, "")
648650 provName = other
649651 cred, method, accountID, credErr = c, m, a, err
650652 break
@@ -857,7 +859,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
857859 append_ = append(append_, skillAddendum)
858860 }
859861 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 {
861877 if autoSubagentsToolAllowed(args) {
862878 homeDir, _ := os.UserHomeDir()
863879 profiles, _ := subagents.Discover(args.CWD, homeDir)
M packages/agent/build_test.go +40 -0
@@ -339,6 +339,46 @@ func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
339339 }
340340 }
341341
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+
342382 func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
343383 root := t.TempDir()
344384 home := filepath.Join(root, "home")
M packages/agent/cli.go +157 -235
@@ -532,10 +532,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
532532 return candidate, nil
533533 }
534534
535-// buildNonInteractiveSessionAgent constructs a provider/model-specific agent
536-// for startup resume while preserving the cwd sandbox and extension tools that
537-// were attached to the initial resolution.
538-func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) {
535+func buildNonInteractiveSessionAgentWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string, prepareRegistry func(core.Registry) core.Registry) (*core.Agent, string, string, error) {
539536 next := args
540537 next.Provider = providerName
541538 next.Model = model
@@ -547,6 +544,9 @@ func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolv
547544 if extMgr != nil {
548545 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
549546 }
547+ if prepareRegistry != nil {
548+ prepareRegistry(resolved.ToolRegistry)
549+ }
550550 ag := resolved.NewAgent()
551551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
552552 return ag, resolved.Provider, resolved.Model, nil
@@ -598,9 +598,44 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
598598 // contract used by interactive session switching to non-interactive startup
599599 // modes. The returned session owns the replacement append handle.
600600 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) {
601605 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
602606 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
604639 })
605640 if err != nil {
606641 return nil, ag, "", "", err
@@ -749,6 +784,11 @@ func runWithArgsRaw(rawArgs []string, version string) error {
749784 fmt.Println("zut", version)
750785 return nil
751786 }
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+ }
752792 // Model catalog: load any cached discovery data before we inspect
753793 // the model list (list-models, print/json, interactive).
754794 prepareRuntimeCatalog()
@@ -762,6 +802,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
762802 }
763803
764804 func runWithArgs(args Args, version string) error {
805+ if err := validateOrchestrationArgs(args); err != nil {
806+ return err
807+ }
765808 ctx := context.Background()
766809 switch args.Mode {
767810 case ModePrint:
@@ -891,6 +934,9 @@ func writePrintStats(path, providerID, model string, usage provider.Usage, elaps
891934 }
892935
893936 func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
937+ if args.Orchestrate {
938+ return runOrchestratedPrintMode(ctx, args, version)
939+ }
894940 if args.NoYolo {
895941 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
896942 }
@@ -969,6 +1015,9 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
9691015 }
9701016
9711017 func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1018+ if args.Orchestrate {
1019+ return runOrchestratedStreamMode(ctx, args, version)
1020+ }
9721021 if args.NoYolo {
9731022 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
9741023 }
@@ -1086,6 +1135,9 @@ func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
10861135 }
10871136
10881137 func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1138+ if args.Orchestrate {
1139+ return runOrchestratedJSONMode(ctx, args, version)
1140+ }
10891141 if args.NoYolo {
10901142 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
10911143 }
@@ -1300,47 +1352,23 @@ func webSearchExplicitlyEnabledForSession(args Args) bool {
13001352 // refreshes the agent tool registry + system prompt so skills/extensions
13011353 // installed by entry.pre are visible to the following turn.
13021354 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) {
13031360 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1304- return nil
1361+ return subagents.WebSearchDeny, nil
13051362 }
13061363 adapter := &extToolAdapter{mgr: extMgr}
13071364 if extMgr != nil {
13081365 _ = extMgr.Reload(ctx, 2*time.Second)
13091366 }
1310- _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)
1311- return err
1367+ return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
13121368 }
13131369
13141370 // ---- interactive mode: opens the TUI even without credentials ----
13151371
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-
13441372 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
13451373 initialCfg, _ := LoadConfig()
13461374 // Resolve WITHOUT requiring credentials.
@@ -1405,78 +1433,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14051433 r.MergeExtensionTools(extToolAdapter)
14061434 webSearchGuard := &webSearchSessionGuard{}
14071435
1408- // Build the subagent supervisor BEFORE the agent so the auto-subagents
1409- // tool can reference it during tool-registry construction. State
1410- // lives under ZutHome/subagents so per-agent meta/events survive
1411- // restarts; the user can hunt orphaned agents down with
1412- // `git worktree list` if anything misbehaves.
1413- //
1414- // subagentsMgr is also captured by loadSession / changeCWD closures
1415- // further down the function, which is why we keep the variable
1416- // in this outer scope rather than scoping it tighter.
1436+ // Keep the shared subagent runtime in the outer scope: session reloads,
1437+ // dashboard updates, agent rebuilds, and shutdown all use the same owner.
1438+ var runtime *subagentRuntime
14171439 var subagentsMgr *subagents.Supervisor
1418- subagentsMgr = subagents.New(subagents.Config{
1419- Context: ctx,
1420- Root: filepath.Join(ZutHome(), "subagents"),
1421- RepoRoot: r.CWD,
1422- Provider: r.Provider,
1423- FastMode: r.FastMode,
1424- WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1425- BaseURL: r.BaseURL,
1426- InsecureTLS: r.InsecureTLS,
1427- Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1428- // Keep an explicit launch key in this callback only. Supervisor persists
1429- // connection settings, but never stores credentials in Agent metadata
1430- // or puts them in the worker's argv/environment.
1431- ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) {
1432- childProvider := canonicalProvider(providerID)
1433- persistMu.Lock()
1434- hostProvider := canonicalProvider(activeProvider)
1435- persistMu.Unlock()
1436- if childProvider == "" {
1437- childProvider = hostProvider
1438- }
1439- explicit := ""
1440- // A launch-time API key is scoped to the provider selected at
1441- // startup. Never send it to an explicitly different child
1442- // provider; resolve that provider's own credential instead.
1443- if args.APIKey != "" && childProvider == hostProvider {
1444- explicit = args.APIKey
1445- }
1446- if childProvider == "ollama" && explicit == "" {
1447- return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
1448- }
1449- credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
1450- if err != nil {
1451- // Providers backed only by a local endpoint do not need a key.
1452- // Leave stdin untouched and let the child resolve that endpoint.
1453- if !CredentialAvailable(childProvider) {
1454- return subagents.Credential{}, nil
1455- }
1456- return subagents.Credential{}, err
1457- }
1458- return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1459- },
1460- })
1461- if args.PermissionSet != nil {
1462- // Packaged zutfile agents have an explicit capability ceiling. A
1463- // child process would otherwise rebuild a fresh unrestricted sandbox,
1464- // so do not expose either slash or tool-based delegation from this
1465- // restricted host until worker capability propagation exists.
1466- subagentsMgr = nil
1467- } else {
1468- // Replaying every historical event log can take seconds for users with
1469- // many completed subagents. The manager is safe for concurrent snapshots,
1470- // so populate the detached-agent dashboard in the background instead of
1471- // blocking the first interactive paint.
1472- go func() { _, _ = subagentsMgr.Reload() }()
1473- }
1474-
1475- // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
1476- // fires after every successful spawn. It hands the agent off to
1477- // the running Interactive so the watcher can flush a summary back
1478- // into chat when all sub-agents finish. Reads `iv` lazily because
1479- // the Interactive is constructed after the agent.
1440+ // These callbacks deliberately resolve iv lazily because the Interactive
1441+ // instance is constructed after the agent and its registry.
14801442 onSpawnedSupervisor := func(a *subagents.Agent, task string) {
14811443 if iv != nil {
14821444 iv.TrackSubagentWorker(a, task)
@@ -1487,86 +1449,66 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14871449 iv.TrackResumedSubagentWorker(a, prompt)
14881450 }
14891451 }
1452+ onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string) func() {
1453+ if iv != nil {
1454+ return iv.PrepareResumedSubagentWorker(a, prompt)
1455+ }
1456+ return nil
1457+ }
14901458 onStopRequestedSupervisor := func(a *subagents.Agent) {
14911459 if iv != nil {
14921460 iv.TrackStoppedSubagentWorker(a)
14931461 }
14941462 }
1495- resolveSubagent := func(name string) (*subagents.Profile, error) {
1496- return findSubagentProfile(r.CWD, name)
1497- }
1498-
1499- // Register the canonical subagent tools whenever launch-time policy permits
1500- // them. The auto-subagents setting controls strict orchestration guidance,
1501- // not explicit user-requested delegation.
1502- injectSubagentTools := func(reg core.Registry) core.Registry {
1503- if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1504- return reg
1505- }
1506- if autoSubagentsToolAllowed(args) {
1507- canonical := &tools.SubagentSpawnTool{
1508- Supervisor: subagentsMgr,
1509- Enabled: func() bool { return true },
1510- DefaultModel: func() string {
1511- persistMu.Lock()
1512- defer persistMu.Unlock()
1513- return activeModel
1514- },
1515- DefaultProvider: func() string {
1516- persistMu.Lock()
1517- defer persistMu.Unlock()
1518- return activeProvider
1519- },
1520- DefaultReasoning: func() string { return r.Reasoning },
1521- ResolveSubagent: resolveSubagent,
1522- OnSpawned: onSpawnedSupervisor,
1523- }
1524- reg[canonical.Name()] = canonical
1525- }
1526- if autoSubagentsStatusToolAllowed(args) {
1527- statusTool := &tools.SubagentStatusTool{
1528- Supervisor: subagentsMgr,
1529- Enabled: func() bool { return true },
1530- }
1531- reg[statusTool.Name()] = statusTool
1532- }
1533- if autoSubagentsStopToolAllowed(args) {
1534- stopTool := &tools.SubagentStopTool{
1535- Supervisor: subagentsMgr,
1536- Enabled: func() bool { return true },
1537- OnStopRequested: onStopRequestedSupervisor,
1538- }
1539- reg[stopTool.Name()] = stopTool
1540- }
1541- if autoSubagentsResumeToolAllowed(args) {
1542- resumeTool := &tools.SubagentResumeTool{
1543- Supervisor: subagentsMgr,
1544- Enabled: func() bool { return true },
1545- OnResumed: onResumedSupervisor,
1546- }
1547- reg[resumeTool.Name()] = resumeTool
1548- }
1549- return reg
1463+ activeProviderForSubagents := func() string {
1464+ persistMu.Lock()
1465+ defer persistMu.Unlock()
1466+ return activeProvider
15501467 }
1551- prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
1552- reg = injectSubagentTools(reg)
1553- return webSearchGuard.wrapRegistry(reg)
1468+ activeModelForSubagents := func() string {
1469+ persistMu.Lock()
1470+ defer persistMu.Unlock()
1471+ return activeModel
15541472 }
1555- prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
1556- // The resolved policy is a ceiling. In particular, an extension named
1557- // web_search must not reintroduce the capability when --no-tools,
1558- // --tools, a packaged PermissionSet, or persisted settings deny it.
1559- if policy != subagents.WebSearchAllow {
1560- delete(reg, "web_search")
1561- }
1562- return prepareInteractiveRegistry(reg)
1473+
1474+ runtime = newSubagentRuntime(subagentRuntimeConfig{
1475+ Context: ctx,
1476+ Args: args,
1477+ Root: filepath.Join(ZutHome(), "subagents"),
1478+ RepoRoot: r.CWD,
1479+ Provider: r.Provider,
1480+ Model: r.Model,
1481+ Reasoning: r.Reasoning,
1482+ BaseURL: r.BaseURL,
1483+ InsecureTLS: r.InsecureTLS,
1484+ FastMode: r.FastMode,
1485+ APIKey: args.APIKey,
1486+ Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1487+ WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1488+ WebSearchGuard: webSearchGuard,
1489+ ActiveProvider: activeProviderForSubagents,
1490+ ActiveModel: activeModelForSubagents,
1491+ ActiveReasoning: func() string { return r.Reasoning },
1492+ OnSpawned: onSpawnedSupervisor,
1493+ OnResumed: onResumedSupervisor,
1494+ BeforeResumed: onBeforeResumedSupervisor,
1495+ OnStopRequested: onStopRequestedSupervisor,
1496+ })
1497+ subagentsMgr = runtime.Supervisor()
1498+ if subagentsMgr != nil {
1499+ // Replaying historical event logs can take seconds. Populate the
1500+ // detached-agent dashboard without blocking the first interactive paint.
1501+ go func() { _, _ = subagentsMgr.Reload() }()
15631502 }
1503+ defer func() { _ = runtime.Close(context.Background()) }()
1504+
1505+ prepareInteractiveRegistry := runtime.PrepareRegistry
1506+ prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
15641507 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
15651508 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
15661509 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1567- if subagentsMgr != nil {
1568- subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy)
1569- }
1510+ runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1511+ resolveSubagent := runtime.resolveSubagent
15701512
15711513 // Confirmation gate: when --no-yolo is on, the agent must ask
15721514 // 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
16501592 return nil, "", "", err
16511593 }
16521594 resolved.UseSandbox(sharedSandbox)
1653- if subagentsMgr != nil {
1654- subagentsMgr.SetProvider(resolved.Provider)
1655- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1656- }
1595+ runtime.SetProvider(resolved.Provider)
1596+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1597+ runtime.SetFastMode(resolved.FastMode)
16571598 resolved.MergeExtensionTools(extToolAdapter)
16581599 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
16591600 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1673,10 +1614,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16731614 return nil, "", "", err
16741615 }
16751616 resolved.UseSandbox(sharedSandbox)
1676- if subagentsMgr != nil {
1677- subagentsMgr.SetProvider(resolved.Provider)
1678- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1679- }
1617+ runtime.SetProvider(resolved.Provider)
1618+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1619+ runtime.SetFastMode(resolved.FastMode)
16801620 resolved.MergeExtensionTools(extToolAdapter)
16811621 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
16821622 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1704,10 +1644,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17041644 return nil, "", "", err
17051645 }
17061646 resolved.UseSandbox(sharedSandbox)
1707- if subagentsMgr != nil {
1708- subagentsMgr.SetProvider(resolved.Provider)
1709- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1710- }
1647+ runtime.SetProvider(resolved.Provider)
1648+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1649+ runtime.SetFastMode(resolved.FastMode)
17111650 resolved.MergeExtensionTools(extToolAdapter)
17121651 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
17131652 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1927,19 +1866,24 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19271866 persistMu.Unlock()
19281867
19291868 // A candidate build must not publish its provider/model as the live
1930- // selection before the candidate commits.
1931- candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1932- if err != nil {
1933- // prepareSessionResume closes its append handle on failure;
1934- // no live agent or session has been changed.
1935- return err
1936- }
1869+ // selection before the candidate commits. The builder also stages
1870+ // provider settings on the shared runtime, so restore those settings on
1871+ // every failed path, including errors returned during preparation.
1872+ runtimeConfig := runtime.snapshotConfiguration()
19371873 committed := false
1874+ var candidate sessionResumeCandidate
19381875 defer func() {
19391876 if !committed {
1877+ runtime.restoreConfiguration(runtimeConfig)
19401878 loadErr = joinSessionCloseError(loadErr, candidate.session)
19411879 }
19421880 }()
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+ }
19431887
19441888 // Wire the private candidate before it can become visible. This is
19451889 // 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
19951939 if iv != nil {
19961940 iv.SetSubagentSessionScope(candidate.session.ID)
19971941 } else {
1998- subagentsMgr.SetActiveSession(candidate.session.ID)
1942+ runtime.SetActiveSession(candidate.session.ID)
19991943 }
20001944 }
20011945 committed = true
@@ -2073,10 +2017,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20732017 oldActiveProvider := activeProvider
20742018 oldActiveModel := activeModel
20752019 persistMu.Unlock()
2076- oldSupervisorRoot := ""
2077- if subagentsMgr != nil {
2078- oldSupervisorRoot = subagentsMgr.RepoRoot()
2079- }
2020+ runtimeConfig := runtime.snapshotConfiguration()
20802021 oldSandboxRoot := ""
20812022 oldSandboxLocked := false
20822023 if sharedSandbox != nil {
@@ -2091,9 +2032,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20912032 activeProvider = oldActiveProvider
20922033 activeModel = oldActiveModel
20932034 persistMu.Unlock()
2094- if subagentsMgr != nil {
2095- subagentsMgr.SetRepoRoot(oldSupervisorRoot)
2096- }
2035+ runtime.restoreConfiguration(runtimeConfig)
20972036 if sharedSandbox != nil {
20982037 sharedSandbox.Root = oldSandboxRoot
20992038 if oldSandboxLocked {
@@ -2106,9 +2045,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21062045
21072046 args.CWD = absPath
21082047 r.CWD = absPath
2109- if subagentsMgr != nil {
2110- subagentsMgr.SetRepoRoot(absPath)
2111- }
2048+ runtime.SetRepoRoot(absPath)
21122049 if args.PermissionSet != nil {
21132050 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
21142051 args.PermissionSet = &expanded
@@ -2189,11 +2126,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21892126 }
21902127
21912128 // Re-scope the subagent dashboard to the new session.
2192- if subagentsMgr != nil && newSess != nil {
2129+ if newSess != nil {
21932130 if iv != nil {
21942131 iv.SetSubagentSessionScope(newSess.ID)
21952132 } else {
2196- subagentsMgr.SetActiveSession(newSess.ID)
2133+ runtime.SetActiveSession(newSess.ID)
21972134 }
21982135 }
21992136 if newSess != nil {
@@ -2272,25 +2209,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
22722209 }
22732210 }
22742211
2275- // subagentsMgr was constructed and reloaded earlier (before the agent
2276- // build, so the auto-subagents tool could capture it). Here we just
2277- // scope the dashboard to the active host session so /subagents only
2278- // shows agents this session spawned (and any pre-upgrade unscoped
2279- // agents — see SnapshotAll docs). Updated again whenever the
2280- // user swaps sessions via loadSession below.
2281- if sess != nil && subagentsMgr != nil {
2282- subagentsMgr.SetActiveSession(sess.ID)
2283- }
2284- // Best-effort shutdown on normal interactive exit: stop only workers
2285- // owned by the active host session. Other session scopes can share this
2286- // supervisor and must remain alive.
2287- if subagentsMgr != nil {
2288- defer func() {
2289- rootSessionID := subagentsMgr.ActiveSession()
2290- if rootSessionID != "" {
2291- _ = subagentsMgr.StopByRootSession(rootSessionID)
2292- }
2293- }()
2212+ // Scope the dashboard to the active host session. The runtime owns
2213+ // shutdown separately and closes the supervisor exactly once.
2214+ if sess != nil {
2215+ runtime.SetActiveSession(sess.ID)
22942216 }
22952217
22962218 var startupSkills []*skills.Skill
@@ -2315,7 +2237,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23152237 if available {
23162238 policy = subagents.WebSearchAllow
23172239 }
2318- subagentsMgr.SetWebSearchPolicy(policy)
2240+ runtime.SetWebSearchPolicy(policy)
23192241 }
23202242
23212243 iv = modes.NewInteractive(modes.InteractiveConfig{
@@ -2624,12 +2546,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
26242546 closeAgentLSP(finalAg)
26252547 closeResolvedLSP(r)
26262548 // Stop supervised workers before exiting. os.Exit skips deferred
2627- // cleanup, so relying only on the normal runInteractive defer would
2628- // leave child daemons (and their descendants) alive after a TERM or
2629- // HUP delivered to the host.
2630- if subagentsMgr != nil {
2631- subagentsMgr.StopAll()
2632- }
2549+ // cleanup, so close the shared runtime explicitly on a TERM or HUP.
2550+ _ = runtime.Close(context.Background())
26332551 // Exit cleanly. Re-raising the signal would skip os.Exit's
26342552 // at-exit hooks; explicit exit is fine because we've already
26352553 // flushed the only at-risk state (the session file) and stopped
@@ -2825,7 +2743,11 @@ func readAllStdin() (string, error) {
28252743 if (fi.Mode() & os.ModeCharDevice) != 0 {
28262744 return "", nil
28272745 }
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)
28292751 return string(b), err
28302752 }
28312753
M packages/agent/cli_session_test.go +52 -0
@@ -18,6 +18,16 @@ import (
1818 "github.com/google/uuid"
1919 )
2020
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+
2131 func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
2232 root := t.TempDir()
2333 cwd := t.TempDir()
@@ -412,6 +422,48 @@ func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
412422 }
413423 }
414424
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+
415467 func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
416468 old := core.NewAgent(nil, "old-model", "", nil)
417469 old.SetMessages([]provider.Message{{
M packages/agent/modes/auto_subagents_test.go +100 -9
@@ -188,7 +188,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
188188 if a.OnTurnEnd == nil {
189189 t.Fatal("resumed worker has no completion watcher")
190190 }
191- a.OnTurnEnd(2, "")
191+ a.OnTurnEnd(1, "")
192192
193193 update := waitForQueuedPrompt(t, iv)
194194 if !strings.Contains(update, "task: "+followUp) {
@@ -199,6 +199,97 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
199199 }
200200 }
201201
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+
202293 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
203294 started := make(chan struct{})
204295 mgr := subagents.New(subagents.Config{
@@ -223,10 +314,11 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
223314 })
224315
225316 iv := newQueuedAutoSubagentsInteractive()
226- entry := &subagentWatchEntry{agent: a, task: a.Task}
227- iv.subagentWatch = []*subagentWatchEntry{entry}
228- iv.completeSupervisorWatchEntry(entry, "completed", "")
229- iv.completeSupervisorWatchEntry(entry, "failed", "late error")
317+ iv.TrackSubagentWorker(a, a.Task)
318+ if a.OnTurnEnd == nil {
319+ t.Fatal("worker has no completion watcher")
320+ }
321+ a.OnTurnEnd(1, "")
230322
231323 update := waitForQueuedPrompt(t, iv)
232324 if !strings.Contains(update, "status: completed") {
@@ -274,10 +366,9 @@ func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
274366 return
275367 }
276368 once.Do(func() {
277- interactive.flushSupervisorSummary([]*subagentWatchEntry{{
278- agent: &subagents.Agent{},
279- task: "smoke test",
280- }})
369+ instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
370+ update := subagents.FormatCompletionUpdate([]subagents.Completion{{Task: "smoke test"}}, instruction)
371+ interactive.SubmitOrQueue(update, nil)
281372 })
282373 }
283374
M packages/agent/modes/interactive.go +126 -137
@@ -774,14 +774,14 @@ type Interactive struct {
774774 extPanel *extPanelDialog
775775 llamaConfigured bool
776776
777- // subagentWatch tracks auto-subagents sub-agents the main agent spawned
778- // via subagent_spawn. Each entry holds the agent + the task text;
779- // a per-entry goroutine waits on the agent's terminal state. When
780- // every tracked entry has finished, the watcher flushes a single
781- // summary turn into the main chat (queued if the main agent is
782- // busy, run immediately if idle).
783- subagentWatchMu sync.Mutex
784- subagentWatch []*subagentWatchEntry
777+ // completionTracker observes auto-subagent turn and process completion.
778+ // Delivery is kept separate so every active set is injected as one
779+ // summary turn, preserving the existing queue behavior.
780+ completionTracker *subagents.CompletionTracker
781+ completionDeliveryMu sync.Mutex
782+ completionDeliveryRunning bool
783+ completionDeliveryRequest bool
784+ completionDeliveryHolds int
785785
786786 // subagentsWaitWatcherDone is an optional test hook invoked when a
787787 // /subagents wait watcher exits.
@@ -920,7 +920,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
920920 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
921921 }
922922 i := &Interactive{
923- cfg: cfg,
923+ cfg: cfg,
924+ completionTracker: subagents.NewCompletionTracker(),
924925 view: &tui.View{
925926 Theme: cfg.Theme,
926927 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
@@ -1026,7 +1027,9 @@ func (i *Interactive) markCtrlCExit() {
10261027
10271028 // Run blocks until the user quits.
10281029 func (i *Interactive) Run(ctx context.Context) error {
1030+ i.mu.Lock()
10291031 i.runCtx = ctx
1032+ i.mu.Unlock()
10301033 term := i.cfg.Terminal
10311034 restore, err := term.EnterRaw()
10321035 if err != nil {
@@ -7672,7 +7675,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
76727675 i.handleEventForPresentation(ev)
76737676 }
76747677
7678+ releaseCompletionHold := i.beginCompletionDeliveryHold()
76757679 go func() {
7680+ defer releaseCompletionHold()
76767681 var err error
76777682 if continueExisting {
76787683 err = i.agent.Continue(ctx, sink)
@@ -8694,166 +8699,150 @@ func (a telegramSenderAdapter) Active() bool {
86948699 return a.bridge != nil && a.bridge.Active()
86958700 }
86968701
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-
87088702 // 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.
87108704 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
8711- i.trackSubagentWorker(a, task, false)
8705+ i.trackSubagentWorker(a, task, false, false)
87128706 }
87138707
87148708 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
87158709 // worker's original task. A long-lived daemon can complete several manager
87168710 // turns, each of which must produce its own automatic delivery.
87178711 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)
87198721 }
87208722
87218723 // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8722-// task watcher already owns the worker, that watcher reports the terminal
8723-// outcome; otherwise this creates a single exit-only watcher, including for a
8724-// worker restored by Reload.
8724+// task watcher already owns the worker, the shared tracker leaves that watcher
8725+// responsible for the terminal outcome.
87258726 func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
87268727 if i == nil || a == nil {
87278728 return
87288729 }
8729- i.subagentWatchMu.Lock()
8730- for _, existing := range i.subagentWatch {
8731- if existing.agent.ID == a.ID && !existing.done {
8732- i.subagentWatchMu.Unlock()
8733- return
8734- }
8735- }
8736- entry := &subagentWatchEntry{agent: a, task: a.Task}
8737- i.subagentWatch = append(i.subagentWatch, entry)
8738- i.subagentWatchMu.Unlock()
8730+ tracker := i.ensureCompletionTracker()
8731+ tracker.TrackExit(a, a.Task)
87398732 i.invalidate()
8740- i.watchSubagentExit(entry)
8733+ i.requestCompletionDelivery()
87418734 }
87428735
8743-// trackSubagentWorker records an auto-subagents turn and subscribes to both
8744-// ways it can finish. Successful long-lived agents report a prompt-level
8745-// turn_end and keep listening; startup failures and unexpected daemon exits
8746-// only unblock Agent.Wait. Whichever signal arrives first completes the entry
8747-// exactly once.
8748-func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) {
8736+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp, future bool) func() {
87498737 if i == nil || a == nil {
8750- return
8738+ return nil
8739+ }
8740+ tracker := i.ensureCompletionTracker()
8741+ var cancel func()
8742+ if future {
8743+ cancel = tracker.TrackFutureTurn(a, task, followUp)
8744+ } else {
8745+ cancel = tracker.TrackTurn(a, task, followUp)
87518746 }
8752- entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp}
8753- i.subagentWatchMu.Lock()
8754- i.subagentWatch = append(i.subagentWatch, entry)
8755- i.subagentWatchMu.Unlock()
87568747 i.invalidate()
8748+ i.requestCompletionDelivery()
8749+ return cancel
8750+}
87578751
8758- a.SetOnTurnEnd(func(step int, errMsg string) {
8759- outcome := "completed"
8760- if errMsg != "" {
8761- outcome = "failed"
8762- }
8763- i.completeSupervisorWatchEntry(entry, outcome, errMsg)
8764- })
8765- i.watchSubagentExit(entry)
8752+func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8753+ i.completionDeliveryMu.Lock()
8754+ defer i.completionDeliveryMu.Unlock()
8755+ if i.completionTracker == nil {
8756+ i.completionTracker = subagents.NewCompletionTracker()
8757+ }
8758+ return i.completionTracker
87668759 }
87678760
8768-func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) {
8769- go func() {
8770- entry.agent.Wait()
8771- snap := entry.agent.Snapshot()
8772- outcome := string(snap.Status)
8773- if snap.Status == subagents.StatusDone {
8774- outcome = "completed"
8775- }
8776- i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
8777- }()
8761+// requestCompletionDelivery starts at most one waiter for the current active
8762+// set. A request arriving while that waiter is formatting or submitting is
8763+// picked up before the waiter exits, so a later worker cannot be lost in the
8764+// handoff between batches.
8765+func (i *Interactive) requestCompletionDelivery() {
8766+ if i == nil {
8767+ return
8768+ }
8769+ i.completionDeliveryMu.Lock()
8770+ i.completionDeliveryRequest = true
8771+ if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
8772+ i.completionDeliveryMu.Unlock()
8773+ return
8774+ }
8775+ i.completionDeliveryRunning = true
8776+ i.completionDeliveryMu.Unlock()
8777+
8778+ go i.deliverCompletionUpdates()
8779+}
8780+
8781+// beginCompletionDeliveryHold keeps all completions observed during one
8782+// parent model turn together. Tool calls are executed sequentially inside the
8783+// core agent, so the release point is the owning registration boundary for
8784+// same-parent-turn batching rather than a timing-based debounce.
8785+func (i *Interactive) beginCompletionDeliveryHold() func() {
8786+ if i == nil {
8787+ return func() {}
8788+ }
8789+ i.completionDeliveryMu.Lock()
8790+ i.completionDeliveryHolds++
8791+ i.completionDeliveryMu.Unlock()
8792+ return i.releaseCompletionDeliveryHold
87788793 }
87798794
8780-func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
8781- i.subagentWatchMu.Lock()
8782- if entry.done {
8783- i.subagentWatchMu.Unlock()
8795+func (i *Interactive) releaseCompletionDeliveryHold() {
8796+ if i == nil {
87848797 return
87858798 }
8786- entry.done = true
8787- entry.outcome = outcome
8788- entry.err = errMsg
8789- allDone := true
8790- for _, e := range i.subagentWatch {
8791- if !e.done {
8792- allDone = false
8793- break
8794- }
8799+ start := false
8800+ i.completionDeliveryMu.Lock()
8801+ if i.completionDeliveryHolds > 0 {
8802+ i.completionDeliveryHolds--
87958803 }
8796- var batch []*subagentWatchEntry
8797- if allDone {
8798- batch = i.subagentWatch
8799- i.subagentWatch = nil
8804+ if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
8805+ i.completionDeliveryRunning = true
8806+ start = true
88008807 }
8801- i.subagentWatchMu.Unlock()
8802- if len(batch) != 0 {
8803- i.flushSupervisorSummary(batch)
8808+ i.completionDeliveryMu.Unlock()
8809+ if start {
8810+ go i.deliverCompletionUpdates()
88048811 }
88058812 }
88068813
8807-// flushSupervisorSummary composes a synthetic user turn describing every
8808-// sub-agent's outcome and injects it via SubmitOrQueue so the main
8809-// agent picks it up at the next safe boundary. The summary is
8810-// phrased as a system update ("Auto-subagents finished: ...") so the
8811-// model treats it as observed state, not as a fresh user request.
8812-func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
8813- if len(batch) == 0 {
8814- return
8814+func (i *Interactive) completionWaitContext() context.Context {
8815+ i.mu.Lock()
8816+ ctx := i.runCtx
8817+ i.mu.Unlock()
8818+ if ctx == nil {
8819+ return context.Background()
88158820 }
8816- var sb strings.Builder
8817- fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
8818- for idx, e := range batch {
8819- snap := e.agent.Snapshot()
8820- status := e.outcome
8821- if status == "" {
8822- status = string(snap.Status)
8823- }
8824- task := e.task
8825- if task == "" || !e.followUp {
8826- task = snap.Task
8827- }
8828- if task == "" {
8829- task = e.task
8830- }
8831- fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
8832- fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
8833- if snap.Err != "" {
8834- fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
8835- } else if e.err != "" {
8836- fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
8837- }
8838- if snap.LastAssistant != "" {
8839- sb.WriteString(" final response:\n")
8840- sb.WriteString(snap.LastAssistant)
8841- sb.WriteString("\n")
8842- } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
8843- fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
8844- }
8845- sb.WriteString("\n")
8846- }
8847- sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
8848- i.SubmitOrQueue(sb.String(), nil)
8849-}
8850-
8851-func truncateForSummary(s string, n int) string {
8852- s = strings.TrimSpace(s)
8853- if len(s) <= n {
8854- return s
8821+ return ctx
8822+}
8823+
8824+func (i *Interactive) deliverCompletionUpdates() {
8825+ for {
8826+ i.completionDeliveryMu.Lock()
8827+ i.completionDeliveryRequest = false
8828+ tracker := i.completionTracker
8829+ i.completionDeliveryMu.Unlock()
8830+
8831+ batch, err := tracker.WaitIdle(i.completionWaitContext())
8832+ if err == nil && len(batch) != 0 {
8833+ instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
8834+ i.SubmitOrQueue(subagents.FormatCompletionUpdate(batch, instruction), nil)
8835+ }
8836+
8837+ i.completionDeliveryMu.Lock()
8838+ if i.completionDeliveryRequest {
8839+ i.completionDeliveryMu.Unlock()
8840+ continue
8841+ }
8842+ i.completionDeliveryRunning = false
8843+ i.completionDeliveryMu.Unlock()
8844+ return
88558845 }
8856- return s[:n-3] + "..."
88578846 }
88588847
88598848 // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
@@ -9055,9 +9044,9 @@ func (i *Interactive) applyAutoSubagentsTool() {
90559044 }
90569045 if i.autoSubagentsResumeToolAllowed() {
90579046 resumeTool := &tools.SubagentResumeTool{
9058- Supervisor: i.cfg.Supervisor,
9059- Enabled: func() bool { return true },
9060- OnResumed: i.TrackResumedSubagentWorker,
9047+ Supervisor: i.cfg.Supervisor,
9048+ Enabled: func() bool { return true },
9049+ BeforeResumed: i.PrepareResumedSubagentWorker,
90619050 }
90629051 next[resumeTool.Name()] = resumeTool
90639052 }
M packages/agent/modes/json.go +7 -9
@@ -3,7 +3,7 @@ package modes
33 import (
44 "context"
55 "encoding/json"
6- "fmt"
6+ "errors"
77 "io"
88
99 "github.com/bnema/zut/packages/core"
@@ -24,8 +24,11 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
2424 // a successful checkpoint.
2525 func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
2626 enc := json.NewEncoder(out)
27+ var writeErr error
2728 write := func(v any) {
28- _ = enc.Encode(v)
29+ if writeErr == nil {
30+ writeErr = enc.Encode(v)
31+ }
2932 }
3033
3134 var runErr error
@@ -42,9 +45,9 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri
4245 }
4346
4447 if runErr != nil {
45- fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)
48+ write(map[string]any{"type": "error", "message": runErr.Error()})
4649 }
47- return recovery, runErr
50+ return recovery, errors.Join(runErr, writeErr)
4851 }
4952
5053 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
@@ -155,8 +158,3 @@ func nullableReasoningTokens(usage provider.Usage) any {
155158 }
156159 return usage.ReasoningTokens
157160 }
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 @@
11 package modes
22
33 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "strings"
49 "testing"
510 "time"
611
712 "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
814 )
915
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+
1072 func TestEventToJSONActivityLifecycle(t *testing.T) {
1173 request := EventToJSON(core.EvRequestStarted{
1274 Provider: "anthropic",
M packages/agent/modes/stream_test.go +4 -0
@@ -11,11 +11,15 @@ import (
1111
1212 type streamTestClient struct {
1313 events []provider.Event
14+ err error
1415 }
1516
1617 func (c *streamTestClient) Name() string { return "stream-test" }
1718
1819 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+ }
1923 out := make(chan provider.Event, len(c.events))
2024 go func() {
2125 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
805805 // terminal worker is restarted with the follow-up as its initial prompt,
806806 // avoiding a race with inbox listener startup.
807807 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) {
808817 if ctx == nil {
809818 ctx = context.Background()
810819 }
@@ -843,10 +852,17 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
843852 // Active turns cannot accept a command through the worker inbox yet.
844853 // Keep the prompt durable and let the idle transition deliver it in
845854 // order after the current message turn finishes.
855+ var undo func()
856+ if before != nil {
857+ undo = before(existing, prompt)
858+ }
846859 acceptedAt := f.cfg.Now()
847860 commandID := existing.queueResumePrompt(prompt, acceptedAt)
848861 if err := f.persistAgent(existing); err != nil {
849862 existing.removeQueuedResumePrompt(commandID)
863+ if undo != nil {
864+ undo()
865+ }
850866 f.operationMu.Unlock()
851867 return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)
852868 }
@@ -859,6 +875,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
859875 }
860876 turnID := existing.CurrentTurnID()
861877 previousRunTurns := existing.resetCurrentRunTurns()
878+ var undo func()
879+ if before != nil {
880+ undo = before(existing, prompt)
881+ }
862882 // Reserve the worker before writing to its inbox. A second manager call
863883 // must not enqueue another turn in the small interval before the worker
864884 // 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) (*
872892 existing.setTurnState(TurnIdle, turnID)
873893 existing.setCurrentRunTurns(previousRunTurns)
874894 existing.restoreResumePrompt(previousPrompt)
895+ if undo != nil {
896+ undo()
897+ }
875898 f.operationMu.Unlock()
876899 return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)
877900 }
@@ -888,6 +911,9 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
888911 retryUnknown = true
889912 } else {
890913 existing.restoreResumePrompt(previousPrompt)
914+ if undo != nil {
915+ undo()
916+ }
891917 }
892918 if persistErr := f.persistAgent(existing); persistErr != nil {
893919 err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)
@@ -905,7 +931,7 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
905931 }
906932 f.operationMu.Unlock()
907933
908- return f.resume(ctx, id, true, prompt)
934+ return f.resumeWithHook(ctx, id, true, prompt, before)
909935 }
910936
911937 // dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a
@@ -986,6 +1012,10 @@ func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error)
9861012 }
9871013
9881014 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) {
9891019 f.operationMu.Lock()
9901020 defer f.operationMu.Unlock()
9911021 if ctx == nil {
@@ -1254,7 +1284,11 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
12541284
12551285 // Refreshing metadata happened before queue admission, so a later
12561286 // 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+ }
12581292 f.armQueueTimeout(a)
12591293 f.schedule()
12601294 return a, nil
M packages/agent/subagents/resume_prompt_test.go +24 -3
@@ -71,9 +71,20 @@ func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
7171 firstFollowUp = "Check the parser behavior."
7272 secondFollowUp = "Then check the error handling."
7373 )
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 {
7578 t.Fatalf("queue first active follow-up: %v", err)
7679 }
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+ }
7788 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {
7889 t.Fatalf("queue second active follow-up: %v", err)
7990 }
@@ -202,7 +213,10 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
202213 a.setTurnCounts(4, 2)
203214
204215 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+ })
206220 if err != nil {
207221 t.Fatalf("continue idle worker: %v", err)
208222 }
@@ -236,10 +250,17 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
236250 if !payload.NewRun {
237251 t.Fatal("idle follow-up did not request a fresh run budget")
238252 }
253+ if a.OnTurnEnd == nil {
254+ t.Fatal("idle resume callback was not installed before prompt delivery")
255+ }
256+ a.OnTurnEnd(5, "")
239257 case <-time.After(time.Second):
240258 t.Fatal("idle worker did not receive the follow-up")
241259 }
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+ }
243264 persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
244265 if err != nil {
245266 t.Fatal(err)
M packages/agent/tools/subagent_lifecycle_test.go +84 -0
@@ -78,6 +78,90 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) {
7878 }
7979 }
8080
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+
81165 func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
82166 root := t.TempDir()
83167 started := make(chan *subagents.Agent, 2)
M packages/agent/tools/subagent_resume.go +10 -5
@@ -20,10 +20,15 @@ type SubagentResumeTool struct {
2020 Supervisor *subagents.Supervisor
2121 Enabled func() bool
2222
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+
2329 // OnResumed receives the worker and follow-up after a successful resume.
24- // It runs synchronously, so callbacks must only register non-blocking
25- // tracking work. The interactive host uses it to watch this new delegated
26- // turn and deliver its outcome back to the manager automatically.
30+ // It remains available for callers that only need post-success
31+ // notification; tracking callers should prefer BeforeResumed.
2732 OnResumed func(agent *subagents.Agent, prompt string)
2833 }
2934
@@ -88,11 +93,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
8893 if !ok {
8994 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
9095 }
91- resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt)
96+ resumed, err := t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, t.BeforeResumed)
9297 if err != nil {
9398 return protocolToolError(prefix + ": " + err.Error())
9499 }
95- if t.OnResumed != nil {
100+ if t.BeforeResumed == nil && t.OnResumed != nil {
96101 t.OnResumed(resumed, args.Prompt)
97102 }
98103
M packages/agent/zutfile.go +13 -0
@@ -114,6 +114,12 @@ func runZutfileCommand(rawArgs []string, version string) (bool, error) {
114114 if yes {
115115 args.Yes = true
116116 }
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+ }
117123 return true, runLocalZutfile(ref, args, version)
118124 default:
119125 return false, nil
@@ -132,6 +138,13 @@ func runLocalZutfile(ref string, args Args, version string) error {
132138 if err := checkZutfileRequirements(zf, version); err != nil {
133139 return err
134140 }
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+ }
135148 agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
136149 if err := os.MkdirAll(agentData, 0o755); err != nil {
137150 return err
M packages/agent/zutfile_test.go +23 -0
@@ -18,6 +18,29 @@ import (
1818 "github.com/bnema/zut/packages/tui"
1919 )
2020
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+
2144 func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
2245 tests := []struct {
2346 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