1mod merge;
13pub mod registry;
14pub mod skills;
15pub mod variables;
16
17use std::collections::HashMap;
18
19use serde::Deserialize;
20
21use crate::config::AgentConfig;
22use crate::error::Error;
23
24#[allow(missing_docs)]
26#[derive(Debug, Clone, Deserialize)]
27pub struct TemplateMeta {
28 pub description: String,
30 #[serde(default = "default_version")]
31 pub version: String,
32 #[serde(default)]
33 pub tags: Vec<String>,
34 #[serde(default)]
36 pub extends: Option<String>,
37}
38
39fn default_version() -> String {
40 "1.0".into()
41}
42
43#[allow(missing_docs)]
46#[derive(Debug, Clone, Default, Deserialize)]
47pub struct PartialAgentConfig {
48 pub system_prompt: Option<String>,
49 pub max_tokens: Option<u32>,
50 pub max_turns: Option<usize>,
51 pub tool_profile: Option<String>,
52 pub dangerous_tools: Option<bool>,
53 pub max_identical_tool_calls: Option<u32>,
54 pub max_fuzzy_identical_tool_calls: Option<u32>,
55 pub max_tool_calls_per_turn: Option<u32>,
56 pub reasoning_effort: Option<String>,
57 pub enable_reflection: Option<bool>,
58 pub tool_timeout_seconds: Option<u64>,
59 pub max_tool_output_bytes: Option<usize>,
60 pub run_timeout_seconds: Option<u64>,
61 pub tool_output_compression_threshold: Option<usize>,
62 pub max_tools_per_turn: Option<usize>,
63 pub response_cache_size: Option<usize>,
64 pub max_total_tokens: Option<u64>,
65}
66
67#[derive(Debug, Clone, Deserialize)]
69pub struct AgentTemplate {
70 pub meta: TemplateMeta,
72 #[serde(default)]
74 pub agent: PartialAgentConfig,
75}
76
77pub fn resolve_agent_config(
83 config: &AgentConfig,
84 variables: &HashMap<String, String>,
85) -> Result<AgentConfig, Error> {
86 let mut resolved = if let Some(ref template_name) = config.template {
87 let template = merge::resolve_template_chain(template_name)?;
89 merge::apply_template(config, &template)
91 } else {
92 config.clone_config()
93 };
94
95 if !config.skills.is_empty() {
97 let skills_section = skills::load_skills(&config.skills)?;
98 resolved.system_prompt = format!("{}{skills_section}", resolved.system_prompt);
99 }
100
101 if !config.skill_dirs.is_empty() {
106 let registry = crate::skill::SkillRegistry::discover(&config.skill_dirs);
107 if let Some(section) = registry.system_prompt_section() {
108 resolved.system_prompt = if resolved.system_prompt.is_empty() {
109 section
110 } else {
111 format!("{}\n\n{section}", resolved.system_prompt)
112 };
113 }
114 }
115
116 let workspace = variables.get("workspace").map(|s| s.as_str());
118 let all_vars =
119 variables::build_variables(&resolved.name, &resolved.description, workspace, variables);
120 resolved.system_prompt = variables::substitute(&resolved.system_prompt, &all_vars);
121
122 Ok(resolved)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn test_config(name: &str) -> AgentConfig {
130 AgentConfig {
131 name: name.into(),
132 description: "test agent".into(),
133 system_prompt: String::new(),
134 mcp_servers: vec![],
135 a2a_agents: vec![],
136 context_strategy: None,
137 summarize_threshold: None,
138 tool_timeout_seconds: None,
139 max_tool_output_bytes: None,
140 max_turns: None,
141 max_tokens: None,
142 response_schema: None,
143 run_timeout_seconds: None,
144 provider: None,
145 reasoning_effort: None,
146 enable_reflection: None,
147 tool_output_compression_threshold: None,
148 max_tools_per_turn: None,
149 tool_profile: None,
150 max_identical_tool_calls: None,
151 max_fuzzy_identical_tool_calls: None,
152 max_tool_calls_per_turn: None,
153 session_prune: None,
154 recursive_summarization: None,
155 reflection_threshold: None,
156 consolidate_on_exit: None,
157 max_total_tokens: None,
158 guardrails: None,
159 response_cache_size: None,
160 mcp_resources: Default::default(),
161 dangerous_tools: false,
162 audit_mode: None,
163 builtin_tools: None,
164 template: None,
165 skills: vec![],
166 skill_dirs: vec![],
167 }
168 }
169
170 #[test]
171 fn resolve_no_template_passthrough() {
172 let config = test_config("plain");
173 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
174 assert_eq!(resolved.name, "plain");
175 assert!(resolved.system_prompt.is_empty());
176 }
177
178 #[test]
179 fn resolve_with_template() {
180 let mut config = test_config("my-coder");
181 config.template = Some("coder".into());
182 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
183
184 assert!(!resolved.system_prompt.is_empty());
185 assert!(resolved.max_tokens.is_some());
186 assert!(resolved.template.is_none()); }
188
189 #[test]
190 fn resolve_with_skills() {
191 let mut config = test_config("skilled");
192 config.skills = vec!["rust-expert".into()];
193 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
194
195 assert!(resolved.system_prompt.contains("Loaded Skills"));
196 assert!(resolved.system_prompt.contains("rust-expert"));
197 }
198
199 #[test]
200 fn resolve_with_template_and_skills() {
201 let mut config = test_config("full");
202 config.template = Some("coder".into());
203 config.skills = vec!["rust-expert".into()];
204 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
205
206 assert!(resolved.system_prompt.contains("Loaded Skills"));
208 assert!(resolved.system_prompt.len() > 100);
209 }
210
211 #[test]
212 fn resolve_with_variables() {
213 let mut config = test_config("var-test");
214 config.system_prompt = "Hello {agent_name}, project: {project}".into();
215
216 let mut vars = HashMap::new();
217 vars.insert("project".into(), "heartbit".into());
218 let resolved = resolve_agent_config(&config, &vars).unwrap();
219
220 assert_eq!(resolved.system_prompt, "Hello var-test, project: heartbit");
221 }
222
223 #[test]
224 fn resolve_variables_in_template_prompt() {
225 let mut config = test_config("tmpl-var");
226 config.template = Some("coder".into());
227 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
228
229 assert!(!resolved.system_prompt.is_empty());
233 }
234
235 #[test]
236 fn resolve_unknown_template_error() {
237 let mut config = test_config("bad");
238 config.template = Some("nonexistent-template".into());
239 let err = resolve_agent_config(&config, &HashMap::new()).unwrap_err();
240 assert!(err.to_string().contains("unknown template"));
241 }
242
243 #[test]
244 fn resolve_unknown_skill_error() {
245 let mut config = test_config("bad");
246 config.skills = vec!["nonexistent-skill".into()];
247 let err = resolve_agent_config(&config, &HashMap::new()).unwrap_err();
248 assert!(err.to_string().contains("unknown skill"));
249 }
250
251 #[test]
252 fn resolve_user_override_wins() {
253 let mut config = test_config("override");
254 config.template = Some("coder".into());
255 config.max_turns = Some(5);
256 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
257
258 assert_eq!(resolved.max_turns, Some(5));
259 }
260
261 #[test]
262 fn backward_compat_no_template_no_skills() {
263 let mut config = test_config("legacy");
264 config.system_prompt = "I am a legacy agent.".into();
265 config.max_tokens = Some(2048);
266 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
267
268 assert_eq!(resolved.system_prompt, "I am a legacy agent.");
269 assert_eq!(resolved.max_tokens, Some(2048));
270 }
271
272 #[test]
273 fn resolve_injects_skill_dir_catalog() {
274 let tmp = tempfile::tempdir().unwrap();
277 let skill_dir = tmp.path().join("pdf-tool");
278 std::fs::create_dir_all(&skill_dir).unwrap();
279 std::fs::write(
280 skill_dir.join("SKILL.md"),
281 "---\nname: pdf-tool\ndescription: Extract text from PDFs. Use for PDF tasks.\n---\n# PDF Tool\n\nSECRET_BODY_MARKER step one.\n",
282 )
283 .unwrap();
284
285 let mut config = test_config("disclosed");
286 config.skill_dirs = vec![tmp.path().to_string_lossy().into_owned()];
287 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
288
289 assert!(
290 resolved
291 .system_prompt
292 .contains("pdf-tool: Extract text from PDFs"),
293 "catalog line missing: {}",
294 resolved.system_prompt
295 );
296 assert!(
297 resolved.system_prompt.contains("`skill` tool"),
298 "should instruct the model to load skills via the skill tool"
299 );
300 assert!(
302 !resolved.system_prompt.contains("SECRET_BODY_MARKER"),
303 "skill body must NOT be injected at level 1: {}",
304 resolved.system_prompt
305 );
306 }
307
308 #[test]
309 fn resolve_without_skill_dirs_injects_no_catalog() {
310 let config = test_config("plain");
311 let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
312 assert!(!resolved.system_prompt.contains("Available skills"));
313 }
314}