Skip to main content

zeroclaw_runtime/tools/
sop_list.rs

1use std::fmt::Write;
2use std::sync::Mutex;
3
4use async_trait::async_trait;
5use serde_json::json;
6
7use crate::sop::SopEngine;
8use zeroclaw_api::tool::{Tool, ToolResult};
9
10/// Lists all loaded SOPs with their triggers, priority, step count, and active runs.
11pub struct SopListTool {
12    engine: std::sync::Arc<Mutex<SopEngine>>,
13}
14
15impl SopListTool {
16    pub fn new(engine: std::sync::Arc<Mutex<SopEngine>>) -> Self {
17        Self { engine }
18    }
19}
20
21#[async_trait]
22impl Tool for SopListTool {
23    fn name(&self) -> &str {
24        "sop_list"
25    }
26
27    fn description(&self) -> &str {
28        "List all loaded Standard Operating Procedures (SOPs) with their triggers, priority, step count, and active run count. Optionally filter by name or priority."
29    }
30
31    fn parameters_schema(&self) -> serde_json::Value {
32        json!({
33            "type": "object",
34            "properties": {
35                "filter": {
36                    "type": "string",
37                    "description": "Filter SOPs by name substring or priority (low/normal/high/critical)"
38                }
39            }
40        })
41    }
42
43    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
44        let filter = args.get("filter").and_then(|v| v.as_str()).unwrap_or("");
45        let filter_lower = filter.to_lowercase();
46
47        let engine = self.engine.lock().map_err(|e| {
48            ::zeroclaw_log::record!(
49                ERROR,
50                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
51                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
52                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
53                "SOP engine lock poisoned"
54            );
55
56            anyhow::Error::msg(format!("Engine lock poisoned: {e}"))
57        })?;
58        let sops = engine.sops();
59
60        if sops.is_empty() {
61            return Ok(ToolResult {
62                success: true,
63                output: "No SOPs loaded.".into(),
64                error: None,
65            });
66        }
67
68        let filtered: Vec<_> = if filter_lower.is_empty() {
69            sops.iter().collect()
70        } else {
71            sops.iter()
72                .filter(|s| {
73                    s.name.to_lowercase().contains(&filter_lower)
74                        || s.priority.to_string() == filter_lower
75                })
76                .collect()
77        };
78
79        if filtered.is_empty() {
80            return Ok(ToolResult {
81                success: true,
82                output: format!("No SOPs match filter '{filter}'."),
83                error: None,
84            });
85        }
86
87        let active_runs = engine.active_runs();
88        let mut output = format!(
89            "Loaded SOPs ({} total, {} shown):\n\n",
90            sops.len(),
91            filtered.len()
92        );
93
94        for sop in &filtered {
95            let active_count = active_runs
96                .values()
97                .filter(|r| r.sop_name == sop.name)
98                .count();
99            let triggers: Vec<String> = sop.triggers.iter().map(|t| t.to_string()).collect();
100
101            let _ = writeln!(
102                output,
103                "- **{}** [{}] — {} steps, {} trigger(s): {}{}",
104                sop.name,
105                sop.priority,
106                sop.steps.len(),
107                sop.triggers.len(),
108                triggers.join(", "),
109                if active_count > 0 {
110                    format!(" (active runs: {active_count})")
111                } else {
112                    String::new()
113                }
114            );
115        }
116
117        Ok(ToolResult {
118            success: true,
119            output,
120            error: None,
121        })
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::sop::engine::SopEngine;
129    use crate::sop::types::*;
130    use std::sync::Arc;
131    use zeroclaw_config::schema::SopConfig;
132
133    fn test_sop(name: &str, priority: SopPriority) -> Sop {
134        Sop {
135            name: name.into(),
136            description: format!("Test SOP: {name}"),
137            version: "1.0.0".into(),
138            priority,
139            execution_mode: SopExecutionMode::Auto,
140            triggers: vec![SopTrigger::Manual],
141            steps: vec![SopStep {
142                number: 1,
143                title: "Step one".into(),
144                body: "Do it".into(),
145                suggested_tools: vec![],
146                requires_confirmation: false,
147                kind: SopStepKind::default(),
148                schema: None,
149            }],
150            cooldown_secs: 0,
151            max_concurrent: 1,
152            location: None,
153            deterministic: false,
154        }
155    }
156
157    fn engine_with_sops(sops: Vec<Sop>) -> Arc<Mutex<SopEngine>> {
158        let mut engine = SopEngine::new(SopConfig::default());
159        engine.set_sops_for_test(sops);
160        Arc::new(Mutex::new(engine))
161    }
162
163    #[tokio::test]
164    async fn list_all_sops() {
165        let engine = engine_with_sops(vec![
166            test_sop("pump-shutdown", SopPriority::Critical),
167            test_sop("daily-check", SopPriority::Normal),
168        ]);
169        let tool = SopListTool::new(engine);
170        let result = tool.execute(json!({})).await.unwrap();
171        assert!(result.success);
172        assert!(result.output.contains("pump-shutdown"));
173        assert!(result.output.contains("daily-check"));
174        assert!(result.output.contains("2 total"));
175    }
176
177    #[tokio::test]
178    async fn list_empty() {
179        let engine = engine_with_sops(vec![]);
180        let tool = SopListTool::new(engine);
181        let result = tool.execute(json!({})).await.unwrap();
182        assert!(result.success);
183        assert!(result.output.contains("No SOPs loaded"));
184    }
185
186    #[tokio::test]
187    async fn filter_by_name() {
188        let engine = engine_with_sops(vec![
189            test_sop("pump-shutdown", SopPriority::Critical),
190            test_sop("daily-check", SopPriority::Normal),
191        ]);
192        let tool = SopListTool::new(engine);
193        let result = tool.execute(json!({"filter": "pump"})).await.unwrap();
194        assert!(result.success);
195        assert!(result.output.contains("pump-shutdown"));
196        assert!(!result.output.contains("daily-check"));
197    }
198
199    #[tokio::test]
200    async fn filter_by_priority() {
201        let engine = engine_with_sops(vec![
202            test_sop("pump-shutdown", SopPriority::Critical),
203            test_sop("daily-check", SopPriority::Normal),
204        ]);
205        let tool = SopListTool::new(engine);
206        let result = tool.execute(json!({"filter": "critical"})).await.unwrap();
207        assert!(result.success);
208        assert!(result.output.contains("pump-shutdown"));
209        assert!(!result.output.contains("daily-check"));
210    }
211
212    #[tokio::test]
213    async fn filter_no_match() {
214        let engine = engine_with_sops(vec![test_sop("pump-shutdown", SopPriority::Critical)]);
215        let tool = SopListTool::new(engine);
216        let result = tool
217            .execute(json!({"filter": "nonexistent"}))
218            .await
219            .unwrap();
220        assert!(result.success);
221        assert!(result.output.contains("No SOPs match"));
222    }
223
224    #[test]
225    fn name_and_schema() {
226        let engine = engine_with_sops(vec![]);
227        let tool = SopListTool::new(engine);
228        assert_eq!(tool.name(), "sop_list");
229        assert!(tool.parameters_schema()["properties"]["filter"].is_object());
230    }
231}