agent_engine/tools/subagent/
collect.rs1use serde_json::{json, Value};
10use crate::{Result, RuntimeError};
11use super::super::{Tool, ToolContext};
12use crate::runtime::subagent::SubagentStatus;
13
14
15pub struct SubagentCollectTool;
16
17#[async_trait::async_trait]
18impl Tool for SubagentCollectTool {
19 fn name(&self) -> &str { "subagent_collect" }
20
21 fn description(&self) -> &str {
22 "Check if a reactive subagent is done and return its result. Non-blocking — \
23 returns immediately. If still running, returns status and partial output. \
24 If finished, returns the full result. Call repeatedly to poll for completion."
25 }
26
27 fn parameters(&self) -> Value {
28 json!({
29 "type": "object",
30 "properties": {
31 "handle_id": {
32 "type": "string",
33 "description": "Handle ID returned by subagent_start (e.g. \"sa_3\")."
34 }
35 },
36 "required": ["handle_id"]
37 })
38 }
39
40 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
41 let handle_id = params["handle_id"].as_str()
42 .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
43 .to_string();
44
45 let registry = ctx.capabilities.subagent_registry.as_ref()
46 .ok_or_else(|| RuntimeError::Tool(
47 "SubagentRegistry not available on this ToolContext".to_string()
48 ))?;
49
50 let mut reg = registry.lock().unwrap();
51 let handle = reg.get_mut(&handle_id)
52 .ok_or_else(|| RuntimeError::Tool(
53 format!(
54 "No subagent found with handle_id '{}'. \
55 Finished handles are retained for {} minutes after completion \
56 and then garbage-collected.",
57 handle_id,
58 crate::runtime::subagent::FINISHED_HANDLE_TTL.as_secs() / 60
59 )
60 ))?;
61
62 let status = handle.status();
63 let output: String = handle.partial_output();
64 let elapsed = handle.elapsed_secs();
65
66 let already_collected = handle.is_collected();
68 if status != SubagentStatus::Running {
69 handle.mark_collected();
70 }
71 drop(reg);
72
73 if status == SubagentStatus::Running {
74 let char_count = output.chars().count();
76 let output_so_far: String = if char_count > 500 {
77 output.chars().skip(char_count - 500).collect()
78 } else {
79 output
80 };
81 return Ok(json!({
82 "handle_id": handle_id,
83 "status": "running",
84 "elapsed_secs": (elapsed * 10.0).round() / 10.0,
85 "output_so_far": output_so_far
86 }).to_string());
87 }
88
89 let mut body = json!({
91 "handle_id": handle_id,
92 "status": status.as_str(),
93 "output": output,
94 "collected": already_collected,
95 });
96 if let Some(reason) = status.failure_reason() {
97 body["error"] = json!(reason);
98 }
99 Ok(body.to_string())
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::tools::test_helpers::create_tool_context;
107 use crate::tools::Tool;
108 use crate::runtime::subagent::{SubagentRegistry, SubagentHandle, SubagentState, SubagentStatus};
109 use serde_json::json;
110 use std::sync::{Arc, Mutex, RwLock};
111 use tokio::sync::{mpsc, oneshot};
112
113 fn make_finished_handle(id: &str, output: &str) -> SubagentHandle {
114 let state = Arc::new(RwLock::new(SubagentState::new()));
115 {
116 let mut s = state.write().unwrap();
117 s.status = SubagentStatus::Completed;
118 s.partial_text = output.to_string();
119 s.finished_at = Some(std::time::Instant::now());
120 }
121 let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
122 let (shutdown_tx, _shutdown_rx) = oneshot::channel();
123 let (_result_tx, result_rx) = oneshot::channel();
124 SubagentHandle::new(
125 id.to_string(),
126 0,
127 "test-agent".to_string(),
128 "test task".to_string(),
129 "claude-sonnet-4-6".to_string(),
130 "system prompt".to_string(),
131 300,
132 state,
133 Some(steer_tx),
134 Some(shutdown_tx),
135 Some(result_rx),
136 )
137 }
138
139 fn make_ctx_with_registry(
140 registry: Arc<Mutex<SubagentRegistry>>,
141 ) -> crate::tools::ToolContext {
142 let mut ctx = create_tool_context();
143 ctx.capabilities.subagent_registry = Some(registry);
144 ctx
145 }
146
147 #[tokio::test]
149 async fn collect_marks_collected_and_is_idempotent() {
150 let tool = SubagentCollectTool;
151
152 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
153 let handle = make_finished_handle("sa_42", "result text");
154 registry.lock().unwrap().register(handle);
155
156 let result1 = tool.execute(
158 json!({"handle_id": "sa_42"}),
159 make_ctx_with_registry(registry.clone()),
160 ).await.unwrap();
161 let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
162 assert_eq!(body1["status"], "completed");
163 assert_eq!(body1["output"], "result text");
164 assert_eq!(body1["collected"], false, "first collect should report collected=false");
165
166 let result2 = tool.execute(
168 json!({"handle_id": "sa_42"}),
169 make_ctx_with_registry(registry.clone()),
170 ).await.unwrap();
171 let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
172 assert_eq!(body2["status"], "completed");
173 assert_eq!(body2["collected"], true, "second collect should report collected=true");
174
175 let reg = registry.lock().unwrap();
177 let h = reg.get("sa_42").unwrap();
178 assert!(h.is_collected(), "handle must be marked collected in registry");
179 }
180}