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 mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
194 .map_err(|e| CodeError::Context(format!("read {}: {e}", dir.display())))?
195 .filter_map(|e| e.ok().map(|e| e.path()))
196 .filter(|p| {
197 p.extension()
198 .and_then(|s| s.to_str())
199 .map(|e| exts.contains(&e))
200 .unwrap_or(false)
201 })
202 .collect();
203 entries.sort();
204 Ok(entries)
205}
206
207fn load_schedules(dir: &Path) -> Result<Vec<ScheduleSpec>> {
208 let mut out = Vec::new();
209 for path in md_files(dir, &["md"])? {
210 let content = std::fs::read_to_string(&path)
211 .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
212 let (front, body) = split_frontmatter(&content);
213 let front = front.ok_or_else(|| {
214 CodeError::Context(format!(
215 "schedule {} has no YAML frontmatter (need `cron:`)",
216 path.display()
217 ))
218 })?;
219 let meta: ScheduleFront = serde_yaml::from_str(&front).map_err(|e| {
220 CodeError::Context(format!("schedule {} frontmatter: {e}", path.display()))
221 })?;
222 out.push(ScheduleSpec {
223 name: meta.name.unwrap_or_else(|| file_stem(&path)),
224 cron: meta.cron,
225 prompt: body.trim().to_string(),
226 enabled: meta.enabled.unwrap_or(true),
227 });
228 }
229 Ok(out)
230}
231
232const SCRIPT_MAX_TIMEOUT_MS: u64 = 600_000; const SCRIPT_MAX_TOOL_CALLS: usize = 1_000;
239const SCRIPT_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; fn validate_script_limits(
244 limits: ScriptToolLimits,
245) -> std::result::Result<ScriptToolLimits, String> {
246 fn check<T: PartialOrd + Copy + std::fmt::Display>(
247 v: Option<T>,
248 max: T,
249 one: T,
250 field: &str,
251 ) -> std::result::Result<(), String> {
252 if let Some(v) = v {
253 if v < one || v > max {
254 return Err(format!("limit {field}={v} is out of range [1, {max}]"));
255 }
256 }
257 Ok(())
258 }
259 check(limits.timeout_ms, SCRIPT_MAX_TIMEOUT_MS, 1, "timeoutMs")?;
260 check(
261 limits.max_tool_calls,
262 SCRIPT_MAX_TOOL_CALLS,
263 1,
264 "maxToolCalls",
265 )?;
266 check(
267 limits.max_output_bytes,
268 SCRIPT_MAX_OUTPUT_BYTES,
269 1,
270 "maxOutputBytes",
271 )?;
272 Ok(limits)
273}
274
275fn load_tools(dir: &Path) -> Result<Vec<ToolSpec>> {
276 let mut out = Vec::new();
277 let mut seen = std::collections::HashSet::new();
278 for path in md_files(dir, &["md"])? {
279 let content = std::fs::read_to_string(&path)
280 .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
281 let (front, body) = split_frontmatter(&content);
282 let front = front.ok_or_else(|| {
283 CodeError::Context(format!(
284 "tool {} has no YAML frontmatter (need `kind:`)",
285 path.display()
286 ))
287 })?;
288 let meta: ToolFront = serde_yaml::from_str(&front)
289 .map_err(|e| CodeError::Context(format!("tool {} frontmatter: {e}", path.display())))?;
290 let spec = match meta.kind.as_str() {
291 "mcp" => {
292 let cfg: McpServerConfig = serde_yaml::from_str(&front).map_err(|e| {
296 CodeError::Context(format!(
297 "tool {} (kind=mcp) is not a valid MCP server config: {e}",
298 path.display()
299 ))
300 })?;
301 ToolSpec::Mcp(cfg)
302 }
303 "script" => {
304 let meta: ScriptFront = serde_yaml::from_str(&front).map_err(|e| {
305 CodeError::Context(format!(
306 "tool {} (kind=script) frontmatter: {e}",
307 path.display()
308 ))
309 })?;
310 let p = meta.path.to_string_lossy();
315 if !(p.ends_with(".js") || p.ends_with(".mjs")) {
316 return Err(CodeError::Context(format!(
317 "tool {} (kind=script) path `{p}` must point to a .js or .mjs file",
318 path.display()
319 )));
320 }
321 crate::workspace::validate_relative_pattern(&p, "script path").map_err(|e| {
322 CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
323 })?;
324 let limits =
325 validate_script_limits(meta.limits.unwrap_or_default()).map_err(|e| {
326 CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
327 })?;
328 let description = meta
329 .description
330 .map(|d| d.trim().to_string())
331 .filter(|d| !d.is_empty())
332 .unwrap_or_else(|| body.trim().to_string());
333 ToolSpec::Script(ScriptToolSpec {
334 name: meta.name.unwrap_or_else(|| file_stem(&path)),
335 description,
336 path: meta.path,
337 allowed_tools: Some(meta.allowed_tools.unwrap_or_default()),
343 limits,
344 })
345 }
346 other => {
347 return Err(CodeError::Context(format!(
348 "tool {} has unsupported kind `{other}` (supported: `mcp`, `script`)",
349 path.display()
350 )));
351 }
352 };
353 if !seen.insert(spec.name().to_string()) {
354 return Err(CodeError::Context(format!(
355 "duplicate tool name `{}` in {}",
356 spec.name(),
357 path.display()
358 )));
359 }
360 out.push(spec);
361 }
362 Ok(out)
363}
364
365fn file_stem(path: &Path) -> String {
366 path.file_stem()
367 .and_then(|s| s.to_str())
368 .unwrap_or("unnamed")
369 .to_string()
370}
371
372fn split_frontmatter(content: &str) -> (Option<String>, String) {
375 let trimmed = content.trim_start();
376 if let Some(rest) = trimmed.strip_prefix("---") {
377 let rest = rest.trim_start_matches(['\r', '\n']);
378 for marker in ["\n---\n", "\n---\r\n", "\n---"] {
380 if let Some(end) = rest.find(marker) {
381 let front = rest[..end].to_string();
382 let body = rest[end + marker.len()..]
383 .trim_start_matches(['\r', '\n'])
384 .to_string();
385 return (Some(front), body);
386 }
387 }
388 }
389 (None, content.to_string())
390}
391
392#[derive(serde::Deserialize)]
393struct ScheduleFront {
394 cron: String,
395 #[serde(default)]
396 name: Option<String>,
397 #[serde(default)]
398 enabled: Option<bool>,
399}
400
401#[derive(serde::Deserialize)]
402struct ToolFront {
403 kind: String,
404}
405
406#[derive(serde::Deserialize)]
409struct ScriptFront {
410 #[serde(default)]
411 name: Option<String>,
412 path: PathBuf,
413 #[serde(default)]
414 description: Option<String>,
415 #[serde(default)]
416 allowed_tools: Option<Vec<String>>,
417 #[serde(default)]
418 limits: Option<ScriptToolLimits>,
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn fixture() -> PathBuf {
427 let base = std::env::temp_dir().join(format!("a3s-agentdir-{}", std::process::id()));
428 let _ = std::fs::remove_dir_all(&base);
429 std::fs::create_dir_all(base.join("skills")).unwrap();
430 std::fs::create_dir_all(base.join("schedules")).unwrap();
431 std::fs::create_dir_all(base.join("tools")).unwrap();
432 std::fs::write(
433 base.join("instructions.md"),
434 "You are a release-notes agent. Be terse and accurate.",
435 )
436 .unwrap();
437 std::fs::write(
438 base.join("skills/summarize.md"),
439 "---\nname: summarize\ndescription: summarize text\n---\n# Summarize\n",
440 )
441 .unwrap();
442 std::fs::write(
443 base.join("schedules/daily.md"),
444 "---\ncron: \"0 9 * * *\"\nname: daily-report\n---\nGenerate the daily report and post it.\n",
445 )
446 .unwrap();
447 std::fs::write(
448 base.join("tools/github.md"),
449 "---\nkind: mcp\nname: github\ntransport: stdio\ncommand: echo\nargs: [\"hi\"]\n---\nGitHub MCP tools.\n",
450 )
451 .unwrap();
452 std::fs::write(
453 base.join("tools/search.md"),
454 "---\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",
455 )
456 .unwrap();
457 base
458 }
459
460 #[test]
461 fn loads_convention_into_slots_and_specs() {
462 let dir = fixture();
463 let agent = AgentDir::load(&dir).unwrap();
464
465 assert_eq!(
467 agent.prompt_slots.role.as_deref(),
468 Some("You are a release-notes agent. Be terse and accurate.")
469 );
470
471 assert!(agent
473 .config
474 .skill_dirs
475 .iter()
476 .any(|p| p.ends_with("skills")));
477
478 assert_eq!(agent.schedules.len(), 1);
480 let s = &agent.schedules[0];
481 assert_eq!(s.name, "daily-report");
482 assert_eq!(s.cron, "0 9 * * *");
483 assert_eq!(s.prompt, "Generate the daily report and post it.");
484 assert!(s.enabled);
485
486 assert_eq!(agent.tools.len(), 2);
488 assert_eq!(agent.tools[0].kind(), "mcp");
489 assert_eq!(agent.tools[0].name(), "github");
490
491 assert_eq!(agent.tools[1].kind(), "script");
494 assert_eq!(agent.tools[1].name(), "search-auth");
495 let ToolSpec::Script(s) = &agent.tools[1] else {
496 panic!("expected a script tool");
497 };
498 assert_eq!(s.path, PathBuf::from("scripts/search.js"));
499 assert_eq!(s.description, "Find auth-related files.");
500 assert_eq!(
501 s.allowed_tools.as_deref(),
502 Some(["grep".to_string(), "read".to_string()].as_slice())
503 );
504 assert_eq!(s.limits.timeout_ms, Some(30000));
505 assert_eq!(s.limits.max_tool_calls, Some(10));
506
507 let _ = std::fs::remove_dir_all(&dir);
508 }
509
510 fn assert_script_tool_load_err(tag: &str, frontmatter: &str) {
512 let base = std::env::temp_dir().join(format!("a3s-agentdir-{tag}-{}", std::process::id()));
513 let _ = std::fs::remove_dir_all(&base);
514 std::fs::create_dir_all(base.join("tools")).unwrap();
515 std::fs::write(base.join("instructions.md"), "role").unwrap();
516 std::fs::write(base.join("tools/x.md"), frontmatter).unwrap();
517 assert!(
518 AgentDir::load(&base).is_err(),
519 "expected load error for: {frontmatter}"
520 );
521 let _ = std::fs::remove_dir_all(&base);
522 }
523
524 #[test]
525 fn script_tool_non_js_path_is_an_error() {
526 assert_script_tool_load_err(
528 "py",
529 "---\nkind: script\nname: x\npath: scripts/run.py\n---\n",
530 );
531 }
532
533 #[test]
534 fn script_tool_escaping_path_is_an_error() {
535 #[cfg(not(windows))]
538 let absolute_path = "/etc/evil.js";
539 #[cfg(windows)]
540 let absolute_path = "C:/etc/evil.js";
541 assert_script_tool_load_err(
542 "abs",
543 &format!("---\nkind: script\nname: x\npath: {absolute_path}\n---\n"),
544 );
545 assert_script_tool_load_err(
546 "dotdot",
547 "---\nkind: script\nname: x\npath: ../../escape.js\n---\n",
548 );
549 }
550
551 #[test]
552 fn script_tool_out_of_range_limits_are_an_error() {
553 assert_script_tool_load_err(
555 "zero",
556 "---\nkind: script\nname: x\npath: a.js\nlimits:\n timeoutMs: 0\n---\n",
557 );
558 assert_script_tool_load_err(
559 "huge",
560 "---\nkind: script\nname: x\npath: a.js\nlimits:\n timeoutMs: 18446744073709551615\n---\n",
561 );
562 assert_script_tool_load_err(
563 "calls",
564 "---\nkind: script\nname: x\npath: a.js\nlimits:\n maxToolCalls: 0\n---\n",
565 );
566 }
567
568 #[test]
569 fn unknown_tool_kind_is_an_error() {
570 let base =
571 std::env::temp_dir().join(format!("a3s-agentdir-toolkind-{}", std::process::id()));
572 let _ = std::fs::remove_dir_all(&base);
573 std::fs::create_dir_all(base.join("tools")).unwrap();
574 std::fs::write(base.join("instructions.md"), "role").unwrap();
575 std::fs::write(base.join("tools/x.md"), "---\nkind: wat\nname: x\n---\n").unwrap();
576 assert!(AgentDir::load(&base).is_err());
577 let _ = std::fs::remove_dir_all(&base);
578 }
579
580 #[test]
581 fn duplicate_tool_name_is_an_error() {
582 let base =
583 std::env::temp_dir().join(format!("a3s-agentdir-tooldup-{}", std::process::id()));
584 let _ = std::fs::remove_dir_all(&base);
585 std::fs::create_dir_all(base.join("tools")).unwrap();
586 std::fs::write(base.join("instructions.md"), "role").unwrap();
587 let spec = "---\nkind: mcp\nname: dup\ntransport: stdio\ncommand: echo\n---\n";
588 std::fs::write(base.join("tools/a.md"), spec).unwrap();
589 std::fs::write(base.join("tools/b.md"), spec).unwrap();
590 assert!(AgentDir::load(&base).is_err());
591 let _ = std::fs::remove_dir_all(&base);
592 }
593
594 #[test]
595 fn script_tool_accepts_mjs_and_frontmatter_description_wins_over_body() {
596 let base = std::env::temp_dir().join(format!("a3s-agentdir-mjs-{}", std::process::id()));
597 let _ = std::fs::remove_dir_all(&base);
598 std::fs::create_dir_all(base.join("tools")).unwrap();
599 std::fs::write(base.join("instructions.md"), "role").unwrap();
600 std::fs::write(
601 base.join("tools/x.md"),
602 "---\nkind: script\nname: x\npath: a.mjs\ndescription: from frontmatter\n---\nbody description\n",
603 )
604 .unwrap();
605
606 let agent = AgentDir::load(&base).unwrap();
607 let ToolSpec::Script(s) = &agent.tools[0] else {
608 panic!("expected script tool");
609 };
610 assert_eq!(s.path, PathBuf::from("a.mjs"), ".mjs is accepted");
611 assert_eq!(
612 s.description, "from frontmatter",
613 "frontmatter description takes precedence over the body"
614 );
615 let _ = std::fs::remove_dir_all(&base);
616 }
617
618 #[test]
619 fn script_tool_omitted_allow_list_fails_closed_to_empty() {
620 let base =
625 std::env::temp_dir().join(format!("a3s-agentdir-noallow-{}", 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 std::fs::write(
630 base.join("tools/x.md"),
631 "---\nkind: script\nname: x\npath: a.js\n---\n",
632 )
633 .unwrap();
634
635 let agent = AgentDir::load(&base).unwrap();
636 let ToolSpec::Script(s) = &agent.tools[0] else {
637 panic!("expected script tool");
638 };
639 assert_eq!(
640 s.allowed_tools.as_deref(),
641 Some([].as_slice()),
642 "omitted allowed_tools must fail closed to an empty list, not None/all"
643 );
644 let _ = std::fs::remove_dir_all(&base);
645 }
646
647 #[test]
648 fn missing_instructions_is_an_error() {
649 let base = std::env::temp_dir().join(format!("a3s-agentdir-empty-{}", std::process::id()));
650 let _ = std::fs::remove_dir_all(&base);
651 std::fs::create_dir_all(&base).unwrap();
652 assert!(AgentDir::load(&base).is_err());
653 let _ = std::fs::remove_dir_all(&base);
654 }
655
656 #[test]
657 fn frontmatter_split_handles_no_frontmatter() {
658 let (f, b) = split_frontmatter("no frontmatter here");
659 assert!(f.is_none());
660 assert_eq!(b, "no frontmatter here");
661 }
662}