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)]
88pub struct ScriptToolSpec {
89 pub name: String,
91 pub description: String,
93 pub path: PathBuf,
95 pub allowed_tools: Option<Vec<String>>,
102 pub limits: ScriptToolLimits,
104}
105
106#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ScriptToolLimits {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub timeout_ms: Option<u64>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub max_tool_calls: Option<usize>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub max_output_bytes: Option<usize>,
118}
119
120#[derive(Debug, Clone)]
128pub struct AgentDir {
129 pub dir: PathBuf,
130 pub config: CodeConfig,
131 pub prompt_slots: SystemPromptSlots,
132 pub schedules: Vec<ScheduleSpec>,
133 pub tools: Vec<ToolSpec>,
134}
135
136impl AgentDir {
137 pub fn load(dir: impl AsRef<Path>) -> Result<Self> {
139 let dir = dir.as_ref().to_path_buf();
140 if !dir.is_dir() {
141 return Err(CodeError::Context(format!(
142 "agent directory not found: {}",
143 dir.display()
144 )));
145 }
146
147 let instructions = std::fs::read_to_string(dir.join("instructions.md")).map_err(|e| {
150 CodeError::Context(format!(
151 "agent dir {} is missing required instructions.md: {e}",
152 dir.display()
153 ))
154 })?;
155 let prompt_slots = SystemPromptSlots {
156 role: Some(instructions.trim().to_string()),
157 ..Default::default()
158 };
159
160 let acl_path = dir.join("agent.acl");
162 let mut config = if acl_path.is_file() {
163 CodeConfig::from_file(&acl_path)?
164 } else {
165 CodeConfig::default()
166 };
167
168 let skills_dir = dir.join("skills");
170 if skills_dir.is_dir() {
171 config.skill_dirs.push(skills_dir);
172 }
173
174 let schedules = load_schedules(&dir.join("schedules"))?;
175 let tools = load_tools(&dir.join("tools"))?;
176
177 Ok(Self {
178 dir,
179 config,
180 prompt_slots,
181 schedules,
182 tools,
183 })
184 }
185}
186
187fn md_files(dir: &Path, exts: &[&str]) -> Result<Vec<PathBuf>> {
190 if !dir.is_dir() {
191 return Ok(Vec::new());
192 }
193 let entries = std::fs::read_dir(dir)
194 .map_err(|e| CodeError::Context(format!("read {}: {e}", dir.display())))?
195 .map(|entry| entry.map(|entry| entry.path()));
196 collect_md_paths(dir, entries, exts)
197}
198
199fn collect_md_paths(
200 dir: &Path,
201 entries: impl IntoIterator<Item = std::io::Result<PathBuf>>,
202 exts: &[&str],
203) -> Result<Vec<PathBuf>> {
204 let mut paths = Vec::new();
205 for entry in entries {
206 let path = entry.map_err(|e| {
207 CodeError::Context(format!("read directory entry in {}: {e}", dir.display()))
208 })?;
209 if path
210 .extension()
211 .and_then(|s| s.to_str())
212 .map(|e| exts.contains(&e))
213 .unwrap_or(false)
214 {
215 paths.push(path);
216 }
217 }
218 paths.sort();
219 Ok(paths)
220}
221
222fn load_schedules(dir: &Path) -> Result<Vec<ScheduleSpec>> {
223 let mut out = Vec::new();
224 for path in md_files(dir, &["md"])? {
225 let content = std::fs::read_to_string(&path)
226 .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
227 let (front, body) = split_frontmatter(&content);
228 let front = front.ok_or_else(|| {
229 CodeError::Context(format!(
230 "schedule {} has no YAML frontmatter (need `cron:`)",
231 path.display()
232 ))
233 })?;
234 let meta: ScheduleFront = serde_yaml::from_str(&front).map_err(|e| {
235 CodeError::Context(format!("schedule {} frontmatter: {e}", path.display()))
236 })?;
237 out.push(ScheduleSpec {
238 name: meta.name.unwrap_or_else(|| file_stem(&path)),
239 cron: meta.cron,
240 prompt: body.trim().to_string(),
241 enabled: meta.enabled.unwrap_or(true),
242 });
243 }
244 Ok(out)
245}
246
247const SCRIPT_MAX_TIMEOUT_MS: u64 = 600_000; const SCRIPT_MAX_TOOL_CALLS: usize = 1_000;
254const SCRIPT_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; fn validate_script_limits(
259 limits: ScriptToolLimits,
260) -> std::result::Result<ScriptToolLimits, String> {
261 fn check<T: PartialOrd + Copy + std::fmt::Display>(
262 v: Option<T>,
263 max: T,
264 one: T,
265 field: &str,
266 ) -> std::result::Result<(), String> {
267 if let Some(v) = v {
268 if v < one || v > max {
269 return Err(format!("limit {field}={v} is out of range [1, {max}]"));
270 }
271 }
272 Ok(())
273 }
274 check(limits.timeout_ms, SCRIPT_MAX_TIMEOUT_MS, 1, "timeoutMs")?;
275 check(
276 limits.max_tool_calls,
277 SCRIPT_MAX_TOOL_CALLS,
278 1,
279 "maxToolCalls",
280 )?;
281 check(
282 limits.max_output_bytes,
283 SCRIPT_MAX_OUTPUT_BYTES,
284 1,
285 "maxOutputBytes",
286 )?;
287 Ok(limits)
288}
289
290fn load_tools(dir: &Path) -> Result<Vec<ToolSpec>> {
291 let mut out = Vec::new();
292 let mut seen = std::collections::HashSet::new();
293 for path in md_files(dir, &["md"])? {
294 let content = std::fs::read_to_string(&path)
295 .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
296 let (front, body) = split_frontmatter(&content);
297 let front = front.ok_or_else(|| {
298 CodeError::Context(format!(
299 "tool {} has no YAML frontmatter (need `kind:`)",
300 path.display()
301 ))
302 })?;
303 let meta: ToolFront = serde_yaml::from_str(&front)
304 .map_err(|e| CodeError::Context(format!("tool {} frontmatter: {e}", path.display())))?;
305 let spec = match meta.kind.as_str() {
306 "mcp" => {
307 let cfg: McpServerConfig = serde_yaml::from_str(&front).map_err(|e| {
311 CodeError::Context(format!(
312 "tool {} (kind=mcp) is not a valid MCP server config: {e}",
313 path.display()
314 ))
315 })?;
316 ToolSpec::Mcp(cfg)
317 }
318 "script" => {
319 let meta: ScriptFront = serde_yaml::from_str(&front).map_err(|e| {
320 CodeError::Context(format!(
321 "tool {} (kind=script) frontmatter: {e}",
322 path.display()
323 ))
324 })?;
325 let p = meta.path.to_string_lossy();
330 if !(p.ends_with(".js") || p.ends_with(".mjs")) {
331 return Err(CodeError::Context(format!(
332 "tool {} (kind=script) path `{p}` must point to a .js or .mjs file",
333 path.display()
334 )));
335 }
336 crate::workspace::validate_relative_pattern(&p, "script path").map_err(|e| {
337 CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
338 })?;
339 let limits =
340 validate_script_limits(meta.limits.unwrap_or_default()).map_err(|e| {
341 CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
342 })?;
343 let description = meta
344 .description
345 .map(|d| d.trim().to_string())
346 .filter(|d| !d.is_empty())
347 .unwrap_or_else(|| body.trim().to_string());
348 ToolSpec::Script(ScriptToolSpec {
349 name: meta.name.unwrap_or_else(|| file_stem(&path)),
350 description,
351 path: meta.path,
352 allowed_tools: Some(meta.allowed_tools.unwrap_or_default()),
358 limits,
359 })
360 }
361 other => {
362 return Err(CodeError::Context(format!(
363 "tool {} has unsupported kind `{other}` (supported: `mcp`, `script`)",
364 path.display()
365 )));
366 }
367 };
368 if !seen.insert(spec.name().to_string()) {
369 return Err(CodeError::Context(format!(
370 "duplicate tool name `{}` in {}",
371 spec.name(),
372 path.display()
373 )));
374 }
375 out.push(spec);
376 }
377 Ok(out)
378}
379
380fn file_stem(path: &Path) -> String {
381 path.file_stem()
382 .and_then(|s| s.to_str())
383 .unwrap_or("unnamed")
384 .to_string()
385}
386
387fn split_frontmatter(content: &str) -> (Option<String>, String) {
390 let trimmed = content.trim_start();
391 if let Some(rest) = trimmed.strip_prefix("---") {
392 let rest = rest.trim_start_matches(['\r', '\n']);
393 for marker in ["\n---\n", "\n---\r\n", "\n---"] {
395 if let Some(end) = rest.find(marker) {
396 let front = rest[..end].to_string();
397 let body = rest[end + marker.len()..]
398 .trim_start_matches(['\r', '\n'])
399 .to_string();
400 return (Some(front), body);
401 }
402 }
403 }
404 (None, content.to_string())
405}
406
407#[derive(serde::Deserialize)]
408struct ScheduleFront {
409 cron: String,
410 #[serde(default)]
411 name: Option<String>,
412 #[serde(default)]
413 enabled: Option<bool>,
414}
415
416#[derive(serde::Deserialize)]
417struct ToolFront {
418 kind: String,
419}
420
421#[derive(serde::Deserialize)]
424struct ScriptFront {
425 #[serde(default)]
426 name: Option<String>,
427 path: PathBuf,
428 #[serde(default)]
429 description: Option<String>,
430 #[serde(default)]
431 allowed_tools: Option<Vec<String>>,
432 #[serde(default)]
433 limits: Option<ScriptToolLimits>,
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn fixture() -> PathBuf {
442 let base = std::env::temp_dir().join(format!("a3s-agentdir-{}", std::process::id()));
443 let _ = std::fs::remove_dir_all(&base);
444 std::fs::create_dir_all(base.join("skills")).unwrap();
445 std::fs::create_dir_all(base.join("schedules")).unwrap();
446 std::fs::create_dir_all(base.join("tools")).unwrap();
447 std::fs::write(
448 base.join("instructions.md"),
449 "You are a release-notes agent. Be terse and accurate.",
450 )
451 .unwrap();
452 std::fs::write(
453 base.join("skills/summarize.md"),
454 "---\nname: summarize\ndescription: summarize text\n---\n# Summarize\n",
455 )
456 .unwrap();
457 std::fs::write(
458 base.join("schedules/daily.md"),
459 "---\ncron: \"0 9 * * *\"\nname: daily-report\n---\nGenerate the daily report and post it.\n",
460 )
461 .unwrap();
462 std::fs::write(
463 base.join("tools/github.md"),
464 "---\nkind: mcp\nname: github\ntransport: stdio\ncommand: echo\nargs: [\"hi\"]\n---\nGitHub MCP tools.\n",
465 )
466 .unwrap();
467 std::fs::write(
468 base.join("tools/search.md"),
469 "---\nkind: script\nname: search-auth\npath: scripts/search.js\nallowed_tools: [search, read]\nlimits:\n timeoutMs: 30000\n maxToolCalls: 10\n---\nFind auth-related files.\n",
470 )
471 .unwrap();
472 base
473 }
474
475 #[test]
476 fn loads_convention_into_slots_and_specs() {
477 let dir = fixture();
478 let agent = AgentDir::load(&dir).unwrap();
479
480 assert_eq!(
482 agent.prompt_slots.role.as_deref(),
483 Some("You are a release-notes agent. Be terse and accurate.")
484 );
485
486 assert!(agent
488 .config
489 .skill_dirs
490 .iter()
491 .any(|p| p.ends_with("skills")));
492
493 assert_eq!(agent.schedules.len(), 1);
495 let s = &agent.schedules[0];
496 assert_eq!(s.name, "daily-report");
497 assert_eq!(s.cron, "0 9 * * *");
498 assert_eq!(s.prompt, "Generate the daily report and post it.");
499 assert!(s.enabled);
500
501 assert_eq!(agent.tools.len(), 2);
503 assert_eq!(agent.tools[0].kind(), "mcp");
504 assert_eq!(agent.tools[0].name(), "github");
505
506 assert_eq!(agent.tools[1].kind(), "script");
509 assert_eq!(agent.tools[1].name(), "search-auth");
510 let ToolSpec::Script(s) = &agent.tools[1] else {
511 panic!("expected a script tool");
512 };
513 assert_eq!(s.path, PathBuf::from("scripts/search.js"));
514 assert_eq!(s.description, "Find auth-related files.");
515 assert_eq!(
516 s.allowed_tools.as_deref(),
517 Some(["search".to_string(), "read".to_string()].as_slice())
518 );
519 assert_eq!(s.limits.timeout_ms, Some(30000));
520 assert_eq!(s.limits.max_tool_calls, Some(10));
521
522 let _ = std::fs::remove_dir_all(&dir);
523 }
524
525 #[test]
526 fn directory_entry_errors_are_not_silently_ignored() {
527 let error = collect_md_paths(
528 Path::new("schedules"),
529 [Err(std::io::Error::new(
530 std::io::ErrorKind::PermissionDenied,
531 "fixture entry denied",
532 ))],
533 &["md"],
534 )
535 .unwrap_err();
536
537 let message = error.to_string();
538 assert!(message.contains("read directory entry in schedules"));
539 assert!(message.contains("fixture entry denied"));
540 }
541
542 fn assert_script_tool_load_err(tag: &str, frontmatter: &str) {
544 let base = std::env::temp_dir().join(format!("a3s-agentdir-{tag}-{}", std::process::id()));
545 let _ = std::fs::remove_dir_all(&base);
546 std::fs::create_dir_all(base.join("tools")).unwrap();
547 std::fs::write(base.join("instructions.md"), "role").unwrap();
548 std::fs::write(base.join("tools/x.md"), frontmatter).unwrap();
549 assert!(
550 AgentDir::load(&base).is_err(),
551 "expected load error for: {frontmatter}"
552 );
553 let _ = std::fs::remove_dir_all(&base);
554 }
555
556 #[test]
557 fn script_tool_non_js_path_is_an_error() {
558 assert_script_tool_load_err(
560 "py",
561 "---\nkind: script\nname: x\npath: scripts/run.py\n---\n",
562 );
563 }
564
565 #[test]
566 fn script_tool_escaping_path_is_an_error() {
567 #[cfg(not(windows))]
570 let absolute_path = "/etc/evil.js";
571 #[cfg(windows)]
572 let absolute_path = "C:/etc/evil.js";
573 assert_script_tool_load_err(
574 "abs",
575 &format!("---\nkind: script\nname: x\npath: {absolute_path}\n---\n"),
576 );
577 assert_script_tool_load_err(
578 "dotdot",
579 "---\nkind: script\nname: x\npath: ../../escape.js\n---\n",
580 );
581 }
582
583 #[test]
584 fn script_tool_out_of_range_limits_are_an_error() {
585 assert_script_tool_load_err(
587 "zero",
588 "---\nkind: script\nname: x\npath: a.js\nlimits:\n timeoutMs: 0\n---\n",
589 );
590 assert_script_tool_load_err(
591 "huge",
592 "---\nkind: script\nname: x\npath: a.js\nlimits:\n timeoutMs: 18446744073709551615\n---\n",
593 );
594 assert_script_tool_load_err(
595 "calls",
596 "---\nkind: script\nname: x\npath: a.js\nlimits:\n maxToolCalls: 0\n---\n",
597 );
598 }
599
600 #[test]
601 fn unknown_tool_kind_is_an_error() {
602 let base =
603 std::env::temp_dir().join(format!("a3s-agentdir-toolkind-{}", std::process::id()));
604 let _ = std::fs::remove_dir_all(&base);
605 std::fs::create_dir_all(base.join("tools")).unwrap();
606 std::fs::write(base.join("instructions.md"), "role").unwrap();
607 std::fs::write(base.join("tools/x.md"), "---\nkind: wat\nname: x\n---\n").unwrap();
608 assert!(AgentDir::load(&base).is_err());
609 let _ = std::fs::remove_dir_all(&base);
610 }
611
612 #[test]
613 fn duplicate_tool_name_is_an_error() {
614 let base =
615 std::env::temp_dir().join(format!("a3s-agentdir-tooldup-{}", std::process::id()));
616 let _ = std::fs::remove_dir_all(&base);
617 std::fs::create_dir_all(base.join("tools")).unwrap();
618 std::fs::write(base.join("instructions.md"), "role").unwrap();
619 let spec = "---\nkind: mcp\nname: dup\ntransport: stdio\ncommand: echo\n---\n";
620 std::fs::write(base.join("tools/a.md"), spec).unwrap();
621 std::fs::write(base.join("tools/b.md"), spec).unwrap();
622 assert!(AgentDir::load(&base).is_err());
623 let _ = std::fs::remove_dir_all(&base);
624 }
625
626 #[test]
627 fn script_tool_accepts_mjs_and_frontmatter_description_wins_over_body() {
628 let base = std::env::temp_dir().join(format!("a3s-agentdir-mjs-{}", std::process::id()));
629 let _ = std::fs::remove_dir_all(&base);
630 std::fs::create_dir_all(base.join("tools")).unwrap();
631 std::fs::write(base.join("instructions.md"), "role").unwrap();
632 std::fs::write(
633 base.join("tools/x.md"),
634 "---\nkind: script\nname: x\npath: a.mjs\ndescription: from frontmatter\n---\nbody description\n",
635 )
636 .unwrap();
637
638 let agent = AgentDir::load(&base).unwrap();
639 let ToolSpec::Script(s) = &agent.tools[0] else {
640 panic!("expected script tool");
641 };
642 assert_eq!(s.path, PathBuf::from("a.mjs"), ".mjs is accepted");
643 assert_eq!(
644 s.description, "from frontmatter",
645 "frontmatter description takes precedence over the body"
646 );
647 let _ = std::fs::remove_dir_all(&base);
648 }
649
650 #[test]
651 fn script_tool_omitted_allow_list_fails_closed_to_empty() {
652 let base =
657 std::env::temp_dir().join(format!("a3s-agentdir-noallow-{}", std::process::id()));
658 let _ = std::fs::remove_dir_all(&base);
659 std::fs::create_dir_all(base.join("tools")).unwrap();
660 std::fs::write(base.join("instructions.md"), "role").unwrap();
661 std::fs::write(
662 base.join("tools/x.md"),
663 "---\nkind: script\nname: x\npath: a.js\n---\n",
664 )
665 .unwrap();
666
667 let agent = AgentDir::load(&base).unwrap();
668 let ToolSpec::Script(s) = &agent.tools[0] else {
669 panic!("expected script tool");
670 };
671 assert_eq!(
672 s.allowed_tools.as_deref(),
673 Some([].as_slice()),
674 "omitted allowed_tools must fail closed to an empty list, not None/all"
675 );
676 let _ = std::fs::remove_dir_all(&base);
677 }
678
679 #[test]
680 fn missing_instructions_is_an_error() {
681 let base = std::env::temp_dir().join(format!("a3s-agentdir-empty-{}", std::process::id()));
682 let _ = std::fs::remove_dir_all(&base);
683 std::fs::create_dir_all(&base).unwrap();
684 assert!(AgentDir::load(&base).is_err());
685 let _ = std::fs::remove_dir_all(&base);
686 }
687
688 #[test]
689 fn frontmatter_split_handles_no_frontmatter() {
690 let (f, b) = split_frontmatter("no frontmatter here");
691 assert!(f.is_none());
692 assert_eq!(b, "no frontmatter here");
693 }
694}