agent_engine/tools/subagent/
status.rs1use super::super::{Tool, ToolContext};
8use crate::{Result, RuntimeError};
9use serde_json::{json, Value};
10
11pub struct SubagentStatusTool;
12
13#[async_trait::async_trait]
14impl Tool for SubagentStatusTool {
15 fn origin(&self) -> crate::tools::ToolOrigin {
16 crate::tools::ToolOrigin::Builtin
17 }
18
19 fn name(&self) -> &str {
20 "subagent_status"
21 }
22
23 fn description(&self) -> &str {
24 "Poll the current state of a reactive subagent. Returns status \
25 (running/completed/cancelled/timed_out/failed), the last 500 characters of output \
26 produced so far, elapsed time in seconds, and the number of tool calls \
27 made. If the status is 'failed', an 'error' field contains the failure reason. \
28 Non-blocking — returns immediately. Use for an occasional progress peek only: \
29 subagents are reactive and push a completion event that wakes you when they \
30 finish, so never call this in a sleep/poll loop."
31 }
32
33 fn parameters(&self) -> Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "handle_id": {
38 "type": "string",
39 "description": "Handle ID returned by subagent_start (e.g. \"sa_3\")."
40 }
41 },
42 "required": ["handle_id"]
43 })
44 }
45
46 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
47 let handle_id = params["handle_id"]
48 .as_str()
49 .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
50 .to_string();
51
52 let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
55 RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
56 })?;
57
58 let reg = registry.lock().unwrap();
59
60 let Some(handle) = reg.get(&handle_id) else {
61 let archive_path = reg.archive_path(&handle_id);
64 let stopped_by_reload = reg.was_stopped_by_reload(&handle_id);
65 drop(reg);
66 return archived_status(&handle_id, archive_path, stopped_by_reload);
67 };
68
69 let full: String = handle.partial_output();
72 let agent_name = handle.agent_name.clone();
73 let status = handle.status();
74 let status_str = status.as_str().to_string();
75 let elapsed = handle.elapsed_secs();
76 let tool_count = handle.tool_log().len();
77 let model = handle.model.clone();
78 let terminal = handle.terminal_diagnostic();
79 let authorization = handle.authorization.clone();
80 let archive_error = handle.archive_error();
81 let context = handle
82 .state
83 .read()
84 .unwrap_or_else(|p| p.into_inner())
85 .context_summary();
86 let _ = handle;
87 drop(reg);
88
89 let char_count = full.chars().count();
90 let partial_output: String = if char_count > 500 {
91 full.chars().skip(char_count - 500).collect()
92 } else {
93 full
94 };
95
96 let output_length = partial_output.chars().count();
97 if let Some(orchestration) = &ctx.capabilities.orchestration {
98 orchestration
99 .poll(&handle_id, &format!("{}:{}", status_str, tool_count))
100 .map_err(RuntimeError::Tool)?;
101 }
102
103 let mut resp = json!({
104 "handle_id": handle_id,
105 "agent_name": agent_name,
106 "status": status_str,
107 "partial_output": partial_output,
108 "elapsed_secs": (elapsed * 10.0).round() / 10.0,
109 "tool_count": tool_count,
110 "model": model,
111 "terminal_cause": terminal,
112 "authorization": authorization,
113 "archiveError": archive_error,
114 "output_length": output_length
115 });
116 if let Some(reason) = status.failure_reason() {
117 resp["error"] = json!(reason);
118 }
119 if let Some(context) = context {
121 resp["context"] = context;
122 }
123 Ok(resp.to_string())
124 }
125}
126
127fn archived_status(
129 handle_id: &str,
130 archive_path: Option<std::path::PathBuf>,
131 stopped_by_reload: bool,
132) -> Result<String> {
133 let not_found = || RuntimeError::Tool(format!("No subagent found with handle_id '{}'", handle_id));
134 let path = archive_path.ok_or_else(not_found)?;
135 let archive = super::archive::read_archive(&path).map_err(|e| {
136 RuntimeError::Tool(format!(
137 "Subagent '{handle_id}' is no longer live and its archive ({}) could not be read: {e}",
138 path.display()
139 ))
140 })?;
141 let full = archive.output;
142 let char_count = full.chars().count();
143 let partial_output: String = if char_count > 500 {
144 full.chars().skip(char_count - 500).collect()
145 } else {
146 full
147 };
148 let note = if stopped_by_reload {
149 "Stopped by a daemon reload (not by the user); not running. subagent_resume continues it \
150 from this archive; subagent_collect reads its result."
151 } else {
152 "Not running: finished and reaped. subagent_collect reads its result from this archive; \
153 subagent_resume continues it."
154 };
155 let mut resp = json!({
156 "handle_id": handle_id,
157 "agent_name": archive.meta.agent_name,
158 "status": archive.status,
159 "error": archive.error,
162 "partial_output": partial_output,
163 "model": archive.meta.model,
164 "archived": true,
165 "stopped_by_reload": stopped_by_reload,
166 "archive_path": path,
167 "note": note,
168 });
169 if let Some(context) = crate::runtime::subagent::archived_context_summary(&archive.history) {
170 resp["context"] = context;
171 }
172 Ok(resp.to_string())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
182 fn archived_status_reports_a_reload_stopped_worker() {
183 let dir = tempfile::tempdir().unwrap();
184 let meta = crate::runtime::subagent::SubagentArchiveMeta {
185 agent_name: "integrator".into(),
186 model: "m".into(),
187 system_prompt: "s".into(),
188 timeout_secs: 60,
189 cwd: None,
190 thinking: None,
191 };
192 let path = super::super::archive::write_archive(
193 dir.path(),
194 "sa_288",
195 &meta,
196 "cancelled",
197 Some("boom"),
198 "partial words",
199 &[],
200 )
201 .unwrap();
202 let v: Value =
203 serde_json::from_str(&archived_status("sa_288", Some(path.clone()), true).unwrap()).unwrap();
204 assert_eq!(v["status"], "cancelled");
205 assert_eq!(v["error"], "boom", "archived status keeps the archive's error");
206 assert_eq!(v["agent_name"], "integrator");
207 assert_eq!(v["archived"], true);
208 assert_eq!(v["stopped_by_reload"], true);
209 assert_eq!(v["partial_output"], "partial words");
210 assert!(v["note"].as_str().unwrap().contains("daemon reload"));
211
212 let v: Value =
213 serde_json::from_str(&archived_status("sa_288", Some(path), false).unwrap()).unwrap();
214 assert!(!v["note"].as_str().unwrap().contains("daemon reload"));
215
216 let err = archived_status("sa_9", None, false).unwrap_err().to_string();
217 assert!(err.contains("No subagent found"), "{err}");
218 }
219
220 #[test]
223 fn archived_status_reports_a_rolled_worker_context() {
224 let dir = tempfile::tempdir().unwrap();
225 let meta = crate::runtime::subagent::SubagentArchiveMeta {
226 agent_name: "spike".into(),
227 model: "m".into(),
228 system_prompt: "s".into(),
229 timeout_secs: 60,
230 cwd: None,
231 thinking: None,
232 };
233 let history = vec![
234 std::sync::Arc::new(json!({"role":"user","content":"brief"})),
235 std::sync::Arc::new(json!({"role":"user","content":"note",
236 "_synaps_worker_context": {"schema":"synaps-worker-context/1","window":2,
237 "transcripts":[{"window":1,"path":"/a/sa_4.window-1.md","messages":8}]}})),
238 ];
239 let rolled = super::super::archive::write_archive(
240 dir.path(), "sa_4", &meta, "completed", None, "done", &history,
241 )
242 .unwrap();
243 let v: Value =
244 serde_json::from_str(&archived_status("sa_4", Some(rolled), false).unwrap()).unwrap();
245 assert_eq!(v["context"]["window"], 2);
246 assert_eq!(v["context"]["transcripts"][0], "/a/sa_4.window-1.md");
247
248 let plain = super::super::archive::write_archive(
249 dir.path(), "sa_5", &meta, "completed", None, "done", &history[..1],
250 )
251 .unwrap();
252 let v: Value =
253 serde_json::from_str(&archived_status("sa_5", Some(plain), false).unwrap()).unwrap();
254 assert!(v.get("context").is_none());
255 }
256}