Skip to main content

a3s_code_core/subagent/
loader.rs

1use super::*;
2
3/// Parse an agent definition from YAML content.
4///
5/// The YAML can describe either a full [`AgentDefinition`] or a cattle-style
6/// [`WorkerAgentSpec`] by including a `kind` field.
7pub fn parse_agent_yaml(content: &str) -> anyhow::Result<AgentDefinition> {
8    let value: serde_yaml::Value = serde_yaml::from_str(content)
9        .map_err(|e| anyhow::anyhow!("Failed to parse agent YAML: {}", e))?;
10
11    parse_agent_yaml_value(value, "agent YAML")
12}
13
14fn parse_agent_yaml_value(
15    value: serde_yaml::Value,
16    context: &str,
17) -> anyhow::Result<AgentDefinition> {
18    let tools = yaml_get_any(&value, &["tools", "allowedTools", "allowed_tools"])
19        .map(parse_tools_field)
20        .unwrap_or_default();
21    let disallowed_tools = yaml_get_any(
22        &value,
23        &["disallowedTools", "disallowed-tools", "disallowed_tools"],
24    )
25    .map(parse_tools_field)
26    .unwrap_or_default();
27
28    if yaml_value_has_key(&value, "kind") {
29        let mut spec: WorkerAgentSpec = serde_yaml::from_value(value)
30            .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
31        validate_agent_name(&spec.name)?;
32        apply_claude_style_tools_to_spec(&mut spec, &tools, &disallowed_tools);
33        return Ok(spec.into_agent_definition());
34    }
35
36    let mut agent: AgentDefinition = serde_yaml::from_value(value)
37        .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", context, e))?;
38    validate_agent_name(&agent.name)?;
39    apply_claude_style_tools_to_agent(&mut agent, &tools, &disallowed_tools);
40    Ok(agent)
41}
42
43fn apply_claude_style_tools_to_agent(
44    agent: &mut AgentDefinition,
45    tools: &[String],
46    disallowed_tools: &[String],
47) {
48    if !tools.is_empty() {
49        agent.permissions = allow_only_permission_policy(tools);
50    }
51    if !disallowed_tools.is_empty() {
52        let base = std::mem::take(&mut agent.permissions);
53        agent.permissions = add_denied_tools(base, disallowed_tools);
54    }
55    if (!tools.is_empty() || !disallowed_tools.is_empty())
56        && agent.confirmation_inheritance.is_none()
57    {
58        agent.confirmation_inheritance = Some(ConfirmationInheritance::AutoApprove);
59    }
60}
61
62fn apply_claude_style_tools_to_spec(
63    spec: &mut WorkerAgentSpec,
64    tools: &[String],
65    disallowed_tools: &[String],
66) {
67    if tools.is_empty() && disallowed_tools.is_empty() {
68        return;
69    }
70
71    let base = if tools.is_empty() {
72        spec.permissions
73            .clone()
74            .unwrap_or_else(|| spec.kind.default_permissions())
75    } else {
76        allow_only_permission_policy(tools)
77    };
78    spec.permissions = Some(add_denied_tools(base, disallowed_tools));
79    if spec.confirmation_inheritance.is_none() {
80        spec.confirmation_inheritance = Some(ConfirmationInheritance::AutoApprove);
81    }
82}
83
84fn parse_worker_yaml_value(
85    value: serde_yaml::Value,
86    context: &str,
87) -> anyhow::Result<WorkerAgentSpec> {
88    let spec: WorkerAgentSpec = serde_yaml::from_value(value)
89        .map_err(|e| anyhow::anyhow!("Failed to parse worker {}: {}", context, e))?;
90    validate_agent_name(&spec.name)?;
91    Ok(spec)
92}
93
94fn yaml_value_has_key(value: &serde_yaml::Value, key: &str) -> bool {
95    value
96        .as_mapping()
97        .map(|mapping| mapping.contains_key(serde_yaml::Value::String(key.to_string())))
98        .unwrap_or(false)
99}
100
101fn yaml_get<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> {
102    value
103        .as_mapping()
104        .and_then(|mapping| mapping.get(serde_yaml::Value::String(key.to_string())))
105}
106
107fn yaml_get_any<'a>(value: &'a serde_yaml::Value, keys: &[&str]) -> Option<&'a serde_yaml::Value> {
108    keys.iter().find_map(|key| yaml_get(value, key))
109}
110
111fn parse_tools_field(value: &serde_yaml::Value) -> Vec<String> {
112    match value {
113        serde_yaml::Value::String(raw) => raw
114            .split(',')
115            .map(str::trim)
116            .filter(|tool| !tool.is_empty())
117            .map(str::to_string)
118            .collect(),
119        serde_yaml::Value::Sequence(items) => items
120            .iter()
121            .filter_map(|item| item.as_str())
122            .map(str::trim)
123            .filter(|tool| !tool.is_empty())
124            .map(str::to_string)
125            .collect(),
126        _ => Vec::new(),
127    }
128}
129
130fn tool_name_to_permission(tool: &str) -> String {
131    let normalized = tool.trim();
132    match normalized.to_ascii_lowercase().as_str() {
133        "*" => "*".to_string(),
134        "read" => "read(*)".to_string(),
135        "write" => "write(*)".to_string(),
136        "edit" => "edit(*)".to_string(),
137        "grep" => "grep(*)".to_string(),
138        "glob" => "glob(*)".to_string(),
139        "ls" => "ls(*)".to_string(),
140        "bash" => "bash(*)".to_string(),
141        "task" => "task(*)".to_string(),
142        "parallel_task" | "parallel-task" => "parallel_task(*)".to_string(),
143        _ if normalized.contains('(') => normalized.to_string(),
144        _ => format!("{normalized}(*)"),
145    }
146}
147
148fn permission_policy_from_tools(tools: &[String]) -> PermissionPolicy {
149    tools.iter().fold(PermissionPolicy::new(), |policy, tool| {
150        policy.allow(&tool_name_to_permission(tool))
151    })
152}
153
154fn allow_only_permission_policy(tools: &[String]) -> PermissionPolicy {
155    let mut policy = permission_policy_from_tools(tools);
156    policy.default_decision = PermissionDecision::Deny;
157    policy
158}
159
160fn add_denied_tools(mut policy: PermissionPolicy, tools: &[String]) -> PermissionPolicy {
161    for tool in tools {
162        policy = policy.deny(&tool_name_to_permission(tool));
163    }
164    policy
165}
166
167fn validate_agent_name(name: &str) -> anyhow::Result<()> {
168    if name.trim().is_empty() {
169        return Err(anyhow::anyhow!("Agent name is required"));
170    }
171    Ok(())
172}
173
174/// Parse an agent definition from Markdown with YAML frontmatter
175///
176/// The frontmatter contains agent metadata, and the body becomes the prompt.
177pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
178    // Parse frontmatter (YAML between --- markers)
179    let parts: Vec<&str> = content.splitn(3, "---").collect();
180
181    if parts.len() < 3 {
182        return Err(anyhow::anyhow!(
183            "Invalid markdown format: missing YAML frontmatter"
184        ));
185    }
186
187    let frontmatter = parts[1].trim();
188    let body = parts[2].trim();
189
190    // Parse the frontmatter as YAML. A `kind` field selects WorkerAgentSpec.
191    let value: serde_yaml::Value = serde_yaml::from_str(frontmatter)
192        .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
193
194    if yaml_value_has_key(&value, "kind") {
195        let tools = yaml_get_any(&value, &["tools", "allowedTools", "allowed_tools"])
196            .map(parse_tools_field)
197            .unwrap_or_default();
198        let disallowed_tools = yaml_get_any(
199            &value,
200            &["disallowedTools", "disallowed-tools", "disallowed_tools"],
201        )
202        .map(parse_tools_field)
203        .unwrap_or_default();
204        let mut spec = parse_worker_yaml_value(value, "frontmatter")?;
205        if spec.prompt.is_none() && !body.is_empty() {
206            spec.prompt = Some(body.to_string());
207        }
208        apply_claude_style_tools_to_spec(&mut spec, &tools, &disallowed_tools);
209        return Ok(spec.into_agent_definition());
210    }
211
212    let mut agent = parse_agent_yaml_value(value, "agent frontmatter")?;
213
214    // Use body as prompt if not already set in frontmatter.
215    if agent.prompt.is_none() && !body.is_empty() {
216        agent.prompt = Some(body.to_string());
217    }
218
219    Ok(agent)
220}
221
222/// Load all agent definitions from a directory
223///
224/// Scans for *.yaml and *.md files and parses them as agent definitions.
225/// Invalid files are logged and skipped.
226pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
227    let mut agents = Vec::new();
228    load_agents_from_dir_inner(dir, &mut agents);
229    agents
230}
231
232fn load_agents_from_dir_inner(dir: &Path, agents: &mut Vec<AgentDefinition>) {
233    let Ok(entries) = std::fs::read_dir(dir) else {
234        tracing::warn!("Failed to read agent directory: {}", dir.display());
235        return;
236    };
237
238    for entry in entries.flatten() {
239        let path = entry.path();
240
241        if path.is_dir() {
242            load_agents_from_dir_inner(&path, agents);
243            continue;
244        }
245        if !path.is_file() {
246            continue;
247        }
248
249        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
250            continue;
251        };
252
253        // Read file content
254        let Ok(content) = std::fs::read_to_string(&path) else {
255            tracing::warn!("Failed to read agent file: {}", path.display());
256            continue;
257        };
258
259        // Parse based on extension
260        let result = match ext {
261            "yaml" | "yml" => parse_agent_yaml(&content),
262            "md" => parse_agent_md(&content),
263            _ => continue,
264        };
265
266        match result {
267            Ok(agent) => {
268                tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
269                agents.push(agent);
270            }
271            Err(e) => {
272                tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
273            }
274        }
275    }
276}