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 _ = handle;
82        drop(reg);
83
84        let char_count = full.chars().count();
85        let partial_output: String = if char_count > 500 {
86            full.chars().skip(char_count - 500).collect()
87        } else {
88            full
89        };
90
91        let output_length = partial_output.chars().count();
92        if let Some(orchestration) = &ctx.capabilities.orchestration {
93            orchestration
94                .poll(&handle_id, &format!("{}:{}", status_str, tool_count))
95                .map_err(RuntimeError::Tool)?;
96        }
97
98        let mut resp = json!({
99            "handle_id":      handle_id,
100            "agent_name":     agent_name,
101            "status":         status_str,
102            "partial_output": partial_output,
103            "elapsed_secs":   (elapsed * 10.0).round() / 10.0,
104            "tool_count":     tool_count,
105            "model":          model,
106            "terminal_cause": terminal,
107            "authorization":   authorization,
108            "archiveError": archive_error,
109            "output_length":  output_length
110        });
111        if let Some(reason) = status.failure_reason() {
112            resp["error"] = json!(reason);
113        }
114        Ok(resp.to_string())
115    }
116}
117
118/// `subagent_status` of a handle that is no longer live, from its archive.
119fn archived_status(
120    handle_id: &str,
121    archive_path: Option<std::path::PathBuf>,
122    stopped_by_reload: bool,
123) -> Result<String> {
124    let not_found = || RuntimeError::Tool(format!("No subagent found with handle_id '{}'", handle_id));
125    let path = archive_path.ok_or_else(not_found)?;
126    let archive = super::archive::read_archive(&path).map_err(|e| {
127        RuntimeError::Tool(format!(
128            "Subagent '{handle_id}' is no longer live and its archive ({}) could not be read: {e}",
129            path.display()
130        ))
131    })?;
132    let full = archive.output;
133    let char_count = full.chars().count();
134    let partial_output: String = if char_count > 500 {
135        full.chars().skip(char_count - 500).collect()
136    } else {
137        full
138    };
139    let note = if stopped_by_reload {
140        "Stopped by a daemon reload (not by the user); not running. subagent_resume continues it \
141         from this archive; subagent_collect reads its result."
142    } else {
143        "Not running: finished and reaped. subagent_collect reads its result from this archive; \
144         subagent_resume continues it."
145    };
146    Ok(json!({
147        "handle_id":      handle_id,
148        "agent_name":     archive.meta.agent_name,
149        "status":         archive.status,
150        // Same contract as the live path and subagent_collect: a failed
151        // worker's reason travels with its status.
152        "error":          archive.error,
153        "partial_output": partial_output,
154        "model":          archive.meta.model,
155        "archived":       true,
156        "stopped_by_reload": stopped_by_reload,
157        "archive_path":   path,
158        "note":           note,
159    })
160    .to_string())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    /// After a reload the old id still answers from its archive, and says
168    /// the reload (not the user) stopped it.
169    #[test]
170    fn archived_status_reports_a_reload_stopped_worker() {
171        let dir = tempfile::tempdir().unwrap();
172        let meta = crate::runtime::subagent::SubagentArchiveMeta {
173            agent_name: "integrator".into(),
174            model: "m".into(),
175            system_prompt: "s".into(),
176            timeout_secs: 60,
177        };
178        let path = super::super::archive::write_archive(
179            dir.path(),
180            "sa_288",
181            &meta,
182            "cancelled",
183            Some("boom"),
184            "partial words",
185            &[],
186        )
187        .unwrap();
188        let v: Value =
189            serde_json::from_str(&archived_status("sa_288", Some(path.clone()), true).unwrap()).unwrap();
190        assert_eq!(v["status"], "cancelled");
191        assert_eq!(v["error"], "boom", "archived status keeps the archive's error");
192        assert_eq!(v["agent_name"], "integrator");
193        assert_eq!(v["archived"], true);
194        assert_eq!(v["stopped_by_reload"], true);
195        assert_eq!(v["partial_output"], "partial words");
196        assert!(v["note"].as_str().unwrap().contains("daemon reload"));
197
198        let v: Value =
199            serde_json::from_str(&archived_status("sa_288", Some(path), false).unwrap()).unwrap();
200        assert!(!v["note"].as_str().unwrap().contains("daemon reload"));
201
202        let err = archived_status("sa_9", None, false).unwrap_err().to_string();
203        assert!(err.contains("No subagent found"), "{err}");
204    }
205}