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: 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 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 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 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 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 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 ctx.capabilities.orchestration = None;
234 ctx
235 }
236
237 #[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 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 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 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 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 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 #[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 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 #[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 #[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 ®istry,
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 ®istry,
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 #[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 let reg = registry.lock().unwrap();
573 assert!(reg.get("sa_repeat_true").unwrap().is_collected());
574 }
575}