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