Skip to main content

ai_agents_skills/
executor.rs

1use std::sync::Arc;
2
3use ai_agents_core::{AgentError, Result, ToolCallSource, ToolExecutionRequest, ToolInvoker};
4use ai_agents_llm::{ChatMessage, LLMRegistry};
5use ai_agents_tools::ToolRegistry;
6use minijinja::Environment;
7
8use crate::definition::{SkillContext, SkillDefinition, SkillStep};
9
10/// Executes skill steps with either prompt-only mode or a runtime tool invoker.
11pub struct SkillExecutor {
12    /// LLM registry used by prompt steps.
13    llm_registry: Arc<LLMRegistry>,
14    /// Tool registry used for direct-mode diagnostics.
15    tools: Arc<ToolRegistry>,
16}
17
18impl SkillExecutor {
19    /// Creates a skill executor from shared LLM and tool registries.
20    pub fn new(llm_registry: Arc<LLMRegistry>, tools: Arc<ToolRegistry>) -> Self {
21        Self {
22            llm_registry,
23            tools,
24        }
25    }
26
27    /// Executes prompt-only skills without a runtime tool invoker.
28    pub async fn execute(
29        &self,
30        skill: &SkillDefinition,
31        user_input: &str,
32        extra_context: serde_json::Value,
33    ) -> Result<String> {
34        let mut ctx = SkillContext::new(user_input).with_extra(extra_context);
35
36        for (index, step) in skill.steps.iter().enumerate() {
37            match step {
38                SkillStep::Tool { tool, .. } => {
39                    if self.tools.get(tool).is_none() {
40                        return Err(AgentError::Skill(format!("Tool not found: {}", tool)));
41                    }
42                    return Err(AgentError::Skill(format!(
43                        "Skill '{}' contains tool step '{}'; use execute_with_invoker so runtime policy, HITL, observability, and eval evidence are preserved",
44                        skill.id, tool
45                    )));
46                }
47                SkillStep::Prompt { prompt, llm } => {
48                    let rendered_prompt = self.render_prompt(prompt, &ctx)?;
49
50                    let llm_provider = match llm {
51                        Some(alias) => self.llm_registry.get(alias)?,
52                        None => self.llm_registry.default()?,
53                    };
54
55                    let response = llm_provider
56                        .complete(&[ChatMessage::user(&rendered_prompt)], None)
57                        .await
58                        .map_err(|e| AgentError::LLM(e.to_string()))?;
59
60                    // Store prompt result directly as string for simpler template access
61                    let result_value =
62                        serde_json::Value::String(response.content.trim().to_string());
63                    ctx.add_result(index, None, result_value);
64
65                    // Only return on the last step
66                    if index == skill.steps.len() - 1 {
67                        return Ok(response.content);
68                    }
69                }
70            }
71        }
72
73        Err(AgentError::Skill(
74            "Skill has no prompt step to generate response".to_string(),
75        ))
76    }
77
78    /// Executes skills through the shared tool invoker for every tool step.
79    pub async fn execute_with_invoker<I>(
80        &self,
81        skill: &SkillDefinition,
82        user_input: &str,
83        extra_context: serde_json::Value,
84        invoker: &I,
85    ) -> Result<String>
86    where
87        I: ToolInvoker + ?Sized,
88    {
89        let mut ctx = SkillContext::new(user_input).with_extra(extra_context);
90
91        for (index, step) in skill.steps.iter().enumerate() {
92            match step {
93                SkillStep::Tool {
94                    tool,
95                    args,
96                    output_as: _,
97                } => {
98                    let rendered_args = self.render_args(args.clone(), &ctx)?;
99                    let record = invoker
100                        .invoke_tool(ToolExecutionRequest::new(
101                            uuid::Uuid::new_v4().to_string(),
102                            tool.clone(),
103                            rendered_args.clone(),
104                            ToolCallSource::Skill {
105                                skill_id: skill.id.clone(),
106                                step_index: index,
107                            },
108                        ))
109                        .await?;
110                    let result_value = record.model_output_value();
111                    let metadata = serde_json::to_value(&record).ok();
112                    ctx.add_result_with_metadata(
113                        index,
114                        Some(rendered_args),
115                        result_value,
116                        metadata,
117                    );
118
119                    if !record.success {
120                        return Err(AgentError::Skill(format!(
121                            "Tool '{}' failed: {}",
122                            tool,
123                            record.model_output_string()
124                        )));
125                    }
126                }
127                SkillStep::Prompt { prompt, llm } => {
128                    let rendered_prompt = self.render_prompt(prompt, &ctx)?;
129                    let llm_provider = match llm {
130                        Some(alias) => self.llm_registry.get(alias)?,
131                        None => self.llm_registry.default()?,
132                    };
133                    let response = llm_provider
134                        .complete(&[ChatMessage::user(&rendered_prompt)], None)
135                        .await
136                        .map_err(|e| AgentError::LLM(e.to_string()))?;
137                    let result_value =
138                        serde_json::Value::String(response.content.trim().to_string());
139                    ctx.add_result(index, None, result_value);
140                    if index == skill.steps.len() - 1 {
141                        return Ok(response.content);
142                    }
143                }
144            }
145        }
146
147        Err(AgentError::Skill(
148            "Skill has no prompt step to generate response".to_string(),
149        ))
150    }
151
152    fn render_args(
153        &self,
154        args: Option<serde_json::Value>,
155        ctx: &SkillContext,
156    ) -> Result<serde_json::Value> {
157        match args {
158            Some(value) => self.render_value(&value, ctx),
159            None => Ok(serde_json::json!({})),
160        }
161    }
162
163    fn render_value(
164        &self,
165        value: &serde_json::Value,
166        ctx: &SkillContext,
167    ) -> Result<serde_json::Value> {
168        match value {
169            serde_json::Value::String(s) => {
170                let rendered = self.render_template_string(s, ctx)?;
171                Ok(serde_json::Value::String(rendered))
172            }
173            serde_json::Value::Object(map) => {
174                let mut new_map = serde_json::Map::new();
175                for (k, v) in map {
176                    new_map.insert(k.clone(), self.render_value(v, ctx)?);
177                }
178                Ok(serde_json::Value::Object(new_map))
179            }
180            serde_json::Value::Array(arr) => {
181                let new_arr: Result<Vec<_>> =
182                    arr.iter().map(|v| self.render_value(v, ctx)).collect();
183                Ok(serde_json::Value::Array(new_arr?))
184            }
185            other => Ok(other.clone()),
186        }
187    }
188
189    fn render_prompt(&self, template: &str, ctx: &SkillContext) -> Result<String> {
190        self.render_template_string(template, ctx)
191    }
192
193    fn render_template_string(&self, template: &str, ctx: &SkillContext) -> Result<String> {
194        let env = Environment::new();
195
196        let tmpl = env
197            .template_from_str(template)
198            .map_err(|e| AgentError::Skill(format!("Template parse error: {}", e)))?;
199
200        let steps: Vec<serde_json::Value> = ctx
201            .step_results
202            .iter()
203            .map(|step| {
204                serde_json::json!({
205                    "result": step.result,
206                    "args": step.args.as_ref().unwrap_or(&serde_json::json!({}))
207                })
208            })
209            .collect();
210
211        let jinja_ctx = minijinja::context! {
212            user_input => &ctx.user_input,
213            steps => steps,
214            context => &ctx.extra,
215        };
216
217        tmpl.render(jinja_ctx)
218            .map_err(|e| AgentError::Skill(format!("Template render error: {}", e)))
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn create_test_context() -> SkillContext {
227        let mut ctx = SkillContext::new("What should I wear?");
228        ctx.add_result(
229            0,
230            Some(serde_json::json!({"location": "Seoul"})),
231            serde_json::json!({"temperature": 15, "condition": "sunny"}),
232        );
233        ctx.extra = serde_json::json!({"user_name": "jay"});
234        ctx
235    }
236
237    #[test]
238    fn test_render_complex_template() {
239        let registry = LLMRegistry::new();
240        let tools = ToolRegistry::new();
241        let executor = SkillExecutor::new(Arc::new(registry), Arc::new(tools));
242
243        let ctx = create_test_context();
244        let template = r#"User {{ context.user_name }} asked: {{ user_input }}
245Current weather in {{ steps[0].args.location }}: {{ steps[0].result.temperature }}°C, {{ steps[0].result.condition }}"#;
246
247        let result = executor.render_template_string(template, &ctx).unwrap();
248        assert!(result.contains("User jay asked: What should I wear?"));
249        assert!(result.contains("Current weather in Seoul: 15°C, sunny"));
250    }
251
252    #[test]
253    fn test_render_with_whitespace_variations() {
254        let registry = LLMRegistry::new();
255        let tools = ToolRegistry::new();
256        let executor = SkillExecutor::new(Arc::new(registry), Arc::new(tools));
257
258        let ctx = create_test_context();
259        let template1 = "{{user_input}}";
260        let template2 = "{{ user_input }}";
261        let template3 = "{{  user_input  }}";
262
263        let result1 = executor.render_template_string(template1, &ctx).unwrap();
264        let result2 = executor.render_template_string(template2, &ctx).unwrap();
265        let result3 = executor.render_template_string(template3, &ctx).unwrap();
266
267        assert_eq!(result1, "What should I wear?");
268        assert_eq!(result2, "What should I wear?");
269        assert_eq!(result3, "What should I wear?");
270    }
271
272    #[test]
273    fn test_render_with_filters() {
274        let registry = LLMRegistry::new();
275        let tools = ToolRegistry::new();
276        let executor = SkillExecutor::new(Arc::new(registry), Arc::new(tools));
277
278        let ctx = create_test_context();
279        let template = "{{ context.user_name | upper }}";
280
281        let result = executor.render_template_string(template, &ctx).unwrap();
282        assert_eq!(result, "JAY");
283    }
284
285    #[tokio::test]
286    async fn direct_execute_rejects_tool_steps() {
287        let registry = LLMRegistry::new();
288        let mut tools = ToolRegistry::new();
289        tools
290            .register(Arc::new(ai_agents_tools::EchoTool::new()))
291            .unwrap();
292        let executor = SkillExecutor::new(Arc::new(registry), Arc::new(tools));
293        let skill = SkillDefinition {
294            id: "tool_skill".to_string(),
295            description: "Uses a tool".to_string(),
296            trigger: "test".to_string(),
297            steps: vec![SkillStep::Tool {
298                tool: "echo".to_string(),
299                args: Some(serde_json::json!({"message": "hello"})),
300                output_as: None,
301            }],
302            reasoning: None,
303            reflection: None,
304            disambiguation: None,
305        };
306
307        let error = executor
308            .execute(&skill, "hello", serde_json::json!({}))
309            .await
310            .unwrap_err();
311
312        assert!(error.to_string().contains("execute_with_invoker"));
313    }
314}