Skip to main content

agent_engine/tools/subagent/
collect.rs

1//! SubagentCollectTool — check if a reactive subagent is done and return its result.
2//!
3//! Non-blocking — checks the registry once and returns immediately.
4//! If the subagent is still running, returns status + partial output.
5//! If done, returns the full result. The natural pair to `subagent_start` —
6//! start async, check when you want the answer.
7//!
8
9use super::super::{Tool, ToolContext};
10use crate::runtime::subagent::SubagentStatus;
11use crate::{Result, RuntimeError};
12use serde_json::{json, Value};
13
14pub struct SubagentCollectTool;
15
16#[async_trait::async_trait]
17impl Tool for SubagentCollectTool {
18    fn origin(&self) -> crate::tools::ToolOrigin {
19        crate::tools::ToolOrigin::Builtin
20    }
21
22    fn name(&self) -> &str {
23        "subagent_collect"
24    }
25
26    fn description(&self) -> &str {
27        "Retrieve a reactive subagent's result. Non-blocking — returns immediately \
28         (status + partial output if still running). Standard flow: poll with \
29         subagent_status while the subagent runs; when subagent_status reports a \
30         terminal status (completed/failed/timed_out/cancelled), call this ONCE with \
31         reconciled=true — that single call both collects the result and attests \
32         reconciliation, clearing the completion gate. Calling without reconciled=true \
33         is an inspection-only read: it leaves the completion gate blocked and forces \
34         a second call. reconciled=true is idempotent and safe on repeats."
35    }
36
37    fn parameters(&self) -> Value {
38        json!({
39            "type": "object",
40            "properties": {
41                "handle_id": {
42                    "type": "string",
43                    "description": "Handle ID returned by subagent_start (e.g. \"sa_3\")."
44                },
45                "reconciled": {
46                    "type": "boolean",
47                    "default": false,
48                    "description": "Set true on your terminal collect (after subagent_status reports done) to attest the result is reconciled with foreground work and unblock completion — one call collects AND reconciles. Leave false only for a deliberate inspection-only read; a later collect with reconciled=true still reconciles (idempotent)."
49                }
50            },
51            "required": ["handle_id"]
52        })
53    }
54
55    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
56        let handle_id = params["handle_id"]
57            .as_str()
58            .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
59            .to_string();
60
61        let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
62            RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
63        })?;
64
65        let mut reg = registry.lock().unwrap();
66        let Some(handle) = reg.get_mut(&handle_id) else {
67            drop(reg);
68            if let Some(orchestration) = &ctx.capabilities.orchestration {
69                if orchestration.is_unreconciled(&handle_id) {
70                    orchestration
71                        .terminal_and_collect(
72                            &handle_id,
73                            agent_core::orchestration::WorkerTerminal::Failed,
74                        )
75                        .map_err(RuntimeError::Tool)?;
76                    if params["reconciled"].as_bool().unwrap_or(false) {
77                        orchestration
78                            .reconcile(&handle_id)
79                            .map_err(RuntimeError::Tool)?;
80                    }
81                    return Ok(json!({
82                        "handle_id": handle_id,
83                        "status": "expired",
84                        "note": "Subagent output expired; orchestration lifecycle remains recoverable.",
85                        "collected": false
86                    })
87                    .to_string());
88                }
89            }
90            return Err(RuntimeError::Tool(format!(
91                "No subagent found with handle_id '{}'. Finished handles are retained for {} minutes after completion and then garbage-collected.",
92                handle_id,
93                crate::runtime::subagent::FINISHED_HANDLE_TTL.as_secs() / 60
94            )));
95        };
96
97        let status = handle.status();
98        let output: String = handle.partial_output();
99        let elapsed = handle.elapsed_secs();
100        let model = handle.model.clone();
101        let terminal = handle.terminal_diagnostic();
102        let authorization = handle.authorization.clone();
103
104        // Mark collected on any terminal read so the reaper knows it's safe to GC.
105        let already_collected = handle.is_collected();
106        if status != SubagentStatus::Running {
107            handle.mark_collected();
108        }
109        drop(reg);
110
111        if status == SubagentStatus::Running {
112            // Still going — return current state, don't block
113            let char_count = output.chars().count();
114            let output_so_far: String = if char_count > 500 {
115                output.chars().skip(char_count - 500).collect()
116            } else {
117                output
118            };
119            return Ok(json!({
120                "handle_id":    handle_id,
121                "status":       "running",
122                "elapsed_secs": (elapsed * 10.0).round() / 10.0,
123                "output_so_far": output_so_far
124            })
125            .to_string());
126        }
127
128        // Lifecycle (intentional caller attestation — never auto-reconcile):
129        // - First terminal collect always drives terminal_and_collect.
130        // - Any terminal collect with reconciled=true must reconcile, including
131        //   a repeated collect after an earlier unreconciled read. Gating
132        //   reconcile on !already_collected left workers stuck in Collected
133        //   forever and blocked completion.
134        // - terminal_and_collect stays first-collect-only so diagnostic
135        //   re-reads remain read-many idempotent at the handle layer.
136        if let Some(orchestration) = &ctx.capabilities.orchestration {
137            if !already_collected {
138                let terminal = match status {
139                    SubagentStatus::Completed => {
140                        agent_core::orchestration::WorkerTerminal::Completed
141                    }
142                    SubagentStatus::TimedOut => agent_core::orchestration::WorkerTerminal::TimedOut,
143                    _ => agent_core::orchestration::WorkerTerminal::Failed,
144                };
145                orchestration
146                    .terminal_and_collect(&handle_id, terminal)
147                    .map_err(RuntimeError::Tool)?;
148            }
149            if params["reconciled"].as_bool().unwrap_or(false) {
150                orchestration
151                    .reconcile(&handle_id)
152                    .map_err(RuntimeError::Tool)?;
153            }
154        }
155
156        // Done — return full result. The registry retains this record, making
157        // repeated collection diagnostically idempotent; `collected` signals
158        // idempotency to the caller.
159        let reconciled_now = params["reconciled"].as_bool().unwrap_or(false);
160        let mut body = json!({
161            "handle_id": handle_id,
162            "status":    status.as_str(),
163            "output":    output,
164            "model":     model,
165            "terminal_cause": terminal,
166            "authorization": authorization,
167            "collected": already_collected,
168        });
169        if !reconciled_now && ctx.capabilities.orchestration.is_some() {
170            // Nudge stragglers: an unreconciled terminal read leaves the
171            // completion gate blocked and forces a second collect. The
172            // standard flow (doctrine + tool description) is a single
173            // collect with reconciled=true after status turns terminal.
174            body["note"] = json!(
175                "UNRECONCILED read — the completion gate is still blocked. \
176                 Call subagent_collect again with reconciled=true after \
177                 integrating this result."
178            );
179        }
180        if let Some(reason) = status.failure_reason() {
181            body["error"] = json!(reason);
182        }
183        Ok(body.to_string())
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::runtime::subagent::{
191        SubagentHandle, SubagentRegistry, SubagentState, SubagentStatus,
192    };
193    use crate::tools::test_helpers::create_tool_context;
194    use crate::tools::Tool;
195    use serde_json::json;
196    use std::sync::{Arc, Mutex, RwLock};
197    use tokio::sync::{mpsc, oneshot};
198
199    fn make_finished_handle(id: &str, output: &str) -> SubagentHandle {
200        let state = Arc::new(RwLock::new(SubagentState::new()));
201        {
202            let mut s = state.write().unwrap();
203            s.status = SubagentStatus::Completed;
204            s.partial_text = output.to_string();
205            s.finished_at = Some(std::time::Instant::now());
206        }
207        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
208        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
209        let (_result_tx, result_rx) = oneshot::channel();
210        SubagentHandle::new(
211            id.to_string(),
212            0,
213            "test-agent".to_string(),
214            "test task".to_string(),
215            "claude-sonnet-4-6".to_string(),
216            "system prompt".to_string(),
217            300,
218            state,
219            Some(steer_tx),
220            Some(shutdown_tx),
221            Some(result_rx),
222        )
223    }
224
225    fn make_ctx_with_registry(registry: Arc<Mutex<SubagentRegistry>>) -> crate::tools::ToolContext {
226        let mut ctx = create_tool_context();
227        ctx.capabilities.subagent_registry = Some(registry);
228        // This suite exercises the registry-level collected/idempotency
229        // contract in isolation. The handle is registered directly (never
230        // dispatched through orchestration), so the policy registry must not
231        // participate — dispatch-path enforcement is covered by the
232        // orchestration and lifecycle suites.
233        ctx.capabilities.orchestration = None;
234        ctx
235    }
236
237    // U5: collect marks collected on first read; second read shows collected=true
238    #[tokio::test]
239    async fn collect_marks_collected_and_is_idempotent() {
240        let tool = SubagentCollectTool;
241
242        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
243        let handle = make_finished_handle("sa_42", "result text");
244        registry.lock().unwrap().register(handle);
245
246        // First collect — should return collected=false (not yet collected before this call)
247        let result1 = tool
248            .execute(
249                json!({"handle_id": "sa_42"}),
250                make_ctx_with_registry(registry.clone()),
251            )
252            .await
253            .unwrap();
254        let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
255        assert_eq!(body1["status"], "completed");
256        assert_eq!(body1["output"], "result text");
257        assert_eq!(
258            body1["collected"], false,
259            "first collect should report collected=false"
260        );
261
262        // Second collect — same handle still in registry (not yet reaped), collected=true
263        let result2 = tool
264            .execute(
265                json!({"handle_id": "sa_42"}),
266                make_ctx_with_registry(registry.clone()),
267            )
268            .await
269            .unwrap();
270        let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
271        assert_eq!(body2["status"], "completed");
272        assert_eq!(
273            body2["collected"], true,
274            "second collect should report collected=true"
275        );
276
277        // After collect, the registry should have the handle marked collected
278        let reg = registry.lock().unwrap();
279        let h = reg.get("sa_42").unwrap();
280        assert!(
281            h.is_collected(),
282            "handle must be marked collected in registry"
283        );
284    }
285
286    /// Dispatch a finished handle through orchestration so collect can drive
287    /// the terminal → collected → reconciled lifecycle. Returns (registry, orch).
288    fn make_orch_ctx(
289        handle_id: &str,
290        output: &str,
291    ) -> (
292        Arc<Mutex<SubagentRegistry>>,
293        Arc<crate::orchestration::OrchestrationRuntime>,
294        crate::tools::ToolContext,
295    ) {
296        use crate::orchestration::OrchestrationRuntime;
297        use agent_core::orchestration::CompletionGate;
298
299        let foreground = agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6")
300            .expect("test foreground is qualified");
301        let orch =
302            Arc::new(OrchestrationRuntime::baseline(foreground, 8, 64).expect("baseline runtime"));
303        orch.authorize(handle_id, "anthropic/claude-sonnet-4-6")
304            .expect("authorize worker");
305        // authorize() leaves the worker Running. Transition to Terminal+Collected
306        // so the gate blocks (it only catches finished-but-unreconciled workers).
307        orch.terminal_and_collect(
308            handle_id,
309            agent_core::orchestration::WorkerTerminal::Completed,
310        )
311        .expect("mark terminal + collected");
312        assert!(
313            matches!(orch.completion_gate(), CompletionGate::Blocked { .. }),
314            "terminal worker must block completion until reconciled"
315        );
316
317        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
318        registry
319            .lock()
320            .unwrap()
321            .register(make_finished_handle(handle_id, output));
322
323        let mut ctx = create_tool_context();
324        ctx.capabilities.subagent_registry = Some(registry.clone());
325        ctx.capabilities.orchestration = Some(orch.clone());
326        (registry, orch, ctx)
327    }
328
329    // (a) first collect without reconciled leaves gate blocked; second collect
330    // with reconciled=true advances Collected → Reconciled and allows completion.
331    #[tokio::test]
332    async fn deferred_reconcile_on_repeat_collect_unblocks_completion() {
333        use agent_core::orchestration::CompletionGate;
334
335        let tool = SubagentCollectTool;
336        let (registry, orch, ctx) = make_orch_ctx("sa_deferred", "result text");
337
338        let result1 = tool
339            .execute(
340                json!({"handle_id": "sa_deferred", "reconciled": false}),
341                ctx,
342            )
343            .await
344            .unwrap();
345        let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
346        assert_eq!(body1["collected"], false);
347        match orch.completion_gate() {
348            CompletionGate::Blocked { workers } => {
349                assert_eq!(workers, vec!["sa_deferred".to_string()]);
350                assert!(
351                    workers.iter().all(|id| id.starts_with("sa_")),
352                    "blocked IDs must be subagent_collect handles, got {workers:?}"
353                );
354                assert!(
355                    workers.iter().all(|id| !id.starts_with("worker-")),
356                    "blocked IDs must never leak policy WorkerHandle, got {workers:?}"
357                );
358            }
359            other => panic!(
360                "first collect without reconcile must leave completion blocked, got {other:?}"
361            ),
362        }
363
364        // Rebuild ctx so the same shared orch/registry participate on the
365        // second call (create_tool_context would allocate a fresh baseline).
366        let mut ctx2 = create_tool_context();
367        ctx2.capabilities.subagent_registry = Some(registry.clone());
368        ctx2.capabilities.orchestration = Some(orch.clone());
369
370        let result2 = tool
371            .execute(
372                json!({"handle_id": "sa_deferred", "reconciled": true}),
373                ctx2,
374            )
375            .await
376            .unwrap();
377        let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
378        assert_eq!(body2["collected"], true);
379        assert_eq!(
380            orch.completion_gate(),
381            CompletionGate::Allowed,
382            "repeat collect with reconciled=true must unblock completion"
383        );
384    }
385
386    // (b) first collect with reconciled=true allows completion immediately.
387    #[tokio::test]
388    async fn first_collect_with_reconcile_allows_completion() {
389        use agent_core::orchestration::CompletionGate;
390
391        let tool = SubagentCollectTool;
392        let (_registry, orch, ctx) = make_orch_ctx("sa_first_true", "result text");
393
394        let result = tool
395            .execute(
396                json!({"handle_id": "sa_first_true", "reconciled": true}),
397                ctx,
398            )
399            .await
400            .unwrap();
401        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
402        assert_eq!(body["collected"], false);
403        assert_eq!(
404            orch.completion_gate(),
405            CompletionGate::Allowed,
406            "first collect with reconciled=true must allow completion"
407        );
408    }
409
410    /// An unreconciled terminal read leaves the gate blocked, so the response
411    /// must carry an explicit nudge to re-collect with reconciled=true —
412    /// otherwise models stall on a gate they can no longer see. The standard
413    /// single-call flow (reconciled=true up front) must NOT carry the note.
414    #[tokio::test]
415    async fn unreconciled_terminal_collect_carries_reconcile_nudge() {
416        let tool = SubagentCollectTool;
417        let (_registry, _orch, ctx) = make_orch_ctx("sa_nudge", "result text");
418
419        let result = tool
420            .execute(json!({"handle_id": "sa_nudge", "reconciled": false}), ctx)
421            .await
422            .unwrap();
423        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
424        let note = body["note"].as_str().expect("unreconciled read must nudge");
425        assert!(note.contains("reconciled=true"), "{note}");
426    }
427
428    #[tokio::test]
429    async fn reconciled_collect_carries_no_nudge() {
430        let tool = SubagentCollectTool;
431        let (_registry, _orch, ctx) = make_orch_ctx("sa_no_nudge", "result text");
432
433        let result = tool
434            .execute(json!({"handle_id": "sa_no_nudge", "reconciled": true}), ctx)
435            .await
436            .unwrap();
437        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
438        assert!(
439            body.get("note").is_none(),
440            "single-call collect+reconcile must not carry a nudge: {body}"
441        );
442    }
443
444    #[tokio::test]
445    async fn forced_expiry_remains_collectible_and_clears_gate() {
446        use agent_core::orchestration::CompletionGate;
447
448        let tool = SubagentCollectTool;
449        let (registry, orch, ctx) = make_orch_ctx("sa_expired", &"result".repeat(10_000));
450        crate::runtime::subagent::reap_finished_with_ttl(
451            &registry,
452            Some(orch.as_ref()),
453            std::time::Duration::ZERO,
454        );
455        assert!(registry
456            .lock()
457            .unwrap()
458            .get("sa_expired")
459            .unwrap()
460            .is_tombstone());
461
462        let result = tool
463            .execute(json!({"handle_id": "sa_expired", "reconciled": true}), ctx)
464            .await
465            .unwrap();
466        assert_ne!(
467            serde_json::from_str::<Value>(&result).unwrap()["status"],
468            "expired"
469        );
470        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
471    }
472
473    #[tokio::test]
474    async fn collected_handle_reaped_then_degraded_reconcile_clears_gate() {
475        use agent_core::orchestration::CompletionGate;
476
477        let tool = SubagentCollectTool;
478        let (registry, orch, ctx) = make_orch_ctx("sa_collected_expired", "result text");
479        tool.execute(
480            json!({"handle_id": "sa_collected_expired", "reconciled": false}),
481            ctx,
482        )
483        .await
484        .unwrap();
485        assert!(matches!(
486            orch.completion_gate(),
487            CompletionGate::Blocked { .. }
488        ));
489
490        crate::runtime::subagent::reap_finished_with_ttl(
491            &registry,
492            Some(orch.as_ref()),
493            std::time::Duration::ZERO,
494        );
495        assert!(registry
496            .lock()
497            .unwrap()
498            .get("sa_collected_expired")
499            .is_none());
500
501        let mut ctx2 = create_tool_context();
502        ctx2.capabilities.subagent_registry = Some(registry);
503        ctx2.capabilities.orchestration = Some(orch.clone());
504        let response = tool
505            .execute(
506                json!({"handle_id": "sa_collected_expired", "reconciled": true}),
507                ctx2,
508            )
509            .await
510            .unwrap();
511        assert_eq!(
512            serde_json::from_str::<Value>(&response).unwrap()["status"],
513            "expired"
514        );
515        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
516    }
517
518    #[tokio::test]
519    async fn missing_mapped_handle_has_degraded_collect_not_404() {
520        use agent_core::orchestration::CompletionGate;
521
522        let tool = SubagentCollectTool;
523        let (registry, orch, ctx) = make_orch_ctx("sa_missing", "lost");
524        registry.lock().unwrap().remove("sa_missing");
525        let result = tool
526            .execute(json!({"handle_id": "sa_missing", "reconciled": true}), ctx)
527            .await
528            .unwrap();
529        let body: Value = serde_json::from_str(&result).unwrap();
530        assert_eq!(body["status"], "expired");
531        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
532    }
533
534    // (c) repeated collect with reconciled=true remains read-many idempotent.
535    #[tokio::test]
536    async fn repeated_reconcile_true_is_idempotent() {
537        use agent_core::orchestration::CompletionGate;
538
539        let tool = SubagentCollectTool;
540        let (registry, orch, ctx) = make_orch_ctx("sa_repeat_true", "result text");
541
542        let result1 = tool
543            .execute(
544                json!({"handle_id": "sa_repeat_true", "reconciled": true}),
545                ctx,
546            )
547            .await
548            .unwrap();
549        let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
550        assert_eq!(body1["collected"], false);
551        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
552
553        let mut ctx2 = create_tool_context();
554        ctx2.capabilities.subagent_registry = Some(registry.clone());
555        ctx2.capabilities.orchestration = Some(orch.clone());
556
557        let result2 = tool
558            .execute(
559                json!({"handle_id": "sa_repeat_true", "reconciled": true}),
560                ctx2,
561            )
562            .await
563            .unwrap();
564        let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
565        assert_eq!(body2["collected"], true);
566        assert_eq!(
567            orch.completion_gate(),
568            CompletionGate::Allowed,
569            "repeat reconciled collect must stay Allowed (idempotent)"
570        );
571        // Registry handle remains readable and marked collected.
572        let reg = registry.lock().unwrap();
573        assert!(reg.get("sa_repeat_true").unwrap().is_collected());
574    }
575}