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
10pub struct SkillExecutor {
12 llm_registry: Arc<LLMRegistry>,
14 tools: Arc<ToolRegistry>,
16}
17
18impl SkillExecutor {
19 pub fn new(llm_registry: Arc<LLMRegistry>, tools: Arc<ToolRegistry>) -> Self {
21 Self {
22 llm_registry,
23 tools,
24 }
25 }
26
27 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 let result_value =
62 serde_json::Value::String(response.content.trim().to_string());
63 ctx.add_result(index, None, result_value);
64
65 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 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}