1use crate::skills::sop::{self, SopPlan};
2use agentzero_core::{Tool, ToolContext, ToolResult};
3use anyhow::{anyhow, Context};
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::Path;
8use tokio::fs;
9
10const SOP_FILE: &str = ".agentzero/sops.json";
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14struct SopStore {
15 plans: HashMap<String, SopPlan>,
16 #[serde(default)]
19 approvals: HashMap<String, bool>,
20}
21
22impl SopStore {
23 async fn load(workspace_root: &str) -> anyhow::Result<Self> {
24 let path = Path::new(workspace_root).join(SOP_FILE);
25 if !path.exists() {
26 return Ok(Self::default());
27 }
28 let data = fs::read_to_string(&path)
29 .await
30 .context("failed to read sop store")?;
31 serde_json::from_str(&data).context("failed to parse sop store")
32 }
33
34 async fn save(&self, workspace_root: &str) -> anyhow::Result<()> {
35 let path = Path::new(workspace_root).join(SOP_FILE);
36 if let Some(parent) = path.parent() {
37 fs::create_dir_all(parent)
38 .await
39 .context("failed to create .agentzero directory")?;
40 }
41 let data = serde_json::to_string_pretty(self).context("failed to serialize sop store")?;
42 fs::write(&path, data)
43 .await
44 .context("failed to write sop store")
45 }
46
47 fn approval_key(plan_id: &str, step_index: usize) -> String {
48 format!("{plan_id}:{step_index}")
49 }
50}
51
52#[derive(Debug, Default, Clone, Copy)]
55pub struct SopListTool;
56
57#[async_trait]
58impl Tool for SopListTool {
59 fn name(&self) -> &'static str {
60 "sop_list"
61 }
62
63 fn description(&self) -> &'static str {
64 "List all standard operating procedures (SOPs) in the workspace."
65 }
66
67 fn input_schema(&self) -> Option<serde_json::Value> {
68 Some(serde_json::json!({
69 "type": "object",
70 "properties": {},
71 "additionalProperties": false
72 }))
73 }
74
75 async fn execute(&self, _input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
76 let store = SopStore::load(&ctx.workspace_root).await?;
77
78 if store.plans.is_empty() {
79 return Ok(ToolResult {
80 output: "no SOPs found".to_string(),
81 });
82 }
83
84 let mut lines: Vec<String> = Vec::new();
85 let mut ids: Vec<&String> = store.plans.keys().collect();
86 ids.sort();
87
88 for id in ids {
89 let plan = &store.plans[id];
90 let completed = plan.steps.iter().filter(|s| s.completed).count();
91 let total = plan.steps.len();
92 let status = if completed == total {
93 "completed"
94 } else if completed > 0 {
95 "in_progress"
96 } else {
97 "pending"
98 };
99 lines.push(format!(
100 "id={id} status={status} progress={completed}/{total}"
101 ));
102 }
103
104 Ok(ToolResult {
105 output: lines.join("\n"),
106 })
107 }
108}
109
110#[derive(Debug, Deserialize)]
113struct SopStatusInput {
114 id: String,
115}
116
117#[derive(Debug, Default, Clone, Copy)]
118pub struct SopStatusTool;
119
120#[async_trait]
121impl Tool for SopStatusTool {
122 fn name(&self) -> &'static str {
123 "sop_status"
124 }
125
126 fn description(&self) -> &'static str {
127 "Get the current status and progress of an SOP."
128 }
129
130 fn input_schema(&self) -> Option<serde_json::Value> {
131 Some(serde_json::json!({
132 "type": "object",
133 "properties": {
134 "id": { "type": "string", "description": "The SOP plan ID" }
135 },
136 "required": ["id"],
137 "additionalProperties": false
138 }))
139 }
140
141 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
142 let req: SopStatusInput =
143 serde_json::from_str(input).context("sop_status expects JSON: {\"id\"}")?;
144
145 if req.id.trim().is_empty() {
146 return Err(anyhow!("id must not be empty"));
147 }
148
149 let store = SopStore::load(&ctx.workspace_root).await?;
150 let plan = store
151 .plans
152 .get(&req.id)
153 .ok_or_else(|| anyhow!("SOP not found: {}", req.id))?;
154
155 let mut lines = vec![format!("sop_id={}", plan.id)];
156 for (i, step) in plan.steps.iter().enumerate() {
157 let approval_key = SopStore::approval_key(&plan.id, i);
158 let needs_approval = store.approvals.contains_key(&approval_key);
159 let approved = store.approvals.get(&approval_key).copied().unwrap_or(false);
160
161 let status = if step.completed {
162 "completed".to_string()
163 } else if needs_approval && !approved {
164 "awaiting_approval".to_string()
165 } else {
166 "pending".to_string()
167 };
168 lines.push(format!(
169 " step[{i}] title=\"{}\" status={status}",
170 step.title
171 ));
172 }
173
174 Ok(ToolResult {
175 output: lines.join("\n"),
176 })
177 }
178}
179
180#[derive(Debug, Deserialize)]
183struct SopAdvanceInput {
184 id: String,
185 step_index: usize,
186}
187
188#[derive(Debug, Default, Clone, Copy)]
189pub struct SopAdvanceTool;
190
191#[async_trait]
192impl Tool for SopAdvanceTool {
193 fn name(&self) -> &'static str {
194 "sop_advance"
195 }
196
197 fn description(&self) -> &'static str {
198 "Advance an SOP to the next step."
199 }
200
201 fn input_schema(&self) -> Option<serde_json::Value> {
202 Some(serde_json::json!({
203 "type": "object",
204 "properties": {
205 "id": { "type": "string", "description": "The SOP plan ID" },
206 "step_index": { "type": "integer", "description": "Index of the step to mark as completed" }
207 },
208 "required": ["id", "step_index"],
209 "additionalProperties": false
210 }))
211 }
212
213 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
214 let req: SopAdvanceInput = serde_json::from_str(input)
215 .context("sop_advance expects JSON: {\"id\", \"step_index\"}")?;
216
217 if req.id.trim().is_empty() {
218 return Err(anyhow!("id must not be empty"));
219 }
220
221 let mut store = SopStore::load(&ctx.workspace_root).await?;
222
223 let approval_key = SopStore::approval_key(&req.id, req.step_index);
225 if store.approvals.get(&approval_key) == Some(&false) {
226 return Err(anyhow!(
227 "step {} requires approval before it can be advanced",
228 req.step_index
229 ));
230 }
231
232 {
233 let plan = store
234 .plans
235 .get_mut(&req.id)
236 .ok_or_else(|| anyhow!("SOP not found: {}", req.id))?;
237 sop::advance_step(plan, req.step_index)?;
238 }
239
240 store.save(&ctx.workspace_root).await?;
241
242 let title = &store.plans[&req.id].steps[req.step_index].title;
243 Ok(ToolResult {
244 output: format!(
245 "advanced sop={} step={} title=\"{title}\"",
246 req.id, req.step_index
247 ),
248 })
249 }
250}
251
252#[derive(Debug, Deserialize)]
255struct SopApproveInput {
256 id: String,
257 step_index: usize,
258}
259
260#[derive(Debug, Default, Clone, Copy)]
261pub struct SopApproveTool;
262
263#[async_trait]
264impl Tool for SopApproveTool {
265 fn name(&self) -> &'static str {
266 "sop_approve"
267 }
268
269 fn description(&self) -> &'static str {
270 "Approve a step in an SOP that requires human approval."
271 }
272
273 fn input_schema(&self) -> Option<serde_json::Value> {
274 Some(serde_json::json!({
275 "type": "object",
276 "properties": {
277 "id": { "type": "string", "description": "The SOP plan ID" },
278 "step_index": { "type": "integer", "description": "Index of the step to approve" }
279 },
280 "required": ["id", "step_index"],
281 "additionalProperties": false
282 }))
283 }
284
285 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
286 let req: SopApproveInput = serde_json::from_str(input)
287 .context("sop_approve expects JSON: {\"id\", \"step_index\"}")?;
288
289 if req.id.trim().is_empty() {
290 return Err(anyhow!("id must not be empty"));
291 }
292
293 let mut store = SopStore::load(&ctx.workspace_root).await?;
294
295 let plan = store
296 .plans
297 .get(&req.id)
298 .ok_or_else(|| anyhow!("SOP not found: {}", req.id))?;
299
300 if req.step_index >= plan.steps.len() {
301 return Err(anyhow!(
302 "step index {} is out of range (plan has {} steps)",
303 req.step_index,
304 plan.steps.len()
305 ));
306 }
307
308 if plan.steps[req.step_index].completed {
309 return Err(anyhow!("step {} is already completed", req.step_index));
310 }
311
312 let approval_key = SopStore::approval_key(&req.id, req.step_index);
313 store.approvals.insert(approval_key, true);
314 store.save(&ctx.workspace_root).await?;
315
316 Ok(ToolResult {
317 output: format!(
318 "approved sop={} step={} title=\"{}\"",
319 req.id, req.step_index, plan.steps[req.step_index].title
320 ),
321 })
322 }
323}
324
325#[derive(Debug, Deserialize)]
328struct SopExecuteInput {
329 id: String,
330 steps: Vec<String>,
331 #[serde(default)]
332 approval_required: Vec<usize>,
333}
334
335#[derive(Debug, Default, Clone, Copy)]
336pub struct SopExecuteTool;
337
338#[async_trait]
339impl Tool for SopExecuteTool {
340 fn name(&self) -> &'static str {
341 "sop_execute"
342 }
343
344 fn description(&self) -> &'static str {
345 "Create and begin executing a new SOP with defined steps."
346 }
347
348 fn input_schema(&self) -> Option<serde_json::Value> {
349 Some(serde_json::json!({
350 "type": "object",
351 "properties": {
352 "id": { "type": "string", "description": "Unique SOP plan ID" },
353 "steps": { "type": "array", "items": { "type": "string" }, "description": "List of step titles" },
354 "approval_required": { "type": "array", "items": { "type": "integer" }, "description": "Indices of steps requiring human approval" }
355 },
356 "required": ["id", "steps"],
357 "additionalProperties": false
358 }))
359 }
360
361 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
362 let req: SopExecuteInput = serde_json::from_str(input).context(
363 "sop_execute expects JSON: {\"id\", \"steps\": [...], \"approval_required\"?: [...]}",
364 )?;
365
366 if req.id.trim().is_empty() {
367 return Err(anyhow!("id must not be empty"));
368 }
369 if req.steps.is_empty() {
370 return Err(anyhow!("steps must not be empty"));
371 }
372
373 let mut store = SopStore::load(&ctx.workspace_root).await?;
374
375 if store.plans.contains_key(&req.id) {
376 return Err(anyhow!("SOP already exists: {}", req.id));
377 }
378
379 let step_refs: Vec<&str> = req.steps.iter().map(|s| s.as_str()).collect();
380 let plan = sop::create_plan(&req.id, &step_refs)?;
381
382 for &idx in &req.approval_required {
384 if idx >= plan.steps.len() {
385 return Err(anyhow!(
386 "approval_required index {} is out of range (plan has {} steps)",
387 idx,
388 plan.steps.len()
389 ));
390 }
391 let key = SopStore::approval_key(&req.id, idx);
392 store.approvals.insert(key, false);
393 }
394
395 let step_count = plan.steps.len();
396 store.plans.insert(req.id.clone(), plan);
397 store.save(&ctx.workspace_root).await?;
398
399 Ok(ToolResult {
400 output: format!(
401 "created sop={} steps={} approval_required={}",
402 req.id,
403 step_count,
404 req.approval_required.len()
405 ),
406 })
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use agentzero_core::{Tool, ToolContext};
414 use std::fs;
415 use std::path::PathBuf;
416 use std::sync::atomic::{AtomicU64, Ordering};
417 use std::time::{SystemTime, UNIX_EPOCH};
418
419 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
420
421 fn temp_dir() -> PathBuf {
422 let nanos = SystemTime::now()
423 .duration_since(UNIX_EPOCH)
424 .expect("clock")
425 .as_nanos();
426 let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
427 let dir = std::env::temp_dir().join(format!(
428 "agentzero-sop-tools-{}-{nanos}-{seq}",
429 std::process::id()
430 ));
431 fs::create_dir_all(&dir).expect("temp dir should be created");
432 dir
433 }
434
435 #[tokio::test]
436 async fn sop_execute_create_and_list() {
437 let dir = temp_dir();
438 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
439
440 let result = SopExecuteTool
441 .execute(
442 r#"{"id": "deploy", "steps": ["build", "test", "ship"]}"#,
443 &ctx,
444 )
445 .await
446 .expect("execute should succeed");
447 assert!(result.output.contains("created sop=deploy"));
448 assert!(result.output.contains("steps=3"));
449
450 let result = SopListTool
451 .execute("{}", &ctx)
452 .await
453 .expect("list should succeed");
454 assert!(result.output.contains("id=deploy"));
455 assert!(result.output.contains("progress=0/3"));
456 assert!(result.output.contains("status=pending"));
457
458 fs::remove_dir_all(dir).ok();
459 }
460
461 #[tokio::test]
462 async fn sop_advance_and_status() {
463 let dir = temp_dir();
464 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
465
466 SopExecuteTool
467 .execute(
468 r#"{"id": "release", "steps": ["prepare", "review", "publish"]}"#,
469 &ctx,
470 )
471 .await
472 .unwrap();
473
474 SopAdvanceTool
475 .execute(r#"{"id": "release", "step_index": 0}"#, &ctx)
476 .await
477 .expect("advance should succeed");
478
479 let result = SopStatusTool
480 .execute(r#"{"id": "release"}"#, &ctx)
481 .await
482 .expect("status should succeed");
483 assert!(result.output.contains("sop_id=release"));
484 assert!(result.output.contains("step[0]"));
485 assert!(result.output.contains("completed"));
486 assert!(result.output.contains("step[1]"));
487 assert!(result.output.contains("pending"));
488
489 let result = SopListTool
491 .execute("{}", &ctx)
492 .await
493 .expect("list should succeed");
494 assert!(result.output.contains("status=in_progress"));
495
496 fs::remove_dir_all(dir).ok();
497 }
498
499 #[tokio::test]
500 async fn sop_approval_flow() {
501 let dir = temp_dir();
502 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
503
504 SopExecuteTool
506 .execute(
507 r#"{"id": "prod-deploy", "steps": ["build", "approve-deploy", "deploy"], "approval_required": [1]}"#,
508 &ctx,
509 )
510 .await
511 .unwrap();
512
513 SopAdvanceTool
515 .execute(r#"{"id": "prod-deploy", "step_index": 0}"#, &ctx)
516 .await
517 .expect("step 0 should advance");
518
519 let err = SopAdvanceTool
521 .execute(r#"{"id": "prod-deploy", "step_index": 1}"#, &ctx)
522 .await
523 .expect_err("unapproved step should fail");
524 assert!(err.to_string().contains("requires approval"));
525
526 let result = SopStatusTool
528 .execute(r#"{"id": "prod-deploy"}"#, &ctx)
529 .await
530 .unwrap();
531 assert!(result.output.contains("awaiting_approval"));
532
533 let result = SopApproveTool
535 .execute(r#"{"id": "prod-deploy", "step_index": 1}"#, &ctx)
536 .await
537 .expect("approve should succeed");
538 assert!(result.output.contains("approved"));
539
540 SopAdvanceTool
542 .execute(r#"{"id": "prod-deploy", "step_index": 1}"#, &ctx)
543 .await
544 .expect("approved step should advance");
545
546 fs::remove_dir_all(dir).ok();
547 }
548
549 #[tokio::test]
550 async fn sop_list_empty() {
551 let dir = temp_dir();
552 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
553
554 let result = SopListTool
555 .execute("{}", &ctx)
556 .await
557 .expect("list should succeed");
558 assert!(result.output.contains("no SOPs found"));
559
560 fs::remove_dir_all(dir).ok();
561 }
562
563 #[tokio::test]
564 async fn sop_execute_rejects_empty_id() {
565 let dir = temp_dir();
566 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
567
568 let err = SopExecuteTool
569 .execute(r#"{"id": "", "steps": ["a"]}"#, &ctx)
570 .await
571 .expect_err("empty id should fail");
572 assert!(err.to_string().contains("id must not be empty"));
573
574 fs::remove_dir_all(dir).ok();
575 }
576
577 #[tokio::test]
578 async fn sop_execute_rejects_duplicate_id() {
579 let dir = temp_dir();
580 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
581
582 SopExecuteTool
583 .execute(r#"{"id": "dup", "steps": ["a"]}"#, &ctx)
584 .await
585 .unwrap();
586
587 let err = SopExecuteTool
588 .execute(r#"{"id": "dup", "steps": ["b"]}"#, &ctx)
589 .await
590 .expect_err("duplicate should fail");
591 assert!(err.to_string().contains("already exists"));
592
593 fs::remove_dir_all(dir).ok();
594 }
595
596 #[tokio::test]
597 async fn sop_status_not_found() {
598 let dir = temp_dir();
599 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
600
601 let err = SopStatusTool
602 .execute(r#"{"id": "nonexistent"}"#, &ctx)
603 .await
604 .expect_err("not found should fail");
605 assert!(err.to_string().contains("not found"));
606
607 fs::remove_dir_all(dir).ok();
608 }
609
610 #[tokio::test]
611 async fn sop_advance_out_of_range() {
612 let dir = temp_dir();
613 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
614
615 SopExecuteTool
616 .execute(r#"{"id": "small", "steps": ["only"]}"#, &ctx)
617 .await
618 .unwrap();
619
620 let err = SopAdvanceTool
621 .execute(r#"{"id": "small", "step_index": 5}"#, &ctx)
622 .await
623 .expect_err("out of range should fail");
624 assert!(err.to_string().contains("out of range"));
625
626 fs::remove_dir_all(dir).ok();
627 }
628
629 #[tokio::test]
630 async fn sop_approve_completed_step_fails() {
631 let dir = temp_dir();
632 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
633
634 SopExecuteTool
635 .execute(r#"{"id": "done", "steps": ["a"]}"#, &ctx)
636 .await
637 .unwrap();
638
639 SopAdvanceTool
640 .execute(r#"{"id": "done", "step_index": 0}"#, &ctx)
641 .await
642 .unwrap();
643
644 let err = SopApproveTool
645 .execute(r#"{"id": "done", "step_index": 0}"#, &ctx)
646 .await
647 .expect_err("approving completed step should fail");
648 assert!(err.to_string().contains("already completed"));
649
650 fs::remove_dir_all(dir).ok();
651 }
652}