Skip to main content

agent_engine/tools/subagent/
status.rs

1//! SubagentStatusTool — poll the current state of a running or completed reactive subagent.
2//!
3//! Returns a lightweight snapshot: lifecycle status, last 500 chars of output,
4//! elapsed wall-clock seconds, and tool-use count. Non-blocking — always returns
5//! immediately regardless of the subagent's progress.
6
7use 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        // ── Registry lookup ────────────────────────────────────────────────────
53
54        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            // Not live: reaped, or carried over a daemon reload. Its archive
62            // still answers (and still resumes).
63            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        // ── Build response ─────────────────────────────────────────────────────
70        // Clone all needed data under the lock, then drop lock before char traversal
71        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        // Present only once the worker's context has rolled over.
120        if let Some(context) = context {
121            resp["context"] = context;
122        }
123        Ok(resp.to_string())
124    }
125}
126
127/// `subagent_status` of a handle that is no longer live, from its archive.
128fn 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        // Same contract as the live path and subagent_collect: a failed
160        // worker's reason travels with its status.
161        "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    /// After a reload the old id still answers from its archive, and says
180    /// the reload (not the user) stopped it.
181    #[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    /// A reaped worker whose context rolled over still reports where its
221    /// earlier windows are; one that never rolled has no `context` key.
222    #[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}