Skip to main content

zeroclaw_runtime/tools/
sop_execute.rs

1use std::sync::{Arc, Mutex};
2
3use async_trait::async_trait;
4use serde_json::json;
5
6use crate::sop::types::{SopEvent, SopRunAction, SopTriggerSource};
7use crate::sop::{SopAuditLogger, SopEngine};
8use zeroclaw_api::tool::{Tool, ToolResult};
9
10/// Manually trigger an SOP by name. Returns the run ID and first step instruction.
11pub struct SopExecuteTool {
12    engine: Arc<Mutex<SopEngine>>,
13    audit: Option<Arc<SopAuditLogger>>,
14}
15
16impl SopExecuteTool {
17    pub fn new(engine: Arc<Mutex<SopEngine>>) -> Self {
18        Self {
19            engine,
20            audit: None,
21        }
22    }
23
24    pub fn with_audit(mut self, audit: Arc<SopAuditLogger>) -> Self {
25        self.audit = Some(audit);
26        self
27    }
28}
29
30#[async_trait]
31impl Tool for SopExecuteTool {
32    fn name(&self) -> &str {
33        "sop_execute"
34    }
35
36    fn description(&self) -> &str {
37        "Manually trigger a Standard Operating Procedure (SOP) by name. Returns the run ID and first step instruction. Use sop_list to see available SOPs."
38    }
39
40    fn parameters_schema(&self) -> serde_json::Value {
41        json!({
42            "type": "object",
43            "properties": {
44                "name": {
45                    "type": "string",
46                    "description": "Name of the SOP to execute"
47                },
48                "payload": {
49                    "type": "string",
50                    "description": "Optional trigger payload (JSON string)"
51                }
52            },
53            "required": ["name"]
54        })
55    }
56
57    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
58        let sop_name = args.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
59            ::zeroclaw_log::record!(
60                WARN,
61                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
62                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
63                    .with_attrs(::serde_json::json!({"param": "name"})),
64                "tool argument validation failed"
65            );
66
67            anyhow::Error::msg("Missing 'name' parameter")
68        })?;
69
70        let payload = args
71            .get("payload")
72            .and_then(|v| v.as_str())
73            .map(String::from);
74
75        let event = SopEvent {
76            source: SopTriggerSource::Manual,
77            topic: None,
78            payload,
79            timestamp: now_iso8601(),
80        };
81
82        // Lock engine, start run, snapshot run for audit, then drop lock
83        let (action, run_snapshot) = {
84            let mut engine = self.engine.lock().map_err(|e| {
85                ::zeroclaw_log::record!(
86                    ERROR,
87                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
88                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
89                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
90                    "SOP engine lock poisoned"
91                );
92
93                anyhow::Error::msg(format!("Engine lock poisoned: {e}"))
94            })?;
95
96            match engine.start_run(sop_name, event) {
97                Ok(action) => {
98                    let run_id = action_run_id(&action);
99                    let snapshot = run_id.and_then(|id| engine.get_run(id).cloned());
100                    (Ok(action), snapshot)
101                }
102                Err(e) => (Err(e), None),
103            }
104        };
105
106        // Audit log (engine lock dropped, safe to await)
107        if let Some(ref audit) = self.audit
108            && let Some(ref run) = run_snapshot
109            && let Err(e) = audit.log_run_start(run).await
110        {
111            ::zeroclaw_log::record!(
112                WARN,
113                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
114                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
115                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
116                "SOP audit log_run_start failed"
117            );
118        }
119
120        match action {
121            Ok(action) => {
122                let output = match action {
123                    SopRunAction::ExecuteStep {
124                        run_id, context, ..
125                    } => {
126                        format!("SOP run started: {run_id}\n\n{context}")
127                    }
128                    SopRunAction::WaitApproval {
129                        run_id, context, ..
130                    } => {
131                        format!("SOP run started: {run_id} (waiting for approval)\n\n{context}")
132                    }
133                    SopRunAction::Completed { run_id, sop_name } => {
134                        format!("SOP '{sop_name}' run {run_id} completed immediately (no steps).")
135                    }
136                    SopRunAction::Failed { run_id, reason, .. } => {
137                        format!("SOP run {run_id} failed: {reason}")
138                    }
139                    SopRunAction::DeterministicStep { run_id, step, .. } => {
140                        format!(
141                            "SOP run started (deterministic): {run_id}\nFirst step: {}",
142                            step.title
143                        )
144                    }
145                    SopRunAction::CheckpointWait { run_id, step, .. } => {
146                        format!(
147                            "SOP run started: {run_id} (paused at checkpoint: {})",
148                            step.title
149                        )
150                    }
151                };
152                Ok(ToolResult {
153                    success: true,
154                    output,
155                    error: None,
156                })
157            }
158            Err(e) => Ok(ToolResult {
159                success: false,
160                output: String::new(),
161                error: Some(format!("Failed to start SOP: {e}")),
162            }),
163        }
164    }
165}
166
167/// Extract run_id from any SopRunAction variant.
168fn action_run_id(action: &SopRunAction) -> Option<&str> {
169    match action {
170        SopRunAction::ExecuteStep { run_id, .. }
171        | SopRunAction::WaitApproval { run_id, .. }
172        | SopRunAction::Completed { run_id, .. }
173        | SopRunAction::Failed { run_id, .. }
174        | SopRunAction::DeterministicStep { run_id, .. }
175        | SopRunAction::CheckpointWait { run_id, .. } => Some(run_id),
176    }
177}
178
179use crate::sop::engine::now_iso8601;
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::sop::engine::SopEngine;
185    use crate::sop::types::*;
186    use zeroclaw_config::schema::SopConfig;
187
188    fn test_sop(name: &str, mode: SopExecutionMode) -> Sop {
189        Sop {
190            name: name.into(),
191            description: format!("Test SOP: {name}"),
192            version: "1.0.0".into(),
193            priority: SopPriority::Normal,
194            execution_mode: mode,
195            triggers: vec![SopTrigger::Manual],
196            steps: vec![
197                SopStep {
198                    number: 1,
199                    title: "Step one".into(),
200                    body: "Do step one".into(),
201                    suggested_tools: vec!["shell".into()],
202                    requires_confirmation: false,
203                    kind: SopStepKind::default(),
204                    schema: None,
205                },
206                SopStep {
207                    number: 2,
208                    title: "Step two".into(),
209                    body: "Do step two".into(),
210                    suggested_tools: vec![],
211                    requires_confirmation: false,
212                    kind: SopStepKind::default(),
213                    schema: None,
214                },
215            ],
216            cooldown_secs: 0,
217            max_concurrent: 1,
218            location: None,
219            deterministic: false,
220        }
221    }
222
223    fn engine_with_sops(sops: Vec<Sop>) -> Arc<Mutex<SopEngine>> {
224        let mut engine = SopEngine::new(SopConfig::default());
225        engine.set_sops_for_test(sops);
226        Arc::new(Mutex::new(engine))
227    }
228
229    #[tokio::test]
230    async fn execute_auto_sop() {
231        let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Auto)]);
232        let tool = SopExecuteTool::new(engine);
233        let result = tool.execute(json!({"name": "test-sop"})).await.unwrap();
234        assert!(result.success);
235        assert!(result.output.contains("run-"));
236        assert!(result.output.contains("Step one"));
237    }
238
239    #[tokio::test]
240    async fn execute_supervised_sop() {
241        let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Supervised)]);
242        let tool = SopExecuteTool::new(engine);
243        let result = tool.execute(json!({"name": "test-sop"})).await.unwrap();
244        assert!(result.success);
245        assert!(result.output.contains("waiting for approval"));
246    }
247
248    #[tokio::test]
249    async fn execute_unknown_sop() {
250        let engine = engine_with_sops(vec![]);
251        let tool = SopExecuteTool::new(engine);
252        let result = tool.execute(json!({"name": "nonexistent"})).await.unwrap();
253        assert!(!result.success);
254        assert!(result.error.unwrap().contains("Failed to start SOP"));
255    }
256
257    #[tokio::test]
258    async fn execute_missing_name() {
259        let engine = engine_with_sops(vec![]);
260        let tool = SopExecuteTool::new(engine);
261        let result = tool.execute(json!({})).await;
262        assert!(result.is_err());
263    }
264
265    #[tokio::test]
266    async fn execute_with_payload() {
267        let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Auto)]);
268        let tool = SopExecuteTool::new(engine);
269        let result = tool
270            .execute(json!({"name": "test-sop", "payload": "{\"value\": 87.3}"}))
271            .await
272            .unwrap();
273        assert!(result.success);
274        assert!(result.output.contains("87.3"));
275    }
276
277    #[test]
278    fn name_and_schema() {
279        let engine = engine_with_sops(vec![]);
280        let tool = SopExecuteTool::new(engine);
281        assert_eq!(tool.name(), "sop_execute");
282        assert!(tool.parameters_schema()["required"].is_array());
283    }
284}