zeroclaw_runtime/tools/
sop_list.rs1use 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
10pub 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}