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
12#![allow(missing_docs)]
13mod merge;
14pub mod registry;
15pub mod skills;
16pub mod variables;
17
18use std::collections::HashMap;
19
20use serde::Deserialize;
21
22use crate::config::AgentConfig;
23use crate::error::Error;
24
25/// Template metadata.
26#[allow(missing_docs)]
27#[derive(Debug, Clone, Deserialize)]
28pub struct TemplateMeta {
29    /// Human-readable description of this template.
30    pub description: String,
31    #[serde(default = "default_version")]
32    pub version: String,
33    #[serde(default)]
34    pub tags: Vec<String>,
35    /// Parent template name for inheritance (recursive, max depth 5).
36    #[serde(default)]
37    pub extends: Option<String>,
38}
39
40fn default_version() -> String {
41    "1.0".into()
42}
43
44/// A partial agent config where all fields are optional.
45/// Used for template defaults that can be overridden by user config.
46#[allow(missing_docs)]
47#[derive(Debug, Clone, Default, Deserialize)]
48pub struct PartialAgentConfig {
49    pub system_prompt: Option<String>,
50    pub max_tokens: Option<u32>,
51    pub max_turns: Option<usize>,
52    pub tool_profile: Option<String>,
53    pub dangerous_tools: Option<bool>,
54    pub max_identical_tool_calls: Option<u32>,
55    pub max_fuzzy_identical_tool_calls: Option<u32>,
56    pub max_tool_calls_per_turn: Option<u32>,
57    pub reasoning_effort: Option<String>,
58    pub enable_reflection: Option<bool>,
59    pub tool_timeout_seconds: Option<u64>,
60    pub max_tool_output_bytes: Option<usize>,
61    pub run_timeout_seconds: Option<u64>,
62    pub tool_output_compression_threshold: Option<usize>,
63    pub max_tools_per_turn: Option<usize>,
64    pub response_cache_size: Option<usize>,
65    pub max_total_tokens: Option<u64>,
66}
67
68/// A bundled or user-defined agent template.
69#[derive(Debug, Clone, Deserialize)]
70pub struct AgentTemplate {
71    pub meta: TemplateMeta,
72    #[serde(default)]
73    pub agent: PartialAgentConfig,
74}
75
76/// Resolve an `AgentConfig` that may reference a template and/or skills
77/// into a fully materialized config with no template references.
78///
79/// Called at the config boundary before agent construction.
80/// If no template or skills are specified, returns a clone of the input unchanged.
81pub fn resolve_agent_config(
82    config: &AgentConfig,
83    variables: &HashMap<String, String>,
84) -> Result<AgentConfig, Error> {
85    let mut resolved = if let Some(ref template_name) = config.template {
86        // Step 1: Resolve template chain
87        let template = merge::resolve_template_chain(template_name)?;
88        // Step 2: Apply template defaults, merge with user config
89        merge::apply_template(config, &template)
90    } else {
91        config.clone_config()
92    };
93
94    // Step 3: Inject skills into system_prompt
95    if !config.skills.is_empty() {
96        let skills_section = skills::load_skills(&config.skills)?;
97        resolved.system_prompt = format!("{}{skills_section}", resolved.system_prompt);
98    }
99
100    // Step 4: Substitute variables
101    let workspace = variables.get("workspace").map(|s| s.as_str());
102    let all_vars =
103        variables::build_variables(&resolved.name, &resolved.description, workspace, variables);
104    resolved.system_prompt = variables::substitute(&resolved.system_prompt, &all_vars);
105
106    Ok(resolved)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn test_config(name: &str) -> AgentConfig {
114        AgentConfig {
115            name: name.into(),
116            description: "test agent".into(),
117            system_prompt: String::new(),
118            mcp_servers: vec![],
119            a2a_agents: vec![],
120            context_strategy: None,
121            summarize_threshold: None,
122            tool_timeout_seconds: None,
123            max_tool_output_bytes: None,
124            max_turns: None,
125            max_tokens: None,
126            response_schema: None,
127            run_timeout_seconds: None,
128            provider: None,
129            reasoning_effort: None,
130            enable_reflection: None,
131            tool_output_compression_threshold: None,
132            max_tools_per_turn: None,
133            tool_profile: None,
134            max_identical_tool_calls: None,
135            max_fuzzy_identical_tool_calls: None,
136            max_tool_calls_per_turn: None,
137            session_prune: None,
138            recursive_summarization: None,
139            reflection_threshold: None,
140            consolidate_on_exit: None,
141            max_total_tokens: None,
142            guardrails: None,
143            response_cache_size: None,
144            mcp_resources: Default::default(),
145            dangerous_tools: false,
146            audit_mode: None,
147            builtin_tools: None,
148            template: None,
149            skills: vec![],
150        }
151    }
152
153    #[test]
154    fn resolve_no_template_passthrough() {
155        let config = test_config("plain");
156        let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
157        assert_eq!(resolved.name, "plain");
158        assert!(resolved.system_prompt.is_empty());
159    }
160
161    #[test]
162    fn resolve_with_template() {
163        let mut config = test_config("my-coder");
164        config.template = Some("coder".into());
165        let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
166
167        assert!(!resolved.system_prompt.is_empty());
168        assert!(resolved.max_tokens.is_some());
169        assert!(resolved.template.is_none()); // Cleared after resolution
170    }
171
172    #[test]
173    fn resolve_with_skills() {
174        let mut config = test_config("skilled");
175        config.skills = vec!["rust-expert".into()];
176        let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
177
178        assert!(resolved.system_prompt.contains("Loaded Skills"));
179        assert!(resolved.system_prompt.contains("rust-expert"));
180    }
181
182    #[test]
183    fn resolve_with_template_and_skills() {
184        let mut config = test_config("full");
185        config.template = Some("coder".into());
186        config.skills = vec!["rust-expert".into()];
187        let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
188
189        // Has both template prompt and skill injection
190        assert!(resolved.system_prompt.contains("Loaded Skills"));
191        assert!(resolved.system_prompt.len() > 100);
192    }
193
194    #[test]
195    fn resolve_with_variables() {
196        let mut config = test_config("var-test");
197        config.system_prompt = "Hello {agent_name}, project: {project}".into();
198
199        let mut vars = HashMap::new();
200        vars.insert("project".into(), "heartbit".into());
201        let resolved = resolve_agent_config(&config, &vars).unwrap();
202
203        assert_eq!(resolved.system_prompt, "Hello var-test, project: heartbit");
204    }
205
206    #[test]
207    fn resolve_variables_in_template_prompt() {
208        let mut config = test_config("tmpl-var");
209        config.template = Some("coder".into());
210        let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
211
212        // Variables like {agent_name} should be substituted
213        // The template prompt may or may not contain {agent_name}, but
214        // the resolution should not error
215        assert!(!resolved.system_prompt.is_empty());
216    }
217
218    #[test]
219    fn resolve_unknown_template_error() {
220        let mut config = test_config("bad");
221        config.template = Some("nonexistent-template".into());
222        let err = resolve_agent_config(&config, &HashMap::new()).unwrap_err();
223        assert!(err.to_string().contains("unknown template"));
224    }
225
226    #[test]
227    fn resolve_unknown_skill_error() {
228        let mut config = test_config("bad");
229        config.skills = vec!["nonexistent-skill".into()];
230        let err = resolve_agent_config(&config, &HashMap::new()).unwrap_err();
231        assert!(err.to_string().contains("unknown skill"));
232    }
233
234    #[test]
235    fn resolve_user_override_wins() {
236        let mut config = test_config("override");
237        config.template = Some("coder".into());
238        config.max_turns = Some(5);
239        let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
240
241        assert_eq!(resolved.max_turns, Some(5));
242    }
243
244    #[test]
245    fn backward_compat_no_template_no_skills() {
246        let mut config = test_config("legacy");
247        config.system_prompt = "I am a legacy agent.".into();
248        config.max_tokens = Some(2048);
249        let resolved = resolve_agent_config(&config, &HashMap::new()).unwrap();
250
251        assert_eq!(resolved.system_prompt, "I am a legacy agent.");
252        assert_eq!(resolved.max_tokens, Some(2048));
253    }
254}