Skip to main content

heartbit_core/template/
mod.rs

1//! Agent template system for reusable, composable agent configurations.
2//!
3//! Three layers, each independently useful:
4//!
5//! - **Templates**: Bundled agent presets (`template = "coder"`)
6//! - **Skills**: Auto-injected domain expertise (`skills = ["rust-expert"]`)
7//! - **Variables**: Prompt variable substitution (`{agent_name}`, custom vars)
8//!
9//! Resolution happens at the config boundary (before agent construction).
10//! All downstream code sees a fully materialized `AgentConfig`.
11
12mod 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/// Template metadata.
25#[allow(missing_docs)]
26#[derive(Debug, Clone, Deserialize)]
27pub struct TemplateMeta {
28    /// Human-readable description of this template.
29    pub description: String,
30    #[serde(default = "default_version")]
31    pub version: String,
32    #[serde(default)]
33    pub tags: Vec<String>,
34    /// Parent template name for inheritance (recursive, max depth 5).
35    #[serde(default)]
36    pub extends: Option<String>,
37}
38
39fn default_version() -> String {
40    "1.0".into()
41}
42
43/// A partial agent config where all fields are optional.
44/// Used for template defaults that can be overridden by user config.
45#[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/// A bundled or user-defined agent template.
68#[derive(Debug, Clone, Deserialize)]
69pub struct AgentTemplate {
70    /// Template metadata (name, description).
71    pub meta: TemplateMeta,
72    /// Default agent settings supplied by the template.
73    #[serde(default)]
74    pub agent: PartialAgentConfig,
75}
76
77/// Resolve an `AgentConfig` that may reference a template and/or skills
78/// into a fully materialized config with no template references.
79///
80/// Called at the config boundary before agent construction.
81/// If no template or skills are specified, returns a clone of the input unchanged.
82pub 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        // Step 1: Resolve template chain
88        let template = merge::resolve_template_chain(template_name)?;
89        // Step 2: Apply template defaults, merge with user config
90        merge::apply_template(config, &template)
91    } else {
92        config.clone_config()
93    };
94
95    // Step 3: Inject skills into system_prompt
96    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    // Step 3b: Inject the Level-1 skill catalog (progressive disclosure) for any
102    // configured `skill_dirs`. Unlike `skills` (eager full-content injection),
103    // this lists only `name: description` per discovered skill so the model knows
104    // which skills exist and loads each one's body on demand via the `skill` tool.
105    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    // Step 4: Substitute variables
117    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()); // Cleared after resolution
187    }
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        // Has both template prompt and skill injection
207        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        // Variables like {agent_name} should be substituted
230        // The template prompt may or may not contain {agent_name}, but
231        // the resolution should not error
232        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        // A skill_dirs entry holding `<name>/SKILL.md` yields a Level-1 catalog
275        // (name: description) in the resolved system prompt — NOT the full body.
276        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        // Progressive disclosure: the BODY must NOT be eagerly injected.
301        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}