telos_agent/orchestration/subagent/
definition.rs1use crate::error::AgentError;
2use crate::model::provider::ModelHint;
3use serde::Deserialize;
4
5#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum AgentIsolation {
17 #[default]
18 None,
19 Worktree,
20}
21
22#[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}