Skip to main content

agentzero_tools/
sop_tools.rs

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/// Persistent store for SOP plans, keyed by plan ID.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14struct SopStore {
15    plans: HashMap<String, SopPlan>,
16    /// Steps that require approval before they can be advanced.
17    /// Key: "plan_id:step_index", Value: true if approved.
18    #[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// --- sop_list ---
53
54#[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// --- sop_status ---
111
112#[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// --- sop_advance ---
181
182#[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        // Check if step requires approval
224        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// --- sop_approve ---
253
254#[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// --- sop_execute ---
326
327#[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        // Register approval requirements
383        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        // List shows in_progress
490        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        // Create SOP with step 1 requiring approval
505        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        // Advance step 0 (no approval needed)
514        SopAdvanceTool
515            .execute(r#"{"id": "prod-deploy", "step_index": 0}"#, &ctx)
516            .await
517            .expect("step 0 should advance");
518
519        // Try to advance step 1 without approval — should fail
520        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        // Status shows awaiting_approval
527        let result = SopStatusTool
528            .execute(r#"{"id": "prod-deploy"}"#, &ctx)
529            .await
530            .unwrap();
531        assert!(result.output.contains("awaiting_approval"));
532
533        // Approve step 1
534        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        // Now advance step 1
541        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}