fix(agent): address orchestration review findings
- commit
- bd96a88783311826a9751d734cd37bc415e1aa11
- author
- brice · · 2026-08-08 20:01 UTC
- parent
- 0c65e7a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +343 -310
M README.md +1 -1
| @@ -256,7 +256,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||
| 256 | 256 | ||
| 257 | 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 | 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. | |
| 259 | + | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. 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 | 260 | ||
| 261 | 261 | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | |
| 262 | 262 | ||
| @@ -256,7 +256,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||||
| 256 | 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. | 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 | 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. | 259 | + | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. 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 | 260 | ||||
| 261 | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | 261 | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | ||
| 262 | 262 | ||||
M docs/subagents.md +1 -1
| @@ -62,7 +62,7 @@ The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited | |||
| 62 | 62 | zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result" | |
| 63 | 63 | ``` | |
| 64 | 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. | |
| 65 | + | The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. 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 | 66 | ||
| 67 | 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 | 68 | ||
| @@ -62,7 +62,7 @@ The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited | |||||
| 62 | zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result" | 62 | zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result" | ||
| 63 | ``` | 63 | ``` | ||
| 64 | 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. | 65 | + | The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. 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 | 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. | 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 | 68 | ||||
M packages/agent/args_test.go +2 -0
| @@ -39,6 +39,8 @@ func TestValidateOrchestrationArgs(t *testing.T) { | |||
| 39 | 39 | {name: "profile", args: Args{Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, want: "--subagent profiles"}, | |
| 40 | 40 | {name: "stats", args: Args{Mode: ModePrint, Orchestrate: true, StatsPath: "stats.json"}, want: "--stats"}, | |
| 41 | 41 | {name: "packaged", args: Args{Mode: ModePrint, Orchestrate: true, PermissionSet: &tools.PermissionSet{}}, want: "packaged"}, | |
| 42 | + | {name: "packaged agent name", args: Args{Mode: ModePrint, Orchestrate: true, AgentName: "bundled"}, want: "packaged"}, | |
| 43 | + | {name: "packaged agent data dir", args: Args{Mode: ModePrint, Orchestrate: true, AgentDataDir: "/tmp/agent-data"}, want: "packaged"}, | |
| 42 | 44 | {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"}, | |
| 43 | 45 | } { | |
| 44 | 46 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -39,6 +39,8 @@ func TestValidateOrchestrationArgs(t *testing.T) { | |||||
| 39 | {name: "profile", args: Args{Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, want: "--subagent profiles"}, | 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"}, | 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"}, | 41 | {name: "packaged", args: Args{Mode: ModePrint, Orchestrate: true, PermissionSet: &tools.PermissionSet{}}, want: "packaged"}, | ||
| 42 | + | {name: "packaged agent name", args: Args{Mode: ModePrint, Orchestrate: true, AgentName: "bundled"}, want: "packaged"}, | |||
| 43 | + | {name: "packaged agent data dir", args: Args{Mode: ModePrint, Orchestrate: true, AgentDataDir: "/tmp/agent-data"}, want: "packaged"}, | |||
| 42 | {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"}, | 44 | {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"}, | ||
| 43 | } { | 45 | } { | ||
| 44 | t.Run(tc.name, func(t *testing.T) { | 46 | t.Run(tc.name, func(t *testing.T) { | ||
M packages/agent/build.go +6 -4
| @@ -863,10 +863,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 863 | 863 | // Headless orchestration owns its prompt contract rather than inheriting | |
| 864 | 864 | // the interactive setting. Discover and append the compact manifest once; | |
| 865 | 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) | |
| 866 | + | if autoSubagentsToolAllowed(args) { | |
| 867 | + | homeDir, _ := os.UserHomeDir() | |
| 868 | + | profiles, _ := subagents.Discover(args.CWD, homeDir) | |
| 869 | + | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | |
| 870 | + | append_ = append(append_, subagentsAddendum) | |
| 871 | + | } | |
| 870 | 872 | } | |
| 871 | 873 | append_ = append(append_, AutoSubagentsSystemAddendumFor( | |
| 872 | 874 | autoSubagentsToolAllowed(args), | |
| @@ -863,10 +863,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 863 | // Headless orchestration owns its prompt contract rather than inheriting | 863 | // Headless orchestration owns its prompt contract rather than inheriting | ||
| 864 | // the interactive setting. Discover and append the compact manifest once; | 864 | // the interactive setting. Discover and append the compact manifest once; | ||
| 865 | // child profile bodies remain private to the selected child. | 865 | // child profile bodies remain private to the selected child. | ||
| 866 | - | homeDir, _ := os.UserHomeDir() | 866 | + | if autoSubagentsToolAllowed(args) { |
| 867 | - | profiles, _ := subagents.Discover(args.CWD, homeDir) | 867 | + | homeDir, _ := os.UserHomeDir() |
| 868 | - | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | 868 | + | profiles, _ := subagents.Discover(args.CWD, homeDir) |
| 869 | - | append_ = append(append_, subagentsAddendum) | 869 | + | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { |
| 870 | + | append_ = append(append_, subagentsAddendum) | |||
| 871 | + | } | |||
| 870 | } | 872 | } | ||
| 871 | append_ = append(append_, AutoSubagentsSystemAddendumFor( | 873 | append_ = append(append_, AutoSubagentsSystemAddendumFor( | ||
| 872 | autoSubagentsToolAllowed(args), | 874 | autoSubagentsToolAllowed(args), | ||
M packages/agent/build_test.go +5 -4
| @@ -361,10 +361,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) | |||
| 361 | 361 | t.Fatal(err) | |
| 362 | 362 | } | |
| 363 | 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) | |
| 364 | + | t.Fatalf("parent profile manifest count = %d, want 1\n%s", got, parent.SystemPrompt) | |
| 365 | 365 | } | |
| 366 | 366 | if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") { | |
| 367 | - | t.Fatalf("parent prompt omitted strict orchestrator contract:\\n%s", parent.SystemPrompt) | |
| 367 | + | t.Fatalf("parent prompt omitted strict orchestrator contract:\n%s", parent.SystemPrompt) | |
| 368 | 368 | } | |
| 369 | 369 | ||
| 370 | 370 | child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false) | |
| @@ -372,10 +372,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) | |||
| 372 | 372 | t.Fatal(err) | |
| 373 | 373 | } | |
| 374 | 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) | |
| 375 | + | t.Fatalf("selected child inherited parent orchestration instructions:\n%s", child.SystemPrompt) | |
| 376 | 376 | } | |
| 377 | 377 | if !strings.Contains(child.SystemPrompt, "Review only.") { | |
| 378 | - | t.Fatalf("selected child profile prompt missing:\\n%s", child.SystemPrompt) | |
| 378 | + | t.Fatalf("selected child profile prompt missing:\n%s", child.SystemPrompt) | |
| 379 | 379 | } | |
| 380 | 380 | } | |
| 381 | 381 | ||
| @@ -466,6 +466,7 @@ func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *tes | |||
| 466 | 466 | {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}}, | |
| 467 | 467 | {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}}, | |
| 468 | 468 | {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}}, | |
| 469 | + | {name: "headless orchestration", args: Args{CWD: project, Mode: ModePrint, Orchestrate: true, NoTools: true}}, | |
| 469 | 470 | } | |
| 470 | 471 | for _, tc := range cases { | |
| 471 | 472 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -361,10 +361,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) | |||||
| 361 | t.Fatal(err) | 361 | t.Fatal(err) | ||
| 362 | } | 362 | } | ||
| 363 | if got := strings.Count(parent.SystemPrompt, "[subagents_list]"); got != 1 { | 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) | 364 | + | t.Fatalf("parent profile manifest count = %d, want 1\n%s", got, parent.SystemPrompt) |
| 365 | } | 365 | } | ||
| 366 | if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") { | 366 | if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") { | ||
| 367 | - | t.Fatalf("parent prompt omitted strict orchestrator contract:\\n%s", parent.SystemPrompt) | 367 | + | t.Fatalf("parent prompt omitted strict orchestrator contract:\n%s", parent.SystemPrompt) |
| 368 | } | 368 | } | ||
| 369 | 369 | ||||
| 370 | child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false) | 370 | child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false) | ||
| @@ -372,10 +372,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) | |||||
| 372 | t.Fatal(err) | 372 | t.Fatal(err) | ||
| 373 | } | 373 | } | ||
| 374 | if strings.Contains(child.SystemPrompt, "primary-agent orchestrator") || strings.Contains(child.SystemPrompt, "[subagents_list]") { | 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) | 375 | + | t.Fatalf("selected child inherited parent orchestration instructions:\n%s", child.SystemPrompt) |
| 376 | } | 376 | } | ||
| 377 | if !strings.Contains(child.SystemPrompt, "Review only.") { | 377 | if !strings.Contains(child.SystemPrompt, "Review only.") { | ||
| 378 | - | t.Fatalf("selected child profile prompt missing:\\n%s", child.SystemPrompt) | 378 | + | t.Fatalf("selected child profile prompt missing:\n%s", child.SystemPrompt) |
| 379 | } | 379 | } | ||
| 380 | } | 380 | } | ||
| 381 | 381 | ||||
| @@ -466,6 +466,7 @@ func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *tes | |||||
| 466 | {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}}, | 466 | {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}}, | ||
| 467 | {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}}, | 467 | {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}}, | ||
| 468 | {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}}, | 468 | {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}}, | ||
| 469 | + | {name: "headless orchestration", args: Args{CWD: project, Mode: ModePrint, Orchestrate: true, NoTools: true}}, | |||
| 469 | } | 470 | } | ||
| 470 | for _, tc := range cases { | 471 | for _, tc := range cases { | ||
| 471 | t.Run(tc.name, func(t *testing.T) { | 472 | t.Run(tc.name, func(t *testing.T) { | ||
M packages/agent/cli.go +26 -5
| @@ -1488,19 +1488,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1488 | 1488 | WebSearchGuard: webSearchGuard, | |
| 1489 | 1489 | ActiveProvider: activeProviderForSubagents, | |
| 1490 | 1490 | ActiveModel: activeModelForSubagents, | |
| 1491 | - | ActiveReasoning: func() string { return r.Reasoning }, | |
| 1492 | 1491 | OnSpawned: onSpawnedSupervisor, | |
| 1493 | 1492 | OnResumed: onResumedSupervisor, | |
| 1494 | 1493 | BeforeResumed: onBeforeResumedSupervisor, | |
| 1495 | 1494 | OnStopRequested: onStopRequestedSupervisor, | |
| 1496 | 1495 | }) | |
| 1497 | 1496 | subagentsMgr = runtime.Supervisor() | |
| 1497 | + | reloadDone := make(chan struct{}) | |
| 1498 | + | var reloadErrs []error | |
| 1498 | 1499 | if subagentsMgr != nil { | |
| 1499 | 1500 | // Replaying historical event logs can take seconds. Populate the | |
| 1500 | 1501 | // detached-agent dashboard without blocking the first interactive paint. | |
| 1501 | - | go func() { _, _ = subagentsMgr.Reload() }() | |
| 1502 | + | go func() { | |
| 1503 | + | _, reloadErrs = subagentsMgr.Reload() | |
| 1504 | + | close(reloadDone) | |
| 1505 | + | }() | |
| 1506 | + | } else { | |
| 1507 | + | close(reloadDone) | |
| 1502 | 1508 | } | |
| 1503 | - | defer func() { _ = runtime.Close(context.Background()) }() | |
| 1509 | + | defer func() { | |
| 1510 | + | // Reload may attach detached workers, so let it finish before shutdown | |
| 1511 | + | // snapshots the supervisor's worker set. | |
| 1512 | + | <-reloadDone | |
| 1513 | + | _ = closeSubagentRuntimeFresh(runtime) | |
| 1514 | + | }() | |
| 1504 | 1515 | ||
| 1505 | 1516 | prepareInteractiveRegistry := runtime.PrepareRegistry | |
| 1506 | 1517 | prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry | |
| @@ -2285,6 +2296,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2285 | 2296 | AuthMethod: r.AuthMethod, | |
| 2286 | 2297 | BaseURL: r.BaseURL, | |
| 2287 | 2298 | Reasoning: r.Reasoning, | |
| 2299 | + | OnReasoningChanged: runtime.SetReasoning, | |
| 2288 | 2300 | SystemPrompt: r.SystemPrompt, | |
| 2289 | 2301 | Tools: r.ToolRegistry, | |
| 2290 | 2302 | MaxSteps: r.MaxSteps, | |
| @@ -2495,6 +2507,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2495 | 2507 | }, | |
| 2496 | 2508 | }) | |
| 2497 | 2509 | extHooks.attachInteractive(iv) | |
| 2510 | + | go func() { | |
| 2511 | + | <-reloadDone | |
| 2512 | + | for _, reloadErr := range reloadErrs { | |
| 2513 | + | if reloadErr != nil { | |
| 2514 | + | iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr)) | |
| 2515 | + | } | |
| 2516 | + | } | |
| 2517 | + | }() | |
| 2498 | 2518 | ||
| 2499 | 2519 | // Bind the interactive TUI as the Confirmer. We deferred this | |
| 2500 | 2520 | // until now because the gate is constructed before the TUI | |
| @@ -2546,8 +2566,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2546 | 2566 | closeAgentLSP(finalAg) | |
| 2547 | 2567 | closeResolvedLSP(r) | |
| 2548 | 2568 | // Stop supervised workers before exiting. os.Exit skips deferred | |
| 2549 | - | // cleanup, so close the shared runtime explicitly on a TERM or HUP. | |
| 2550 | - | _ = runtime.Close(context.Background()) | |
| 2569 | + | // cleanup, so wait for reload and close the shared runtime explicitly. | |
| 2570 | + | <-reloadDone | |
| 2571 | + | _ = closeSubagentRuntimeFresh(runtime) | |
| 2551 | 2572 | // Exit cleanly. Re-raising the signal would skip os.Exit's | |
| 2552 | 2573 | // at-exit hooks; explicit exit is fine because we've already | |
| 2553 | 2574 | // flushed the only at-risk state (the session file) and stopped | |
| @@ -1488,19 +1488,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1488 | WebSearchGuard: webSearchGuard, | 1488 | WebSearchGuard: webSearchGuard, | ||
| 1489 | ActiveProvider: activeProviderForSubagents, | 1489 | ActiveProvider: activeProviderForSubagents, | ||
| 1490 | ActiveModel: activeModelForSubagents, | 1490 | ActiveModel: activeModelForSubagents, | ||
| 1491 | - | ActiveReasoning: func() string { return r.Reasoning }, | |||
| 1492 | OnSpawned: onSpawnedSupervisor, | 1491 | OnSpawned: onSpawnedSupervisor, | ||
| 1493 | OnResumed: onResumedSupervisor, | 1492 | OnResumed: onResumedSupervisor, | ||
| 1494 | BeforeResumed: onBeforeResumedSupervisor, | 1493 | BeforeResumed: onBeforeResumedSupervisor, | ||
| 1495 | OnStopRequested: onStopRequestedSupervisor, | 1494 | OnStopRequested: onStopRequestedSupervisor, | ||
| 1496 | }) | 1495 | }) | ||
| 1497 | subagentsMgr = runtime.Supervisor() | 1496 | subagentsMgr = runtime.Supervisor() | ||
| 1497 | + | reloadDone := make(chan struct{}) | |||
| 1498 | + | var reloadErrs []error | |||
| 1498 | if subagentsMgr != nil { | 1499 | if subagentsMgr != nil { | ||
| 1499 | // Replaying historical event logs can take seconds. Populate the | 1500 | // Replaying historical event logs can take seconds. Populate the | ||
| 1500 | // detached-agent dashboard without blocking the first interactive paint. | 1501 | // detached-agent dashboard without blocking the first interactive paint. | ||
| 1501 | - | go func() { _, _ = subagentsMgr.Reload() }() | 1502 | + | go func() { |
| 1503 | + | _, reloadErrs = subagentsMgr.Reload() | |||
| 1504 | + | close(reloadDone) | |||
| 1505 | + | }() | |||
| 1506 | + | } else { | |||
| 1507 | + | close(reloadDone) | |||
| 1502 | } | 1508 | } | ||
| 1503 | - | defer func() { _ = runtime.Close(context.Background()) }() | 1509 | + | defer func() { |
| 1510 | + | // Reload may attach detached workers, so let it finish before shutdown | |||
| 1511 | + | // snapshots the supervisor's worker set. | |||
| 1512 | + | <-reloadDone | |||
| 1513 | + | _ = closeSubagentRuntimeFresh(runtime) | |||
| 1514 | + | }() | |||
| 1504 | 1515 | ||||
| 1505 | prepareInteractiveRegistry := runtime.PrepareRegistry | 1516 | prepareInteractiveRegistry := runtime.PrepareRegistry | ||
| 1506 | prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry | 1517 | prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry | ||
| @@ -2285,6 +2296,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2285 | AuthMethod: r.AuthMethod, | 2296 | AuthMethod: r.AuthMethod, | ||
| 2286 | BaseURL: r.BaseURL, | 2297 | BaseURL: r.BaseURL, | ||
| 2287 | Reasoning: r.Reasoning, | 2298 | Reasoning: r.Reasoning, | ||
| 2299 | + | OnReasoningChanged: runtime.SetReasoning, | |||
| 2288 | SystemPrompt: r.SystemPrompt, | 2300 | SystemPrompt: r.SystemPrompt, | ||
| 2289 | Tools: r.ToolRegistry, | 2301 | Tools: r.ToolRegistry, | ||
| 2290 | MaxSteps: r.MaxSteps, | 2302 | MaxSteps: r.MaxSteps, | ||
| @@ -2495,6 +2507,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2495 | }, | 2507 | }, | ||
| 2496 | }) | 2508 | }) | ||
| 2497 | extHooks.attachInteractive(iv) | 2509 | extHooks.attachInteractive(iv) | ||
| 2510 | + | go func() { | |||
| 2511 | + | <-reloadDone | |||
| 2512 | + | for _, reloadErr := range reloadErrs { | |||
| 2513 | + | if reloadErr != nil { | |||
| 2514 | + | iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr)) | |||
| 2515 | + | } | |||
| 2516 | + | } | |||
| 2517 | + | }() | |||
| 2498 | 2518 | ||||
| 2499 | // Bind the interactive TUI as the Confirmer. We deferred this | 2519 | // Bind the interactive TUI as the Confirmer. We deferred this | ||
| 2500 | // until now because the gate is constructed before the TUI | 2520 | // until now because the gate is constructed before the TUI | ||
| @@ -2546,8 +2566,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2546 | closeAgentLSP(finalAg) | 2566 | closeAgentLSP(finalAg) | ||
| 2547 | closeResolvedLSP(r) | 2567 | closeResolvedLSP(r) | ||
| 2548 | // Stop supervised workers before exiting. os.Exit skips deferred | 2568 | // Stop supervised workers before exiting. os.Exit skips deferred | ||
| 2549 | - | // cleanup, so close the shared runtime explicitly on a TERM or HUP. | 2569 | + | // cleanup, so wait for reload and close the shared runtime explicitly. |
| 2550 | - | _ = runtime.Close(context.Background()) | 2570 | + | <-reloadDone |
| 2571 | + | _ = closeSubagentRuntimeFresh(runtime) | |||
| 2551 | // Exit cleanly. Re-raising the signal would skip os.Exit's | 2572 | // Exit cleanly. Re-raising the signal would skip os.Exit's | ||
| 2552 | // at-exit hooks; explicit exit is fine because we've already | 2573 | // at-exit hooks; explicit exit is fine because we've already | ||
| 2553 | // flushed the only at-risk state (the session file) and stopped | 2574 | // flushed the only at-risk state (the session file) and stopped | ||
M packages/agent/modes/auto_subagents_test.go +6 -0
| @@ -288,6 +288,12 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) { | |||
| 288 | 288 | if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") { | |
| 289 | 289 | t.Fatalf("batched update omitted one task: %q", update) | |
| 290 | 290 | } | |
| 291 | + | iv.mu.Lock() | |
| 292 | + | queued := len(iv.queued) | |
| 293 | + | iv.mu.Unlock() | |
| 294 | + | if queued != 1 { | |
| 295 | + | t.Fatalf("queued updates = %d; want exactly one batched update", queued) | |
| 296 | + | } | |
| 291 | 297 | } | |
| 292 | 298 | ||
| 293 | 299 | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |
| @@ -288,6 +288,12 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) { | |||||
| 288 | if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") { | 288 | if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") { | ||
| 289 | t.Fatalf("batched update omitted one task: %q", update) | 289 | t.Fatalf("batched update omitted one task: %q", update) | ||
| 290 | } | 290 | } | ||
| 291 | + | iv.mu.Lock() | |||
| 292 | + | queued := len(iv.queued) | |||
| 293 | + | iv.mu.Unlock() | |||
| 294 | + | if queued != 1 { | |||
| 295 | + | t.Fatalf("queued updates = %d; want exactly one batched update", queued) | |||
| 296 | + | } | |||
| 291 | } | 297 | } | ||
| 292 | 298 | ||||
| 293 | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | 299 | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | ||
M packages/agent/modes/interactive.go +10 -2
| @@ -345,6 +345,9 @@ type InteractiveConfig struct { | |||
| 345 | 345 | // It should update config.json and (if there's an active session) | |
| 346 | 346 | // write a new meta row so resume picks up the same model. | |
| 347 | 347 | PersistModel func(providerName, model string) | |
| 348 | + | // OnReasoningChanged updates host-owned runtime defaults after the setting | |
| 349 | + | // has been persisted successfully. | |
| 350 | + | OnReasoningChanged func(level string) | |
| 348 | 351 | ||
| 349 | 352 | // InitialSessionTitle is the persisted title of the session loaded before | |
| 350 | 353 | // the TUI starts. It is shown in the terminal without another model call. | |
| @@ -920,8 +923,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 920 | 923 | startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...) | |
| 921 | 924 | } | |
| 922 | 925 | i := &Interactive{ | |
| 923 | - | cfg: cfg, | |
| 924 | - | completionTracker: subagents.NewCompletionTracker(), | |
| 926 | + | cfg: cfg, | |
| 925 | 927 | view: &tui.View{ | |
| 926 | 928 | Theme: cfg.Theme, | |
| 927 | 929 | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| @@ -5632,6 +5634,9 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||
| 5632 | 5634 | return | |
| 5633 | 5635 | } | |
| 5634 | 5636 | } | |
| 5637 | + | if i.cfg.OnReasoningChanged != nil { | |
| 5638 | + | i.cfg.OnReasoningChanged(level) | |
| 5639 | + | } | |
| 5635 | 5640 | i.mu.Lock() | |
| 5636 | 5641 | if i.agent != nil { | |
| 5637 | 5642 | i.agent.Reasoning = level | |
| @@ -8750,6 +8755,8 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, follo | |||
| 8750 | 8755 | } | |
| 8751 | 8756 | ||
| 8752 | 8757 | func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker { | |
| 8758 | + | // Tests and lightweight embedders may construct Interactive with a struct | |
| 8759 | + | // literal instead of NewInteractive, so retain this lazy fallback. | |
| 8753 | 8760 | i.completionDeliveryMu.Lock() | |
| 8754 | 8761 | defer i.completionDeliveryMu.Unlock() | |
| 8755 | 8762 | if i.completionTracker == nil { | |
| @@ -8766,6 +8773,7 @@ func (i *Interactive) requestCompletionDelivery() { | |||
| 8766 | 8773 | if i == nil { | |
| 8767 | 8774 | return | |
| 8768 | 8775 | } | |
| 8776 | + | i.ensureCompletionTracker() | |
| 8769 | 8777 | i.completionDeliveryMu.Lock() | |
| 8770 | 8778 | i.completionDeliveryRequest = true | |
| 8771 | 8779 | if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 { | |
| @@ -345,6 +345,9 @@ type InteractiveConfig struct { | |||||
| 345 | // It should update config.json and (if there's an active session) | 345 | // It should update config.json and (if there's an active session) | ||
| 346 | // write a new meta row so resume picks up the same model. | 346 | // write a new meta row so resume picks up the same model. | ||
| 347 | PersistModel func(providerName, model string) | 347 | PersistModel func(providerName, model string) | ||
| 348 | + | // OnReasoningChanged updates host-owned runtime defaults after the setting | |||
| 349 | + | // has been persisted successfully. | |||
| 350 | + | OnReasoningChanged func(level string) | |||
| 348 | 351 | ||||
| 349 | // InitialSessionTitle is the persisted title of the session loaded before | 352 | // InitialSessionTitle is the persisted title of the session loaded before | ||
| 350 | // the TUI starts. It is shown in the terminal without another model call. | 353 | // the TUI starts. It is shown in the terminal without another model call. | ||
| @@ -920,8 +923,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 920 | startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...) | 923 | startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...) | ||
| 921 | } | 924 | } | ||
| 922 | i := &Interactive{ | 925 | i := &Interactive{ | ||
| 923 | - | cfg: cfg, | 926 | + | cfg: cfg, |
| 924 | - | completionTracker: subagents.NewCompletionTracker(), | |||
| 925 | view: &tui.View{ | 927 | view: &tui.View{ | ||
| 926 | Theme: cfg.Theme, | 928 | Theme: cfg.Theme, | ||
| 927 | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | 929 | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | ||
| @@ -5632,6 +5634,9 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||||
| 5632 | return | 5634 | return | ||
| 5633 | } | 5635 | } | ||
| 5634 | } | 5636 | } | ||
| 5637 | + | if i.cfg.OnReasoningChanged != nil { | |||
| 5638 | + | i.cfg.OnReasoningChanged(level) | |||
| 5639 | + | } | |||
| 5635 | i.mu.Lock() | 5640 | i.mu.Lock() | ||
| 5636 | if i.agent != nil { | 5641 | if i.agent != nil { | ||
| 5637 | i.agent.Reasoning = level | 5642 | i.agent.Reasoning = level | ||
| @@ -8750,6 +8755,8 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, follo | |||||
| 8750 | } | 8755 | } | ||
| 8751 | 8756 | ||||
| 8752 | func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker { | 8757 | func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker { | ||
| 8758 | + | // Tests and lightweight embedders may construct Interactive with a struct | |||
| 8759 | + | // literal instead of NewInteractive, so retain this lazy fallback. | |||
| 8753 | i.completionDeliveryMu.Lock() | 8760 | i.completionDeliveryMu.Lock() | ||
| 8754 | defer i.completionDeliveryMu.Unlock() | 8761 | defer i.completionDeliveryMu.Unlock() | ||
| 8755 | if i.completionTracker == nil { | 8762 | if i.completionTracker == nil { | ||
| @@ -8766,6 +8773,7 @@ func (i *Interactive) requestCompletionDelivery() { | |||||
| 8766 | if i == nil { | 8773 | if i == nil { | ||
| 8767 | return | 8774 | return | ||
| 8768 | } | 8775 | } | ||
| 8776 | + | i.ensureCompletionTracker() | |||
| 8769 | i.completionDeliveryMu.Lock() | 8777 | i.completionDeliveryMu.Lock() | ||
| 8770 | i.completionDeliveryRequest = true | 8778 | i.completionDeliveryRequest = true | ||
| 8771 | if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 { | 8779 | if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 { | ||
M packages/agent/modes/json.go +8 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | + | "sync" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zut/packages/core" | |
| 10 | 11 | "github.com/bnema/zut/packages/provider" | |
| @@ -24,8 +25,11 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||
| 24 | 25 | // a successful checkpoint. | |
| 25 | 26 | func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | |
| 26 | 27 | enc := json.NewEncoder(out) | |
| 28 | + | var writeMu sync.Mutex | |
| 27 | 29 | var writeErr error | |
| 28 | 30 | write := func(v any) { | |
| 31 | + | writeMu.Lock() | |
| 32 | + | defer writeMu.Unlock() | |
| 29 | 33 | if writeErr == nil { | |
| 30 | 34 | writeErr = enc.Encode(v) | |
| 31 | 35 | } | |
| @@ -47,7 +51,10 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri | |||
| 47 | 51 | if runErr != nil { | |
| 48 | 52 | write(map[string]any{"type": "error", "message": runErr.Error()}) | |
| 49 | 53 | } | |
| 50 | - | return recovery, errors.Join(runErr, writeErr) | |
| 54 | + | writeMu.Lock() | |
| 55 | + | outputErr := writeErr | |
| 56 | + | writeMu.Unlock() | |
| 57 | + | return recovery, errors.Join(runErr, outputErr) | |
| 51 | 58 | } | |
| 52 | 59 | ||
| 53 | 60 | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | + | "sync" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zut/packages/core" | 10 | "github.com/bnema/zut/packages/core" | ||
| 10 | "github.com/bnema/zut/packages/provider" | 11 | "github.com/bnema/zut/packages/provider" | ||
| @@ -24,8 +25,11 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||||
| 24 | // a successful checkpoint. | 25 | // a successful checkpoint. | ||
| 25 | func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | 26 | func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | ||
| 26 | enc := json.NewEncoder(out) | 27 | enc := json.NewEncoder(out) | ||
| 28 | + | var writeMu sync.Mutex | |||
| 27 | var writeErr error | 29 | var writeErr error | ||
| 28 | write := func(v any) { | 30 | write := func(v any) { | ||
| 31 | + | writeMu.Lock() | |||
| 32 | + | defer writeMu.Unlock() | |||
| 29 | if writeErr == nil { | 33 | if writeErr == nil { | ||
| 30 | writeErr = enc.Encode(v) | 34 | writeErr = enc.Encode(v) | ||
| 31 | } | 35 | } | ||
| @@ -47,7 +51,10 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri | |||||
| 47 | if runErr != nil { | 51 | if runErr != nil { | ||
| 48 | write(map[string]any{"type": "error", "message": runErr.Error()}) | 52 | write(map[string]any{"type": "error", "message": runErr.Error()}) | ||
| 49 | } | 53 | } | ||
| 50 | - | return recovery, errors.Join(runErr, writeErr) | 54 | + | writeMu.Lock() |
| 55 | + | outputErr := writeErr | |||
| 56 | + | writeMu.Unlock() | |||
| 57 | + | return recovery, errors.Join(runErr, outputErr) | |||
| 51 | } | 58 | } | ||
| 52 | 59 | ||||
| 53 | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | 60 | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | ||
M packages/agent/orchestration_cli_e2e_test.go +37 -12
| @@ -18,6 +18,22 @@ import ( | |||
| 18 | 18 | "time" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | + | var ( | |
| 22 | + | orchestrationBuildOnce sync.Once | |
| 23 | + | orchestrationBuildDir string | |
| 24 | + | orchestrationBuildBinary string | |
| 25 | + | orchestrationBuildOutput []byte | |
| 26 | + | orchestrationBuildErr error | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | func TestMain(m *testing.M) { | |
| 30 | + | code := m.Run() | |
| 31 | + | if orchestrationBuildDir != "" { | |
| 32 | + | _ = os.RemoveAll(orchestrationBuildDir) | |
| 33 | + | } | |
| 34 | + | os.Exit(code) | |
| 35 | + | } | |
| 36 | + | ||
| 21 | 37 | func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) { | |
| 22 | 38 | if runtime.GOOS == "windows" { | |
| 23 | 39 | t.Skip("signal and subprocess semantics differ on Windows") | |
| @@ -82,7 +98,7 @@ func TestOrchestratedCLICancellation(t *testing.T) { | |||
| 82 | 98 | t.Skip("skip CLI orchestration end-to-end test in -short mode") | |
| 83 | 99 | } | |
| 84 | 100 | binary := buildOrchestrationCLI(t) | |
| 85 | - | server, workerReady, releaseWorker := newCancellationProvider(t) | |
| 101 | + | server, workerReady, releaseWorker := newCancellationProvider() | |
| 86 | 102 | defer server.Close() | |
| 87 | 103 | defer close(releaseWorker) | |
| 88 | 104 | ||
| @@ -142,7 +158,7 @@ func TestOrchestratedCLIParentFailure(t *testing.T) { | |||
| 142 | 158 | binary := buildOrchestrationCLI(t) | |
| 143 | 159 | ||
| 144 | 160 | t.Run("failure-jsonl", func(t *testing.T) { | |
| 145 | - | server := newFailingProvider(t) | |
| 161 | + | server := newFailingProvider() | |
| 146 | 162 | defer server.Close() | |
| 147 | 163 | cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail") | |
| 148 | 164 | cmd.Env = orchestrationCLIEnv(binary, t.TempDir()) | |
| @@ -226,7 +242,7 @@ func (p *twoWaveProvider) serveHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 226 | 242 | writeText(w, fmt.Sprintf("worker result %d", worker)) | |
| 227 | 243 | } | |
| 228 | 244 | ||
| 229 | - | func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, chan<- struct{}) { | |
| 245 | + | func newCancellationProvider() (*httptest.Server, <-chan struct{}, chan<- struct{}) { | |
| 230 | 246 | workerReady := make(chan struct{}) | |
| 231 | 247 | releaseWorker := make(chan struct{}) | |
| 232 | 248 | var once sync.Once | |
| @@ -250,7 +266,7 @@ func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, c | |||
| 250 | 266 | return server, workerReady, releaseWorker | |
| 251 | 267 | } | |
| 252 | 268 | ||
| 253 | - | func newFailingProvider(t *testing.T) *httptest.Server { | |
| 269 | + | func newFailingProvider() *httptest.Server { | |
| 254 | 270 | var mu sync.Mutex | |
| 255 | 271 | parentCalls := 0 | |
| 256 | 272 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| @@ -388,18 +404,27 @@ func assertJSONOrchestrationWaves(t *testing.T, events []map[string]any) { | |||
| 388 | 404 | } | |
| 389 | 405 | ||
| 390 | 406 | func orchestrationCLIEnv(binary, home string) []string { | |
| 407 | + | // Restrict PATH so spawned workers resolve the same freshly built test | |
| 408 | + | // binary instead of any installed zut executable on the host. | |
| 391 | 409 | return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary)) | |
| 392 | 410 | } | |
| 393 | 411 | ||
| 394 | 412 | func buildOrchestrationCLI(t *testing.T) string { | |
| 395 | 413 | 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) | |
| 414 | + | orchestrationBuildOnce.Do(func() { | |
| 415 | + | orchestrationBuildDir, orchestrationBuildErr = os.MkdirTemp("", "zut-orchestration-cli-") | |
| 416 | + | if orchestrationBuildErr != nil { | |
| 417 | + | return | |
| 418 | + | } | |
| 419 | + | orchestrationBuildBinary = filepath.Join(orchestrationBuildDir, "zut") | |
| 420 | + | root := filepath.Join("..", "..") | |
| 421 | + | cmd := exec.Command("go", "build", "-o", orchestrationBuildBinary, "./cmd/zut") | |
| 422 | + | cmd.Dir = root | |
| 423 | + | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") | |
| 424 | + | orchestrationBuildOutput, orchestrationBuildErr = cmd.CombinedOutput() | |
| 425 | + | }) | |
| 426 | + | if orchestrationBuildErr != nil { | |
| 427 | + | t.Fatalf("build zut: %v\n%s", orchestrationBuildErr, orchestrationBuildOutput) | |
| 403 | 428 | } | |
| 404 | - | return out | |
| 429 | + | return orchestrationBuildBinary | |
| 405 | 430 | } | |
| @@ -18,6 +18,22 @@ import ( | |||||
| 18 | "time" | 18 | "time" | ||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | + | var ( | |||
| 22 | + | orchestrationBuildOnce sync.Once | |||
| 23 | + | orchestrationBuildDir string | |||
| 24 | + | orchestrationBuildBinary string | |||
| 25 | + | orchestrationBuildOutput []byte | |||
| 26 | + | orchestrationBuildErr error | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | func TestMain(m *testing.M) { | |||
| 30 | + | code := m.Run() | |||
| 31 | + | if orchestrationBuildDir != "" { | |||
| 32 | + | _ = os.RemoveAll(orchestrationBuildDir) | |||
| 33 | + | } | |||
| 34 | + | os.Exit(code) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 21 | func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) { | 37 | func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) { | ||
| 22 | if runtime.GOOS == "windows" { | 38 | if runtime.GOOS == "windows" { | ||
| 23 | t.Skip("signal and subprocess semantics differ on Windows") | 39 | t.Skip("signal and subprocess semantics differ on Windows") | ||
| @@ -82,7 +98,7 @@ func TestOrchestratedCLICancellation(t *testing.T) { | |||||
| 82 | t.Skip("skip CLI orchestration end-to-end test in -short mode") | 98 | t.Skip("skip CLI orchestration end-to-end test in -short mode") | ||
| 83 | } | 99 | } | ||
| 84 | binary := buildOrchestrationCLI(t) | 100 | binary := buildOrchestrationCLI(t) | ||
| 85 | - | server, workerReady, releaseWorker := newCancellationProvider(t) | 101 | + | server, workerReady, releaseWorker := newCancellationProvider() |
| 86 | defer server.Close() | 102 | defer server.Close() | ||
| 87 | defer close(releaseWorker) | 103 | defer close(releaseWorker) | ||
| 88 | 104 | ||||
| @@ -142,7 +158,7 @@ func TestOrchestratedCLIParentFailure(t *testing.T) { | |||||
| 142 | binary := buildOrchestrationCLI(t) | 158 | binary := buildOrchestrationCLI(t) | ||
| 143 | 159 | ||||
| 144 | t.Run("failure-jsonl", func(t *testing.T) { | 160 | t.Run("failure-jsonl", func(t *testing.T) { | ||
| 145 | - | server := newFailingProvider(t) | 161 | + | server := newFailingProvider() |
| 146 | defer server.Close() | 162 | defer server.Close() | ||
| 147 | cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail") | 163 | 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()) | 164 | cmd.Env = orchestrationCLIEnv(binary, t.TempDir()) | ||
| @@ -226,7 +242,7 @@ func (p *twoWaveProvider) serveHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 226 | writeText(w, fmt.Sprintf("worker result %d", worker)) | 242 | writeText(w, fmt.Sprintf("worker result %d", worker)) | ||
| 227 | } | 243 | } | ||
| 228 | 244 | ||||
| 229 | - | func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, chan<- struct{}) { | 245 | + | func newCancellationProvider() (*httptest.Server, <-chan struct{}, chan<- struct{}) { |
| 230 | workerReady := make(chan struct{}) | 246 | workerReady := make(chan struct{}) | ||
| 231 | releaseWorker := make(chan struct{}) | 247 | releaseWorker := make(chan struct{}) | ||
| 232 | var once sync.Once | 248 | var once sync.Once | ||
| @@ -250,7 +266,7 @@ func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, c | |||||
| 250 | return server, workerReady, releaseWorker | 266 | return server, workerReady, releaseWorker | ||
| 251 | } | 267 | } | ||
| 252 | 268 | ||||
| 253 | - | func newFailingProvider(t *testing.T) *httptest.Server { | 269 | + | func newFailingProvider() *httptest.Server { |
| 254 | var mu sync.Mutex | 270 | var mu sync.Mutex | ||
| 255 | parentCalls := 0 | 271 | parentCalls := 0 | ||
| 256 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 272 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| @@ -388,18 +404,27 @@ func assertJSONOrchestrationWaves(t *testing.T, events []map[string]any) { | |||||
| 388 | } | 404 | } | ||
| 389 | 405 | ||||
| 390 | func orchestrationCLIEnv(binary, home string) []string { | 406 | func orchestrationCLIEnv(binary, home string) []string { | ||
| 407 | + | // Restrict PATH so spawned workers resolve the same freshly built test | |||
| 408 | + | // binary instead of any installed zut executable on the host. | |||
| 391 | return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary)) | 409 | return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary)) | ||
| 392 | } | 410 | } | ||
| 393 | 411 | ||||
| 394 | func buildOrchestrationCLI(t *testing.T) string { | 412 | func buildOrchestrationCLI(t *testing.T) string { | ||
| 395 | t.Helper() | 413 | t.Helper() | ||
| 396 | - | out := filepath.Join(t.TempDir(), "zut") | 414 | + | orchestrationBuildOnce.Do(func() { |
| 397 | - | root := filepath.Join("..", "..") | 415 | + | orchestrationBuildDir, orchestrationBuildErr = os.MkdirTemp("", "zut-orchestration-cli-") |
| 398 | - | cmd := exec.Command("go", "build", "-o", out, "./cmd/zut") | 416 | + | if orchestrationBuildErr != nil { |
| 399 | - | cmd.Dir = root | 417 | + | return |
| 400 | - | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") | 418 | + | } |
| 401 | - | if output, err := cmd.CombinedOutput(); err != nil { | 419 | + | orchestrationBuildBinary = filepath.Join(orchestrationBuildDir, "zut") |
| 402 | - | t.Fatalf("build zut: %v\n%s", err, output) | 420 | + | root := filepath.Join("..", "..") |
| 421 | + | cmd := exec.Command("go", "build", "-o", orchestrationBuildBinary, "./cmd/zut") | |||
| 422 | + | cmd.Dir = root | |||
| 423 | + | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") | |||
| 424 | + | orchestrationBuildOutput, orchestrationBuildErr = cmd.CombinedOutput() | |||
| 425 | + | }) | |||
| 426 | + | if orchestrationBuildErr != nil { | |||
| 427 | + | t.Fatalf("build zut: %v\n%s", orchestrationBuildErr, orchestrationBuildOutput) | |||
| 403 | } | 428 | } | ||
| 404 | - | return out | 429 | + | return orchestrationBuildBinary |
| 405 | } | 430 | } | ||
M packages/agent/orchestration_loop.go +10 -4
| @@ -2,6 +2,7 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/zut/packages/agent/subagents" | |
| 7 | 8 | ) | |
| @@ -31,16 +32,20 @@ func (e *headlessCompletionWaitError) Unwrap() error { | |||
| 31 | 32 | return e.err | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 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 | + | const ( | |
| 36 | + | 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.` | |
| 37 | + | maxHeadlessCompletionWaves = 32 | |
| 38 | + | ) | |
| 35 | 39 | ||
| 36 | 40 | // runHeadlessContinuation waits for one completion batch and gives the parent | |
| 37 | 41 | // one follow-up turn. A follow-up may register another batch while it runs, | |
| 38 | 42 | // which is how dependency waves continue without polling. The initial parent | |
| 39 | 43 | // turn is supplied by finalText; no worker path therefore returns immediately. | |
| 40 | 44 | func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) { | |
| 41 | - | if tracker == nil { | |
| 45 | + | if tracker == nil || runParent == nil { | |
| 42 | 46 | return finalText, nil | |
| 43 | 47 | } | |
| 48 | + | waves := 0 | |
| 44 | 49 | for { | |
| 45 | 50 | batch, err := tracker.WaitIdle(ctx) | |
| 46 | 51 | if err != nil { | |
| @@ -49,13 +54,14 @@ func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, | |||
| 49 | 54 | if len(batch) == 0 { | |
| 50 | 55 | return finalText, nil | |
| 51 | 56 | } | |
| 52 | - | if runParent == nil { | |
| 53 | - | return finalText, nil | |
| 57 | + | if waves >= maxHeadlessCompletionWaves { | |
| 58 | + | return finalText, fmt.Errorf("orchestration exceeded the maximum of %d completion waves", maxHeadlessCompletionWaves) | |
| 54 | 59 | } | |
| 55 | 60 | update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction) | |
| 56 | 61 | finalText, err = runParent(ctx, update) | |
| 57 | 62 | if err != nil { | |
| 58 | 63 | return finalText, err | |
| 59 | 64 | } | |
| 65 | + | waves++ | |
| 60 | 66 | } | |
| 61 | 67 | } | |
| @@ -2,6 +2,7 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/zut/packages/agent/subagents" | 7 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 7 | ) | 8 | ) | ||
| @@ -31,16 +32,20 @@ func (e *headlessCompletionWaitError) Unwrap() error { | |||||
| 31 | return e.err | 32 | return e.err | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 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 | + | const ( |
| 36 | + | 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.` | |||
| 37 | + | maxHeadlessCompletionWaves = 32 | |||
| 38 | + | ) | |||
| 35 | 39 | ||||
| 36 | // runHeadlessContinuation waits for one completion batch and gives the parent | 40 | // 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, | 41 | // 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 | 42 | // which is how dependency waves continue without polling. The initial parent | ||
| 39 | // turn is supplied by finalText; no worker path therefore returns immediately. | 43 | // 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) { | 44 | func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) { | ||
| 41 | - | if tracker == nil { | 45 | + | if tracker == nil || runParent == nil { |
| 42 | return finalText, nil | 46 | return finalText, nil | ||
| 43 | } | 47 | } | ||
| 48 | + | waves := 0 | |||
| 44 | for { | 49 | for { | ||
| 45 | batch, err := tracker.WaitIdle(ctx) | 50 | batch, err := tracker.WaitIdle(ctx) | ||
| 46 | if err != nil { | 51 | if err != nil { | ||
| @@ -49,13 +54,14 @@ func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, | |||||
| 49 | if len(batch) == 0 { | 54 | if len(batch) == 0 { | ||
| 50 | return finalText, nil | 55 | return finalText, nil | ||
| 51 | } | 56 | } | ||
| 52 | - | if runParent == nil { | 57 | + | if waves >= maxHeadlessCompletionWaves { |
| 53 | - | return finalText, nil | 58 | + | return finalText, fmt.Errorf("orchestration exceeded the maximum of %d completion waves", maxHeadlessCompletionWaves) |
| 54 | } | 59 | } | ||
| 55 | update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction) | 60 | update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction) | ||
| 56 | finalText, err = runParent(ctx, update) | 61 | finalText, err = runParent(ctx, update) | ||
| 57 | if err != nil { | 62 | if err != nil { | ||
| 58 | return finalText, err | 63 | return finalText, err | ||
| 59 | } | 64 | } | ||
| 65 | + | waves++ | |||
| 60 | } | 66 | } | ||
| 61 | } | 67 | } | ||
M packages/agent/orchestration_loop_test.go +22 -0
| @@ -15,6 +15,13 @@ type fakeCompletionBatches struct { | |||
| 15 | 15 | waits int | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | + | type endlessCompletionBatches struct{ waits int } | |
| 19 | + | ||
| 20 | + | func (f *endlessCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) { | |
| 21 | + | f.waits++ | |
| 22 | + | return []subagents.Completion{{AgentID: "worker", Status: "completed"}}, nil | |
| 23 | + | } | |
| 24 | + | ||
| 18 | 25 | func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) { | |
| 19 | 26 | index := f.waits | |
| 20 | 27 | f.waits++ | |
| @@ -70,6 +77,21 @@ func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) { | |||
| 70 | 77 | } | |
| 71 | 78 | } | |
| 72 | 79 | ||
| 80 | + | func TestRunHeadlessContinuationStopsAtWaveLimit(t *testing.T) { | |
| 81 | + | tracker := &endlessCompletionBatches{} | |
| 82 | + | calls := 0 | |
| 83 | + | got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) { | |
| 84 | + | calls++ | |
| 85 | + | return "synthesis", nil | |
| 86 | + | }) | |
| 87 | + | if err == nil || !strings.Contains(err.Error(), "maximum of 32 completion waves") { | |
| 88 | + | t.Fatalf("error = %v, want wave-limit error", err) | |
| 89 | + | } | |
| 90 | + | if got != "synthesis" || calls != maxHeadlessCompletionWaves || tracker.waits != maxHeadlessCompletionWaves+1 { | |
| 91 | + | t.Fatalf("result=%q calls=%d waits=%d", got, calls, tracker.waits) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 73 | 95 | func TestRunHeadlessContinuationCancellation(t *testing.T) { | |
| 74 | 96 | ctx, cancel := context.WithCancel(context.Background()) | |
| 75 | 97 | cancel() | |
| @@ -15,6 +15,13 @@ type fakeCompletionBatches struct { | |||||
| 15 | waits int | 15 | waits int | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | + | type endlessCompletionBatches struct{ waits int } | |||
| 19 | + | ||||
| 20 | + | func (f *endlessCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) { | |||
| 21 | + | f.waits++ | |||
| 22 | + | return []subagents.Completion{{AgentID: "worker", Status: "completed"}}, nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 18 | func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) { | 25 | func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) { | ||
| 19 | index := f.waits | 26 | index := f.waits | ||
| 20 | f.waits++ | 27 | f.waits++ | ||
| @@ -70,6 +77,21 @@ func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) { | |||||
| 70 | } | 77 | } | ||
| 71 | } | 78 | } | ||
| 72 | 79 | ||||
| 80 | + | func TestRunHeadlessContinuationStopsAtWaveLimit(t *testing.T) { | |||
| 81 | + | tracker := &endlessCompletionBatches{} | |||
| 82 | + | calls := 0 | |||
| 83 | + | got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) { | |||
| 84 | + | calls++ | |||
| 85 | + | return "synthesis", nil | |||
| 86 | + | }) | |||
| 87 | + | if err == nil || !strings.Contains(err.Error(), "maximum of 32 completion waves") { | |||
| 88 | + | t.Fatalf("error = %v, want wave-limit error", err) | |||
| 89 | + | } | |||
| 90 | + | if got != "synthesis" || calls != maxHeadlessCompletionWaves || tracker.waits != maxHeadlessCompletionWaves+1 { | |||
| 91 | + | t.Fatalf("result=%q calls=%d waits=%d", got, calls, tracker.waits) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 73 | func TestRunHeadlessContinuationCancellation(t *testing.T) { | 95 | func TestRunHeadlessContinuationCancellation(t *testing.T) { | ||
| 74 | ctx, cancel := context.WithCancel(context.Background()) | 96 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 75 | cancel() | 97 | cancel() | ||
M packages/agent/orchestration_modes.go +3 -0
| @@ -190,6 +190,9 @@ func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Conf | |||
| 190 | 190 | tracker.TrackTurn(a, task, false) | |
| 191 | 191 | } | |
| 192 | 192 | onResumed := func(a *subagents.Agent, prompt string) { | |
| 193 | + | // BeforeResumed normally owns future-turn registration. Keep this | |
| 194 | + | // fallback for direct runtime callers that do not install the pre-hook; | |
| 195 | + | // SubagentResumeTool suppresses it when BeforeResumed accepted delivery. | |
| 193 | 196 | tracker.TrackTurn(a, prompt, true) | |
| 194 | 197 | } | |
| 195 | 198 | onBeforeResumed := func(a *subagents.Agent, prompt string) func() { | |
| @@ -190,6 +190,9 @@ func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Conf | |||||
| 190 | tracker.TrackTurn(a, task, false) | 190 | tracker.TrackTurn(a, task, false) | ||
| 191 | } | 191 | } | ||
| 192 | onResumed := func(a *subagents.Agent, prompt string) { | 192 | onResumed := func(a *subagents.Agent, prompt string) { | ||
| 193 | + | // BeforeResumed normally owns future-turn registration. Keep this | |||
| 194 | + | // fallback for direct runtime callers that do not install the pre-hook; | |||
| 195 | + | // SubagentResumeTool suppresses it when BeforeResumed accepted delivery. | |||
| 193 | tracker.TrackTurn(a, prompt, true) | 196 | tracker.TrackTurn(a, prompt, true) | ||
| 194 | } | 197 | } | ||
| 195 | onBeforeResumed := func(a *subagents.Agent, prompt string) func() { | 198 | onBeforeResumed := func(a *subagents.Agent, prompt string) func() { | ||
M packages/agent/orchestration_modes_test.go +58 -46
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 7 | 8 | "io" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "net/http/httptest" | |
| @@ -18,32 +19,21 @@ func TestRunOrchestratedStreamUsesStreamRenderer(t *testing.T) { | |||
| 18 | 19 | server := orchestratedModeTestServer("stream answer") | |
| 19 | 20 | defer server.Close() | |
| 20 | 21 | ||
| 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() | |
| 22 | + | output, runErr := captureTestStdout(t, func() error { | |
| 23 | + | return runOrchestratedStreamMode(context.Background(), Args{ | |
| 24 | + | Mode: ModeStream, | |
| 25 | + | Orchestrate: true, | |
| 26 | + | Provider: "openai", | |
| 27 | + | Model: "gpt-5", | |
| 28 | + | BaseURL: server.URL, | |
| 29 | + | APIKey: "test-key", | |
| 30 | + | Prompt: "say hello", | |
| 31 | + | }, "test") | |
| 32 | + | }) | |
| 40 | 33 | if runErr != nil { | |
| 41 | 34 | t.Fatalf("runOrchestratedStreamMode error: %v", runErr) | |
| 42 | 35 | } | |
| 43 | - | if readErr != nil { | |
| 44 | - | t.Fatal(readErr) | |
| 45 | - | } | |
| 46 | - | if got := string(output); got != "stream answer\n" { | |
| 36 | + | if got := output; got != "stream answer\n" { | |
| 47 | 37 | t.Fatalf("stdout = %q, want one rendered answer newline", got) | |
| 48 | 38 | } | |
| 49 | 39 | } | |
| @@ -54,34 +44,23 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) { | |||
| 54 | 44 | server := orchestratedModeTestServer("json answer") | |
| 55 | 45 | defer server.Close() | |
| 56 | 46 | ||
| 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() | |
| 47 | + | output, runErr := captureTestStdout(t, func() error { | |
| 48 | + | return runOrchestratedJSONMode(context.Background(), Args{ | |
| 49 | + | Mode: ModeJSON, | |
| 50 | + | Orchestrate: true, | |
| 51 | + | Provider: "openai", | |
| 52 | + | Model: "gpt-5", | |
| 53 | + | BaseURL: server.URL, | |
| 54 | + | APIKey: "test-key", | |
| 55 | + | Prompt: "say hello", | |
| 56 | + | }, "test") | |
| 57 | + | }) | |
| 76 | 58 | if runErr != nil { | |
| 77 | 59 | t.Fatalf("runOrchestratedJSONMode error: %v", runErr) | |
| 78 | 60 | } | |
| 79 | - | if readErr != nil { | |
| 80 | - | t.Fatal(readErr) | |
| 81 | - | } | |
| 82 | 61 | ||
| 83 | 62 | seen := map[string]bool{} | |
| 84 | - | scanner := bufio.NewScanner(strings.NewReader(string(output))) | |
| 63 | + | scanner := bufio.NewScanner(strings.NewReader(output)) | |
| 85 | 64 | for scanner.Scan() { | |
| 86 | 65 | var event map[string]any | |
| 87 | 66 | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | |
| @@ -101,6 +80,39 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) { | |||
| 101 | 80 | } | |
| 102 | 81 | } | |
| 103 | 82 | ||
| 83 | + | func captureTestStdout(t *testing.T, run func() error) (string, error) { | |
| 84 | + | t.Helper() | |
| 85 | + | oldStdout := os.Stdout | |
| 86 | + | reader, writer, err := os.Pipe() | |
| 87 | + | if err != nil { | |
| 88 | + | return "", err | |
| 89 | + | } | |
| 90 | + | os.Stdout = writer | |
| 91 | + | t.Cleanup(func() { | |
| 92 | + | os.Stdout = oldStdout | |
| 93 | + | _ = writer.Close() | |
| 94 | + | _ = reader.Close() | |
| 95 | + | }) | |
| 96 | + | readDone := make(chan struct { | |
| 97 | + | output []byte | |
| 98 | + | err error | |
| 99 | + | }, 1) | |
| 100 | + | go func() { | |
| 101 | + | output, readErr := io.ReadAll(reader) | |
| 102 | + | readDone <- struct { | |
| 103 | + | output []byte | |
| 104 | + | err error | |
| 105 | + | }{output: output, err: readErr} | |
| 106 | + | }() | |
| 107 | + | ||
| 108 | + | runErr := run() | |
| 109 | + | _ = writer.Close() | |
| 110 | + | os.Stdout = oldStdout | |
| 111 | + | result := <-readDone | |
| 112 | + | _ = reader.Close() | |
| 113 | + | return string(result.output), errors.Join(runErr, result.err) | |
| 114 | + | } | |
| 115 | + | ||
| 104 | 116 | func orchestratedModeTestServer(answer string) *httptest.Server { | |
| 105 | 117 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 106 | 118 | w.Header().Set("content-type", "text/event-stream") | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 7 | "io" | 8 | "io" | ||
| 8 | "net/http" | 9 | "net/http" | ||
| 9 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| @@ -18,32 +19,21 @@ func TestRunOrchestratedStreamUsesStreamRenderer(t *testing.T) { | |||||
| 18 | server := orchestratedModeTestServer("stream answer") | 19 | server := orchestratedModeTestServer("stream answer") | ||
| 19 | defer server.Close() | 20 | defer server.Close() | ||
| 20 | 21 | ||||
| 21 | - | oldStdout := os.Stdout | 22 | + | output, runErr := captureTestStdout(t, func() error { |
| 22 | - | reader, writer, err := os.Pipe() | 23 | + | return runOrchestratedStreamMode(context.Background(), Args{ |
| 23 | - | if err != nil { | 24 | + | Mode: ModeStream, |
| 24 | - | t.Fatal(err) | 25 | + | Orchestrate: true, |
| 25 | - | } | 26 | + | Provider: "openai", |
| 26 | - | os.Stdout = writer | 27 | + | Model: "gpt-5", |
| 27 | - | runErr := runOrchestratedStreamMode(context.Background(), Args{ | 28 | + | BaseURL: server.URL, |
| 28 | - | Mode: ModeStream, | 29 | + | APIKey: "test-key", |
| 29 | - | Orchestrate: true, | 30 | + | Prompt: "say hello", |
| 30 | - | Provider: "openai", | 31 | + | }, "test") |
| 31 | - | Model: "gpt-5", | 32 | + | }) |
| 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 { | 33 | if runErr != nil { | ||
| 41 | t.Fatalf("runOrchestratedStreamMode error: %v", runErr) | 34 | t.Fatalf("runOrchestratedStreamMode error: %v", runErr) | ||
| 42 | } | 35 | } | ||
| 43 | - | if readErr != nil { | 36 | + | if got := output; got != "stream answer\n" { |
| 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) | 37 | t.Fatalf("stdout = %q, want one rendered answer newline", got) | ||
| 48 | } | 38 | } | ||
| 49 | } | 39 | } | ||
| @@ -54,34 +44,23 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) { | |||||
| 54 | server := orchestratedModeTestServer("json answer") | 44 | server := orchestratedModeTestServer("json answer") | ||
| 55 | defer server.Close() | 45 | defer server.Close() | ||
| 56 | 46 | ||||
| 57 | - | oldStdout := os.Stdout | 47 | + | output, runErr := captureTestStdout(t, func() error { |
| 58 | - | reader, writer, err := os.Pipe() | 48 | + | return runOrchestratedJSONMode(context.Background(), Args{ |
| 59 | - | if err != nil { | 49 | + | Mode: ModeJSON, |
| 60 | - | t.Fatal(err) | 50 | + | Orchestrate: true, |
| 61 | - | } | 51 | + | Provider: "openai", |
| 62 | - | os.Stdout = writer | 52 | + | Model: "gpt-5", |
| 63 | - | runErr := runOrchestratedJSONMode(context.Background(), Args{ | 53 | + | BaseURL: server.URL, |
| 64 | - | Mode: ModeJSON, | 54 | + | APIKey: "test-key", |
| 65 | - | Orchestrate: true, | 55 | + | Prompt: "say hello", |
| 66 | - | Provider: "openai", | 56 | + | }, "test") |
| 67 | - | Model: "gpt-5", | 57 | + | }) |
| 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 { | 58 | if runErr != nil { | ||
| 77 | t.Fatalf("runOrchestratedJSONMode error: %v", runErr) | 59 | t.Fatalf("runOrchestratedJSONMode error: %v", runErr) | ||
| 78 | } | 60 | } | ||
| 79 | - | if readErr != nil { | |||
| 80 | - | t.Fatal(readErr) | |||
| 81 | - | } | |||
| 82 | 61 | ||||
| 83 | seen := map[string]bool{} | 62 | seen := map[string]bool{} | ||
| 84 | - | scanner := bufio.NewScanner(strings.NewReader(string(output))) | 63 | + | scanner := bufio.NewScanner(strings.NewReader(output)) |
| 85 | for scanner.Scan() { | 64 | for scanner.Scan() { | ||
| 86 | var event map[string]any | 65 | var event map[string]any | ||
| 87 | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | 66 | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | ||
| @@ -101,6 +80,39 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) { | |||||
| 101 | } | 80 | } | ||
| 102 | } | 81 | } | ||
| 103 | 82 | ||||
| 83 | + | func captureTestStdout(t *testing.T, run func() error) (string, error) { | |||
| 84 | + | t.Helper() | |||
| 85 | + | oldStdout := os.Stdout | |||
| 86 | + | reader, writer, err := os.Pipe() | |||
| 87 | + | if err != nil { | |||
| 88 | + | return "", err | |||
| 89 | + | } | |||
| 90 | + | os.Stdout = writer | |||
| 91 | + | t.Cleanup(func() { | |||
| 92 | + | os.Stdout = oldStdout | |||
| 93 | + | _ = writer.Close() | |||
| 94 | + | _ = reader.Close() | |||
| 95 | + | }) | |||
| 96 | + | readDone := make(chan struct { | |||
| 97 | + | output []byte | |||
| 98 | + | err error | |||
| 99 | + | }, 1) | |||
| 100 | + | go func() { | |||
| 101 | + | output, readErr := io.ReadAll(reader) | |||
| 102 | + | readDone <- struct { | |||
| 103 | + | output []byte | |||
| 104 | + | err error | |||
| 105 | + | }{output: output, err: readErr} | |||
| 106 | + | }() | |||
| 107 | + | ||||
| 108 | + | runErr := run() | |||
| 109 | + | _ = writer.Close() | |||
| 110 | + | os.Stdout = oldStdout | |||
| 111 | + | result := <-readDone | |||
| 112 | + | _ = reader.Close() | |||
| 113 | + | return string(result.output), errors.Join(runErr, result.err) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 104 | func orchestratedModeTestServer(answer string) *httptest.Server { | 116 | func orchestratedModeTestServer(answer string) *httptest.Server { | ||
| 105 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 117 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 106 | w.Header().Set("content-type", "text/event-stream") | 118 | w.Header().Set("content-type", "text/event-stream") | ||
M packages/agent/orchestration_print.go +15 -169
| @@ -3,16 +3,11 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | - | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "os" | |
| 9 | - | "os/signal" | |
| 10 | - | "path/filepath" | |
| 11 | 8 | "strings" | |
| 12 | - | "syscall" | |
| 13 | 9 | ||
| 14 | 10 | "github.com/bnema/zut/packages/agent/modes" | |
| 15 | - | "github.com/bnema/zut/packages/agent/subagents" | |
| 16 | 11 | "github.com/bnema/zut/packages/core" | |
| 17 | 12 | "github.com/bnema/zut/packages/provider" | |
| 18 | 13 | ) | |
| @@ -20,169 +15,20 @@ import ( | |||
| 20 | 15 | // runOrchestratedPrintMode is deliberately separate from ordinary print mode: | |
| 21 | 16 | // ordinary print continues to stream its existing one-turn implementation, | |
| 22 | 17 | // 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 { | |
| 18 | + | func runOrchestratedPrintMode(ctx context.Context, args Args, version string) error { | |
| 19 | + | return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{ | |
| 20 | + | promptLabel: "print", | |
| 21 | + | startupSink: func() (func(core.AgentEvent), func()) { | |
| 22 | + | return nil, func() {} | |
| 23 | + | }, | |
| 24 | + | runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) { | |
| 25 | + | var captured bytes.Buffer | |
| 26 | + | _, recovery, err := modes.RunPrintWithContextRecovery(turnCtx, ag, prompt, nil, &captured, persist) | |
| 27 | + | return strings.TrimSuffix(captured.String(), "\n"), recovery, err | |
| 28 | + | }, | |
| 29 | + | finish: func(finalText string) error { | |
| 30 | + | _, err := fmt.Fprintln(os.Stdout, finalText) | |
| 120 | 31 | 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 | |
| 32 | + | }, | |
| 33 | + | }) | |
| 188 | 34 | } | |
| @@ -3,16 +3,11 @@ package agent | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | - | "errors" | |||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "os" | 7 | "os" | ||
| 9 | - | "os/signal" | |||
| 10 | - | "path/filepath" | |||
| 11 | "strings" | 8 | "strings" | ||
| 12 | - | "syscall" | |||
| 13 | 9 | ||||
| 14 | "github.com/bnema/zut/packages/agent/modes" | 10 | "github.com/bnema/zut/packages/agent/modes" | ||
| 15 | - | "github.com/bnema/zut/packages/agent/subagents" | |||
| 16 | "github.com/bnema/zut/packages/core" | 11 | "github.com/bnema/zut/packages/core" | ||
| 17 | "github.com/bnema/zut/packages/provider" | 12 | "github.com/bnema/zut/packages/provider" | ||
| 18 | ) | 13 | ) | ||
| @@ -20,169 +15,20 @@ import ( | |||||
| 20 | // runOrchestratedPrintMode is deliberately separate from ordinary print mode: | 15 | // runOrchestratedPrintMode is deliberately separate from ordinary print mode: | ||
| 21 | // ordinary print continues to stream its existing one-turn implementation, | 16 | // ordinary print continues to stream its existing one-turn implementation, | ||
| 22 | // while this path keeps every parent turn in memory until terminal synthesis. | 17 | // while this path keeps every parent turn in memory until terminal synthesis. | ||
| 23 | - | func runOrchestratedPrintMode(parentCtx context.Context, args Args, version string) (runErr error) { | 18 | + | func runOrchestratedPrintMode(ctx context.Context, args Args, version string) error { |
| 24 | - | ctx, stopSignal := signal.NotifyContext(parentCtx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) | 19 | + | return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{ |
| 25 | - | defer stopSignal() | 20 | + | promptLabel: "print", |
| 26 | - | 21 | + | startupSink: func() (func(core.AgentEvent), func()) { | |
| 27 | - | if args.NoYolo { | 22 | + | return nil, func() {} |
| 28 | - | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | 23 | + | }, |
| 29 | - | } | 24 | + | runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) { |
| 30 | - | r, err := Resolve(args, true) | 25 | + | var captured bytes.Buffer |
| 31 | - | if err != nil { | 26 | + | _, recovery, err := modes.RunPrintWithContextRecovery(turnCtx, ag, prompt, nil, &captured, persist) |
| 32 | - | return err | 27 | + | return strings.TrimSuffix(captured.String(), "\n"), recovery, err |
| 33 | - | } | 28 | + | }, |
| 34 | - | initialCfg, err := LoadConfig() | 29 | + | finish: func(finalText string) error { |
| 35 | - | if err != nil { | 30 | + | _, err := fmt.Fprintln(os.Stdout, finalText) |
| 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 | 31 | return err | ||
| 121 | - | } | 32 | + | }, |
| 122 | - | r.Provider, r.Model = providerName, model | 33 | + | }) |
| 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 | } | 34 | } | ||
M packages/agent/orchestration_print_test.go +13 -31
| @@ -2,9 +2,6 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "io" | |
| 6 | - | "net/http" | |
| 7 | - | "net/http/httptest" | |
| 8 | 5 | "os" | |
| 9 | 6 | "strings" | |
| 10 | 7 | "testing" | |
| @@ -13,39 +10,24 @@ import ( | |||
| 13 | 10 | func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) { | |
| 14 | 11 | home := t.TempDir() | |
| 15 | 12 | 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 | - | })) | |
| 13 | + | server := orchestratedModeTestServer("final answer") | |
| 21 | 14 | defer server.Close() | |
| 22 | 15 | ||
| 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() | |
| 16 | + | output, runErr := captureTestStdout(t, func() error { | |
| 17 | + | return runOrchestratedPrintMode(context.Background(), Args{ | |
| 18 | + | Mode: ModePrint, | |
| 19 | + | Orchestrate: true, | |
| 20 | + | Provider: "openai", | |
| 21 | + | Model: "gpt-5", | |
| 22 | + | BaseURL: server.URL, | |
| 23 | + | APIKey: "test-key", | |
| 24 | + | Prompt: "say hello", | |
| 25 | + | }, "test") | |
| 26 | + | }) | |
| 42 | 27 | if runErr != nil { | |
| 43 | 28 | t.Fatalf("runOrchestratedPrintMode error: %v", runErr) | |
| 44 | 29 | } | |
| 45 | - | if readErr != nil { | |
| 46 | - | t.Fatal(readErr) | |
| 47 | - | } | |
| 48 | - | if got := string(output); got != "final answer\n" { | |
| 30 | + | if got := output; got != "final answer\n" { | |
| 49 | 31 | t.Fatalf("stdout = %q, want final answer only", got) | |
| 50 | 32 | } | |
| 51 | 33 | } | |
| @@ -2,9 +2,6 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "io" | |||
| 6 | - | "net/http" | |||
| 7 | - | "net/http/httptest" | |||
| 8 | "os" | 5 | "os" | ||
| 9 | "strings" | 6 | "strings" | ||
| 10 | "testing" | 7 | "testing" | ||
| @@ -13,39 +10,24 @@ import ( | |||||
| 13 | func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) { | 10 | func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) { | ||
| 14 | home := t.TempDir() | 11 | home := t.TempDir() | ||
| 15 | t.Setenv("ZUT_HOME", home) | 12 | t.Setenv("ZUT_HOME", home) | ||
| 16 | - | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 13 | + | server := orchestratedModeTestServer("final answer") |
| 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() | 14 | defer server.Close() | ||
| 22 | 15 | ||||
| 23 | - | oldStdout := os.Stdout | 16 | + | output, runErr := captureTestStdout(t, func() error { |
| 24 | - | reader, writer, err := os.Pipe() | 17 | + | return runOrchestratedPrintMode(context.Background(), Args{ |
| 25 | - | if err != nil { | 18 | + | Mode: ModePrint, |
| 26 | - | t.Fatal(err) | 19 | + | Orchestrate: true, |
| 27 | - | } | 20 | + | Provider: "openai", |
| 28 | - | os.Stdout = writer | 21 | + | Model: "gpt-5", |
| 29 | - | runErr := runOrchestratedPrintMode(context.Background(), Args{ | 22 | + | BaseURL: server.URL, |
| 30 | - | Mode: ModePrint, | 23 | + | APIKey: "test-key", |
| 31 | - | Orchestrate: true, | 24 | + | Prompt: "say hello", |
| 32 | - | Provider: "openai", | 25 | + | }, "test") |
| 33 | - | Model: "gpt-5", | 26 | + | }) |
| 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 { | 27 | if runErr != nil { | ||
| 43 | t.Fatalf("runOrchestratedPrintMode error: %v", runErr) | 28 | t.Fatalf("runOrchestratedPrintMode error: %v", runErr) | ||
| 44 | } | 29 | } | ||
| 45 | - | if readErr != nil { | 30 | + | if got := output; got != "final answer\n" { |
| 46 | - | t.Fatal(readErr) | |||
| 47 | - | } | |||
| 48 | - | if got := string(output); got != "final answer\n" { | |||
| 49 | t.Fatalf("stdout = %q, want final answer only", got) | 31 | t.Fatalf("stdout = %q, want final answer only", got) | ||
| 50 | } | 32 | } | ||
| 51 | } | 33 | } | ||
M packages/agent/subagent_runtime.go +67 -16
| @@ -43,8 +43,9 @@ type subagentRuntime struct { | |||
| 43 | 43 | beforeResumed func(*subagents.Agent, string) func() | |
| 44 | 44 | onStopRequested func(*subagents.Agent) | |
| 45 | 45 | ||
| 46 | - | closeMu sync.Mutex | |
| 47 | - | closed bool | |
| 46 | + | settingsMu sync.RWMutex | |
| 47 | + | closeMu sync.Mutex | |
| 48 | + | closed bool | |
| 48 | 49 | } | |
| 49 | 50 | ||
| 50 | 51 | type subagentRuntimeConfiguration struct { | |
| @@ -114,13 +115,25 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||
| 114 | 115 | onStopRequested: cfg.OnStopRequested, | |
| 115 | 116 | } | |
| 116 | 117 | if rt.activeProvider == nil { | |
| 117 | - | rt.activeProvider = func() string { return rt.provider } | |
| 118 | + | rt.activeProvider = func() string { | |
| 119 | + | rt.settingsMu.RLock() | |
| 120 | + | defer rt.settingsMu.RUnlock() | |
| 121 | + | return rt.provider | |
| 122 | + | } | |
| 118 | 123 | } | |
| 119 | 124 | if rt.activeModel == nil { | |
| 120 | - | rt.activeModel = func() string { return rt.model } | |
| 125 | + | rt.activeModel = func() string { | |
| 126 | + | rt.settingsMu.RLock() | |
| 127 | + | defer rt.settingsMu.RUnlock() | |
| 128 | + | return rt.model | |
| 129 | + | } | |
| 121 | 130 | } | |
| 122 | 131 | if rt.activeReasoning == nil { | |
| 123 | - | rt.activeReasoning = func() string { return rt.reasoning } | |
| 132 | + | rt.activeReasoning = func() string { | |
| 133 | + | rt.settingsMu.RLock() | |
| 134 | + | defer rt.settingsMu.RUnlock() | |
| 135 | + | return rt.reasoning | |
| 136 | + | } | |
| 124 | 137 | } | |
| 125 | 138 | if rt.apiKey != "" { | |
| 126 | 139 | // A launch-time API key belongs to the provider selected at startup. It | |
| @@ -179,7 +192,18 @@ func (rt *subagentRuntime) SetModel(model string) { | |||
| 179 | 192 | if rt == nil { | |
| 180 | 193 | return | |
| 181 | 194 | } | |
| 195 | + | rt.settingsMu.Lock() | |
| 182 | 196 | rt.model = strings.TrimSpace(model) | |
| 197 | + | rt.settingsMu.Unlock() | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (rt *subagentRuntime) SetReasoning(reasoning string) { | |
| 201 | + | if rt == nil { | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | rt.settingsMu.Lock() | |
| 205 | + | rt.reasoning = strings.TrimSpace(reasoning) | |
| 206 | + | rt.settingsMu.Unlock() | |
| 183 | 207 | } | |
| 184 | 208 | ||
| 185 | 209 | func (rt *subagentRuntime) currentReasoning() string { | |
| @@ -193,9 +217,12 @@ func (rt *subagentRuntime) SetProvider(providerID string) { | |||
| 193 | 217 | if rt == nil { | |
| 194 | 218 | return | |
| 195 | 219 | } | |
| 196 | - | rt.provider = strings.TrimSpace(providerID) | |
| 220 | + | providerID = strings.TrimSpace(providerID) | |
| 221 | + | rt.settingsMu.Lock() | |
| 222 | + | rt.provider = providerID | |
| 223 | + | rt.settingsMu.Unlock() | |
| 197 | 224 | if rt.supervisor != nil { | |
| 198 | - | rt.supervisor.SetProvider(rt.provider) | |
| 225 | + | rt.supervisor.SetProvider(providerID) | |
| 199 | 226 | } | |
| 200 | 227 | } | |
| 201 | 228 | ||
| @@ -203,10 +230,13 @@ func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) | |||
| 203 | 230 | if rt == nil { | |
| 204 | 231 | return | |
| 205 | 232 | } | |
| 206 | - | rt.baseURL = strings.TrimSpace(baseURL) | |
| 233 | + | baseURL = strings.TrimSpace(baseURL) | |
| 234 | + | rt.settingsMu.Lock() | |
| 235 | + | rt.baseURL = baseURL | |
| 207 | 236 | rt.insecureTLS = insecureTLS | |
| 237 | + | rt.settingsMu.Unlock() | |
| 208 | 238 | if rt.supervisor != nil { | |
| 209 | - | rt.supervisor.SetProviderSettings(rt.baseURL, rt.insecureTLS) | |
| 239 | + | rt.supervisor.SetProviderSettings(baseURL, insecureTLS) | |
| 210 | 240 | } | |
| 211 | 241 | } | |
| 212 | 242 | ||
| @@ -214,7 +244,9 @@ func (rt *subagentRuntime) SetFastMode(fastMode bool) { | |||
| 214 | 244 | if rt == nil { | |
| 215 | 245 | return | |
| 216 | 246 | } | |
| 247 | + | rt.settingsMu.Lock() | |
| 217 | 248 | rt.fastMode = fastMode | |
| 249 | + | rt.settingsMu.Unlock() | |
| 218 | 250 | if rt.supervisor != nil { | |
| 219 | 251 | rt.supervisor.SetFastMode(fastMode) | |
| 220 | 252 | } | |
| @@ -224,7 +256,9 @@ func (rt *subagentRuntime) SetRepoRoot(repoRoot string) { | |||
| 224 | 256 | if rt == nil { | |
| 225 | 257 | return | |
| 226 | 258 | } | |
| 259 | + | rt.settingsMu.Lock() | |
| 227 | 260 | rt.repoRoot = repoRoot | |
| 261 | + | rt.settingsMu.Unlock() | |
| 228 | 262 | if rt.supervisor != nil { | |
| 229 | 263 | rt.supervisor.SetRepoRoot(repoRoot) | |
| 230 | 264 | } | |
| @@ -237,6 +271,8 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration | |||
| 237 | 271 | if rt == nil { | |
| 238 | 272 | return subagentRuntimeConfiguration{} | |
| 239 | 273 | } | |
| 274 | + | rt.settingsMu.RLock() | |
| 275 | + | defer rt.settingsMu.RUnlock() | |
| 240 | 276 | return subagentRuntimeConfiguration{ | |
| 241 | 277 | provider: rt.provider, | |
| 242 | 278 | model: rt.model, | |
| @@ -252,12 +288,22 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur | |||
| 252 | 288 | if rt == nil { | |
| 253 | 289 | return | |
| 254 | 290 | } | |
| 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) | |
| 291 | + | rt.settingsMu.Lock() | |
| 292 | + | rt.provider = snapshot.provider | |
| 293 | + | rt.model = snapshot.model | |
| 294 | + | rt.baseURL = snapshot.baseURL | |
| 295 | + | rt.insecureTLS = snapshot.insecureTLS | |
| 296 | + | rt.fastMode = snapshot.fastMode | |
| 297 | + | rt.repoRoot = snapshot.repoRoot | |
| 298 | + | rt.webSearchPolicy = snapshot.webSearchPolicy | |
| 299 | + | rt.settingsMu.Unlock() | |
| 300 | + | if rt.supervisor != nil { | |
| 301 | + | rt.supervisor.SetProvider(snapshot.provider) | |
| 302 | + | rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS) | |
| 303 | + | rt.supervisor.SetFastMode(snapshot.fastMode) | |
| 304 | + | rt.supervisor.SetRepoRoot(snapshot.repoRoot) | |
| 305 | + | rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy) | |
| 306 | + | } | |
| 261 | 307 | } | |
| 262 | 308 | ||
| 263 | 309 | func (rt *subagentRuntime) SetActiveSession(sessionID string) { | |
| @@ -270,7 +316,9 @@ func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) | |||
| 270 | 316 | if rt == nil { | |
| 271 | 317 | return | |
| 272 | 318 | } | |
| 319 | + | rt.settingsMu.Lock() | |
| 273 | 320 | rt.webSearchPolicy = policy | |
| 321 | + | rt.settingsMu.Unlock() | |
| 274 | 322 | if rt.supervisor != nil { | |
| 275 | 323 | rt.supervisor.SetWebSearchPolicy(policy) | |
| 276 | 324 | } | |
| @@ -307,7 +355,10 @@ func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, err | |||
| 307 | 355 | if rt == nil { | |
| 308 | 356 | return nil, nil | |
| 309 | 357 | } | |
| 310 | - | return findSubagentProfile(rt.repoRoot, name) | |
| 358 | + | rt.settingsMu.RLock() | |
| 359 | + | repoRoot := rt.repoRoot | |
| 360 | + | rt.settingsMu.RUnlock() | |
| 361 | + | return findSubagentProfile(repoRoot, name) | |
| 311 | 362 | } | |
| 312 | 363 | ||
| 313 | 364 | // InjectTools adds the canonical manager tools allowed by the host launch | |
| @@ -43,8 +43,9 @@ type subagentRuntime struct { | |||||
| 43 | beforeResumed func(*subagents.Agent, string) func() | 43 | beforeResumed func(*subagents.Agent, string) func() | ||
| 44 | onStopRequested func(*subagents.Agent) | 44 | onStopRequested func(*subagents.Agent) | ||
| 45 | 45 | ||||
| 46 | - | closeMu sync.Mutex | 46 | + | settingsMu sync.RWMutex |
| 47 | - | closed bool | 47 | + | closeMu sync.Mutex |
| 48 | + | closed bool | |||
| 48 | } | 49 | } | ||
| 49 | 50 | ||||
| 50 | type subagentRuntimeConfiguration struct { | 51 | type subagentRuntimeConfiguration struct { | ||
| @@ -114,13 +115,25 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||||
| 114 | onStopRequested: cfg.OnStopRequested, | 115 | onStopRequested: cfg.OnStopRequested, | ||
| 115 | } | 116 | } | ||
| 116 | if rt.activeProvider == nil { | 117 | if rt.activeProvider == nil { | ||
| 117 | - | rt.activeProvider = func() string { return rt.provider } | 118 | + | rt.activeProvider = func() string { |
| 119 | + | rt.settingsMu.RLock() | |||
| 120 | + | defer rt.settingsMu.RUnlock() | |||
| 121 | + | return rt.provider | |||
| 122 | + | } | |||
| 118 | } | 123 | } | ||
| 119 | if rt.activeModel == nil { | 124 | if rt.activeModel == nil { | ||
| 120 | - | rt.activeModel = func() string { return rt.model } | 125 | + | rt.activeModel = func() string { |
| 126 | + | rt.settingsMu.RLock() | |||
| 127 | + | defer rt.settingsMu.RUnlock() | |||
| 128 | + | return rt.model | |||
| 129 | + | } | |||
| 121 | } | 130 | } | ||
| 122 | if rt.activeReasoning == nil { | 131 | if rt.activeReasoning == nil { | ||
| 123 | - | rt.activeReasoning = func() string { return rt.reasoning } | 132 | + | rt.activeReasoning = func() string { |
| 133 | + | rt.settingsMu.RLock() | |||
| 134 | + | defer rt.settingsMu.RUnlock() | |||
| 135 | + | return rt.reasoning | |||
| 136 | + | } | |||
| 124 | } | 137 | } | ||
| 125 | if rt.apiKey != "" { | 138 | if rt.apiKey != "" { | ||
| 126 | // A launch-time API key belongs to the provider selected at startup. It | 139 | // A launch-time API key belongs to the provider selected at startup. It | ||
| @@ -179,7 +192,18 @@ func (rt *subagentRuntime) SetModel(model string) { | |||||
| 179 | if rt == nil { | 192 | if rt == nil { | ||
| 180 | return | 193 | return | ||
| 181 | } | 194 | } | ||
| 195 | + | rt.settingsMu.Lock() | |||
| 182 | rt.model = strings.TrimSpace(model) | 196 | rt.model = strings.TrimSpace(model) | ||
| 197 | + | rt.settingsMu.Unlock() | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (rt *subagentRuntime) SetReasoning(reasoning string) { | |||
| 201 | + | if rt == nil { | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | rt.settingsMu.Lock() | |||
| 205 | + | rt.reasoning = strings.TrimSpace(reasoning) | |||
| 206 | + | rt.settingsMu.Unlock() | |||
| 183 | } | 207 | } | ||
| 184 | 208 | ||||
| 185 | func (rt *subagentRuntime) currentReasoning() string { | 209 | func (rt *subagentRuntime) currentReasoning() string { | ||
| @@ -193,9 +217,12 @@ func (rt *subagentRuntime) SetProvider(providerID string) { | |||||
| 193 | if rt == nil { | 217 | if rt == nil { | ||
| 194 | return | 218 | return | ||
| 195 | } | 219 | } | ||
| 196 | - | rt.provider = strings.TrimSpace(providerID) | 220 | + | providerID = strings.TrimSpace(providerID) |
| 221 | + | rt.settingsMu.Lock() | |||
| 222 | + | rt.provider = providerID | |||
| 223 | + | rt.settingsMu.Unlock() | |||
| 197 | if rt.supervisor != nil { | 224 | if rt.supervisor != nil { | ||
| 198 | - | rt.supervisor.SetProvider(rt.provider) | 225 | + | rt.supervisor.SetProvider(providerID) |
| 199 | } | 226 | } | ||
| 200 | } | 227 | } | ||
| 201 | 228 | ||||
| @@ -203,10 +230,13 @@ func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) | |||||
| 203 | if rt == nil { | 230 | if rt == nil { | ||
| 204 | return | 231 | return | ||
| 205 | } | 232 | } | ||
| 206 | - | rt.baseURL = strings.TrimSpace(baseURL) | 233 | + | baseURL = strings.TrimSpace(baseURL) |
| 234 | + | rt.settingsMu.Lock() | |||
| 235 | + | rt.baseURL = baseURL | |||
| 207 | rt.insecureTLS = insecureTLS | 236 | rt.insecureTLS = insecureTLS | ||
| 237 | + | rt.settingsMu.Unlock() | |||
| 208 | if rt.supervisor != nil { | 238 | if rt.supervisor != nil { | ||
| 209 | - | rt.supervisor.SetProviderSettings(rt.baseURL, rt.insecureTLS) | 239 | + | rt.supervisor.SetProviderSettings(baseURL, insecureTLS) |
| 210 | } | 240 | } | ||
| 211 | } | 241 | } | ||
| 212 | 242 | ||||
| @@ -214,7 +244,9 @@ func (rt *subagentRuntime) SetFastMode(fastMode bool) { | |||||
| 214 | if rt == nil { | 244 | if rt == nil { | ||
| 215 | return | 245 | return | ||
| 216 | } | 246 | } | ||
| 247 | + | rt.settingsMu.Lock() | |||
| 217 | rt.fastMode = fastMode | 248 | rt.fastMode = fastMode | ||
| 249 | + | rt.settingsMu.Unlock() | |||
| 218 | if rt.supervisor != nil { | 250 | if rt.supervisor != nil { | ||
| 219 | rt.supervisor.SetFastMode(fastMode) | 251 | rt.supervisor.SetFastMode(fastMode) | ||
| 220 | } | 252 | } | ||
| @@ -224,7 +256,9 @@ func (rt *subagentRuntime) SetRepoRoot(repoRoot string) { | |||||
| 224 | if rt == nil { | 256 | if rt == nil { | ||
| 225 | return | 257 | return | ||
| 226 | } | 258 | } | ||
| 259 | + | rt.settingsMu.Lock() | |||
| 227 | rt.repoRoot = repoRoot | 260 | rt.repoRoot = repoRoot | ||
| 261 | + | rt.settingsMu.Unlock() | |||
| 228 | if rt.supervisor != nil { | 262 | if rt.supervisor != nil { | ||
| 229 | rt.supervisor.SetRepoRoot(repoRoot) | 263 | rt.supervisor.SetRepoRoot(repoRoot) | ||
| 230 | } | 264 | } | ||
| @@ -237,6 +271,8 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration | |||||
| 237 | if rt == nil { | 271 | if rt == nil { | ||
| 238 | return subagentRuntimeConfiguration{} | 272 | return subagentRuntimeConfiguration{} | ||
| 239 | } | 273 | } | ||
| 274 | + | rt.settingsMu.RLock() | |||
| 275 | + | defer rt.settingsMu.RUnlock() | |||
| 240 | return subagentRuntimeConfiguration{ | 276 | return subagentRuntimeConfiguration{ | ||
| 241 | provider: rt.provider, | 277 | provider: rt.provider, | ||
| 242 | model: rt.model, | 278 | model: rt.model, | ||
| @@ -252,12 +288,22 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur | |||||
| 252 | if rt == nil { | 288 | if rt == nil { | ||
| 253 | return | 289 | return | ||
| 254 | } | 290 | } | ||
| 255 | - | rt.SetProvider(snapshot.provider) | 291 | + | rt.settingsMu.Lock() |
| 256 | - | rt.SetModel(snapshot.model) | 292 | + | rt.provider = snapshot.provider |
| 257 | - | rt.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS) | 293 | + | rt.model = snapshot.model |
| 258 | - | rt.SetFastMode(snapshot.fastMode) | 294 | + | rt.baseURL = snapshot.baseURL |
| 259 | - | rt.SetRepoRoot(snapshot.repoRoot) | 295 | + | rt.insecureTLS = snapshot.insecureTLS |
| 260 | - | rt.SetWebSearchPolicy(snapshot.webSearchPolicy) | 296 | + | rt.fastMode = snapshot.fastMode |
| 297 | + | rt.repoRoot = snapshot.repoRoot | |||
| 298 | + | rt.webSearchPolicy = snapshot.webSearchPolicy | |||
| 299 | + | rt.settingsMu.Unlock() | |||
| 300 | + | if rt.supervisor != nil { | |||
| 301 | + | rt.supervisor.SetProvider(snapshot.provider) | |||
| 302 | + | rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS) | |||
| 303 | + | rt.supervisor.SetFastMode(snapshot.fastMode) | |||
| 304 | + | rt.supervisor.SetRepoRoot(snapshot.repoRoot) | |||
| 305 | + | rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy) | |||
| 306 | + | } | |||
| 261 | } | 307 | } | ||
| 262 | 308 | ||||
| 263 | func (rt *subagentRuntime) SetActiveSession(sessionID string) { | 309 | func (rt *subagentRuntime) SetActiveSession(sessionID string) { | ||
| @@ -270,7 +316,9 @@ func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) | |||||
| 270 | if rt == nil { | 316 | if rt == nil { | ||
| 271 | return | 317 | return | ||
| 272 | } | 318 | } | ||
| 319 | + | rt.settingsMu.Lock() | |||
| 273 | rt.webSearchPolicy = policy | 320 | rt.webSearchPolicy = policy | ||
| 321 | + | rt.settingsMu.Unlock() | |||
| 274 | if rt.supervisor != nil { | 322 | if rt.supervisor != nil { | ||
| 275 | rt.supervisor.SetWebSearchPolicy(policy) | 323 | rt.supervisor.SetWebSearchPolicy(policy) | ||
| 276 | } | 324 | } | ||
| @@ -307,7 +355,10 @@ func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, err | |||||
| 307 | if rt == nil { | 355 | if rt == nil { | ||
| 308 | return nil, nil | 356 | return nil, nil | ||
| 309 | } | 357 | } | ||
| 310 | - | return findSubagentProfile(rt.repoRoot, name) | 358 | + | rt.settingsMu.RLock() |
| 359 | + | repoRoot := rt.repoRoot | |||
| 360 | + | rt.settingsMu.RUnlock() | |||
| 361 | + | return findSubagentProfile(repoRoot, name) | |||
| 311 | } | 362 | } | ||
| 312 | 363 | ||||
| 313 | // InjectTools adds the canonical manager tools allowed by the host launch | 364 | // InjectTools adds the canonical manager tools allowed by the host launch | ||
M packages/agent/subagent_runtime_test.go +38 -5
| @@ -133,6 +133,40 @@ func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) { | |||
| 133 | 133 | } | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | + | func TestSubagentRuntimeConfigurationConcurrentAccess(t *testing.T) { | |
| 137 | + | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | |
| 138 | + | return &runtimeTestRunner{} | |
| 139 | + | }) | |
| 140 | + | rt := newSubagentRuntime(cfg) | |
| 141 | + | defer func() { _ = rt.Close(context.Background()) }() | |
| 142 | + | repoRoot := t.TempDir() | |
| 143 | + | ||
| 144 | + | var wg sync.WaitGroup | |
| 145 | + | wg.Add(2) | |
| 146 | + | go func() { | |
| 147 | + | defer wg.Done() | |
| 148 | + | for index := 0; index < 100; index++ { | |
| 149 | + | rt.SetProvider("provider") | |
| 150 | + | rt.SetModel("model") | |
| 151 | + | rt.SetReasoning("medium") | |
| 152 | + | rt.SetProviderSettings("https://provider.example/v1", index%2 == 0) | |
| 153 | + | rt.SetFastMode(index%2 == 0) | |
| 154 | + | rt.SetRepoRoot(repoRoot) | |
| 155 | + | rt.SetWebSearchPolicy(subagents.WebSearchDeny) | |
| 156 | + | } | |
| 157 | + | }() | |
| 158 | + | go func() { | |
| 159 | + | defer wg.Done() | |
| 160 | + | for index := 0; index < 100; index++ { | |
| 161 | + | _ = rt.currentProvider() | |
| 162 | + | _ = rt.currentModel() | |
| 163 | + | _ = rt.currentReasoning() | |
| 164 | + | _ = rt.snapshotConfiguration() | |
| 165 | + | } | |
| 166 | + | }() | |
| 167 | + | wg.Wait() | |
| 168 | + | } | |
| 169 | + | ||
| 136 | 170 | func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) { | |
| 137 | 171 | var captured []*subagents.Agent | |
| 138 | 172 | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner { | |
| @@ -349,16 +383,15 @@ func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *t | |||
| 349 | 383 | t.Setenv("ZUT_AGENT_PROFILES", profilesDir) | |
| 350 | 384 | ||
| 351 | 385 | var captured []*subagents.Agent | |
| 352 | - | liveReasoning := "high" | |
| 353 | 386 | cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner { | |
| 354 | 387 | captured = append(captured, a) | |
| 355 | 388 | return &runtimeTestRunner{} | |
| 356 | 389 | }) | |
| 357 | - | cfg.ActiveReasoning = func() string { return liveReasoning } | |
| 390 | + | cfg.Reasoning = "high" | |
| 358 | 391 | 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" | |
| 392 | + | // Change the host-owned setting after runtime construction. A child without | |
| 393 | + | // a profile override must observe this new live value. | |
| 394 | + | rt.SetReasoning("medium") | |
| 362 | 395 | defer func() { _ = rt.Close(context.Background()) }() | |
| 363 | 396 | spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"] | |
| 364 | 397 | ||
| @@ -133,6 +133,40 @@ func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) { | |||||
| 133 | } | 133 | } | ||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | + | func TestSubagentRuntimeConfigurationConcurrentAccess(t *testing.T) { | |||
| 137 | + | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | |||
| 138 | + | return &runtimeTestRunner{} | |||
| 139 | + | }) | |||
| 140 | + | rt := newSubagentRuntime(cfg) | |||
| 141 | + | defer func() { _ = rt.Close(context.Background()) }() | |||
| 142 | + | repoRoot := t.TempDir() | |||
| 143 | + | ||||
| 144 | + | var wg sync.WaitGroup | |||
| 145 | + | wg.Add(2) | |||
| 146 | + | go func() { | |||
| 147 | + | defer wg.Done() | |||
| 148 | + | for index := 0; index < 100; index++ { | |||
| 149 | + | rt.SetProvider("provider") | |||
| 150 | + | rt.SetModel("model") | |||
| 151 | + | rt.SetReasoning("medium") | |||
| 152 | + | rt.SetProviderSettings("https://provider.example/v1", index%2 == 0) | |||
| 153 | + | rt.SetFastMode(index%2 == 0) | |||
| 154 | + | rt.SetRepoRoot(repoRoot) | |||
| 155 | + | rt.SetWebSearchPolicy(subagents.WebSearchDeny) | |||
| 156 | + | } | |||
| 157 | + | }() | |||
| 158 | + | go func() { | |||
| 159 | + | defer wg.Done() | |||
| 160 | + | for index := 0; index < 100; index++ { | |||
| 161 | + | _ = rt.currentProvider() | |||
| 162 | + | _ = rt.currentModel() | |||
| 163 | + | _ = rt.currentReasoning() | |||
| 164 | + | _ = rt.snapshotConfiguration() | |||
| 165 | + | } | |||
| 166 | + | }() | |||
| 167 | + | wg.Wait() | |||
| 168 | + | } | |||
| 169 | + | ||||
| 136 | func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) { | 170 | func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) { | ||
| 137 | var captured []*subagents.Agent | 171 | var captured []*subagents.Agent | ||
| 138 | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner { | 172 | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner { | ||
| @@ -349,16 +383,15 @@ func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *t | |||||
| 349 | t.Setenv("ZUT_AGENT_PROFILES", profilesDir) | 383 | t.Setenv("ZUT_AGENT_PROFILES", profilesDir) | ||
| 350 | 384 | ||||
| 351 | var captured []*subagents.Agent | 385 | var captured []*subagents.Agent | ||
| 352 | - | liveReasoning := "high" | |||
| 353 | cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner { | 386 | cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner { | ||
| 354 | captured = append(captured, a) | 387 | captured = append(captured, a) | ||
| 355 | return &runtimeTestRunner{} | 388 | return &runtimeTestRunner{} | ||
| 356 | }) | 389 | }) | ||
| 357 | - | cfg.ActiveReasoning = func() string { return liveReasoning } | 390 | + | cfg.Reasoning = "high" |
| 358 | rt := newSubagentRuntime(cfg) | 391 | rt := newSubagentRuntime(cfg) | ||
| 359 | - | // Change the host callback after runtime construction. A child without a | 392 | + | // Change the host-owned setting after runtime construction. A child without |
| 360 | - | // profile override must observe this new live value. | 393 | + | // a profile override must observe this new live value. |
| 361 | - | liveReasoning = "medium" | 394 | + | rt.SetReasoning("medium") |
| 362 | defer func() { _ = rt.Close(context.Background()) }() | 395 | defer func() { _ = rt.Close(context.Background()) }() | ||
| 363 | spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"] | 396 | spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"] | ||
| 364 | 397 | ||||
M packages/agent/subagents/completion_test.go +8 -6
| @@ -22,6 +22,7 @@ func newCompletionTestAgent(id, task string, status Status) *Agent { | |||
| 22 | 22 | ||
| 23 | 23 | func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) { | |
| 24 | 24 | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | |
| 25 | + | t.Cleanup(func() { close(agent.done) }) | |
| 25 | 26 | tracker := NewCompletionTracker() | |
| 26 | 27 | tracker.TrackTurn(agent, "ignored task", false) | |
| 27 | 28 | ||
| @@ -46,8 +47,6 @@ func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) { | |||
| 46 | 47 | if tracker.Pending() != 0 { | |
| 47 | 48 | t.Fatalf("pending = %d, want 0", tracker.Pending()) | |
| 48 | 49 | } | |
| 49 | - | ||
| 50 | - | close(agent.done) | |
| 51 | 50 | } | |
| 52 | 51 | ||
| 53 | 52 | func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) { | |
| @@ -363,16 +362,19 @@ func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) { | |||
| 363 | 362 | t.Fatalf("update instructs the parent to poll: %q", update) | |
| 364 | 363 | } | |
| 365 | 364 | ||
| 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 | - | } | |
| 365 | + | taskLine := "" | |
| 370 | 366 | errorLine := "" | |
| 371 | 367 | for _, line := range strings.Split(update, "\n") { | |
| 368 | + | if strings.HasPrefix(line, " task: ") { | |
| 369 | + | taskLine = strings.TrimPrefix(line, " task: ") | |
| 370 | + | } | |
| 372 | 371 | if strings.HasPrefix(line, " error: ") { | |
| 373 | 372 | errorLine = strings.TrimPrefix(line, " error: ") | |
| 374 | 373 | } | |
| 375 | 374 | } | |
| 375 | + | if got := len([]rune(taskLine)); got != 240 { | |
| 376 | + | t.Fatalf("formatted task length = %d, want 240", got) | |
| 377 | + | } | |
| 376 | 378 | if got := len([]rune(errorLine)); got != 240 { | |
| 377 | 379 | t.Fatalf("formatted error length = %d, want 240", got) | |
| 378 | 380 | } | |
| @@ -22,6 +22,7 @@ func newCompletionTestAgent(id, task string, status Status) *Agent { | |||||
| 22 | 22 | ||||
| 23 | func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) { | 23 | func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) { | ||
| 24 | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | 24 | agent := newCompletionTestAgent("worker-1", "original task", StatusRunning) | ||
| 25 | + | t.Cleanup(func() { close(agent.done) }) | |||
| 25 | tracker := NewCompletionTracker() | 26 | tracker := NewCompletionTracker() | ||
| 26 | tracker.TrackTurn(agent, "ignored task", false) | 27 | tracker.TrackTurn(agent, "ignored task", false) | ||
| 27 | 28 | ||||
| @@ -46,8 +47,6 @@ func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) { | |||||
| 46 | if tracker.Pending() != 0 { | 47 | if tracker.Pending() != 0 { | ||
| 47 | t.Fatalf("pending = %d, want 0", tracker.Pending()) | 48 | t.Fatalf("pending = %d, want 0", tracker.Pending()) | ||
| 48 | } | 49 | } | ||
| 49 | - | ||||
| 50 | - | close(agent.done) | |||
| 51 | } | 50 | } | ||
| 52 | 51 | ||||
| 53 | func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) { | 52 | func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) { | ||
| @@ -363,16 +362,19 @@ func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) { | |||||
| 363 | t.Fatalf("update instructs the parent to poll: %q", update) | 362 | t.Fatalf("update instructs the parent to poll: %q", update) | ||
| 364 | } | 363 | } | ||
| 365 | 364 | ||||
| 366 | - | taskLine := strings.Split(update, "\n")[3] | 365 | + | taskLine := "" |
| 367 | - | if got := len([]rune(strings.TrimPrefix(taskLine, " task: "))); got != 240 { | |||
| 368 | - | t.Fatalf("formatted task length = %d, want 240", got) | |||
| 369 | - | } | |||
| 370 | errorLine := "" | 366 | errorLine := "" | ||
| 371 | for _, line := range strings.Split(update, "\n") { | 367 | for _, line := range strings.Split(update, "\n") { | ||
| 368 | + | if strings.HasPrefix(line, " task: ") { | |||
| 369 | + | taskLine = strings.TrimPrefix(line, " task: ") | |||
| 370 | + | } | |||
| 372 | if strings.HasPrefix(line, " error: ") { | 371 | if strings.HasPrefix(line, " error: ") { | ||
| 373 | errorLine = strings.TrimPrefix(line, " error: ") | 372 | errorLine = strings.TrimPrefix(line, " error: ") | ||
| 374 | } | 373 | } | ||
| 375 | } | 374 | } | ||
| 375 | + | if got := len([]rune(taskLine)); got != 240 { | |||
| 376 | + | t.Fatalf("formatted task length = %d, want 240", got) | |||
| 377 | + | } | |||
| 376 | if got := len([]rune(errorLine)); got != 240 { | 378 | if got := len([]rune(errorLine)); got != 240 { | ||
| 377 | t.Fatalf("formatted error length = %d, want 240", got) | 379 | t.Fatalf("formatted error length = %d, want 240", got) | ||
| 378 | } | 380 | } | ||
M packages/agent/subagents/persist.go +7 -3
| @@ -811,8 +811,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (* | |||
| 811 | 811 | // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The | |
| 812 | 812 | // hook runs after the target Agent is known but before a queued or direct | |
| 813 | 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. | |
| 814 | + | // Agent is built and before its runner is scheduled. For queued and direct | |
| 815 | + | // delivery, the hook runs while operationMu is held and must not block or call | |
| 816 | + | // back into Supervisor methods. A non-nil cleanup is called when the operation | |
| 817 | + | // is rejected before delivery. | |
| 816 | 818 | func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) { | |
| 817 | 819 | if ctx == nil { | |
| 818 | 820 | ctx = context.Background() | |
| @@ -1287,7 +1289,9 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1287 | 1289 | // has not reached its first event yet. Register the turn before the | |
| 1288 | 1290 | // scheduler can start the worker and emit a fast turn_end. | |
| 1289 | 1291 | if before != nil && resumePrompt != "" { | |
| 1290 | - | before(a, resumePrompt) | |
| 1292 | + | // No rejecting operation remains before scheduling, so there is no path | |
| 1293 | + | // that needs the hook's rollback after this point. | |
| 1294 | + | _ = before(a, resumePrompt) | |
| 1291 | 1295 | } | |
| 1292 | 1296 | f.armQueueTimeout(a) | |
| 1293 | 1297 | f.schedule() | |
| @@ -811,8 +811,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (* | |||||
| 811 | // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The | 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 | 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 | 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 | 814 | + | // Agent is built and before its runner is scheduled. For queued and direct |
| 815 | - | // called when the operation is rejected before delivery. | 815 | + | // delivery, the hook runs while operationMu is held and must not block or call |
| 816 | + | // back into Supervisor methods. A non-nil cleanup is called when the operation | |||
| 817 | + | // is rejected before delivery. | |||
| 816 | func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) { | 818 | func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) { | ||
| 817 | if ctx == nil { | 819 | if ctx == nil { | ||
| 818 | ctx = context.Background() | 820 | ctx = context.Background() | ||
| @@ -1287,7 +1289,9 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1287 | // has not reached its first event yet. Register the turn before the | 1289 | // has not reached its first event yet. Register the turn before the | ||
| 1288 | // scheduler can start the worker and emit a fast turn_end. | 1290 | // scheduler can start the worker and emit a fast turn_end. | ||
| 1289 | if before != nil && resumePrompt != "" { | 1291 | if before != nil && resumePrompt != "" { | ||
| 1290 | - | before(a, resumePrompt) | 1292 | + | // No rejecting operation remains before scheduling, so there is no path |
| 1293 | + | // that needs the hook's rollback after this point. | |||
| 1294 | + | _ = before(a, resumePrompt) | |||
| 1291 | } | 1295 | } | ||
| 1292 | f.armQueueTimeout(a) | 1296 | f.armQueueTimeout(a) | ||
| 1293 | f.schedule() | 1297 | f.schedule() | ||