feat(subagents): support bounded spawn waits
- commit
- 073fe61296d8acc295c0685b55a8751dfcdfade8
- author
- brice · · 2026-08-18 14:02 UTC
- parent
- cabc432
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +213 -68
M docs/subagents.md +8 -5
| @@ -99,15 +99,18 @@ explicit successful follow-up. A host restart never retries it automatically. | |||
| 99 | 99 | The model-facing tools retain their logical names: | |
| 100 | 100 | ||
| 101 | 101 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | |
| 102 | - | `reasoning`, `fast_mode`, `required`, and `isolation` (`shared` or | |
| 103 | - | `worktree`). It returns a logical `subagent://<id>` reference. | |
| 102 | + | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | |
| 103 | + | `worktree`). `wait` is an explicit whole-second value from 1 through 300; | |
| 104 | + | when omitted, spawning returns immediately. A timed-out wait leaves the | |
| 105 | + | child running. It returns a logical `subagent://<id>` reference. | |
| 104 | 106 | - `subagent_status` returns bounded state for one child or the current set. | |
| 105 | 107 | - `subagent_stop` stops one live child. | |
| 106 | 108 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | |
| 107 | 109 | ||
| 108 | - | These calls are asynchronous. Completion arrives through the host’s typed | |
| 109 | - | completion update, not process polling. Do not use sleep loops, repeated | |
| 110 | - | status calls, journal files, or terminal UI inspection as a completion signal. | |
| 110 | + | These calls are asynchronous unless `subagent_spawn` receives an explicit | |
| 111 | + | `wait` value. Otherwise completion arrives through the host’s typed completion | |
| 112 | + | update, not process polling. Do not use sleep loops, repeated status calls, | |
| 113 | + | journal files, or terminal UI inspection as a completion signal. | |
| 111 | 114 | Successful completions include the final visible assistant summary, capped at | |
| 112 | 115 | 256 KiB; open the child session for the complete durable transcript. | |
| 113 | 116 | ||
| @@ -99,15 +99,18 @@ explicit successful follow-up. A host restart never retries it automatically. | |||||
| 99 | The model-facing tools retain their logical names: | 99 | The model-facing tools retain their logical names: | ||
| 100 | 100 | ||||
| 101 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | 101 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | ||
| 102 | - | `reasoning`, `fast_mode`, `required`, and `isolation` (`shared` or | 102 | + | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or |
| 103 | - | `worktree`). It returns a logical `subagent://<id>` reference. | 103 | + | `worktree`). `wait` is an explicit whole-second value from 1 through 300; |
| 104 | + | when omitted, spawning returns immediately. A timed-out wait leaves the | |||
| 105 | + | child running. It returns a logical `subagent://<id>` reference. | |||
| 104 | - `subagent_status` returns bounded state for one child or the current set. | 106 | - `subagent_status` returns bounded state for one child or the current set. | ||
| 105 | - `subagent_stop` stops one live child. | 107 | - `subagent_stop` stops one live child. | ||
| 106 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | 108 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | ||
| 107 | 109 | ||||
| 108 | - | These calls are asynchronous. Completion arrives through the host’s typed | 110 | + | These calls are asynchronous unless `subagent_spawn` receives an explicit |
| 109 | - | completion update, not process polling. Do not use sleep loops, repeated | 111 | + | `wait` value. Otherwise completion arrives through the host’s typed completion |
| 110 | - | status calls, journal files, or terminal UI inspection as a completion signal. | 112 | + | update, not process polling. Do not use sleep loops, repeated status calls, |
| 113 | + | journal files, or terminal UI inspection as a completion signal. | |||
| 111 | Successful completions include the final visible assistant summary, capped at | 114 | Successful completions include the final visible assistant summary, capped at | ||
| 112 | 256 KiB; open the child session for the complete durable transcript. | 115 | 256 KiB; open the child session for the complete durable transcript. | ||
| 113 | 116 | ||||
M packages/agent/settings_store.go +3 -3
| @@ -258,15 +258,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro | |||
| 258 | 258 | ||
| 259 | 259 | Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1. | |
| 260 | 260 | ||
| 261 | - | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. | |
| 261 | + | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. | |
| 262 | 262 | ||
| 263 | - | Completion is host-event-driven for every resident child. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests. | |
| 263 | + | Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. | |
| 264 | 264 | ||
| 265 | 265 | When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.` | |
| 266 | 266 | ||
| 267 | 267 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | |
| 268 | 268 | // available without enabling proactive primary-agent orchestration. | |
| 269 | - | const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` | |
| 269 | + | const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; a timed-out wait leaves the child running. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` | |
| 270 | 270 | ||
| 271 | 271 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | |
| 272 | 272 | // orchestrator contract still applies when launch-time policy withholds the | |
| @@ -258,15 +258,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro | |||||
| 258 | 258 | ||||
| 259 | Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1. | 259 | Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1. | ||
| 260 | 260 | ||||
| 261 | - | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. | 261 | + | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. |
| 262 | 262 | ||||
| 263 | - | Completion is host-event-driven for every resident child. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests. | 263 | + | Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. |
| 264 | 264 | ||||
| 265 | When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.` | 265 | When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.` | ||
| 266 | 266 | ||||
| 267 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | 267 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | ||
| 268 | // available without enabling proactive primary-agent orchestration. | 268 | // available without enabling proactive primary-agent orchestration. | ||
| 269 | - | const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` | 269 | + | const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; a timed-out wait leaves the child running. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` |
| 270 | 270 | ||||
| 271 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | 271 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | ||
| 272 | // orchestrator contract still applies when launch-time policy withholds the | 272 | // orchestrator contract still applies when launch-time policy withholds the | ||
M packages/agent/subagents/resident_manager.go +96 -55
| @@ -21,26 +21,27 @@ import ( | |||
| 21 | 21 | type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) | |
| 22 | 22 | ||
| 23 | 23 | type ResidentManager struct { | |
| 24 | - | root string | |
| 25 | - | factory ResidentFactory | |
| 26 | - | prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) | |
| 27 | - | mu sync.Mutex | |
| 28 | - | lifecycleMu sync.Mutex | |
| 29 | - | closed bool | |
| 30 | - | children map[string]*ResidentChild | |
| 31 | - | recovered map[string]ResidentSnapshot | |
| 32 | - | recoveredSpec map[string]ResidentChildSpec | |
| 33 | - | pending map[string]struct{} | |
| 34 | - | activeChildren map[string]struct{} | |
| 35 | - | scheduler *ResidentScheduler | |
| 36 | - | queueTimeout time.Duration | |
| 37 | - | allowedRoots []string | |
| 38 | - | dispatchMu sync.Mutex | |
| 39 | - | onCompletion func(ResidentCompletion) | |
| 40 | - | onAccepted func(ResidentChildSpec, string, string) | |
| 41 | - | onUpdate func(string) | |
| 42 | - | onHistoryUpdate func(string) | |
| 43 | - | onActivity func(bool) | |
| 24 | + | root string | |
| 25 | + | factory ResidentFactory | |
| 26 | + | prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) | |
| 27 | + | mu sync.Mutex | |
| 28 | + | lifecycleMu sync.Mutex | |
| 29 | + | closed bool | |
| 30 | + | children map[string]*ResidentChild | |
| 31 | + | recovered map[string]ResidentSnapshot | |
| 32 | + | recoveredSpec map[string]ResidentChildSpec | |
| 33 | + | pending map[string]struct{} | |
| 34 | + | activeChildren map[string]struct{} | |
| 35 | + | scheduler *ResidentScheduler | |
| 36 | + | queueTimeout time.Duration | |
| 37 | + | allowedRoots []string | |
| 38 | + | dispatchMu sync.Mutex | |
| 39 | + | onCompletion func(ResidentCompletion) | |
| 40 | + | completionWaiters map[string][]chan ResidentCompletion | |
| 41 | + | onAccepted func(ResidentChildSpec, string, string) | |
| 42 | + | onUpdate func(string) | |
| 43 | + | onHistoryUpdate func(string) | |
| 44 | + | onActivity func(bool) | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion)) { | |
| @@ -52,6 +53,64 @@ func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion | |||
| 52 | 53 | m.mu.Unlock() | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 56 | + | // WatchCompletion registers for one accepted resident turn. The caller must | |
| 57 | + | // register before making the turn visible, then call the returned cancel | |
| 58 | + | // function if it stops waiting before completion. | |
| 59 | + | func (m *ResidentManager) WatchCompletion(childID, turnID string) (<-chan ResidentCompletion, func()) { | |
| 60 | + | result := make(chan ResidentCompletion, 1) | |
| 61 | + | key := completionKey(childID, turnID) | |
| 62 | + | if m == nil || key == "" { | |
| 63 | + | close(result) | |
| 64 | + | return result, func() {} | |
| 65 | + | } | |
| 66 | + | m.mu.Lock() | |
| 67 | + | if m.closed { | |
| 68 | + | m.mu.Unlock() | |
| 69 | + | close(result) | |
| 70 | + | return result, func() {} | |
| 71 | + | } | |
| 72 | + | if m.completionWaiters == nil { | |
| 73 | + | m.completionWaiters = make(map[string][]chan ResidentCompletion) | |
| 74 | + | } | |
| 75 | + | m.completionWaiters[key] = append(m.completionWaiters[key], result) | |
| 76 | + | m.mu.Unlock() | |
| 77 | + | return result, func() { | |
| 78 | + | m.mu.Lock() | |
| 79 | + | waiters := m.completionWaiters[key] | |
| 80 | + | for i, waiter := range waiters { | |
| 81 | + | if waiter == result { | |
| 82 | + | waiters = append(waiters[:i], waiters[i+1:]...) | |
| 83 | + | break | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | if len(waiters) == 0 { | |
| 87 | + | delete(m.completionWaiters, key) | |
| 88 | + | } else { | |
| 89 | + | m.completionWaiters[key] = waiters | |
| 90 | + | } | |
| 91 | + | m.mu.Unlock() | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (m *ResidentManager) reportCompletion(completion ResidentCompletion) { | |
| 96 | + | if m == nil { | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | key := completionKey(completion.ChildID, completion.TurnID) | |
| 100 | + | m.mu.Lock() | |
| 101 | + | waiters := m.completionWaiters[key] | |
| 102 | + | delete(m.completionWaiters, key) | |
| 103 | + | observer := m.onCompletion | |
| 104 | + | m.mu.Unlock() | |
| 105 | + | for _, waiter := range waiters { | |
| 106 | + | waiter <- completion | |
| 107 | + | close(waiter) | |
| 108 | + | } | |
| 109 | + | if observer != nil { | |
| 110 | + | observer(completion) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 55 | 114 | // SetAcceptedObserver receives an accepted turn's ID and prompt only after | |
| 56 | 115 | // the authoritative child.accepted record has synchronized and before it can | |
| 57 | 116 | // be scheduled. | |
| @@ -202,17 +261,18 @@ func newResidentManager(root string, policy SubagentPolicy, prepare func(context | |||
| 202 | 261 | prepare = PrepareWorkspace | |
| 203 | 262 | } | |
| 204 | 263 | return &ResidentManager{ | |
| 205 | - | root: root, | |
| 206 | - | factory: factory, | |
| 207 | - | prepareWorkspace: prepare, | |
| 208 | - | children: make(map[string]*ResidentChild), | |
| 209 | - | recovered: make(map[string]ResidentSnapshot), | |
| 210 | - | recoveredSpec: make(map[string]ResidentChildSpec), | |
| 211 | - | pending: make(map[string]struct{}), | |
| 212 | - | activeChildren: make(map[string]struct{}), | |
| 213 | - | scheduler: NewResidentScheduler(policy.MaxConcurrent), | |
| 214 | - | queueTimeout: policy.QueueTimeout, | |
| 215 | - | allowedRoots: append([]string(nil), policy.AllowedRoots...), | |
| 264 | + | root: root, | |
| 265 | + | factory: factory, | |
| 266 | + | prepareWorkspace: prepare, | |
| 267 | + | children: make(map[string]*ResidentChild), | |
| 268 | + | recovered: make(map[string]ResidentSnapshot), | |
| 269 | + | recoveredSpec: make(map[string]ResidentChildSpec), | |
| 270 | + | pending: make(map[string]struct{}), | |
| 271 | + | activeChildren: make(map[string]struct{}), | |
| 272 | + | completionWaiters: make(map[string][]chan ResidentCompletion), | |
| 273 | + | scheduler: NewResidentScheduler(policy.MaxConcurrent), | |
| 274 | + | queueTimeout: policy.QueueTimeout, | |
| 275 | + | allowedRoots: append([]string(nil), policy.AllowedRoots...), | |
| 216 | 276 | } | |
| 217 | 277 | } | |
| 218 | 278 | ||
| @@ -288,18 +348,10 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas | |||
| 288 | 348 | if err != nil { | |
| 289 | 349 | _ = journal.RecordFailure(spec) | |
| 290 | 350 | _ = journal.Close() | |
| 291 | - | m.mu.Lock() | |
| 292 | - | completion := m.onCompletion | |
| 293 | - | m.mu.Unlock() | |
| 294 | - | if completion != nil { | |
| 295 | - | completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) | |
| 296 | - | } | |
| 351 | + | m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) | |
| 297 | 352 | return nil, err | |
| 298 | 353 | } | |
| 299 | - | m.mu.Lock() | |
| 300 | - | observer := m.onCompletion | |
| 301 | - | m.mu.Unlock() | |
| 302 | - | child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer) | |
| 354 | + | child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion) | |
| 303 | 355 | child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) }) | |
| 304 | 356 | child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) }) | |
| 305 | 357 | m.mu.Lock() | |
| @@ -315,14 +367,11 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas | |||
| 315 | 367 | if m.children[spec.ID] == child { | |
| 316 | 368 | delete(m.children, spec.ID) | |
| 317 | 369 | } | |
| 318 | - | completion := m.onCompletion | |
| 319 | 370 | m.mu.Unlock() | |
| 320 | 371 | _ = child.Close(context.Background()) | |
| 321 | 372 | if !childAccepted { | |
| 322 | 373 | _ = journal.RecordTurnInterrupted(spec, spec.InitialTurnID) | |
| 323 | - | if completion != nil { | |
| 324 | - | completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) | |
| 325 | - | } | |
| 374 | + | m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) | |
| 326 | 375 | } | |
| 327 | 376 | return nil, err | |
| 328 | 377 | } | |
| @@ -469,10 +518,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 469 | 518 | _ = journal.Close() | |
| 470 | 519 | return fmt.Errorf("resident manager rebuild child: %w", err) | |
| 471 | 520 | } | |
| 472 | - | m.mu.Lock() | |
| 473 | - | observer := m.onCompletion | |
| 474 | - | m.mu.Unlock() | |
| 475 | - | child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer) | |
| 521 | + | child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion) | |
| 476 | 522 | child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) }) | |
| 477 | 523 | child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) }) | |
| 478 | 524 | m.mu.Lock() | |
| @@ -506,12 +552,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 506 | 552 | if childAccepted, err := child.resumeAccepted(ctx, turnID, prompt); err != nil { | |
| 507 | 553 | if !childAccepted { | |
| 508 | 554 | _ = child.journal.RecordTurnInterrupted(child.spec, turnID) | |
| 509 | - | m.mu.Lock() | |
| 510 | - | completion := m.onCompletion | |
| 511 | - | m.mu.Unlock() | |
| 512 | - | if completion != nil { | |
| 513 | - | completion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err}) | |
| 514 | - | } | |
| 555 | + | m.reportCompletion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err}) | |
| 515 | 556 | } | |
| 516 | 557 | return err | |
| 517 | 558 | } | |
| @@ -21,26 +21,27 @@ import ( | |||||
| 21 | type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) | 21 | type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) | ||
| 22 | 22 | ||||
| 23 | type ResidentManager struct { | 23 | type ResidentManager struct { | ||
| 24 | - | root string | 24 | + | root string |
| 25 | - | factory ResidentFactory | 25 | + | factory ResidentFactory |
| 26 | - | prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) | 26 | + | prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) |
| 27 | - | mu sync.Mutex | 27 | + | mu sync.Mutex |
| 28 | - | lifecycleMu sync.Mutex | 28 | + | lifecycleMu sync.Mutex |
| 29 | - | closed bool | 29 | + | closed bool |
| 30 | - | children map[string]*ResidentChild | 30 | + | children map[string]*ResidentChild |
| 31 | - | recovered map[string]ResidentSnapshot | 31 | + | recovered map[string]ResidentSnapshot |
| 32 | - | recoveredSpec map[string]ResidentChildSpec | 32 | + | recoveredSpec map[string]ResidentChildSpec |
| 33 | - | pending map[string]struct{} | 33 | + | pending map[string]struct{} |
| 34 | - | activeChildren map[string]struct{} | 34 | + | activeChildren map[string]struct{} |
| 35 | - | scheduler *ResidentScheduler | 35 | + | scheduler *ResidentScheduler |
| 36 | - | queueTimeout time.Duration | 36 | + | queueTimeout time.Duration |
| 37 | - | allowedRoots []string | 37 | + | allowedRoots []string |
| 38 | - | dispatchMu sync.Mutex | 38 | + | dispatchMu sync.Mutex |
| 39 | - | onCompletion func(ResidentCompletion) | 39 | + | onCompletion func(ResidentCompletion) |
| 40 | - | onAccepted func(ResidentChildSpec, string, string) | 40 | + | completionWaiters map[string][]chan ResidentCompletion |
| 41 | - | onUpdate func(string) | 41 | + | onAccepted func(ResidentChildSpec, string, string) |
| 42 | - | onHistoryUpdate func(string) | 42 | + | onUpdate func(string) |
| 43 | - | onActivity func(bool) | 43 | + | onHistoryUpdate func(string) |
| 44 | + | onActivity func(bool) | |||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 46 | func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion)) { | 47 | func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion)) { | ||
| @@ -52,6 +53,64 @@ func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion | |||||
| 52 | m.mu.Unlock() | 53 | m.mu.Unlock() | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 56 | + | // WatchCompletion registers for one accepted resident turn. The caller must | |||
| 57 | + | // register before making the turn visible, then call the returned cancel | |||
| 58 | + | // function if it stops waiting before completion. | |||
| 59 | + | func (m *ResidentManager) WatchCompletion(childID, turnID string) (<-chan ResidentCompletion, func()) { | |||
| 60 | + | result := make(chan ResidentCompletion, 1) | |||
| 61 | + | key := completionKey(childID, turnID) | |||
| 62 | + | if m == nil || key == "" { | |||
| 63 | + | close(result) | |||
| 64 | + | return result, func() {} | |||
| 65 | + | } | |||
| 66 | + | m.mu.Lock() | |||
| 67 | + | if m.closed { | |||
| 68 | + | m.mu.Unlock() | |||
| 69 | + | close(result) | |||
| 70 | + | return result, func() {} | |||
| 71 | + | } | |||
| 72 | + | if m.completionWaiters == nil { | |||
| 73 | + | m.completionWaiters = make(map[string][]chan ResidentCompletion) | |||
| 74 | + | } | |||
| 75 | + | m.completionWaiters[key] = append(m.completionWaiters[key], result) | |||
| 76 | + | m.mu.Unlock() | |||
| 77 | + | return result, func() { | |||
| 78 | + | m.mu.Lock() | |||
| 79 | + | waiters := m.completionWaiters[key] | |||
| 80 | + | for i, waiter := range waiters { | |||
| 81 | + | if waiter == result { | |||
| 82 | + | waiters = append(waiters[:i], waiters[i+1:]...) | |||
| 83 | + | break | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | if len(waiters) == 0 { | |||
| 87 | + | delete(m.completionWaiters, key) | |||
| 88 | + | } else { | |||
| 89 | + | m.completionWaiters[key] = waiters | |||
| 90 | + | } | |||
| 91 | + | m.mu.Unlock() | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (m *ResidentManager) reportCompletion(completion ResidentCompletion) { | |||
| 96 | + | if m == nil { | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | key := completionKey(completion.ChildID, completion.TurnID) | |||
| 100 | + | m.mu.Lock() | |||
| 101 | + | waiters := m.completionWaiters[key] | |||
| 102 | + | delete(m.completionWaiters, key) | |||
| 103 | + | observer := m.onCompletion | |||
| 104 | + | m.mu.Unlock() | |||
| 105 | + | for _, waiter := range waiters { | |||
| 106 | + | waiter <- completion | |||
| 107 | + | close(waiter) | |||
| 108 | + | } | |||
| 109 | + | if observer != nil { | |||
| 110 | + | observer(completion) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 55 | // SetAcceptedObserver receives an accepted turn's ID and prompt only after | 114 | // SetAcceptedObserver receives an accepted turn's ID and prompt only after | ||
| 56 | // the authoritative child.accepted record has synchronized and before it can | 115 | // the authoritative child.accepted record has synchronized and before it can | ||
| 57 | // be scheduled. | 116 | // be scheduled. | ||
| @@ -202,17 +261,18 @@ func newResidentManager(root string, policy SubagentPolicy, prepare func(context | |||||
| 202 | prepare = PrepareWorkspace | 261 | prepare = PrepareWorkspace | ||
| 203 | } | 262 | } | ||
| 204 | return &ResidentManager{ | 263 | return &ResidentManager{ | ||
| 205 | - | root: root, | 264 | + | root: root, |
| 206 | - | factory: factory, | 265 | + | factory: factory, |
| 207 | - | prepareWorkspace: prepare, | 266 | + | prepareWorkspace: prepare, |
| 208 | - | children: make(map[string]*ResidentChild), | 267 | + | children: make(map[string]*ResidentChild), |
| 209 | - | recovered: make(map[string]ResidentSnapshot), | 268 | + | recovered: make(map[string]ResidentSnapshot), |
| 210 | - | recoveredSpec: make(map[string]ResidentChildSpec), | 269 | + | recoveredSpec: make(map[string]ResidentChildSpec), |
| 211 | - | pending: make(map[string]struct{}), | 270 | + | pending: make(map[string]struct{}), |
| 212 | - | activeChildren: make(map[string]struct{}), | 271 | + | activeChildren: make(map[string]struct{}), |
| 213 | - | scheduler: NewResidentScheduler(policy.MaxConcurrent), | 272 | + | completionWaiters: make(map[string][]chan ResidentCompletion), |
| 214 | - | queueTimeout: policy.QueueTimeout, | 273 | + | scheduler: NewResidentScheduler(policy.MaxConcurrent), |
| 215 | - | allowedRoots: append([]string(nil), policy.AllowedRoots...), | 274 | + | queueTimeout: policy.QueueTimeout, |
| 275 | + | allowedRoots: append([]string(nil), policy.AllowedRoots...), | |||
| 216 | } | 276 | } | ||
| 217 | } | 277 | } | ||
| 218 | 278 | ||||
| @@ -288,18 +348,10 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas | |||||
| 288 | if err != nil { | 348 | if err != nil { | ||
| 289 | _ = journal.RecordFailure(spec) | 349 | _ = journal.RecordFailure(spec) | ||
| 290 | _ = journal.Close() | 350 | _ = journal.Close() | ||
| 291 | - | m.mu.Lock() | 351 | + | m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) |
| 292 | - | completion := m.onCompletion | |||
| 293 | - | m.mu.Unlock() | |||
| 294 | - | if completion != nil { | |||
| 295 | - | completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) | |||
| 296 | - | } | |||
| 297 | return nil, err | 352 | return nil, err | ||
| 298 | } | 353 | } | ||
| 299 | - | m.mu.Lock() | 354 | + | child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion) |
| 300 | - | observer := m.onCompletion | |||
| 301 | - | m.mu.Unlock() | |||
| 302 | - | child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer) | |||
| 303 | child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) }) | 355 | child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) }) | ||
| 304 | child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) }) | 356 | child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) }) | ||
| 305 | m.mu.Lock() | 357 | m.mu.Lock() | ||
| @@ -315,14 +367,11 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas | |||||
| 315 | if m.children[spec.ID] == child { | 367 | if m.children[spec.ID] == child { | ||
| 316 | delete(m.children, spec.ID) | 368 | delete(m.children, spec.ID) | ||
| 317 | } | 369 | } | ||
| 318 | - | completion := m.onCompletion | |||
| 319 | m.mu.Unlock() | 370 | m.mu.Unlock() | ||
| 320 | _ = child.Close(context.Background()) | 371 | _ = child.Close(context.Background()) | ||
| 321 | if !childAccepted { | 372 | if !childAccepted { | ||
| 322 | _ = journal.RecordTurnInterrupted(spec, spec.InitialTurnID) | 373 | _ = journal.RecordTurnInterrupted(spec, spec.InitialTurnID) | ||
| 323 | - | if completion != nil { | 374 | + | m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) |
| 324 | - | completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err}) | |||
| 325 | - | } | |||
| 326 | } | 375 | } | ||
| 327 | return nil, err | 376 | return nil, err | ||
| 328 | } | 377 | } | ||
| @@ -469,10 +518,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||||
| 469 | _ = journal.Close() | 518 | _ = journal.Close() | ||
| 470 | return fmt.Errorf("resident manager rebuild child: %w", err) | 519 | return fmt.Errorf("resident manager rebuild child: %w", err) | ||
| 471 | } | 520 | } | ||
| 472 | - | m.mu.Lock() | 521 | + | child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion) |
| 473 | - | observer := m.onCompletion | |||
| 474 | - | m.mu.Unlock() | |||
| 475 | - | child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer) | |||
| 476 | child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) }) | 522 | child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) }) | ||
| 477 | child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) }) | 523 | child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) }) | ||
| 478 | m.mu.Lock() | 524 | m.mu.Lock() | ||
| @@ -506,12 +552,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||||
| 506 | if childAccepted, err := child.resumeAccepted(ctx, turnID, prompt); err != nil { | 552 | if childAccepted, err := child.resumeAccepted(ctx, turnID, prompt); err != nil { | ||
| 507 | if !childAccepted { | 553 | if !childAccepted { | ||
| 508 | _ = child.journal.RecordTurnInterrupted(child.spec, turnID) | 554 | _ = child.journal.RecordTurnInterrupted(child.spec, turnID) | ||
| 509 | - | m.mu.Lock() | 555 | + | m.reportCompletion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err}) |
| 510 | - | completion := m.onCompletion | |||
| 511 | - | m.mu.Unlock() | |||
| 512 | - | if completion != nil { | |||
| 513 | - | completion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err}) | |||
| 514 | - | } | |||
| 515 | } | 556 | } | ||
| 516 | return err | 557 | return err | ||
| 517 | } | 558 | } | ||
M packages/agent/tools/resident_tools_test.go +38 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | 10 | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | func TestResidentToolsUseManagerOnly(t *testing.T) { | |
| @@ -52,6 +53,29 @@ func TestResidentToolsUseManagerOnly(t *testing.T) { | |||
| 52 | 53 | } | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 56 | + | func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) { | |
| 57 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 58 | + | return func(context.Context, string) error { return nil }, nil | |
| 59 | + | }) | |
| 60 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 61 | + | spawn := &SubagentSpawnTool{ | |
| 62 | + | ResidentManager: manager, | |
| 63 | + | Enabled: func() bool { return true }, | |
| 64 | + | DefaultProvider: func() string { return "openai" }, | |
| 65 | + | DefaultModel: func() string { return "gpt-5" }, | |
| 66 | + | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | |
| 67 | + | return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil | |
| 68 | + | }, | |
| 69 | + | } | |
| 70 | + | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil) | |
| 71 | + | if err != nil || result.IsError { | |
| 72 | + | t.Fatalf("Execute = (%#v, %v)", result, err) | |
| 73 | + | } | |
| 74 | + | if got := toolResultText(t, result); !strings.Contains(got, "state: completed") { | |
| 75 | + | t.Fatalf("wait result = %q, want completed state", got) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 55 | 79 | func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |
| 56 | 80 | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 57 | 81 | return func(context.Context, string) error { return nil }, nil | |
| @@ -70,6 +94,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||
| 70 | 94 | `{"task":"x","unexpected":true}`, | |
| 71 | 95 | `{"task":"x"}{}`, | |
| 72 | 96 | `{"task":"x","model":"gpt-5"}`, | |
| 97 | + | `{"task":"x","wait":0}`, | |
| 98 | + | `{"task":"x","wait":301}`, | |
| 73 | 99 | `{"task":"x","isolation":"outside"}`, | |
| 74 | 100 | } { | |
| 75 | 101 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | |
| @@ -130,6 +156,18 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) { | |||
| 130 | 156 | } | |
| 131 | 157 | } | |
| 132 | 158 | ||
| 159 | + | func toolResultText(t *testing.T, result core.ToolResult) string { | |
| 160 | + | t.Helper() | |
| 161 | + | if len(result.Content) != 1 { | |
| 162 | + | t.Fatalf("content = %#v, want one text block", result.Content) | |
| 163 | + | } | |
| 164 | + | block, ok := result.Content[0].(provider.TextBlock) | |
| 165 | + | if !ok { | |
| 166 | + | t.Fatalf("content = %#v, want text block", result.Content) | |
| 167 | + | } | |
| 168 | + | return block.Text | |
| 169 | + | } | |
| 170 | + | ||
| 133 | 171 | func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) { | |
| 134 | 172 | _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab") | |
| 135 | 173 | if ok { | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | "github.com/bnema/zut/packages/agent/subagents" | 9 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 10 | "github.com/bnema/zut/packages/core" | 10 | "github.com/bnema/zut/packages/core" | ||
| 11 | + | "github.com/bnema/zut/packages/provider" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | func TestResidentToolsUseManagerOnly(t *testing.T) { | 14 | func TestResidentToolsUseManagerOnly(t *testing.T) { | ||
| @@ -52,6 +53,29 @@ func TestResidentToolsUseManagerOnly(t *testing.T) { | |||||
| 52 | } | 53 | } | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 56 | + | func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) { | |||
| 57 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 58 | + | return func(context.Context, string) error { return nil }, nil | |||
| 59 | + | }) | |||
| 60 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 61 | + | spawn := &SubagentSpawnTool{ | |||
| 62 | + | ResidentManager: manager, | |||
| 63 | + | Enabled: func() bool { return true }, | |||
| 64 | + | DefaultProvider: func() string { return "openai" }, | |||
| 65 | + | DefaultModel: func() string { return "gpt-5" }, | |||
| 66 | + | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | |||
| 67 | + | return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil | |||
| 68 | + | }, | |||
| 69 | + | } | |||
| 70 | + | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil) | |||
| 71 | + | if err != nil || result.IsError { | |||
| 72 | + | t.Fatalf("Execute = (%#v, %v)", result, err) | |||
| 73 | + | } | |||
| 74 | + | if got := toolResultText(t, result); !strings.Contains(got, "state: completed") { | |||
| 75 | + | t.Fatalf("wait result = %q, want completed state", got) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 55 | func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | 79 | func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | ||
| 56 | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 80 | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | ||
| 57 | return func(context.Context, string) error { return nil }, nil | 81 | return func(context.Context, string) error { return nil }, nil | ||
| @@ -70,6 +94,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||||
| 70 | `{"task":"x","unexpected":true}`, | 94 | `{"task":"x","unexpected":true}`, | ||
| 71 | `{"task":"x"}{}`, | 95 | `{"task":"x"}{}`, | ||
| 72 | `{"task":"x","model":"gpt-5"}`, | 96 | `{"task":"x","model":"gpt-5"}`, | ||
| 97 | + | `{"task":"x","wait":0}`, | |||
| 98 | + | `{"task":"x","wait":301}`, | |||
| 73 | `{"task":"x","isolation":"outside"}`, | 99 | `{"task":"x","isolation":"outside"}`, | ||
| 74 | } { | 100 | } { | ||
| 75 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | 101 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | ||
| @@ -130,6 +156,18 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) { | |||||
| 130 | } | 156 | } | ||
| 131 | } | 157 | } | ||
| 132 | 158 | ||||
| 159 | + | func toolResultText(t *testing.T, result core.ToolResult) string { | |||
| 160 | + | t.Helper() | |||
| 161 | + | if len(result.Content) != 1 { | |||
| 162 | + | t.Fatalf("content = %#v, want one text block", result.Content) | |||
| 163 | + | } | |||
| 164 | + | block, ok := result.Content[0].(provider.TextBlock) | |||
| 165 | + | if !ok { | |||
| 166 | + | t.Fatalf("content = %#v, want text block", result.Content) | |||
| 167 | + | } | |||
| 168 | + | return block.Text | |||
| 169 | + | } | |||
| 170 | + | ||||
| 133 | func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) { | 171 | func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) { | ||
| 134 | _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab") | 172 | _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab") | ||
| 135 | if ok { | 173 | if ok { | ||
M packages/agent/tools/subagent_spawn.go +68 -5
| @@ -7,10 +7,12 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | 9 | "strings" | |
| 10 | + | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/zut/packages/agent/subagents" | |
| 12 | 13 | "github.com/bnema/zut/packages/core" | |
| 13 | 14 | "github.com/bnema/zut/packages/provider" | |
| 15 | + | "github.com/google/uuid" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | // SubagentSpawnTool lets the main agent delegate work through the resident | |
| @@ -25,6 +27,8 @@ const ( | |||
| 25 | 27 | SubagentStatusToolName = "subagent_status" | |
| 26 | 28 | SubagentStopToolName = "subagent_stop" | |
| 27 | 29 | SubagentResumeToolName = "subagent_resume" | |
| 30 | + | ||
| 31 | + | maxSubagentWaitSeconds = 5 * 60 | |
| 28 | 32 | ) | |
| 29 | 33 | ||
| 30 | 34 | type SubagentSpawnTool struct { | |
| @@ -69,6 +73,7 @@ type subagentSpawnArgs struct { | |||
| 69 | 73 | Reasoning string `json:"reasoning,omitempty"` | |
| 70 | 74 | FastMode *bool `json:"fast_mode,omitempty"` | |
| 71 | 75 | Required bool `json:"required,omitempty"` | |
| 76 | + | Wait *int `json:"wait,omitempty"` | |
| 72 | 77 | Isolation string `json:"isolation,omitempty"` | |
| 73 | 78 | } | |
| 74 | 79 | ||
| @@ -104,6 +109,12 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 104 | 109 | "type": "boolean", | |
| 105 | 110 | "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." | |
| 106 | 111 | }, | |
| 112 | + | "wait": { | |
| 113 | + | "type": "integer", | |
| 114 | + | "minimum": 1, | |
| 115 | + | "maximum": 300, | |
| 116 | + | "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires." | |
| 117 | + | }, | |
| 107 | 118 | "isolation": { | |
| 108 | 119 | "type": "string", | |
| 109 | 120 | "enum": ["shared", "worktree"], | |
| @@ -115,7 +126,7 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 115 | 126 | ||
| 116 | 127 | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } | |
| 117 | 128 | func (t *SubagentSpawnTool) Description() string { | |
| 118 | - | return "Delegate work to a resident sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed." | |
| 129 | + | return "Delegate work to a resident sub-agent. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn." | |
| 119 | 130 | } | |
| 120 | 131 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | |
| 121 | 132 | return json.RawMessage(subagentSpawnSchemaTemplate) | |
| @@ -137,6 +148,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 137 | 148 | if task == "" { | |
| 138 | 149 | return protocolToolError(prefix + ": task is required") | |
| 139 | 150 | } | |
| 151 | + | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | |
| 152 | + | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | |
| 153 | + | } | |
| 140 | 154 | ||
| 141 | 155 | workspaceMode := subagents.WorkspaceShared | |
| 142 | 156 | if value := strings.TrimSpace(a.Isolation); value != "" { | |
| @@ -217,11 +231,44 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 217 | 231 | if err != nil { | |
| 218 | 232 | return protocolToolError(prefix + ": " + err.Error()) | |
| 219 | 233 | } | |
| 220 | - | if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |
| 234 | + | var completion subagents.ResidentCompletion | |
| 235 | + | waitTimedOut := false | |
| 236 | + | if a.Wait != nil { | |
| 237 | + | spec.InitialTurnID = uuid.NewString() | |
| 238 | + | completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID) | |
| 239 | + | defer cancelWait() | |
| 240 | + | if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |
| 241 | + | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |
| 242 | + | } | |
| 243 | + | waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second) | |
| 244 | + | defer cancel() | |
| 245 | + | select { | |
| 246 | + | case result, ok := <-completionResult: | |
| 247 | + | if !ok { | |
| 248 | + | return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix) | |
| 249 | + | } | |
| 250 | + | completion = result | |
| 251 | + | case <-waitCtx.Done(): | |
| 252 | + | if ctx.Err() != nil { | |
| 253 | + | return core.ToolResult{}, ctx.Err() | |
| 254 | + | } | |
| 255 | + | waitTimedOut = true | |
| 256 | + | } | |
| 257 | + | } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |
| 221 | 258 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |
| 222 | 259 | } | |
| 260 | + | state := "queued" | |
| 261 | + | if a.Wait != nil { | |
| 262 | + | if waitTimedOut { | |
| 263 | + | state = "running" | |
| 264 | + | } else if completion.Err != nil { | |
| 265 | + | state = "failed" | |
| 266 | + | } else { | |
| 267 | + | state = "completed" | |
| 268 | + | } | |
| 269 | + | } | |
| 223 | 270 | var sb strings.Builder | |
| 224 | - | fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: queued\nworkspace: %s\ntask: %s\n", spec.ID, spec.WorkspaceMode, truncateTask(task, 200)) | |
| 271 | + | fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200)) | |
| 225 | 272 | if spec.Profile != "" { | |
| 226 | 273 | fmt.Fprintf(&sb, "agent: %s\n", spec.Profile) | |
| 227 | 274 | } | |
| @@ -230,9 +277,25 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 230 | 277 | fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning) | |
| 231 | 278 | } | |
| 232 | 279 | if spec.Required { | |
| 233 | - | sb.WriteString("required: pending\n") | |
| 280 | + | fmt.Fprintf(&sb, "required: %s\n", state) | |
| 281 | + | } | |
| 282 | + | if a.Wait != nil { | |
| 283 | + | if waitTimedOut { | |
| 284 | + | fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait) | |
| 285 | + | sb.WriteString("\nThe sub-agent is still running in the background. Completion is host-event-driven through [auto-subagents update].") | |
| 286 | + | } else if completion.Err != nil { | |
| 287 | + | fmt.Fprintf(&sb, "error: %s\n", completion.Err) | |
| 288 | + | if completion.Summary != "" { | |
| 289 | + | fmt.Fprintf(&sb, "final: %s\n", completion.Summary) | |
| 290 | + | } | |
| 291 | + | } else { | |
| 292 | + | if completion.Summary != "" { | |
| 293 | + | fmt.Fprintf(&sb, "final: %s\n", completion.Summary) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | } else { | |
| 297 | + | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].") | |
| 234 | 298 | } | |
| 235 | - | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].") | |
| 236 | 299 | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil | |
| 237 | 300 | } | |
| 238 | 301 | ||
| @@ -7,10 +7,12 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | + | "time" | |||
| 10 | 11 | ||||
| 11 | "github.com/bnema/zut/packages/agent/subagents" | 12 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 12 | "github.com/bnema/zut/packages/core" | 13 | "github.com/bnema/zut/packages/core" | ||
| 13 | "github.com/bnema/zut/packages/provider" | 14 | "github.com/bnema/zut/packages/provider" | ||
| 15 | + | "github.com/google/uuid" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | // SubagentSpawnTool lets the main agent delegate work through the resident | 18 | // SubagentSpawnTool lets the main agent delegate work through the resident | ||
| @@ -25,6 +27,8 @@ const ( | |||||
| 25 | SubagentStatusToolName = "subagent_status" | 27 | SubagentStatusToolName = "subagent_status" | ||
| 26 | SubagentStopToolName = "subagent_stop" | 28 | SubagentStopToolName = "subagent_stop" | ||
| 27 | SubagentResumeToolName = "subagent_resume" | 29 | SubagentResumeToolName = "subagent_resume" | ||
| 30 | + | ||||
| 31 | + | maxSubagentWaitSeconds = 5 * 60 | |||
| 28 | ) | 32 | ) | ||
| 29 | 33 | ||||
| 30 | type SubagentSpawnTool struct { | 34 | type SubagentSpawnTool struct { | ||
| @@ -69,6 +73,7 @@ type subagentSpawnArgs struct { | |||||
| 69 | Reasoning string `json:"reasoning,omitempty"` | 73 | Reasoning string `json:"reasoning,omitempty"` | ||
| 70 | FastMode *bool `json:"fast_mode,omitempty"` | 74 | FastMode *bool `json:"fast_mode,omitempty"` | ||
| 71 | Required bool `json:"required,omitempty"` | 75 | Required bool `json:"required,omitempty"` | ||
| 76 | + | Wait *int `json:"wait,omitempty"` | |||
| 72 | Isolation string `json:"isolation,omitempty"` | 77 | Isolation string `json:"isolation,omitempty"` | ||
| 73 | } | 78 | } | ||
| 74 | 79 | ||||
| @@ -104,6 +109,12 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 104 | "type": "boolean", | 109 | "type": "boolean", | ||
| 105 | "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." | 110 | "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." | ||
| 106 | }, | 111 | }, | ||
| 112 | + | "wait": { | |||
| 113 | + | "type": "integer", | |||
| 114 | + | "minimum": 1, | |||
| 115 | + | "maximum": 300, | |||
| 116 | + | "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires." | |||
| 117 | + | }, | |||
| 107 | "isolation": { | 118 | "isolation": { | ||
| 108 | "type": "string", | 119 | "type": "string", | ||
| 109 | "enum": ["shared", "worktree"], | 120 | "enum": ["shared", "worktree"], | ||
| @@ -115,7 +126,7 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 115 | 126 | ||||
| 116 | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } | 127 | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } | ||
| 117 | func (t *SubagentSpawnTool) Description() string { | 128 | func (t *SubagentSpawnTool) Description() string { | ||
| 118 | - | return "Delegate work to a resident sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed." | 129 | + | return "Delegate work to a resident sub-agent. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn." |
| 119 | } | 130 | } | ||
| 120 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | 131 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | ||
| 121 | return json.RawMessage(subagentSpawnSchemaTemplate) | 132 | return json.RawMessage(subagentSpawnSchemaTemplate) | ||
| @@ -137,6 +148,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 137 | if task == "" { | 148 | if task == "" { | ||
| 138 | return protocolToolError(prefix + ": task is required") | 149 | return protocolToolError(prefix + ": task is required") | ||
| 139 | } | 150 | } | ||
| 151 | + | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | |||
| 152 | + | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | |||
| 153 | + | } | |||
| 140 | 154 | ||||
| 141 | workspaceMode := subagents.WorkspaceShared | 155 | workspaceMode := subagents.WorkspaceShared | ||
| 142 | if value := strings.TrimSpace(a.Isolation); value != "" { | 156 | if value := strings.TrimSpace(a.Isolation); value != "" { | ||
| @@ -217,11 +231,44 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 217 | if err != nil { | 231 | if err != nil { | ||
| 218 | return protocolToolError(prefix + ": " + err.Error()) | 232 | return protocolToolError(prefix + ": " + err.Error()) | ||
| 219 | } | 233 | } | ||
| 220 | - | if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | 234 | + | var completion subagents.ResidentCompletion |
| 235 | + | waitTimedOut := false | |||
| 236 | + | if a.Wait != nil { | |||
| 237 | + | spec.InitialTurnID = uuid.NewString() | |||
| 238 | + | completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID) | |||
| 239 | + | defer cancelWait() | |||
| 240 | + | if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |||
| 241 | + | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |||
| 242 | + | } | |||
| 243 | + | waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second) | |||
| 244 | + | defer cancel() | |||
| 245 | + | select { | |||
| 246 | + | case result, ok := <-completionResult: | |||
| 247 | + | if !ok { | |||
| 248 | + | return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix) | |||
| 249 | + | } | |||
| 250 | + | completion = result | |||
| 251 | + | case <-waitCtx.Done(): | |||
| 252 | + | if ctx.Err() != nil { | |||
| 253 | + | return core.ToolResult{}, ctx.Err() | |||
| 254 | + | } | |||
| 255 | + | waitTimedOut = true | |||
| 256 | + | } | |||
| 257 | + | } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |||
| 221 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | 258 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | ||
| 222 | } | 259 | } | ||
| 260 | + | state := "queued" | |||
| 261 | + | if a.Wait != nil { | |||
| 262 | + | if waitTimedOut { | |||
| 263 | + | state = "running" | |||
| 264 | + | } else if completion.Err != nil { | |||
| 265 | + | state = "failed" | |||
| 266 | + | } else { | |||
| 267 | + | state = "completed" | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 223 | var sb strings.Builder | 270 | var sb strings.Builder | ||
| 224 | - | fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: queued\nworkspace: %s\ntask: %s\n", spec.ID, spec.WorkspaceMode, truncateTask(task, 200)) | 271 | + | fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200)) |
| 225 | if spec.Profile != "" { | 272 | if spec.Profile != "" { | ||
| 226 | fmt.Fprintf(&sb, "agent: %s\n", spec.Profile) | 273 | fmt.Fprintf(&sb, "agent: %s\n", spec.Profile) | ||
| 227 | } | 274 | } | ||
| @@ -230,9 +277,25 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 230 | fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning) | 277 | fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning) | ||
| 231 | } | 278 | } | ||
| 232 | if spec.Required { | 279 | if spec.Required { | ||
| 233 | - | sb.WriteString("required: pending\n") | 280 | + | fmt.Fprintf(&sb, "required: %s\n", state) |
| 281 | + | } | |||
| 282 | + | if a.Wait != nil { | |||
| 283 | + | if waitTimedOut { | |||
| 284 | + | fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait) | |||
| 285 | + | sb.WriteString("\nThe sub-agent is still running in the background. Completion is host-event-driven through [auto-subagents update].") | |||
| 286 | + | } else if completion.Err != nil { | |||
| 287 | + | fmt.Fprintf(&sb, "error: %s\n", completion.Err) | |||
| 288 | + | if completion.Summary != "" { | |||
| 289 | + | fmt.Fprintf(&sb, "final: %s\n", completion.Summary) | |||
| 290 | + | } | |||
| 291 | + | } else { | |||
| 292 | + | if completion.Summary != "" { | |||
| 293 | + | fmt.Fprintf(&sb, "final: %s\n", completion.Summary) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | } else { | |||
| 297 | + | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].") | |||
| 234 | } | 298 | } | ||
| 235 | - | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].") | |||
| 236 | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil | 299 | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil | ||
| 237 | } | 300 | } | ||
| 238 | 301 | ||||