Skip to main content

thulp_skills/
executor.rs

1//! Skill execution abstraction.
2//!
3//! This module provides the [`SkillExecutor`] trait for pluggable skill execution,
4//! along with [`ExecutionContext`] for managing state between steps.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use thulp_skills::{SkillExecutor, ExecutionContext, DefaultSkillExecutor};
10//!
11//! let executor = DefaultSkillExecutor::new(transport);
12//! let mut context = ExecutionContext::new()
13//!     .with_input("query", json!("search term"));
14//!
15//! let result = executor.execute(&skill, &mut context).await?;
16//! ```
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::collections::HashMap;
22
23use crate::{ExecutionConfig, Skill, SkillError, SkillResult, SkillStep};
24
25/// Result of executing a single step.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct StepResult {
28    /// Name of the step that was executed
29    pub step_name: String,
30
31    /// Whether the step completed successfully
32    pub success: bool,
33
34    /// Output data from the step
35    pub output: Option<Value>,
36
37    /// Error message if the step failed
38    pub error: Option<String>,
39
40    /// Duration of the step execution in milliseconds
41    pub duration_ms: u64,
42
43    /// Number of retry attempts made
44    pub retry_attempts: usize,
45}
46
47impl StepResult {
48    /// Create a successful step result.
49    pub fn success(step_name: impl Into<String>, output: Option<Value>, duration_ms: u64) -> Self {
50        Self {
51            step_name: step_name.into(),
52            success: true,
53            output,
54            error: None,
55            duration_ms,
56            retry_attempts: 0,
57        }
58    }
59
60    /// Create a failed step result.
61    pub fn failure(
62        step_name: impl Into<String>,
63        error: impl Into<String>,
64        duration_ms: u64,
65    ) -> Self {
66        Self {
67            step_name: step_name.into(),
68            success: false,
69            output: None,
70            error: Some(error.into()),
71            duration_ms,
72            retry_attempts: 0,
73        }
74    }
75
76    /// Set the number of retry attempts.
77    pub fn with_retry_attempts(mut self, attempts: usize) -> Self {
78        self.retry_attempts = attempts;
79        self
80    }
81}
82
83/// Context passed through skill execution, carrying inputs, outputs, and configuration.
84///
85/// The execution context maintains state between steps, allowing later steps to
86/// reference outputs from earlier steps.
87#[derive(Debug, Clone)]
88pub struct ExecutionContext {
89    /// Input arguments provided to the skill
90    inputs: HashMap<String, Value>,
91
92    /// Outputs from completed steps, keyed by step name
93    outputs: HashMap<String, Value>,
94
95    /// Execution configuration (timeouts, retries, etc.)
96    config: ExecutionConfig,
97
98    /// Optional metadata for tracking/debugging
99    metadata: HashMap<String, Value>,
100}
101
102impl Default for ExecutionContext {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl ExecutionContext {
109    /// Create a new empty execution context with default configuration.
110    pub fn new() -> Self {
111        Self {
112            inputs: HashMap::new(),
113            outputs: HashMap::new(),
114            config: ExecutionConfig::default(),
115            metadata: HashMap::new(),
116        }
117    }
118
119    /// Create a context from input arguments.
120    pub fn from_inputs(inputs: HashMap<String, Value>) -> Self {
121        Self {
122            inputs,
123            outputs: HashMap::new(),
124            config: ExecutionConfig::default(),
125            metadata: HashMap::new(),
126        }
127    }
128
129    /// Add an input value.
130    pub fn with_input(mut self, key: impl Into<String>, value: Value) -> Self {
131        self.inputs.insert(key.into(), value);
132        self
133    }
134
135    /// Set the execution configuration.
136    pub fn with_config(mut self, config: ExecutionConfig) -> Self {
137        self.config = config;
138        self
139    }
140
141    /// Add metadata.
142    pub fn with_metadata(mut self, key: impl Into<String>, value: Value) -> Self {
143        self.metadata.insert(key.into(), value);
144        self
145    }
146
147    /// Get an input value by key.
148    pub fn get_input(&self, key: &str) -> Option<&Value> {
149        self.inputs.get(key)
150    }
151
152    /// Get all inputs.
153    pub fn inputs(&self) -> &HashMap<String, Value> {
154        &self.inputs
155    }
156
157    /// Get an output value by step name.
158    pub fn get_output(&self, step_name: &str) -> Option<&Value> {
159        self.outputs.get(step_name)
160    }
161
162    /// Get all outputs.
163    pub fn outputs(&self) -> &HashMap<String, Value> {
164        &self.outputs
165    }
166
167    /// Set an output value for a step.
168    pub fn set_output(&mut self, step_name: impl Into<String>, value: Value) {
169        self.outputs.insert(step_name.into(), value);
170    }
171
172    /// Get the execution configuration.
173    pub fn config(&self) -> &ExecutionConfig {
174        &self.config
175    }
176
177    /// Get mutable reference to the execution configuration.
178    pub fn config_mut(&mut self) -> &mut ExecutionConfig {
179        &mut self.config
180    }
181
182    /// Get metadata value.
183    pub fn get_metadata(&self, key: &str) -> Option<&Value> {
184        self.metadata.get(key)
185    }
186
187    /// Get all metadata.
188    pub fn metadata(&self) -> &HashMap<String, Value> {
189        &self.metadata
190    }
191
192    /// Set metadata value.
193    pub fn set_metadata(&mut self, key: impl Into<String>, value: Value) {
194        self.metadata.insert(key.into(), value);
195    }
196
197    /// Get a combined view of inputs and outputs for variable substitution.
198    ///
199    /// Outputs take precedence over inputs if there are key conflicts.
200    pub fn variables(&self) -> HashMap<String, Value> {
201        let mut vars = self.inputs.clone();
202        vars.extend(self.outputs.clone());
203        vars
204    }
205
206    /// Clear all outputs (useful for re-execution).
207    pub fn clear_outputs(&mut self) {
208        self.outputs.clear();
209    }
210}
211
212/// Trait for executing skills.
213///
214/// This trait abstracts the execution of skills, allowing different implementations
215/// to handle execution in different ways (e.g., local execution, remote execution,
216/// cached execution, etc.).
217///
218/// # Example
219///
220/// ```ignore
221/// use thulp_skills::{SkillExecutor, ExecutionContext};
222///
223/// struct MyExecutor;
224///
225/// #[async_trait]
226/// impl SkillExecutor for MyExecutor {
227///     async fn execute(
228///         &self,
229///         skill: &Skill,
230///         context: &mut ExecutionContext,
231///     ) -> Result<SkillResult, SkillError> {
232///         // Custom execution logic
233///     }
234///
235///     async fn execute_step(
236///         &self,
237///         step: &SkillStep,
238///         context: &mut ExecutionContext,
239///     ) -> Result<StepResult, SkillError> {
240///         // Custom step execution logic
241///     }
242/// }
243/// ```
244#[async_trait]
245pub trait SkillExecutor: Send + Sync {
246    /// Execute a complete skill.
247    ///
248    /// This method executes all steps in the skill sequentially, passing
249    /// outputs from earlier steps to later steps via the context.
250    ///
251    /// # Arguments
252    ///
253    /// * `skill` - The skill to execute
254    /// * `context` - Mutable execution context for inputs/outputs
255    ///
256    /// # Returns
257    ///
258    /// A `SkillResult` containing the outcome of all steps.
259    async fn execute(
260        &self,
261        skill: &Skill,
262        context: &mut ExecutionContext,
263    ) -> Result<SkillResult, SkillError>;
264
265    /// Execute a single step.
266    ///
267    /// This method executes a single step from a skill workflow.
268    ///
269    /// # Arguments
270    ///
271    /// * `step` - The step to execute
272    /// * `context` - Mutable execution context for inputs/outputs
273    ///
274    /// # Returns
275    ///
276    /// A `StepResult` containing the outcome of the step.
277    async fn execute_step(
278        &self,
279        step: &SkillStep,
280        context: &mut ExecutionContext,
281    ) -> Result<StepResult, SkillError>;
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_step_result_success() {
290        let result = StepResult::success("step1", Some(serde_json::json!({"data": "test"})), 100);
291
292        assert!(result.success);
293        assert_eq!(result.step_name, "step1");
294        assert!(result.output.is_some());
295        assert!(result.error.is_none());
296        assert_eq!(result.duration_ms, 100);
297    }
298
299    #[test]
300    fn test_step_result_failure() {
301        let result = StepResult::failure("step1", "something went wrong", 50);
302
303        assert!(!result.success);
304        assert_eq!(result.step_name, "step1");
305        assert!(result.output.is_none());
306        assert_eq!(result.error, Some("something went wrong".to_string()));
307    }
308
309    #[test]
310    fn test_step_result_with_retry_attempts() {
311        let result = StepResult::success("step1", None, 100).with_retry_attempts(3);
312
313        assert_eq!(result.retry_attempts, 3);
314    }
315
316    #[test]
317    fn test_execution_context_new() {
318        let context = ExecutionContext::new();
319
320        assert!(context.inputs().is_empty());
321        assert!(context.outputs().is_empty());
322        assert!(context.metadata().is_empty());
323    }
324
325    #[test]
326    fn test_execution_context_with_inputs() {
327        let context = ExecutionContext::new()
328            .with_input("query", serde_json::json!("test"))
329            .with_input("limit", serde_json::json!(10));
330
331        assert_eq!(context.inputs().len(), 2);
332        assert_eq!(context.get_input("query"), Some(&serde_json::json!("test")));
333        assert_eq!(context.get_input("limit"), Some(&serde_json::json!(10)));
334    }
335
336    #[test]
337    fn test_execution_context_from_inputs() {
338        let mut inputs = HashMap::new();
339        inputs.insert("key".to_string(), serde_json::json!("value"));
340
341        let context = ExecutionContext::from_inputs(inputs);
342
343        assert_eq!(context.inputs().len(), 1);
344        assert_eq!(context.get_input("key"), Some(&serde_json::json!("value")));
345    }
346
347    #[test]
348    fn test_execution_context_outputs() {
349        let mut context = ExecutionContext::new();
350
351        context.set_output("step1", serde_json::json!({"result": 42}));
352
353        assert_eq!(
354            context.get_output("step1"),
355            Some(&serde_json::json!({"result": 42}))
356        );
357    }
358
359    #[test]
360    fn test_execution_context_variables() {
361        let mut context = ExecutionContext::new()
362            .with_input("input1", serde_json::json!("in"))
363            .with_input("shared", serde_json::json!("from_input"));
364
365        context.set_output("step1", serde_json::json!("out"));
366        context.set_output("shared", serde_json::json!("from_output"));
367
368        let vars = context.variables();
369
370        // Should have all keys
371        assert_eq!(vars.len(), 3);
372        // Outputs override inputs for shared keys
373        assert_eq!(vars.get("shared"), Some(&serde_json::json!("from_output")));
374    }
375
376    #[test]
377    fn test_execution_context_metadata() {
378        let context = ExecutionContext::new()
379            .with_metadata("trace_id", serde_json::json!("abc123"))
380            .with_metadata("user_id", serde_json::json!(42));
381
382        assert_eq!(context.metadata().len(), 2);
383        assert_eq!(
384            context.get_metadata("trace_id"),
385            Some(&serde_json::json!("abc123"))
386        );
387    }
388
389    #[test]
390    fn test_execution_context_clear_outputs() {
391        let mut context = ExecutionContext::new();
392        context.set_output("step1", serde_json::json!("result"));
393
394        assert_eq!(context.outputs().len(), 1);
395
396        context.clear_outputs();
397
398        assert!(context.outputs().is_empty());
399    }
400
401    #[test]
402    fn test_execution_context_with_config() {
403        use crate::TimeoutConfig;
404        use std::time::Duration;
405
406        let config = ExecutionConfig::new()
407            .with_timeout(TimeoutConfig::new().with_step_timeout(Duration::from_secs(60)));
408
409        let context = ExecutionContext::new().with_config(config);
410
411        assert_eq!(
412            context.config().timeout.step_timeout,
413            Duration::from_secs(60)
414        );
415    }
416}