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