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        "search" => "search(*)".to_string(),
138        "ls" => "ls(*)".to_string(),
139        "bash" => "bash(*)".to_string(),
140        "task" => "task(*)".to_string(),
141        "parallel_task" | "parallel-task" => "parallel_task(*)".to_string(),
142        _ if normalized.contains('(') => normalized.to_string(),
143        _ => format!("{normalized}(*)"),
144    }
145}
146
147fn permission_policy_from_tools(tools: &[String]) -> PermissionPolicy {
148    tools.iter().fold(PermissionPolicy::new(), |policy, tool| {
149        policy.allow(&tool_name_to_permission(tool))
150    })
151}
152
153fn allow_only_permission_policy(tools: &[String]) -> PermissionPolicy {
154    let mut policy = permission_policy_from_tools(tools);
155    policy.default_decision = PermissionDecision::Deny;
156    policy
157}
158
159fn add_denied_tools(mut policy: PermissionPolicy, tools: &[String]) -> PermissionPolicy {
160    for tool in tools {
161        policy = policy.deny(&tool_name_to_permission(tool));
162    }
163    policy
164}
165
166fn validate_agent_name(name: &str) -> anyhow::Result<()> {
167    if name.trim().is_empty() {
168        return Err(anyhow::anyhow!("Agent name is required"));
169    }
170    Ok(())
171}
172
173/// Parse an agent definition from Markdown with YAML frontmatter
174///
175/// The frontmatter contains agent metadata, and the body becomes the prompt.
176pub fn parse_agent_md(content: &str) -> anyhow::Result<AgentDefinition> {
177    // Parse frontmatter (YAML between --- markers)
178    let parts: Vec<&str> = content.splitn(3, "---").collect();
179
180    if parts.len() < 3 {
181        return Err(anyhow::anyhow!(
182            "Invalid markdown format: missing YAML frontmatter"
183        ));
184    }
185
186    let frontmatter = parts[1].trim();
187    let body = parts[2].trim();
188
189    // Parse the frontmatter as YAML. A `kind` field selects WorkerAgentSpec.
190    let value: serde_yaml::Value = serde_yaml::from_str(frontmatter)
191        .map_err(|e| anyhow::anyhow!("Failed to parse agent frontmatter: {}", e))?;
192
193    if yaml_value_has_key(&value, "kind") {
194        let tools = yaml_get_any(&value, &["tools", "allowedTools", "allowed_tools"])
195            .map(parse_tools_field)
196            .unwrap_or_default();
197        let disallowed_tools = yaml_get_any(
198            &value,
199            &["disallowedTools", "disallowed-tools", "disallowed_tools"],
200        )
201        .map(parse_tools_field)
202        .unwrap_or_default();
203        let mut spec = parse_worker_yaml_value(value, "frontmatter")?;
204        if spec.prompt.is_none() && !body.is_empty() {
205            spec.prompt = Some(body.to_string());
206        }
207        apply_claude_style_tools_to_spec(&mut spec, &tools, &disallowed_tools);
208        return Ok(spec.into_agent_definition());
209    }
210
211    let mut agent = parse_agent_yaml_value(value, "agent frontmatter")?;
212
213    // Use body as prompt if not already set in frontmatter.
214    if agent.prompt.is_none() && !body.is_empty() {
215        agent.prompt = Some(body.to_string());
216    }
217
218    Ok(agent)
219}
220
221/// Load all agent definitions from a directory
222///
223/// Scans for *.yaml and *.md files and parses them as agent definitions.
224/// Invalid files are logged and skipped.
225pub fn load_agents_from_dir(dir: &Path) -> Vec<AgentDefinition> {
226    let mut agents = Vec::new();
227    load_agents_from_dir_inner(dir, &mut agents);
228    agents
229}
230
231fn load_agents_from_dir_inner(dir: &Path, agents: &mut Vec<AgentDefinition>) {
232    let Ok(entries) = std::fs::read_dir(dir) else {
233        tracing::warn!("Failed to read agent directory: {}", dir.display());
234        return;
235    };
236
237    for entry in entries.flatten() {
238        let path = entry.path();
239
240        if path.is_dir() {
241            load_agents_from_dir_inner(&path, agents);
242            continue;
243        }
244        if !path.is_file() {
245            continue;
246        }
247
248        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
249            continue;
250        };
251
252        // Read file content
253        let Ok(content) = std::fs::read_to_string(&path) else {
254            tracing::warn!("Failed to read agent file: {}", path.display());
255            continue;
256        };
257
258        // Parse based on extension
259        let result = match ext {
260            "yaml" | "yml" => parse_agent_yaml(&content),
261            "md" => parse_agent_md(&content),
262            _ => continue,
263        };
264
265        match result {
266            Ok(agent) => {
267                tracing::debug!("Loaded agent '{}' from {}", agent.name, path.display());
268                agents.push(agent);
269            }
270            Err(e) => {
271                tracing::warn!("Failed to parse agent file {}: {}", path.display(), e);
272            }
273        }
274    }
275}