1use serde::Deserialize;
34use serde_json::{Value, json};
35use std::collections::BTreeMap;
36use std::path::Path;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Format {
41 Json,
43 Yaml,
45}
46
47impl Format {
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Format::Json => "json",
51 Format::Yaml => "yaml",
52 }
53 }
54
55 pub fn detect(path: Option<&Path>, text: &str) -> Format {
61 if let Some(ext) = path.and_then(|p| p.extension()).and_then(|e| e.to_str()) {
62 match ext.to_ascii_lowercase().as_str() {
63 "yaml" | "yml" => return Format::Yaml,
64 "json" | "jsonc" => return Format::Json,
65 _ => {}
66 }
67 }
68 Format::sniff(text)
69 }
70
71 fn sniff(text: &str) -> Format {
72 let t = text.strip_prefix('\u{feff}').unwrap_or(text);
73 let bytes = t.as_bytes();
74 let mut i = 0;
75 loop {
76 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
77 i += 1;
78 }
79 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
80 while i < bytes.len() && bytes[i] != b'\n' {
81 i += 1;
82 }
83 continue;
84 }
85 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
86 i += 2;
87 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
88 i += 1;
89 }
90 i += 2;
91 continue;
92 }
93 break;
94 }
95 match bytes.get(i) {
96 Some(b'{') | Some(b'[') => Format::Json,
97 _ => Format::Yaml,
98 }
99 }
100}
101
102pub fn parse_document(text: &str, format: Format) -> Result<Value, String> {
106 let doc = match format {
107 Format::Json => {
108 let stripped = strip_jsonc(text);
109 serde_json::from_str::<Value>(&stripped)
110 .map_err(|e| format!("config file parse error (json): {e}"))?
111 }
112 Format::Yaml => {
113 super::yaml::parse(text).map_err(|e| format!("config file parse error (yaml): {e}"))?
114 }
115 };
116 match doc {
117 Value::Object(_) => Ok(doc),
118 Value::Null if format == Format::Yaml => Ok(Value::Object(serde_json::Map::new())),
119 other => Err(format!(
120 "config file must be a mapping (an object) at the top level, got {}",
121 kind_name(&other)
122 )),
123 }
124}
125
126pub fn read_document(path: &str) -> Result<(Value, Format), String> {
129 let text = std::fs::read_to_string(path)
130 .map_err(|e| format!("cannot read config file {path}: {e}"))?;
131 let format = Format::detect(Some(Path::new(path)), &text);
132 let doc = parse_document(&text, format).map_err(|e| format!("{path}: {e}"))?;
133 Ok((doc, format))
134}
135
136pub fn read_documents(paths: &[String]) -> Result<(Value, Vec<(String, Format)>), String> {
143 read_documents_checked(paths, &|doc, source| {
144 ConfigFile::from_document(doc.clone(), source).map(|_| ())
145 })
146}
147
148pub fn read_documents_checked(
152 paths: &[String],
153 check: &dyn Fn(&Value, &str) -> Result<(), String>,
154) -> Result<(Value, Vec<(String, Format)>), String> {
155 let mut merged = Value::Object(serde_json::Map::new());
156 let mut loaded = Vec::with_capacity(paths.len());
157 for path in paths {
158 let (doc, format) = read_document(path)?;
159 check(&doc, &format!("config file {path}"))?;
160 merge_into(&mut merged, doc);
161 loaded.push((path.clone(), format));
162 }
163 Ok((merged, loaded))
164}
165
166pub fn merge_into(base: &mut Value, overlay: Value) {
171 match overlay {
172 Value::Object(over) => {
173 if !base.is_object() {
174 *base = Value::Object(serde_json::Map::new());
175 }
176 let map = base.as_object_mut().expect("just ensured an object");
177 for (k, v) in over {
178 match v {
179 Value::Null => {
180 map.remove(&k);
181 }
182 Value::Object(_) => {
183 let slot = map
184 .entry(k)
185 .or_insert(Value::Object(serde_json::Map::new()));
186 merge_into(slot, v);
187 }
188 other => {
189 map.insert(k, other);
190 }
191 }
192 }
193 }
194 other => *base = other,
195 }
196}
197
198fn kind_name(v: &Value) -> &'static str {
199 match v {
200 Value::Null => "null",
201 Value::Bool(_) => "a boolean",
202 Value::Number(_) => "a number",
203 Value::String(_) => "a string",
204 Value::Array(_) => "a list",
205 Value::Object(_) => "an object",
206 }
207}
208
209pub const SCHEMA_CONTRACT_VERSION: &str = "1.0";
213
214#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
220#[serde(deny_unknown_fields)]
221pub struct ConfigFile {
222 pub config_version: Option<String>,
224 pub intelligence: Option<String>,
231 pub model_swap: Option<String>,
235 pub model: Option<String>,
237 pub max_tokens: Option<u64>,
239 pub limits: Option<LimitsFile>,
241 #[serde(default)]
243 pub mcp_servers: Vec<McpServerFile>,
244 #[serde(default)]
246 pub subscribe: Vec<String>,
247 #[serde(default)]
249 pub a2a_peers: Vec<A2aPeerFile>,
250 pub log_level: Option<String>,
252 #[serde(default)]
256 pub intelligence_headers: BTreeMap<String, String>,
257}
258
259#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
261#[serde(deny_unknown_fields)]
262pub struct LimitsFile {
263 pub max_steps: Option<u32>,
265 pub max_depth: Option<u32>,
267 pub deadline_secs: Option<u64>,
269 pub lifetime_tokens: Option<u64>,
273}
274
275#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
281#[serde(deny_unknown_fields)]
282pub struct McpServerFile {
283 pub name: String,
284 pub endpoint: Option<String>,
286 #[serde(default)]
289 pub headers: BTreeMap<String, String>,
290 #[serde(default)]
292 pub tags: BTreeMap<String, Vec<String>>,
293 #[serde(default)]
297 pub aauth: Option<bool>,
298}
299
300#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
302#[serde(deny_unknown_fields)]
303pub struct A2aPeerFile {
304 pub name: String,
305 pub endpoint: String,
306 #[serde(default)]
309 pub headers: BTreeMap<String, String>,
310 #[serde(default)]
313 pub client_cert: Option<String>,
314 #[serde(default)]
315 pub client_key: Option<String>,
316}
317
318pub const CONFIG_FILE_FIELDS: &[&str] = &[
322 "config_version",
323 "intelligence",
324 "model_swap",
325 "model",
326 "max_tokens",
327 "limits",
328 "mcp_servers",
329 "subscribe",
330 "a2a_peers",
331 "log_level",
332 "intelligence_headers",
333];
334
335impl ConfigFile {
336 pub fn parse(text: &str) -> Result<ConfigFile, String> {
341 let doc = parse_document(text, Format::detect(None, text))?;
342 Self::from_document(doc, "config file")
343 }
344
345 pub fn from_document(doc: Value, source: &str) -> Result<ConfigFile, String> {
349 serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
350 }
351
352 pub fn load(path: &str) -> Result<ConfigFile, String> {
355 let (doc, _format) = read_document(path)?;
356 Self::from_document(doc, "config file")
357 }
358}
359
360fn strip_jsonc(src: &str) -> String {
377 let bytes = src.as_bytes();
378 let mut out = String::with_capacity(src.len());
379 let mut i = 0;
380 let mut in_str = false;
381 let mut run = 0;
384 while i < bytes.len() {
385 let b = bytes[i];
386 if in_str {
387 if b == b'\\' && i + 1 < bytes.len() {
388 i += 2;
391 continue;
392 }
393 if b == b'"' {
394 in_str = false;
395 }
396 i += 1;
397 continue;
398 }
399 if b == b'"' {
400 in_str = true;
401 i += 1;
402 continue;
403 }
404 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
405 out.push_str(&src[run..i]);
407 while i < bytes.len() && bytes[i] != b'\n' {
408 i += 1;
409 }
410 run = i;
411 continue;
412 }
413 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
414 out.push_str(&src[run..i]);
416 i += 2;
417 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
418 i += 1;
419 }
420 i = (i + 2).min(bytes.len());
424 run = i;
425 continue;
426 }
427 i += 1;
428 }
429 out.push_str(&src[run..]);
430 out
431}
432
433pub fn config_schema() -> Value {
441 json!({
442 "$schema": "https://json-schema.org/draft/2020-12/schema",
443 "$id": format!("https://agentd.dev/schema/config/{SCHEMA_CONTRACT_VERSION}"),
444 "x-agentd-contract-version": SCHEMA_CONTRACT_VERSION,
445 "title": "agentd config file",
446 "type": "object",
447 "additionalProperties": false,
448 "properties": {
449 "config_version": { "type": "string" },
450 "intelligence": { "type": "string" },
451 "model_swap": { "enum": ["finish-on-old", "restart-turn"] },
452 "model": { "type": "string" },
453 "max_tokens": { "type": "integer", "minimum": 1 },
454 "limits": { "$ref": "#/$defs/Limits" },
455 "mcp_servers": { "type": "array", "items": { "$ref": "#/$defs/McpServer" } },
456 "subscribe": { "type": "array", "items": { "type": "string" } },
457 "a2a_peers": { "type": "array", "items": { "$ref": "#/$defs/A2aPeer" } },
458 "log_level": { "enum": ["trace", "debug", "info", "warn", "error"] },
459 "intelligence_headers": {
460 "type": "object",
461 "additionalProperties": { "type": "string" }
462 }
463 },
464 "$defs": {
465 "Limits": {
466 "type": "object",
467 "additionalProperties": false,
468 "properties": {
469 "max_steps": { "type": "integer", "minimum": 1 },
470 "max_depth": { "type": "integer", "minimum": 0 },
471 "deadline_secs": { "type": "integer", "minimum": 0 },
472 "lifetime_tokens": { "type": "integer", "minimum": 0 }
473 }
474 },
475 "McpServer": {
476 "type": "object",
477 "additionalProperties": false,
478 "required": ["name", "endpoint"],
479 "properties": {
480 "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
481 "endpoint": { "type": "string" },
482 "headers": {
483 "type": "object",
484 "additionalProperties": { "type": "string" }
485 },
486 "tags": {
487 "type": "object",
488 "additionalProperties": {
489 "type": "array",
490 "items": { "enum": ["untrusted_input", "sensitive", "egress"] }
491 }
492 },
493 "aauth": {
494 "type": "boolean",
495 "description": "sign requests to this server with the AAuth agent identity (RFC 0023); omit to inherit the global default"
496 }
497 }
498 },
499 "A2aPeer": {
500 "type": "object",
501 "additionalProperties": false,
502 "required": ["name", "endpoint"],
503 "properties": {
504 "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
505 "endpoint": { "type": "string" },
506 "headers": {
507 "type": "object",
508 "additionalProperties": { "type": "string" },
509 "description": "secret-free auth header templates presented to the peer ({{secret:NAME}} references)"
510 },
511 "client_cert": { "type": "string", "description": "client certificate PEM file path (mutual TLS to the peer; requires client_key)" },
512 "client_key": { "type": "string", "description": "client private-key PEM file path" }
513 }
514 }
515 }
516 })
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn parses_a_full_file() {
525 let src = r#"{
526 "config_version": "1.0",
527 "model": "claude-opus-4",
528 "max_tokens": 2000000,
529 "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
530 "mcp_servers": [
531 { "name": "web", "endpoint": "https://web.example.com/mcp",
532 "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
533 "tags": { "*": ["untrusted_input"] } }
534 ],
535 "subscribe": ["fs:file:///watch/inbox"],
536 "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
537 "log_level": "info",
538 "intelligence_headers": { "anthropic-version": "2023-06-01" }
539 }"#;
540 let cf = ConfigFile::parse(src).unwrap();
541 assert_eq!(cf.model.as_deref(), Some("claude-opus-4"));
542 assert_eq!(cf.max_tokens, Some(2_000_000));
543 assert_eq!(cf.limits.unwrap().max_steps, Some(200));
544 assert_eq!(cf.mcp_servers.len(), 1);
545 assert_eq!(
546 cf.mcp_servers[0].endpoint.as_deref(),
547 Some("https://web.example.com/mcp")
548 );
549 assert_eq!(cf.subscribe, vec!["fs:file:///watch/inbox"]);
550 assert_eq!(cf.a2a_peers[0].name, "mesh");
551 assert_eq!(cf.log_level.as_deref(), Some("info"));
552 }
553
554 #[test]
555 fn unknown_key_is_rejected() {
556 let e = ConfigFile::parse(r#"{ "max_token": 5 }"#).unwrap_err();
558 assert!(e.contains("parse error"), "got: {e}");
559 assert!(e.contains("max_token"), "names the key: {e}");
560 let e = ConfigFile::parse("max_token: 5\n").unwrap_err();
562 assert!(
563 e.contains("parse error") && e.contains("max_token"),
564 "got: {e}"
565 );
566 }
567
568 #[test]
569 fn yaml_and_json_documents_type_identically() {
570 let yaml = r#"
571# the same document as parses_a_full_file, in YAML
572config_version: "1.0"
573model: claude-opus-4
574max_tokens: 2000000
575limits:
576 max_steps: 200
577 max_depth: 4
578 deadline_secs: 600
579mcp_servers:
580 - name: web
581 endpoint: https://web.example.com/mcp
582 headers:
583 Authorization: "Bearer {{secret:WEB_TOKEN}}"
584 tags:
585 "*": [untrusted_input]
586subscribe: [fs:file:///watch/inbox]
587a2a_peers:
588 - name: mesh
589 endpoint: unix:/run/peer.sock
590log_level: info
591intelligence_headers:
592 anthropic-version: "2023-06-01"
593"#;
594 let json = r#"{
595 "config_version": "1.0",
596 "model": "claude-opus-4",
597 "max_tokens": 2000000,
598 "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
599 "mcp_servers": [
600 { "name": "web", "endpoint": "https://web.example.com/mcp",
601 "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
602 "tags": { "*": ["untrusted_input"] } }
603 ],
604 "subscribe": ["fs:file:///watch/inbox"],
605 "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
606 "log_level": "info",
607 "intelligence_headers": { "anthropic-version": "2023-06-01" }
608 }"#;
609 let from_yaml = ConfigFile::parse(yaml).expect("yaml parses");
610 let from_json = ConfigFile::parse(json).expect("json parses");
611 assert_eq!(from_yaml, from_json, "one document model, two syntaxes");
612 assert_eq!(from_yaml.limits.as_ref().unwrap().max_steps, Some(200));
613 assert_eq!(from_yaml.mcp_servers[0].tags["*"], vec!["untrusted_input"]);
614 }
615
616 #[test]
617 fn format_detection_by_extension_then_sniff() {
618 assert_eq!(
619 Format::detect(Some(Path::new("/etc/agentd/config.yaml")), "{}"),
620 Format::Yaml
621 );
622 assert_eq!(Format::detect(Some(Path::new("c.YML")), "{}"), Format::Yaml);
623 assert_eq!(
624 Format::detect(Some(Path::new("c.json")), "model: x"),
625 Format::Json
626 );
627 assert_eq!(
628 Format::detect(Some(Path::new("c.jsonc")), "model: x"),
629 Format::Json
630 );
631 assert_eq!(
633 Format::detect(Some(Path::new("agentd.conf")), " { \"a\": 1 }"),
634 Format::Json
635 );
636 assert_eq!(Format::detect(None, "// jsonc\n{ \"a\": 1 }"), Format::Json);
637 assert_eq!(Format::detect(None, "/* c */ [1]"), Format::Json);
638 assert_eq!(Format::detect(None, "# yaml\nmodel: x\n"), Format::Yaml);
639 assert_eq!(Format::detect(None, "model: x\n"), Format::Yaml);
640 assert_eq!(Format::detect(None, ""), Format::Yaml);
641 }
642
643 #[test]
644 fn merge_follows_json_merge_patch() {
645 let mut base = json!({
646 "model": "base",
647 "limits": {"max_steps": 1, "max_depth": 2},
648 "subscribe": ["a", "b"],
649 "intelligence_headers": {"h1": "v1"},
650 "log_level": "info"
651 });
652 merge_into(
653 &mut base,
654 json!({
655 "model": "over", "limits": {"max_steps": 9}, "subscribe": ["c"], "intelligence_headers": {"h2": "v2"}, "log_level": null }),
661 );
662 assert_eq!(
663 base,
664 json!({
665 "model": "over",
666 "limits": {"max_steps": 9, "max_depth": 2},
667 "subscribe": ["c"],
668 "intelligence_headers": {"h1": "v1", "h2": "v2"}
669 })
670 );
671 let mut base = json!({"limits": 5});
673 merge_into(&mut base, json!({"limits": {"max_steps": 1}}));
674 assert_eq!(base, json!({"limits": {"max_steps": 1}}));
675 }
676
677 #[test]
678 fn multiple_files_merge_in_order_later_wins() {
679 let dir = tempfile::tempdir().unwrap();
680 let base = dir.path().join("base.yaml");
681 let prod = dir.path().join("prod.yaml");
682 let extra = dir.path().join("extra.json");
683 std::fs::write(
684 &base,
685 "model: base\nlimits:\n max_steps: 1\n max_depth: 2\nsubscribe: [a, b]\n",
686 )
687 .unwrap();
688 std::fs::write(
689 &prod,
690 "model: prod\nlimits:\n max_steps: 9\nsubscribe: [c]\n",
691 )
692 .unwrap();
693 std::fs::write(
694 &extra,
695 r#"{ "log_level": "warn", "limits": { "max_depth": null } }"#,
696 )
697 .unwrap();
698 let paths: Vec<String> = [&base, &prod, &extra]
699 .iter()
700 .map(|p| p.to_str().unwrap().to_string())
701 .collect();
702 let (doc, loaded) = read_documents(&paths).unwrap();
703 assert_eq!(
704 doc,
705 json!({
706 "model": "prod",
707 "limits": {"max_steps": 9},
708 "subscribe": ["c"],
709 "log_level": "warn"
710 })
711 );
712 assert_eq!(loaded.len(), 3);
713 assert_eq!(loaded[0].1, Format::Yaml);
714 assert_eq!(loaded[2].1, Format::Json);
715 std::fs::write(&prod, "modle: typo\n").unwrap();
717 let e = read_documents(&paths).unwrap_err();
718 assert!(e.contains("prod.yaml") && e.contains("modle"), "{e}");
719 let e = read_documents(&["/no/such/agentd.yaml".to_string()]).unwrap_err();
721 assert!(e.contains("/no/such/agentd.yaml"), "{e}");
722 }
723
724 #[test]
725 fn a_non_mapping_document_is_rejected() {
726 let e = parse_document("- a\n- b\n", Format::Yaml).unwrap_err();
727 assert!(e.contains("mapping"), "{e}");
728 let e = parse_document("[1, 2]", Format::Json).unwrap_err();
729 assert!(e.contains("mapping"), "{e}");
730 assert_eq!(
732 parse_document("# nothing yet\n", Format::Yaml).unwrap(),
733 json!({})
734 );
735 let e = parse_document("a: 1\n\tb: 2\n", Format::Yaml).unwrap_err();
737 assert!(e.contains("(yaml)") && e.contains("line 2"), "{e}");
738 }
739
740 #[test]
741 fn malformed_json_is_an_error() {
742 assert!(ConfigFile::parse("{ not json").is_err());
743 }
744
745 #[test]
746 fn jsonc_comments_are_stripped() {
747 let src = r#"{
748 // a line comment
749 "model": "m", /* block */ "max_tokens": 10,
750 "subscribe": ["http://x//path"] // a // inside a string is data
751 }"#;
752 let cf = ConfigFile::parse(src).unwrap();
753 assert_eq!(cf.model.as_deref(), Some("m"));
754 assert_eq!(cf.max_tokens, Some(10));
755 assert_eq!(cf.subscribe, vec!["http://x//path"]);
757 }
758
759 #[test]
760 fn non_ascii_round_trips_through_the_jsonc_stripper() {
761 let model = "Ünïcøde — 日本語 μοντέλο";
767 let src = format!(
768 "{{\n /* 日本語 block */\"model\": \"{model}\",/*é*/\n \"subscribe\": [\"fs:file:///wätch/收件箱\"] // — trailing 日本語\n}}"
769 );
770 let cf = ConfigFile::parse(&src).unwrap();
771 assert_eq!(cf.model.as_deref(), Some(model), "mojibake in the value");
772 assert_eq!(cf.subscribe, vec!["fs:file:///wätch/收件箱"]);
773 let plain = format!("{{ \"model\": \"{model}\" }}");
775 assert_eq!(strip_jsonc(&plain), plain);
776 let cf = ConfigFile::parse("{ \"model\": \"a\\\"—\\\\é\" }").unwrap();
778 assert_eq!(cf.model.as_deref(), Some("a\"—\\é"));
779 }
780
781 #[test]
782 fn schema_is_parseable_draft_2020_12() {
783 let s = config_schema();
784 assert_eq!(
785 s["$schema"],
786 json!("https://json-schema.org/draft/2020-12/schema")
787 );
788 assert_eq!(s["additionalProperties"], json!(false));
789 assert_eq!(
790 s["x-agentd-contract-version"],
791 json!(SCHEMA_CONTRACT_VERSION)
792 );
793 let text = serde_json::to_string(&s).unwrap();
795 let _: Value = serde_json::from_str(&text).unwrap();
796 }
797
798 #[test]
799 fn schema_properties_match_struct_fields() {
800 let s = config_schema();
803 let props = s["properties"].as_object().unwrap();
804 let schema_keys: std::collections::BTreeSet<&str> =
805 props.keys().map(String::as_str).collect();
806 let struct_keys: std::collections::BTreeSet<&str> =
807 CONFIG_FILE_FIELDS.iter().copied().collect();
808 assert_eq!(
809 schema_keys, struct_keys,
810 "schema properties drifted from ConfigFile fields"
811 );
812 }
813
814 #[test]
815 fn config_file_fields_const_matches_a_full_deser() {
816 let mut obj = serde_json::Map::new();
820 for k in CONFIG_FILE_FIELDS {
821 let v = match *k {
822 "config_version" | "model" | "log_level" | "intelligence" => json!("x"),
823 "model_swap" => json!("finish-on-old"),
824 "max_tokens" => json!(1),
825 "limits" => json!({}),
826 "mcp_servers" => json!([{ "name": "a", "endpoint": "unix:/a.sock" }]),
827 "subscribe" => json!(["u"]),
828 "a2a_peers" => json!([{ "name": "p", "endpoint": "unix:/x" }]),
829 "intelligence_headers" => json!({ "h": "v" }),
830 other => panic!("CONFIG_FILE_FIELDS has an unmapped key {other}"),
831 };
832 obj.insert((*k).to_string(), v);
833 }
834 let text = serde_json::to_string(&Value::Object(obj)).unwrap();
835 ConfigFile::parse(&text).expect("every CONFIG_FILE_FIELDS key must deserialize");
836 }
837}