Skip to main content

telos_agent/orchestration/subagent/
definition.rs

1use crate::error::AgentError;
2use crate::model::provider::ModelHint;
3use serde::Deserialize;
4
5/// Where an agent definition came from.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum AgentSource {
8    BuiltIn,
9    Project { path: String },
10    Plugin { plugin: String, path: String },
11    User { path: String },
12}
13
14/// Isolation mode requested by an agent definition.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum AgentIsolation {
17    #[default]
18    None,
19    Worktree,
20}
21
22/// Registry-ready subagent definition.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct AgentDefinition {
25    pub name: String,
26    pub description: String,
27    pub system_prompt: String,
28    pub allowed_tools: Vec<String>,
29    pub disallowed_tools: Vec<String>,
30    pub model_hint: Option<ModelHint>,
31    pub max_iterations: Option<usize>,
32    pub background: bool,
33    pub isolation: AgentIsolation,
34    pub initial_prompt: Option<String>,
35    pub permission_mode: Option<String>,
36    pub skills: Vec<String>,
37    pub effort: Option<String>,
38    pub source: AgentSource,
39}
40
41#[derive(Debug, Deserialize)]
42#[serde(rename_all = "camelCase")]
43struct AgentFrontmatter {
44    name: Option<String>,
45    description: Option<String>,
46    #[serde(default)]
47    tools: Vec<String>,
48    #[serde(default)]
49    disallowed_tools: Vec<String>,
50    model: Option<String>,
51    max_iterations: Option<usize>,
52    #[serde(default)]
53    background: bool,
54    isolation: Option<String>,
55    initial_prompt: Option<String>,
56    permission_mode: Option<String>,
57    #[serde(default)]
58    skills: Vec<String>,
59    effort: Option<String>,
60}
61
62impl AgentDefinition {
63    pub fn new(
64        name: impl Into<String>,
65        description: impl Into<String>,
66        system_prompt: impl Into<String>,
67        source: AgentSource,
68    ) -> Self {
69        Self {
70            name: name.into(),
71            description: description.into(),
72            system_prompt: system_prompt.into(),
73            allowed_tools: Vec::new(),
74            disallowed_tools: Vec::new(),
75            model_hint: None,
76            max_iterations: None,
77            background: false,
78            isolation: AgentIsolation::None,
79            initial_prompt: None,
80            permission_mode: None,
81            skills: Vec::new(),
82            effort: None,
83            source,
84        }
85    }
86
87    pub fn from_markdown(markdown: &str, source: AgentSource) -> Result<Self, AgentError> {
88        let (frontmatter, body) = split_frontmatter(markdown).ok_or_else(|| {
89            AgentError::Validation("agent markdown missing YAML frontmatter".into())
90        })?;
91        let frontmatter: AgentFrontmatter = serde_yaml::from_str(frontmatter)
92            .map_err(|err| AgentError::Validation(format!("invalid agent frontmatter: {err}")))?;
93
94        let name = required_frontmatter_string(frontmatter.name, "name")?;
95        let description = required_frontmatter_string(frontmatter.description, "description")?;
96        let model_hint = match frontmatter.model {
97            Some(model) => parse_model_hint(&model)?,
98            None => None,
99        };
100        let isolation = match frontmatter
101            .isolation
102            .as_deref()
103            .unwrap_or("none")
104            .to_ascii_lowercase()
105            .as_str()
106        {
107            "none" => AgentIsolation::None,
108            "worktree" => AgentIsolation::Worktree,
109            other => {
110                return Err(AgentError::Validation(format!(
111                    "invalid agent isolation `{other}`; expected `none` or `worktree`"
112                )));
113            }
114        };
115
116        Ok(Self {
117            name,
118            description,
119            system_prompt: body.trim().to_string(),
120            allowed_tools: frontmatter.tools,
121            disallowed_tools: frontmatter.disallowed_tools,
122            model_hint,
123            max_iterations: frontmatter.max_iterations,
124            background: frontmatter.background,
125            isolation,
126            initial_prompt: optional_trimmed_string(frontmatter.initial_prompt),
127            permission_mode: optional_trimmed_string(frontmatter.permission_mode),
128            skills: frontmatter.skills,
129            effort: optional_trimmed_string(frontmatter.effort),
130            source,
131        })
132    }
133}
134
135fn split_frontmatter(markdown: &str) -> Option<(&str, &str)> {
136    let rest = markdown.strip_prefix("---\n")?;
137    let (frontmatter, body) = rest.split_once("\n---")?;
138    Some((frontmatter, body.trim_start_matches(['\r', '\n'])))
139}
140
141fn required_frontmatter_string(value: Option<String>, key: &str) -> Result<String, AgentError> {
142    let Some(value) = value else {
143        return Err(AgentError::Validation(format!("missing required `{key}`")));
144    };
145    let value = value.trim();
146    if value.is_empty() {
147        Err(AgentError::Validation(format!("missing required `{key}`")))
148    } else {
149        Ok(value.to_string())
150    }
151}
152
153fn optional_trimmed_string(value: Option<String>) -> Option<String> {
154    value.and_then(|value| {
155        let value = value.trim();
156        if value.is_empty() { None } else { Some(value.to_string()) }
157    })
158}
159
160pub(crate) fn parse_model_hint(raw: &str) -> Result<Option<ModelHint>, AgentError> {
161    match raw.trim().to_ascii_lowercase().as_str() {
162        "inherit" | "default" | "none" => Ok(None),
163        "thinking" => Ok(Some(ModelHint::Thinking)),
164        "execution" => Ok(Some(ModelHint::Execution)),
165        "recovery" => Ok(Some(ModelHint::Recovery)),
166        "summarization" | "summary" => Ok(Some(ModelHint::Summarization)),
167        other => Err(AgentError::Validation(format!(
168            "invalid agent model `{other}`; expected thinking, execution, recovery, summarization, or inherit"
169        ))),
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::model::provider::ModelHint;
177
178    #[test]
179    fn parses_agent_markdown_frontmatter() {
180        let markdown = r#"---
181name: Explore
182description: Use for broad read-only exploration.
183tools: [Read, Grep, Glob]
184disallowedTools: [Write, Edit]
185model: execution
186maxIterations: 8
187background: true
188isolation: worktree
189initialPrompt: Read README.md first.
190permissionMode: plan
191skills: [debug, verify]
192effort: high
193---
194You inspect the codebase and report findings.
195"#;
196
197        let definition = AgentDefinition::from_markdown(
198            markdown,
199            AgentSource::Project { path: "agents/explore.md".into() },
200        )
201        .unwrap();
202
203        assert_eq!(definition.name, "Explore");
204        assert_eq!(definition.description, "Use for broad read-only exploration.");
205        assert_eq!(definition.system_prompt, "You inspect the codebase and report findings.");
206        assert_eq!(definition.allowed_tools, vec!["Read", "Grep", "Glob"]);
207        assert_eq!(definition.disallowed_tools, vec!["Write", "Edit"]);
208        assert_eq!(definition.model_hint, Some(ModelHint::Execution));
209        assert_eq!(definition.max_iterations, Some(8));
210        assert!(definition.background);
211        assert_eq!(definition.isolation, AgentIsolation::Worktree);
212        assert_eq!(definition.initial_prompt.as_deref(), Some("Read README.md first."));
213        assert_eq!(definition.permission_mode.as_deref(), Some("plan"));
214        assert_eq!(definition.skills, vec!["debug", "verify"]);
215        assert_eq!(definition.effort.as_deref(), Some("high"));
216    }
217
218    #[test]
219    fn rejects_missing_required_agent_frontmatter() {
220        let missing_name = r#"---
221description: Missing name.
222---
223Prompt.
224"#;
225        let error = AgentDefinition::from_markdown(
226            missing_name,
227            AgentSource::Project { path: "agents/bad.md".into() },
228        )
229        .unwrap_err();
230        assert!(error.to_string().contains("missing required `name`"));
231
232        let missing_description = r#"---
233name: Bad
234---
235Prompt.
236"#;
237        let error = AgentDefinition::from_markdown(
238            missing_description,
239            AgentSource::Project { path: "agents/bad.md".into() },
240        )
241        .unwrap_err();
242        assert!(error.to_string().contains("missing required `description`"));
243    }
244
245    #[test]
246    fn parses_model_hint_aliases() {
247        assert_eq!(parse_model_hint("thinking").unwrap(), Some(ModelHint::Thinking));
248        assert_eq!(parse_model_hint("execution").unwrap(), Some(ModelHint::Execution));
249        assert_eq!(parse_model_hint("recovery").unwrap(), Some(ModelHint::Recovery));
250        assert_eq!(parse_model_hint("summarization").unwrap(), Some(ModelHint::Summarization));
251        assert!(parse_model_hint("inherit").unwrap().is_none());
252        assert!(parse_model_hint("unknown").is_err());
253    }
254}