1use 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: dispatch with \
29 subagent_start, carry on (or end your turn) — the subagent's completion event \
30 wakes you with a terminal status (completed/failed/timed_out/cancelled); then \
31 call this ONCE with 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 archive_path = reg.archive_path(&handle_id);
67 let Some(handle) = reg.get_mut(&handle_id) else {
68 drop(reg);
69 let archived = archive_path
71 .as_ref()
72 .and_then(|path| super::archive::read_archive(path).ok());
73 if let Some(orchestration) = &ctx.capabilities.orchestration {
74 if orchestration.is_unreconciled(&handle_id) {
75 orchestration
76 .terminal_and_collect(
77 &handle_id,
78 agent_core::orchestration::WorkerTerminal::Failed,
79 )
80 .map_err(RuntimeError::Tool)?;
81 if params["reconciled"].as_bool().unwrap_or(false) {
82 orchestration
83 .reconcile(&handle_id)
84 .map_err(RuntimeError::Tool)?;
85 }
86 if archived.is_none() {
87 return Ok(json!({
88 "handle_id": handle_id,
89 "status": "expired",
90 "note": "Subagent output expired; orchestration lifecycle remains recoverable.",
91 "collected": false
92 })
93 .to_string());
94 }
95 }
96 }
97 if let (Some(archive), Some(path)) = (archived, archive_path) {
98 return Ok(json!({
99 "handle_id": handle_id,
100 "status": archive.status,
101 "output": archive.output,
102 "model": archive.meta.model,
103 "collected": true,
104 "archive_path": path,
105 "note": "Handle was reaped; result read from its resume archive. \
106 subagent_resume can still continue this worker."
107 })
108 .to_string());
109 }
110 return Err(RuntimeError::Tool(format!(
111 "No subagent found with handle_id '{}'. Finished handles are retained for {} minutes after completion and then garbage-collected.",
112 handle_id,
113 crate::runtime::subagent::FINISHED_HANDLE_TTL.as_secs() / 60
114 )));
115 };
116
117 super::archive::retry_failed_archive(&handle.state, &handle_id);
118 let status = handle.status();
119 let output: String = handle.partial_output();
120 let elapsed = handle.elapsed_secs();
121 let model = handle.model.clone();
122 let terminal = handle.terminal_diagnostic();
123 let authorization = handle.authorization.clone();
124 let archive_error = handle.archive_error();
125
126 let already_collected = handle.is_collected();
128 if status != SubagentStatus::Running {
129 handle.mark_collected();
130 }
131 drop(reg);
132
133 if status == SubagentStatus::Running {
134 let char_count = output.chars().count();
136 let output_so_far: String = if char_count > 500 {
137 output.chars().skip(char_count - 500).collect()
138 } else {
139 output
140 };
141 return Ok(json!({
142 "handle_id": handle_id,
143 "status": "running",
144 "elapsed_secs": (elapsed * 10.0).round() / 10.0,
145 "output_so_far": output_so_far
146 })
147 .to_string());
148 }
149
150 if let Some(orchestration) = &ctx.capabilities.orchestration {
159 if !already_collected {
160 let terminal = match status {
161 SubagentStatus::Completed => {
162 agent_core::orchestration::WorkerTerminal::Completed
163 }
164 SubagentStatus::TimedOut => agent_core::orchestration::WorkerTerminal::TimedOut,
165 _ => agent_core::orchestration::WorkerTerminal::Failed,
166 };
167 orchestration
168 .terminal_and_collect(&handle_id, terminal)
169 .map_err(RuntimeError::Tool)?;
170 }
171 if params["reconciled"].as_bool().unwrap_or(false) {
172 orchestration
173 .reconcile(&handle_id)
174 .map_err(RuntimeError::Tool)?;
175 }
176 }
177
178 let reconciled_now = params["reconciled"].as_bool().unwrap_or(false);
182 let mut body = json!({
183 "archiveError": archive_error,
184 "handle_id": handle_id,
185 "status": status.as_str(),
186 "output": output,
187 "model": model,
188 "terminal_cause": terminal,
189 "authorization": authorization,
190 "collected": already_collected,
191 });
192 if !reconciled_now && ctx.capabilities.orchestration.is_some() {
193 body["note"] = json!(
198 "UNRECONCILED read — the completion gate is still blocked. \
199 Call subagent_collect again with reconciled=true after \
200 integrating this result."
201 );
202 }
203 if let Some(reason) = status.failure_reason() {
204 body["error"] = json!(reason);
205 }
206 if let Some(path) = archive_path {
207 body["archive_path"] = json!(path);
209 }
210 Ok(body.to_string())
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::runtime::subagent::{
218 SubagentHandle, SubagentRegistry, SubagentState, SubagentStatus,
219 };
220 use crate::tools::test_helpers::create_tool_context;
221 use crate::tools::Tool;
222 use serde_json::json;
223 use std::sync::{Arc, Mutex, RwLock};
224 use tokio::sync::{mpsc, oneshot};
225
226 fn make_finished_handle(id: &str, output: &str) -> SubagentHandle {
227 let state = Arc::new(RwLock::new(SubagentState::new()));
228 {
229 let mut s = state.write().unwrap();
230 s.status = SubagentStatus::Completed;
231 s.partial_text = output.to_string();
232 s.finished_at = Some(std::time::Instant::now());
233 }
234 let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
235 let (shutdown_tx, _shutdown_rx) = oneshot::channel();
236 let (_result_tx, result_rx) = oneshot::channel();
237 SubagentHandle::new(
238 id.to_string(),
239 0,
240 "test-agent".to_string(),
241 "test task".to_string(),
242 "claude-sonnet-4-6".to_string(),
243 "system prompt".to_string(),
244 300,
245 state,
246 Some(steer_tx),
247 Some(shutdown_tx),
248 Some(result_rx),
249 )
250 }
251
252 #[tokio::test]
253 async fn managed_terminal_waits_for_finalization_before_collection() {
254 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
255 let h = make_finished_handle("sa_pending", "still draining");
256 let state = Arc::clone(&h.state);
257 {
258 let mut s = state.write().unwrap();
259 s.finished_at = None;
260 s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
261 agent_name: "worker".into(),
262 model: "model".into(),
263 system_prompt: "system".into(),
264 timeout_secs: 1,
265 });
266 }
267 registry.lock().unwrap().register(h);
268 let response = SubagentCollectTool
269 .execute(
270 json!({"handle_id":"sa_pending", "reconciled":true}),
271 make_ctx_with_registry(Arc::clone(®istry)),
272 )
273 .await
274 .unwrap();
275 assert_eq!(
276 serde_json::from_str::<Value>(&response).unwrap()["status"],
277 "running"
278 );
279 let mut reg = registry.lock().unwrap();
280 assert!(!reg.get("sa_pending").unwrap().is_collected());
281 assert_eq!(reg.display_rows()[0].status, SubagentStatus::Running);
282 reg.release_finished_resources("sa_pending");
283 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
284 assert!(reg.get("sa_pending").is_some());
285 assert!(state.read().unwrap().finished_at.is_none());
286 }
287
288 fn make_ctx_with_registry(registry: Arc<Mutex<SubagentRegistry>>) -> crate::tools::ToolContext {
289 let mut ctx = create_tool_context();
290 ctx.capabilities.subagent_registry = Some(registry);
291 ctx.capabilities.orchestration = None;
297 ctx
298 }
299
300 #[tokio::test]
301 async fn collect_after_reap_reads_the_archive() {
302 let dir = tempfile::tempdir().unwrap();
303 let meta = crate::runtime::subagent::SubagentArchiveMeta {
304 agent_name: "test-agent".into(),
305 model: "anthropic/claude-opus-5-5".into(),
306 system_prompt: "s".into(),
307 timeout_secs: 60,
308 };
309 let path = super::super::archive::write_archive(
310 dir.path(),
311 "sa_gone",
312 &meta,
313 "timed_out",
314 "[TIMED OUT after 60s] latest progress",
315 &[Arc::new(json!({"role": "user", "content": "task"}))],
316 )
317 .unwrap();
318 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
319 {
320 let mut reg = registry.lock().unwrap();
321 let state = Arc::new(RwLock::new(SubagentState::new()));
322 {
323 let mut st = state.write().unwrap();
324 st.status = SubagentStatus::TimedOut;
325 st.finished_at = Some(std::time::Instant::now());
326 st.archive_path = Some(path.clone());
327 }
328 let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
329 let (shutdown_tx, _shutdown_rx) = oneshot::channel();
330 let (_result_tx, result_rx) = oneshot::channel();
331 let mut h = SubagentHandle::new(
332 "sa_gone".into(),
333 0,
334 "test-agent".into(),
335 "task".into(),
336 "anthropic/claude-opus-5-5".into(),
337 "s".into(),
338 60,
339 state,
340 Some(steer_tx),
341 Some(shutdown_tx),
342 Some(result_rx),
343 );
344 h.mark_collected();
345 reg.register(h);
346 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
347 assert!(reg.get("sa_gone").is_none());
348 }
349 let body: serde_json::Value = serde_json::from_str(
350 &SubagentCollectTool
351 .execute(
352 json!({"handle_id": "sa_gone"}),
353 make_ctx_with_registry(registry),
354 )
355 .await
356 .unwrap(),
357 )
358 .unwrap();
359 assert_eq!(body["status"], "timed_out");
360 assert_eq!(body["output"], "[TIMED OUT after 60s] latest progress");
361 assert_eq!(body["archive_path"], json!(path));
362 }
363
364 #[tokio::test]
366 async fn collect_marks_collected_and_is_idempotent() {
367 let tool = SubagentCollectTool;
368
369 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
370 let handle = make_finished_handle("sa_42", "result text");
371 registry.lock().unwrap().register(handle);
372
373 let result1 = tool
375 .execute(
376 json!({"handle_id": "sa_42"}),
377 make_ctx_with_registry(registry.clone()),
378 )
379 .await
380 .unwrap();
381 let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
382 assert_eq!(body1["status"], "completed");
383 assert_eq!(body1["output"], "result text");
384 assert_eq!(
385 body1["collected"], false,
386 "first collect should report collected=false"
387 );
388
389 let result2 = tool
391 .execute(
392 json!({"handle_id": "sa_42"}),
393 make_ctx_with_registry(registry.clone()),
394 )
395 .await
396 .unwrap();
397 let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
398 assert_eq!(body2["status"], "completed");
399 assert_eq!(
400 body2["collected"], true,
401 "second collect should report collected=true"
402 );
403
404 let reg = registry.lock().unwrap();
406 let h = reg.get("sa_42").unwrap();
407 assert!(
408 h.is_collected(),
409 "handle must be marked collected in registry"
410 );
411 }
412
413 fn make_orch_ctx(
416 handle_id: &str,
417 output: &str,
418 ) -> (
419 Arc<Mutex<SubagentRegistry>>,
420 Arc<crate::orchestration::OrchestrationRuntime>,
421 crate::tools::ToolContext,
422 ) {
423 use crate::orchestration::OrchestrationRuntime;
424 use agent_core::orchestration::CompletionGate;
425
426 let foreground = agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6")
427 .expect("test foreground is qualified");
428 let orch =
429 Arc::new(OrchestrationRuntime::baseline(foreground, 8, 64).expect("baseline runtime"));
430 orch.authorize(handle_id, "anthropic/claude-sonnet-4-6")
431 .expect("authorize worker");
432 orch.terminal_and_collect(
435 handle_id,
436 agent_core::orchestration::WorkerTerminal::Completed,
437 )
438 .expect("mark terminal + collected");
439 assert!(
440 matches!(orch.completion_gate(), CompletionGate::Blocked { .. }),
441 "terminal worker must block completion until reconciled"
442 );
443
444 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
445 registry
446 .lock()
447 .unwrap()
448 .register(make_finished_handle(handle_id, output));
449
450 let mut ctx = create_tool_context();
451 ctx.capabilities.subagent_registry = Some(registry.clone());
452 ctx.capabilities.orchestration = Some(orch.clone());
453 (registry, orch, ctx)
454 }
455
456 #[tokio::test]
459 async fn deferred_reconcile_on_repeat_collect_unblocks_completion() {
460 use agent_core::orchestration::CompletionGate;
461
462 let tool = SubagentCollectTool;
463 let (registry, orch, ctx) = make_orch_ctx("sa_deferred", "result text");
464
465 let result1 = tool
466 .execute(
467 json!({"handle_id": "sa_deferred", "reconciled": false}),
468 ctx,
469 )
470 .await
471 .unwrap();
472 let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
473 assert_eq!(body1["collected"], false);
474 match orch.completion_gate() {
475 CompletionGate::Blocked { workers } => {
476 assert_eq!(workers, vec!["sa_deferred".to_string()]);
477 assert!(
478 workers.iter().all(|id| id.starts_with("sa_")),
479 "blocked IDs must be subagent_collect handles, got {workers:?}"
480 );
481 assert!(
482 workers.iter().all(|id| !id.starts_with("worker-")),
483 "blocked IDs must never leak policy WorkerHandle, got {workers:?}"
484 );
485 }
486 other => panic!(
487 "first collect without reconcile must leave completion blocked, got {other:?}"
488 ),
489 }
490
491 let mut ctx2 = create_tool_context();
494 ctx2.capabilities.subagent_registry = Some(registry.clone());
495 ctx2.capabilities.orchestration = Some(orch.clone());
496
497 let result2 = tool
498 .execute(
499 json!({"handle_id": "sa_deferred", "reconciled": true}),
500 ctx2,
501 )
502 .await
503 .unwrap();
504 let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
505 assert_eq!(body2["collected"], true);
506 assert_eq!(
507 orch.completion_gate(),
508 CompletionGate::Allowed,
509 "repeat collect with reconciled=true must unblock completion"
510 );
511 }
512
513 #[tokio::test]
515 async fn first_collect_with_reconcile_allows_completion() {
516 use agent_core::orchestration::CompletionGate;
517
518 let tool = SubagentCollectTool;
519 let (_registry, orch, ctx) = make_orch_ctx("sa_first_true", "result text");
520
521 let result = tool
522 .execute(
523 json!({"handle_id": "sa_first_true", "reconciled": true}),
524 ctx,
525 )
526 .await
527 .unwrap();
528 let body: serde_json::Value = serde_json::from_str(&result).unwrap();
529 assert_eq!(body["collected"], false);
530 assert_eq!(
531 orch.completion_gate(),
532 CompletionGate::Allowed,
533 "first collect with reconciled=true must allow completion"
534 );
535 }
536
537 #[tokio::test]
542 async fn unreconciled_terminal_collect_carries_reconcile_nudge() {
543 let tool = SubagentCollectTool;
544 let (_registry, _orch, ctx) = make_orch_ctx("sa_nudge", "result text");
545
546 let result = tool
547 .execute(json!({"handle_id": "sa_nudge", "reconciled": false}), ctx)
548 .await
549 .unwrap();
550 let body: serde_json::Value = serde_json::from_str(&result).unwrap();
551 let note = body["note"].as_str().expect("unreconciled read must nudge");
552 assert!(note.contains("reconciled=true"), "{note}");
553 }
554
555 #[tokio::test]
556 async fn reconciled_collect_carries_no_nudge() {
557 let tool = SubagentCollectTool;
558 let (_registry, _orch, ctx) = make_orch_ctx("sa_no_nudge", "result text");
559
560 let result = tool
561 .execute(json!({"handle_id": "sa_no_nudge", "reconciled": true}), ctx)
562 .await
563 .unwrap();
564 let body: serde_json::Value = serde_json::from_str(&result).unwrap();
565 assert!(
566 body.get("note").is_none(),
567 "single-call collect+reconcile must not carry a nudge: {body}"
568 );
569 }
570
571 #[tokio::test]
572 async fn forced_expiry_remains_collectible_and_clears_gate() {
573 use agent_core::orchestration::CompletionGate;
574
575 let tool = SubagentCollectTool;
576 let (registry, orch, ctx) = make_orch_ctx("sa_expired", &"result".repeat(10_000));
577 crate::runtime::subagent::reap_finished_with_ttl(
578 ®istry,
579 Some(orch.as_ref()),
580 std::time::Duration::ZERO,
581 );
582 assert!(registry
583 .lock()
584 .unwrap()
585 .get("sa_expired")
586 .unwrap()
587 .is_tombstone());
588
589 let result = tool
590 .execute(json!({"handle_id": "sa_expired", "reconciled": true}), ctx)
591 .await
592 .unwrap();
593 assert_ne!(
594 serde_json::from_str::<Value>(&result).unwrap()["status"],
595 "expired"
596 );
597 assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
598 }
599
600 #[tokio::test]
601 async fn collected_handle_reaped_then_degraded_reconcile_clears_gate() {
602 use agent_core::orchestration::CompletionGate;
603
604 let tool = SubagentCollectTool;
605 let (registry, orch, ctx) = make_orch_ctx("sa_collected_expired", "result text");
606 tool.execute(
607 json!({"handle_id": "sa_collected_expired", "reconciled": false}),
608 ctx,
609 )
610 .await
611 .unwrap();
612 assert!(matches!(
613 orch.completion_gate(),
614 CompletionGate::Blocked { .. }
615 ));
616
617 crate::runtime::subagent::reap_finished_with_ttl(
618 ®istry,
619 Some(orch.as_ref()),
620 std::time::Duration::ZERO,
621 );
622 assert!(registry
623 .lock()
624 .unwrap()
625 .get("sa_collected_expired")
626 .is_none());
627
628 let mut ctx2 = create_tool_context();
629 ctx2.capabilities.subagent_registry = Some(registry);
630 ctx2.capabilities.orchestration = Some(orch.clone());
631 let response = tool
632 .execute(
633 json!({"handle_id": "sa_collected_expired", "reconciled": true}),
634 ctx2,
635 )
636 .await
637 .unwrap();
638 assert_eq!(
639 serde_json::from_str::<Value>(&response).unwrap()["status"],
640 "expired"
641 );
642 assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
643 }
644
645 #[tokio::test]
646 async fn missing_mapped_handle_has_degraded_collect_not_404() {
647 use agent_core::orchestration::CompletionGate;
648
649 let tool = SubagentCollectTool;
650 let (registry, orch, ctx) = make_orch_ctx("sa_missing", "lost");
651 registry.lock().unwrap().remove("sa_missing");
652 let result = tool
653 .execute(json!({"handle_id": "sa_missing", "reconciled": true}), ctx)
654 .await
655 .unwrap();
656 let body: Value = serde_json::from_str(&result).unwrap();
657 assert_eq!(body["status"], "expired");
658 assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
659 }
660
661 #[tokio::test]
663 async fn repeated_reconcile_true_is_idempotent() {
664 use agent_core::orchestration::CompletionGate;
665
666 let tool = SubagentCollectTool;
667 let (registry, orch, ctx) = make_orch_ctx("sa_repeat_true", "result text");
668
669 let result1 = tool
670 .execute(
671 json!({"handle_id": "sa_repeat_true", "reconciled": true}),
672 ctx,
673 )
674 .await
675 .unwrap();
676 let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
677 assert_eq!(body1["collected"], false);
678 assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
679
680 let mut ctx2 = create_tool_context();
681 ctx2.capabilities.subagent_registry = Some(registry.clone());
682 ctx2.capabilities.orchestration = Some(orch.clone());
683
684 let result2 = tool
685 .execute(
686 json!({"handle_id": "sa_repeat_true", "reconciled": true}),
687 ctx2,
688 )
689 .await
690 .unwrap();
691 let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
692 assert_eq!(body2["collected"], true);
693 assert_eq!(
694 orch.completion_gate(),
695 CompletionGate::Allowed,
696 "repeat reconciled collect must stay Allowed (idempotent)"
697 );
698 let reg = registry.lock().unwrap();
700 assert!(reg.get("sa_repeat_true").unwrap().is_collected());
701 }
702}