Skip to main content

agentzero_tools/
delegate_coordination_status.rs

1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::Context;
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::path::Path;
7use tokio::fs;
8
9const COORDINATION_FILE: &str = ".agentzero/coordination.json";
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12struct DelegationRecord {
13    agent_name: String,
14    status: String,
15    prompt_summary: String,
16    #[serde(default)]
17    iterations_used: usize,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21struct CoordinationStore {
22    delegations: Vec<DelegationRecord>,
23}
24
25impl CoordinationStore {
26    async fn load(workspace_root: &str) -> anyhow::Result<Self> {
27        let path = Path::new(workspace_root).join(COORDINATION_FILE);
28        if !path.exists() {
29            return Ok(Self::default());
30        }
31        let data = fs::read_to_string(&path)
32            .await
33            .context("failed to read coordination store")?;
34        serde_json::from_str(&data).context("failed to parse coordination store")
35    }
36
37    async fn save(&self, workspace_root: &str) -> anyhow::Result<()> {
38        let path = Path::new(workspace_root).join(COORDINATION_FILE);
39        if let Some(parent) = path.parent() {
40            fs::create_dir_all(parent)
41                .await
42                .context("failed to create .agentzero directory")?;
43        }
44        let data =
45            serde_json::to_string_pretty(self).context("failed to serialize coordination store")?;
46        fs::write(&path, data)
47            .await
48            .context("failed to write coordination store")
49    }
50}
51
52#[derive(Debug, Deserialize)]
53struct Input {
54    op: String,
55    #[serde(default)]
56    agent_name: Option<String>,
57    #[serde(default)]
58    status: Option<String>,
59    #[serde(default)]
60    prompt_summary: Option<String>,
61    #[serde(default)]
62    iterations_used: Option<usize>,
63}
64
65/// Query and update delegation coordination state across sub-agents.
66///
67/// Operations:
68/// - `list`: List all delegation records
69/// - `record`: Record a delegation event
70/// - `clear`: Clear all delegation records
71#[derive(Debug, Default, Clone, Copy)]
72pub struct DelegateCoordinationStatusTool;
73
74#[async_trait]
75impl Tool for DelegateCoordinationStatusTool {
76    fn name(&self) -> &'static str {
77        "delegate_coordination_status"
78    }
79
80    fn description(&self) -> &'static str {
81        "Track and manage delegation coordination: list, record, or clear delegation events."
82    }
83
84    fn input_schema(&self) -> Option<serde_json::Value> {
85        Some(json!({
86            "type": "object",
87            "required": ["op"],
88            "properties": {
89                "op": {"type": "string", "description": "Operation: list, record, or clear"},
90                "agent_name": {"type": "string", "description": "Agent name (for record)"},
91                "status": {"type": "string", "description": "Status string (for record)"},
92                "prompt_summary": {"type": "string", "description": "Prompt summary (for record)"},
93                "iterations_used": {"type": "integer", "description": "Iterations used (for record)"}
94            }
95        }))
96    }
97
98    async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
99        let parsed: Input = serde_json::from_str(input)
100            .context("delegate_coordination_status expects JSON: {\"op\", ...}")?;
101
102        match parsed.op.as_str() {
103            "list" => {
104                let store = CoordinationStore::load(&ctx.workspace_root).await?;
105                if store.delegations.is_empty() {
106                    return Ok(ToolResult {
107                        output: "no delegation records".to_string(),
108                    });
109                }
110                let records: Vec<serde_json::Value> = store
111                    .delegations
112                    .iter()
113                    .map(|d| {
114                        json!({
115                            "agent_name": d.agent_name,
116                            "status": d.status,
117                            "prompt_summary": d.prompt_summary,
118                            "iterations_used": d.iterations_used,
119                        })
120                    })
121                    .collect();
122                Ok(ToolResult {
123                    output: serde_json::to_string_pretty(&records)
124                        .unwrap_or_else(|_| "[]".to_string()),
125                })
126            }
127            "record" => {
128                let agent_name = parsed
129                    .agent_name
130                    .as_deref()
131                    .ok_or_else(|| anyhow::anyhow!("record requires `agent_name`"))?;
132                let status = parsed
133                    .status
134                    .as_deref()
135                    .ok_or_else(|| anyhow::anyhow!("record requires `status`"))?;
136                let prompt_summary = parsed
137                    .prompt_summary
138                    .as_deref()
139                    .ok_or_else(|| anyhow::anyhow!("record requires `prompt_summary`"))?;
140
141                if agent_name.trim().is_empty() {
142                    return Err(anyhow::anyhow!("agent_name must not be empty"));
143                }
144
145                let mut store = CoordinationStore::load(&ctx.workspace_root).await?;
146                store.delegations.push(DelegationRecord {
147                    agent_name: agent_name.to_string(),
148                    status: status.to_string(),
149                    prompt_summary: prompt_summary.to_string(),
150                    iterations_used: parsed.iterations_used.unwrap_or(0),
151                });
152                store.save(&ctx.workspace_root).await?;
153
154                Ok(ToolResult {
155                    output: format!("recorded delegation: agent={agent_name} status={status}"),
156                })
157            }
158            "clear" => {
159                let store = CoordinationStore::default();
160                store.save(&ctx.workspace_root).await?;
161                Ok(ToolResult {
162                    output: "cleared all delegation records".to_string(),
163                })
164            }
165            other => Ok(ToolResult {
166                output: json!({ "error": format!("unknown op: {other}") }).to_string(),
167            }),
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use agentzero_core::ToolContext;
176    use std::fs;
177    use std::path::PathBuf;
178    use std::sync::atomic::{AtomicU64, Ordering};
179    use std::time::{SystemTime, UNIX_EPOCH};
180
181    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
182
183    fn temp_dir() -> PathBuf {
184        let nanos = SystemTime::now()
185            .duration_since(UNIX_EPOCH)
186            .expect("clock")
187            .as_nanos();
188        let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
189        let dir = std::env::temp_dir().join(format!(
190            "agentzero-coord-tools-{}-{nanos}-{seq}",
191            std::process::id()
192        ));
193        fs::create_dir_all(&dir).expect("temp dir should be created");
194        dir
195    }
196
197    #[tokio::test]
198    async fn list_empty_returns_no_records() {
199        let dir = temp_dir();
200        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
201
202        let result = DelegateCoordinationStatusTool
203            .execute(r#"{"op": "list"}"#, &ctx)
204            .await
205            .expect("list should succeed");
206        assert!(result.output.contains("no delegation records"));
207
208        fs::remove_dir_all(dir).ok();
209    }
210
211    #[tokio::test]
212    async fn record_and_list_roundtrip() {
213        let dir = temp_dir();
214        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
215
216        DelegateCoordinationStatusTool
217            .execute(
218                r#"{"op": "record", "agent_name": "researcher", "status": "completed", "prompt_summary": "Find docs", "iterations_used": 3}"#,
219                &ctx,
220            )
221            .await
222            .expect("record should succeed");
223
224        let result = DelegateCoordinationStatusTool
225            .execute(r#"{"op": "list"}"#, &ctx)
226            .await
227            .expect("list should succeed");
228        assert!(result.output.contains("researcher"));
229        assert!(result.output.contains("completed"));
230        assert!(result.output.contains("Find docs"));
231
232        fs::remove_dir_all(dir).ok();
233    }
234
235    #[tokio::test]
236    async fn clear_removes_all_records() {
237        let dir = temp_dir();
238        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
239
240        DelegateCoordinationStatusTool
241            .execute(
242                r#"{"op": "record", "agent_name": "a1", "status": "done", "prompt_summary": "task"}"#,
243                &ctx,
244            )
245            .await
246            .unwrap();
247
248        DelegateCoordinationStatusTool
249            .execute(r#"{"op": "clear"}"#, &ctx)
250            .await
251            .expect("clear should succeed");
252
253        let result = DelegateCoordinationStatusTool
254            .execute(r#"{"op": "list"}"#, &ctx)
255            .await
256            .unwrap();
257        assert!(result.output.contains("no delegation records"));
258
259        fs::remove_dir_all(dir).ok();
260    }
261
262    #[tokio::test]
263    async fn record_empty_agent_name_fails() {
264        let dir = temp_dir();
265        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
266
267        let err = DelegateCoordinationStatusTool
268            .execute(
269                r#"{"op": "record", "agent_name": "", "status": "done", "prompt_summary": "x"}"#,
270                &ctx,
271            )
272            .await
273            .expect_err("empty agent_name should fail");
274        assert!(err.to_string().contains("agent_name must not be empty"));
275
276        fs::remove_dir_all(dir).ok();
277    }
278
279    #[tokio::test]
280    async fn record_missing_fields_fails() {
281        let dir = temp_dir();
282        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
283
284        let err = DelegateCoordinationStatusTool
285            .execute(r#"{"op": "record", "agent_name": "a1"}"#, &ctx)
286            .await
287            .expect_err("missing status should fail");
288        assert!(err.to_string().contains("requires `status`"));
289
290        fs::remove_dir_all(dir).ok();
291    }
292}