agentzero_tools/
delegate_coordination_status.rs1use 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#[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}