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 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        // Mark collected on any terminal read so the reaper knows it's safe to GC.
67        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            // Still going — return current state, don't block
75            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        // Done — return full result including collected flag for idempotency signaling
90        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    // U5: collect marks collected on first read; second read shows collected=true
148    #[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        // First collect — should return collected=false (not yet collected before this call)
157        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        // Second collect — same handle still in registry (not yet reaped), collected=true
167        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        // After collect, the registry should have the handle marked collected
176        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}