1use std::path::{Path, PathBuf};
25
26use crate::config::CodeConfig;
27use crate::error::{CodeError, Result};
28use crate::mcp::McpServerConfig;
29use crate::prompts::SystemPromptSlots;
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct ScheduleSpec {
34 pub name: String,
36 pub cron: String,
38 pub prompt: String,
40 pub enabled: bool,
42}
43
44#[derive(Debug, Clone)]
52pub enum ToolSpec {
53 Mcp(McpServerConfig),
56 Script(ScriptToolSpec),
60}
61
62impl ToolSpec {
63 pub fn name(&self) -> &str {
65 match self {
66 ToolSpec::Mcp(cfg) => &cfg.name,
67 ToolSpec::Script(spec) => &spec.name,
68 }
69 }
70
71 pub fn kind(&self) -> &str {
73 match self {
74 ToolSpec::Mcp(_) => "mcp",
75 ToolSpec::Script(_) => "script",
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
87pub struct ScriptToolSpec {
88 pub name: String,
90 pub description: String,
92 pub path: PathBuf,
94 pub allowed_tools: Option<Vec<String>>,
101 pub limits: ScriptToolLimits,
103}
104
105#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ScriptToolLimits {
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub timeout_ms: Option<u64>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub max_tool_calls: Option<usize>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub max_output_bytes: Option<usize>,
117}
118
119#[derive(Debug, Clone)]
127pub struct AgentDir {
128 pub dir: PathBuf,
129 pub config: CodeConfig,
130 pub prompt_slots: SystemPromptSlots,
131 pub schedules: Vec<ScheduleSpec>,
132 pub tools: Vec<ToolSpec>,
133}
134
135impl AgentDir {
136 pub fn load(dir: impl AsRef<Path>) -> Result<Self> {
138 let dir = dir.as_ref().to_path_buf();
139 if !dir.is_dir() {
140 return Err(CodeError::Context(format!(
141 "agent directory not found: {}",
142 dir.display()
143 )));
144 }
145
146 let instructions = std::fs::read_to_string(dir.join("instructions.md")).map_err(|e| {
149 CodeError::Context(format!(
150 "agent dir {} is missing required instructions.md: {e}",
151 dir.display()
152 ))
153 })?;
154 let prompt_slots = SystemPromptSlots {
155 role: Some(instructions.trim().to_string()),
156 ..Default::default()
157 };
158
159 let acl_path = dir.join("agent.acl");
161 let mut config = if acl_path.is_file() {
162 CodeConfig::from_file(&acl_path)?
163 } else {
164 CodeConfig::default()
165 };
166
167 let skills_dir = dir.join("skills");
169 if skills_dir.is_dir() {
170 config.skill_dirs.push(skills_dir);
171 }
172
173 let schedules = load_schedules(&dir.join("schedules"))?;
174 let tools = load_tools(&dir.join("tools"))?;
175
176 Ok(Self {
177 dir,
178 config,
179 prompt_slots,
180 schedules,
181 tools,
182 })
183 }
184}
185
186fn md_files(dir: &Path, exts: &[&str]) -> Result<Vec<PathBuf>> {
189 if !dir.is_dir() {
190 return Ok(Vec::new());
191 }
192 let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
193 .map_err(|e| CodeError::Context(format!("read {}: {e}", dir.display())))?
194 .filter_map(|e| e.ok().map(|e| e.path()))
195 .filter(|p| {
196 p.extension()
197 .and_then(|s| s.to_str())
198 .map(|e| exts.contains(&e))
199 .unwrap_or(false)
200 })
201 .collect();
202 entries.sort();
203 Ok(entries)
204}
205
206fn load_schedules(dir: &Path) -> Result<Vec<ScheduleSpec>> {
207 let mut out = Vec::new();
208 for path in md_files(dir, &["md"])? {
209 let content = std::fs::read_to_string(&path)
210 .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
211 let (front, body) = split_frontmatter(&content);
212 let front = front.ok_or_else(|| {
213 CodeError::Context(format!(
214 "schedule {} has no YAML frontmatter (need `cron:`)",
215 path.display()
216 ))
217 })?;
218 let meta: ScheduleFront = serde_yaml::from_str(&front).map_err(|e| {
219 CodeError::Context(format!("schedule {} frontmatter: {e}", path.display()))
220 })?;
221 out.push(ScheduleSpec {
222 name: meta.name.unwrap_or_else(|| file_stem(&path)),
223 cron: meta.cron,
224 prompt: body.trim().to_string(),
225 enabled: meta.enabled.unwrap_or(true),
226 });
227 }
228 Ok(out)
229}
230
231const SCRIPT_MAX_TIMEOUT_MS: u64 = 600_000; const SCRIPT_MAX_TOOL_CALLS: usize = 1_000;
238const SCRIPT_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; fn validate_script_limits(
243 limits: ScriptToolLimits,
244) -> std::result::Result<ScriptToolLimits, String> {
245 fn check<T: PartialOrd + Copy + std::fmt::Display>(
246 v: Option<T>,
247 max: T,
248 one: T,
249 field: &str,
250 ) -> std::result::Result<(), String> {
251 if let Some(v) = v {
252 if v < one || v > max {
253 return Err(format!("limit {field}={v} is out of range [1, {max}]"));
254 }
255 }
256 Ok(())
257 }
258 check(limits.timeout_ms, SCRIPT_MAX_TIMEOUT_MS, 1, "timeoutMs")?;
259 check(
260 limits.max_tool_calls,
261 SCRIPT_MAX_TOOL_CALLS,
262 1,
263 "maxToolCalls",
264 )?;
265 check(
266 limits.max_output_bytes,
267 SCRIPT_MAX_OUTPUT_BYTES,
268 1,
269 "maxOutputBytes",
270 )?;
271 Ok(limits)
272}
273
274fn load_tools(dir: &Path) -> Result<Vec<ToolSpec>> {
275 let mut out = Vec::new();
276 let mut seen = std::collections::HashSet::new();
277 for path in md_files(dir, &["md"])? {
278 let content = std::fs::read_to_string(&path)
279 .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
280 let (front, body) = split_frontmatter(&content);
281 let front = front.ok_or_else(|| {
282 CodeError::Context(format!(
283 "tool {} has no YAML frontmatter (need `kind:`)",
284 path.display()
285 ))
286 })?;
287 let meta: ToolFront = serde_yaml::from_str(&front)
288 .map_err(|e| CodeError::Context(format!("tool {} frontmatter: {e}", path.display())))?;
289 let spec = match meta.kind.as_str() {
290 "mcp" => {
291 let cfg: McpServerConfig = serde_yaml::from_str(&front).map_err(|e| {
295 CodeError::Context(format!(
296 "tool {} (kind=mcp) is not a valid MCP server config: {e}",
297 path.display()
298 ))
299 })?;
300 ToolSpec::Mcp(cfg)
301 }
302 "script" => {
303 let meta: ScriptFront = serde_yaml::from_str(&front).map_err(|e| {
304 CodeError::Context(format!(
305 "tool {} (kind=script) frontmatter: {e}",
306 path.display()
307 ))
308 })?;
309 let p = meta.path.to_string_lossy();
314 if !(p.ends_with(".js") || p.ends_with(".mjs")) {
315 return Err(CodeError::Context(format!(
316 "tool {} (kind=script) path `{p}` must point to a .js or .mjs file",
317 path.display()
318 )));
319 }
320 crate::workspace::validate_relative_pattern(&p, "script path").map_err(|e| {
321 CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
322 })?;
323 let limits =
324 validate_script_limits(meta.limits.unwrap_or_default()).map_err(|e| {
325 CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
326 })?;
327 let description = meta
328 .description
329 .map(|d| d.trim().to_string())
330 .filter(|d| !d.is_empty())
331 .unwrap_or_else(|| body.trim().to_string());
332 ToolSpec::Script(ScriptToolSpec {
333 name: meta.name.unwrap_or_else(|| file_stem(&path)),
334 description,
335 path: meta.path,
336 allowed_tools: Some(meta.allowed_tools.unwrap_or_default()),
342 limits,
343 })
344 }
345 other => {
346 return Err(CodeError::Context(format!(
347 "tool {} has unsupported kind `{other}` (supported: `mcp`, `script`)",
348 path.display()
349 )));
350 }
351 };
352 if !seen.insert(spec.name().to_string()) {
353 return Err(CodeError::Context(format!(
354 "duplicate tool name `{}` in {}",
355 spec.name(),
356 path.display()
357 )));
358 }
359 out.push(spec);
360 }
361 Ok(out)
362}
363
364fn file_stem(path: &Path) -> String {
365 path.file_stem()
366 .and_then(|s| s.to_str())
367 .unwrap_or("unnamed")
368 .to_string()
369}
370
371fn split_frontmatter(content: &str) -> (Option<String>, String) {
374 let trimmed = content.trim_start();
375 if let Some(rest) = trimmed.strip_prefix("---") {
376 let rest = rest.trim_start_matches(['\r', '\n']);
377 for marker in ["\n---\n", "\n---\r\n", "\n---"] {
379 if let Some(end) = rest.find(marker) {
380 let front = rest[..end].to_string();
381 let body = rest[end + marker.len()..]
382 .trim_start_matches(['\r', '\n'])
383 .to_string();
384 return (Some(front), body);
385 }
386 }
387 }
388 (None, content.to_string())
389}
390
391#[derive(serde::Deserialize)]
392struct ScheduleFront {
393 cron: String,
394 #[serde(default)]
395 name: Option<String>,
396 #[serde(default)]
397 enabled: Option<bool>,
398}
399
400#[derive(serde::Deserialize)]
401struct ToolFront {
402 kind: String,
403}
404
405#[derive(serde::Deserialize)]
408struct ScriptFront {
409 #[serde(default)]
410 name: Option<String>,
411 path: PathBuf,
412 #[serde(default)]
413 description: Option<String>,
414 #[serde(default)]
415 allowed_tools: Option<Vec<String>>,
416 #[serde(default)]
417 limits: Option<ScriptToolLimits>,
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn fixture() -> PathBuf {
426 let base = std::env::temp_dir().join(format!("a3s-agentdir-{}", std::process::id()));
427 let _ = std::fs::remove_dir_all(&base);
428 std::fs::create_dir_all(base.join("skills")).unwrap();
429 std::fs::create_dir_all(base.join("schedules")).unwrap();
430 std::fs::create_dir_all(base.join("tools")).unwrap();
431 std::fs::write(
432 base.join("instructions.md"),
433 "You are a release-notes agent. Be terse and accurate.",
434 )
435 .unwrap();
436 std::fs::write(
437 base.join("skills/summarize.md"),
438 "---\nname: summarize\ndescription: summarize text\n---\n# Summarize\n",
439 )
440 .unwrap();
441 std::fs::write(
442 base.join("schedules/daily.md"),
443 "---\ncron: \"0 9 * * *\"\nname: daily-report\n---\nGenerate the daily report and post it.\n",
444 )
445 .unwrap();
446 std::fs::write(
447 base.join("tools/github.md"),
448 "---\nkind: mcp\nname: github\ntransport: stdio\ncommand: echo\nargs: [\"hi\"]\n---\nGitHub MCP tools.\n",
449 )
450 .unwrap();
451 std::fs::write(
452 base.join("tools/search.md"),
453 "---\nkind: script\nname: search-auth\npath: scripts/search.js\nallowed_tools: [grep, read]\nlimits:\n timeoutMs: 30000\n maxToolCalls: 10\n---\nFind auth-related files.\n",
454 )
455 .unwrap();
456 base
457 }
458
459 #[test]
460 fn loads_convention_into_slots_and_specs() {
461 let dir = fixture();
462 let agent = AgentDir::load(&dir).unwrap();
463
464 assert_eq!(
466 agent.prompt_slots.role.as_deref(),
467 Some("You are a release-notes agent. Be terse and accurate.")
468 );
469
470 assert!(agent
472 .config
473 .skill_dirs
474 .iter()
475 .any(|p| p.ends_with("skills")));
476
477 assert_eq!(agent.schedules.len(), 1);
479 let s = &agent.schedules[0];
480 assert_eq!(s.name, "daily-report");
481 assert_eq!(s.cron, "0 9 * * *");
482 assert_eq!(s.prompt, "Generate the daily report and post it.");
483 assert!(s.enabled);
484
485 assert_eq!(agent.tools.len(), 2);
487 assert_eq!(agent.tools[0].kind(), "mcp");
488 assert_eq!(agent.tools[0].name(), "github");
489
490 assert_eq!(agent.tools[1].kind(), "script");
493 assert_eq!(agent.tools[1].name(), "search-auth");
494 let ToolSpec::Script(s) = &agent.tools[1] else {
495 panic!("expected a script tool");
496 };
497 assert_eq!(s.path, PathBuf::from("scripts/search.js"));
498 assert_eq!(s.description, "Find auth-related files.");
499 assert_eq!(
500 s.allowed_tools.as_deref(),
501 Some(["grep".to_string(), "read".to_string()].as_slice())
502 );
503 assert_eq!(s.limits.timeout_ms, Some(30000));
504 assert_eq!(s.limits.max_tool_calls, Some(10));
505
506 let _ = std::fs::remove_dir_all(&dir);
507 }
508
509 fn assert_script_tool_load_err(tag: &str, frontmatter: &str) {
511 let base = std::env::temp_dir().join(format!("a3s-agentdir-{tag}-{}", std::process::id()));
512 let _ = std::fs::remove_dir_all(&base);
513 std::fs::create_dir_all(base.join("tools")).unwrap();
514 std::fs::write(base.join("instructions.md"), "role").unwrap();
515 std::fs::write(base.join("tools/x.md"), frontmatter).unwrap();
516 assert!(
517 AgentDir::load(&base).is_err(),
518 "expected load error for: {frontmatter}"
519 );
520 let _ = std::fs::remove_dir_all(&base);
521 }
522
523 #[test]
524 fn script_tool_non_js_path_is_an_error() {
525 assert_script_tool_load_err(
527 "py",
528 "---\nkind: script\nname: x\npath: scripts/run.py\n---\n",
529 );
530 }
531
532 #[test]
533 fn script_tool_escaping_path_is_an_error() {
534 assert_script_tool_load_err(
537 "abs",
538 "---\nkind: script\nname: x\npath: /etc/evil.js\n---\n",
539 );
540 assert_script_tool_load_err(
541 "dotdot",
542 "---\nkind: script\nname: x\npath: ../../escape.js\n---\n",
543 );
544 }
545
546 #[test]
547 fn script_tool_out_of_range_limits_are_an_error() {
548 assert_script_tool_load_err(
550 "zero",
551 "---\nkind: script\nname: x\npath: a.js\nlimits:\n timeoutMs: 0\n---\n",
552 );
553 assert_script_tool_load_err(
554 "huge",
555 "---\nkind: script\nname: x\npath: a.js\nlimits:\n timeoutMs: 18446744073709551615\n---\n",
556 );
557 assert_script_tool_load_err(
558 "calls",
559 "---\nkind: script\nname: x\npath: a.js\nlimits:\n maxToolCalls: 0\n---\n",
560 );
561 }
562
563 #[test]
564 fn unknown_tool_kind_is_an_error() {
565 let base =
566 std::env::temp_dir().join(format!("a3s-agentdir-toolkind-{}", std::process::id()));
567 let _ = std::fs::remove_dir_all(&base);
568 std::fs::create_dir_all(base.join("tools")).unwrap();
569 std::fs::write(base.join("instructions.md"), "role").unwrap();
570 std::fs::write(base.join("tools/x.md"), "---\nkind: wat\nname: x\n---\n").unwrap();
571 assert!(AgentDir::load(&base).is_err());
572 let _ = std::fs::remove_dir_all(&base);
573 }
574
575 #[test]
576 fn duplicate_tool_name_is_an_error() {
577 let base =
578 std::env::temp_dir().join(format!("a3s-agentdir-tooldup-{}", std::process::id()));
579 let _ = std::fs::remove_dir_all(&base);
580 std::fs::create_dir_all(base.join("tools")).unwrap();
581 std::fs::write(base.join("instructions.md"), "role").unwrap();
582 let spec = "---\nkind: mcp\nname: dup\ntransport: stdio\ncommand: echo\n---\n";
583 std::fs::write(base.join("tools/a.md"), spec).unwrap();
584 std::fs::write(base.join("tools/b.md"), spec).unwrap();
585 assert!(AgentDir::load(&base).is_err());
586 let _ = std::fs::remove_dir_all(&base);
587 }
588
589 #[test]
590 fn script_tool_accepts_mjs_and_frontmatter_description_wins_over_body() {
591 let base = std::env::temp_dir().join(format!("a3s-agentdir-mjs-{}", std::process::id()));
592 let _ = std::fs::remove_dir_all(&base);
593 std::fs::create_dir_all(base.join("tools")).unwrap();
594 std::fs::write(base.join("instructions.md"), "role").unwrap();
595 std::fs::write(
596 base.join("tools/x.md"),
597 "---\nkind: script\nname: x\npath: a.mjs\ndescription: from frontmatter\n---\nbody description\n",
598 )
599 .unwrap();
600
601 let agent = AgentDir::load(&base).unwrap();
602 let ToolSpec::Script(s) = &agent.tools[0] else {
603 panic!("expected script tool");
604 };
605 assert_eq!(s.path, PathBuf::from("a.mjs"), ".mjs is accepted");
606 assert_eq!(
607 s.description, "from frontmatter",
608 "frontmatter description takes precedence over the body"
609 );
610 let _ = std::fs::remove_dir_all(&base);
611 }
612
613 #[test]
614 fn script_tool_omitted_allow_list_fails_closed_to_empty() {
615 let base =
620 std::env::temp_dir().join(format!("a3s-agentdir-noallow-{}", std::process::id()));
621 let _ = std::fs::remove_dir_all(&base);
622 std::fs::create_dir_all(base.join("tools")).unwrap();
623 std::fs::write(base.join("instructions.md"), "role").unwrap();
624 std::fs::write(
625 base.join("tools/x.md"),
626 "---\nkind: script\nname: x\npath: a.js\n---\n",
627 )
628 .unwrap();
629
630 let agent = AgentDir::load(&base).unwrap();
631 let ToolSpec::Script(s) = &agent.tools[0] else {
632 panic!("expected script tool");
633 };
634 assert_eq!(
635 s.allowed_tools.as_deref(),
636 Some([].as_slice()),
637 "omitted allowed_tools must fail closed to an empty list, not None/all"
638 );
639 let _ = std::fs::remove_dir_all(&base);
640 }
641
642 #[test]
643 fn missing_instructions_is_an_error() {
644 let base = std::env::temp_dir().join(format!("a3s-agentdir-empty-{}", std::process::id()));
645 let _ = std::fs::remove_dir_all(&base);
646 std::fs::create_dir_all(&base).unwrap();
647 assert!(AgentDir::load(&base).is_err());
648 let _ = std::fs::remove_dir_all(&base);
649 }
650
651 #[test]
652 fn frontmatter_split_handles_no_frontmatter() {
653 let (f, b) = split_frontmatter("no frontmatter here");
654 assert!(f.is_none());
655 assert_eq!(b, "no frontmatter here");
656 }
657}