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";
214
215#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
221#[serde(deny_unknown_fields)]
222pub struct ConfigFile {
223 pub config_version: Option<String>,
225 pub intelligence: Option<String>,
233 pub model_swap: Option<String>,
238 pub model: Option<String>,
240 pub max_tokens: Option<u64>,
242 pub limits: Option<LimitsFile>,
244 #[serde(default)]
246 pub mcp_servers: Vec<McpServerFile>,
247 #[serde(default)]
249 pub subscribe: Vec<String>,
250 #[serde(default)]
252 pub a2a_peers: Vec<A2aPeerFile>,
253 pub log_level: Option<String>,
255 #[serde(default)]
261 pub intelligence_headers: BTreeMap<String, String>,
262}
263
264#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
266#[serde(deny_unknown_fields)]
267pub struct LimitsFile {
268 pub max_steps: Option<u32>,
270 pub max_depth: Option<u32>,
272 pub deadline_secs: Option<u64>,
274 pub lifetime_tokens: Option<u64>,
278}
279
280#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
287#[serde(deny_unknown_fields)]
288pub struct McpServerFile {
289 pub name: String,
290 pub endpoint: Option<String>,
292 #[serde(default)]
295 pub headers: BTreeMap<String, String>,
296 #[serde(default)]
300 pub tags: BTreeMap<String, Vec<String>>,
301 #[serde(default)]
305 pub aauth: Option<bool>,
306}
307
308#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
310#[serde(deny_unknown_fields)]
311pub struct A2aPeerFile {
312 pub name: String,
313 pub endpoint: String,
314 #[serde(default)]
317 pub headers: BTreeMap<String, String>,
318 #[serde(default)]
321 pub client_cert: Option<String>,
322 #[serde(default)]
323 pub client_key: Option<String>,
324}
325
326pub const CONFIG_FILE_FIELDS: &[&str] = &[
330 "config_version",
331 "intelligence",
332 "model_swap",
333 "model",
334 "max_tokens",
335 "limits",
336 "mcp_servers",
337 "subscribe",
338 "a2a_peers",
339 "log_level",
340 "intelligence_headers",
341];
342
343impl ConfigFile {
344 pub fn parse(text: &str) -> Result<ConfigFile, String> {
349 let doc = parse_document(text, Format::detect(None, text))?;
350 Self::from_document(doc, "config file")
351 }
352
353 pub fn from_document(doc: Value, source: &str) -> Result<ConfigFile, String> {
357 serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
358 }
359
360 pub fn load(path: &str) -> Result<ConfigFile, String> {
363 let (doc, _format) = read_document(path)?;
364 Self::from_document(doc, "config file")
365 }
366}
367
368fn strip_jsonc(src: &str) -> String {
385 let bytes = src.as_bytes();
386 let mut out = String::with_capacity(src.len());
387 let mut i = 0;
388 let mut in_str = false;
389 let mut run = 0;
392 while i < bytes.len() {
393 let b = bytes[i];
394 if in_str {
395 if b == b'\\' && i + 1 < bytes.len() {
396 i += 2;
399 continue;
400 }
401 if b == b'"' {
402 in_str = false;
403 }
404 i += 1;
405 continue;
406 }
407 if b == b'"' {
408 in_str = true;
409 i += 1;
410 continue;
411 }
412 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
413 out.push_str(&src[run..i]);
415 while i < bytes.len() && bytes[i] != b'\n' {
416 i += 1;
417 }
418 run = i;
419 continue;
420 }
421 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
422 out.push_str(&src[run..i]);
424 i += 2;
425 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
426 i += 1;
427 }
428 i = (i + 2).min(bytes.len());
432 run = i;
433 continue;
434 }
435 i += 1;
436 }
437 out.push_str(&src[run..]);
438 out
439}
440
441pub fn config_schema() -> Value {
449 json!({
450 "$schema": "https://json-schema.org/draft/2020-12/schema",
451 "$id": format!("https://agentd.dev/schema/internal/config-file-{SCHEMA_CONTRACT_VERSION}.json"),
457 "x-agentd-contract-version": SCHEMA_CONTRACT_VERSION,
458 "title": "agentd config file",
459 "type": "object",
460 "additionalProperties": false,
461 "properties": {
462 "config_version": { "type": "string" },
463 "intelligence": { "type": "string" },
464 "model_swap": { "enum": ["finish-on-old", "restart-turn"] },
465 "model": { "type": "string" },
466 "max_tokens": { "type": "integer", "minimum": 1 },
467 "limits": { "$ref": "#/$defs/Limits" },
468 "mcp_servers": { "type": "array", "items": { "$ref": "#/$defs/McpServer" } },
469 "subscribe": { "type": "array", "items": { "type": "string" } },
470 "a2a_peers": { "type": "array", "items": { "$ref": "#/$defs/A2aPeer" } },
471 "log_level": { "enum": ["trace", "debug", "info", "warn", "error"] },
472 "intelligence_headers": {
473 "type": "object",
474 "additionalProperties": { "type": "string" }
475 }
476 },
477 "$defs": {
478 "Limits": {
479 "type": "object",
480 "additionalProperties": false,
481 "properties": {
482 "max_steps": { "type": "integer", "minimum": 1 },
483 "max_depth": { "type": "integer", "minimum": 0 },
484 "deadline_secs": { "type": "integer", "minimum": 0 },
485 "lifetime_tokens": { "type": "integer", "minimum": 0 }
486 }
487 },
488 "McpServer": {
489 "type": "object",
490 "additionalProperties": false,
491 "required": ["name", "endpoint"],
492 "properties": {
493 "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
494 "endpoint": { "type": "string" },
495 "headers": {
496 "type": "object",
497 "additionalProperties": { "type": "string" }
498 },
499 "tags": {
500 "type": "object",
501 "additionalProperties": {
502 "type": "array",
503 "items": { "enum": ["untrusted_input", "sensitive", "egress"] }
504 }
505 },
506 "aauth": {
507 "type": "boolean",
508 "description": "sign requests to this server with the AAuth agent identity; omit to inherit the global default"
509 }
510 }
511 },
512 "A2aPeer": {
513 "type": "object",
514 "additionalProperties": false,
515 "required": ["name", "endpoint"],
516 "properties": {
517 "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
518 "endpoint": { "type": "string" },
519 "headers": {
520 "type": "object",
521 "additionalProperties": { "type": "string" },
522 "description": "secret-free auth header templates presented to the peer ({{secret:NAME}} references)"
523 },
524 "client_cert": { "type": "string", "description": "client certificate PEM file path (mutual TLS to the peer; requires client_key)" },
525 "client_key": { "type": "string", "description": "client private-key PEM file path" }
526 }
527 }
528 }
529 })
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn parses_a_full_file() {
538 let src = r#"{
539 "config_version": "1.0",
540 "model": "claude-opus-4",
541 "max_tokens": 2000000,
542 "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
543 "mcp_servers": [
544 { "name": "web", "endpoint": "https://web.example.com/mcp",
545 "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
546 "tags": { "*": ["untrusted_input"] } }
547 ],
548 "subscribe": ["fs:file:///watch/inbox"],
549 "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
550 "log_level": "info",
551 "intelligence_headers": { "anthropic-version": "2023-06-01" }
552 }"#;
553 let cf = ConfigFile::parse(src).unwrap();
554 assert_eq!(cf.model.as_deref(), Some("claude-opus-4"));
555 assert_eq!(cf.max_tokens, Some(2_000_000));
556 assert_eq!(cf.limits.unwrap().max_steps, Some(200));
557 assert_eq!(cf.mcp_servers.len(), 1);
558 assert_eq!(
559 cf.mcp_servers[0].endpoint.as_deref(),
560 Some("https://web.example.com/mcp")
561 );
562 assert_eq!(cf.subscribe, vec!["fs:file:///watch/inbox"]);
563 assert_eq!(cf.a2a_peers[0].name, "mesh");
564 assert_eq!(cf.log_level.as_deref(), Some("info"));
565 }
566
567 #[test]
568 fn unknown_key_is_rejected() {
569 let e = ConfigFile::parse(r#"{ "max_token": 5 }"#).unwrap_err();
571 assert!(e.contains("parse error"), "got: {e}");
572 assert!(e.contains("max_token"), "names the key: {e}");
573 let e = ConfigFile::parse("max_token: 5\n").unwrap_err();
575 assert!(
576 e.contains("parse error") && e.contains("max_token"),
577 "got: {e}"
578 );
579 }
580
581 #[test]
582 fn yaml_and_json_documents_type_identically() {
583 let yaml = r#"
584# the same document as parses_a_full_file, in YAML
585config_version: "1.0"
586model: claude-opus-4
587max_tokens: 2000000
588limits:
589 max_steps: 200
590 max_depth: 4
591 deadline_secs: 600
592mcp_servers:
593 - name: web
594 endpoint: https://web.example.com/mcp
595 headers:
596 Authorization: "Bearer {{secret:WEB_TOKEN}}"
597 tags:
598 "*": [untrusted_input]
599subscribe: [fs:file:///watch/inbox]
600a2a_peers:
601 - name: mesh
602 endpoint: unix:/run/peer.sock
603log_level: info
604intelligence_headers:
605 anthropic-version: "2023-06-01"
606"#;
607 let json = r#"{
608 "config_version": "1.0",
609 "model": "claude-opus-4",
610 "max_tokens": 2000000,
611 "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
612 "mcp_servers": [
613 { "name": "web", "endpoint": "https://web.example.com/mcp",
614 "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
615 "tags": { "*": ["untrusted_input"] } }
616 ],
617 "subscribe": ["fs:file:///watch/inbox"],
618 "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
619 "log_level": "info",
620 "intelligence_headers": { "anthropic-version": "2023-06-01" }
621 }"#;
622 let from_yaml = ConfigFile::parse(yaml).expect("yaml parses");
623 let from_json = ConfigFile::parse(json).expect("json parses");
624 assert_eq!(from_yaml, from_json, "one document model, two syntaxes");
625 assert_eq!(from_yaml.limits.as_ref().unwrap().max_steps, Some(200));
626 assert_eq!(from_yaml.mcp_servers[0].tags["*"], vec!["untrusted_input"]);
627 }
628
629 #[test]
630 fn format_detection_by_extension_then_sniff() {
631 assert_eq!(
632 Format::detect(Some(Path::new("/etc/agentd/config.yaml")), "{}"),
633 Format::Yaml
634 );
635 assert_eq!(Format::detect(Some(Path::new("c.YML")), "{}"), Format::Yaml);
636 assert_eq!(
637 Format::detect(Some(Path::new("c.json")), "model: x"),
638 Format::Json
639 );
640 assert_eq!(
641 Format::detect(Some(Path::new("c.jsonc")), "model: x"),
642 Format::Json
643 );
644 assert_eq!(
646 Format::detect(Some(Path::new("agentd.conf")), " { \"a\": 1 }"),
647 Format::Json
648 );
649 assert_eq!(Format::detect(None, "// jsonc\n{ \"a\": 1 }"), Format::Json);
650 assert_eq!(Format::detect(None, "/* c */ [1]"), Format::Json);
651 assert_eq!(Format::detect(None, "# yaml\nmodel: x\n"), Format::Yaml);
652 assert_eq!(Format::detect(None, "model: x\n"), Format::Yaml);
653 assert_eq!(Format::detect(None, ""), Format::Yaml);
654 }
655
656 #[test]
657 fn merge_follows_json_merge_patch() {
658 let mut base = json!({
659 "model": "base",
660 "limits": {"max_steps": 1, "max_depth": 2},
661 "subscribe": ["a", "b"],
662 "intelligence_headers": {"h1": "v1"},
663 "log_level": "info"
664 });
665 merge_into(
666 &mut base,
667 json!({
668 "model": "over", "limits": {"max_steps": 9}, "subscribe": ["c"], "intelligence_headers": {"h2": "v2"}, "log_level": null }),
674 );
675 assert_eq!(
676 base,
677 json!({
678 "model": "over",
679 "limits": {"max_steps": 9, "max_depth": 2},
680 "subscribe": ["c"],
681 "intelligence_headers": {"h1": "v1", "h2": "v2"}
682 })
683 );
684 let mut base = json!({"limits": 5});
686 merge_into(&mut base, json!({"limits": {"max_steps": 1}}));
687 assert_eq!(base, json!({"limits": {"max_steps": 1}}));
688 }
689
690 #[test]
691 fn multiple_files_merge_in_order_later_wins() {
692 let dir = tempfile::tempdir().unwrap();
693 let base = dir.path().join("base.yaml");
694 let prod = dir.path().join("prod.yaml");
695 let extra = dir.path().join("extra.json");
696 std::fs::write(
697 &base,
698 "model: base\nlimits:\n max_steps: 1\n max_depth: 2\nsubscribe: [a, b]\n",
699 )
700 .unwrap();
701 std::fs::write(
702 &prod,
703 "model: prod\nlimits:\n max_steps: 9\nsubscribe: [c]\n",
704 )
705 .unwrap();
706 std::fs::write(
707 &extra,
708 r#"{ "log_level": "warn", "limits": { "max_depth": null } }"#,
709 )
710 .unwrap();
711 let paths: Vec<String> = [&base, &prod, &extra]
712 .iter()
713 .map(|p| p.to_str().unwrap().to_string())
714 .collect();
715 let (doc, loaded) = read_documents(&paths).unwrap();
716 assert_eq!(
717 doc,
718 json!({
719 "model": "prod",
720 "limits": {"max_steps": 9},
721 "subscribe": ["c"],
722 "log_level": "warn"
723 })
724 );
725 assert_eq!(loaded.len(), 3);
726 assert_eq!(loaded[0].1, Format::Yaml);
727 assert_eq!(loaded[2].1, Format::Json);
728 std::fs::write(&prod, "modle: typo\n").unwrap();
730 let e = read_documents(&paths).unwrap_err();
731 assert!(e.contains("prod.yaml") && e.contains("modle"), "{e}");
732 let e = read_documents(&["/no/such/agentd.yaml".to_string()]).unwrap_err();
734 assert!(e.contains("/no/such/agentd.yaml"), "{e}");
735 }
736
737 #[test]
738 fn a_non_mapping_document_is_rejected() {
739 let e = parse_document("- a\n- b\n", Format::Yaml).unwrap_err();
740 assert!(e.contains("mapping"), "{e}");
741 let e = parse_document("[1, 2]", Format::Json).unwrap_err();
742 assert!(e.contains("mapping"), "{e}");
743 assert_eq!(
745 parse_document("# nothing yet\n", Format::Yaml).unwrap(),
746 json!({})
747 );
748 let e = parse_document("a: 1\n\tb: 2\n", Format::Yaml).unwrap_err();
750 assert!(e.contains("(yaml)") && e.contains("line 2"), "{e}");
751 }
752
753 #[test]
754 fn malformed_json_is_an_error() {
755 assert!(ConfigFile::parse("{ not json").is_err());
756 }
757
758 #[test]
759 fn jsonc_comments_are_stripped() {
760 let src = r#"{
761 // a line comment
762 "model": "m", /* block */ "max_tokens": 10,
763 "subscribe": ["http://x//path"] // a // inside a string is data
764 }"#;
765 let cf = ConfigFile::parse(src).unwrap();
766 assert_eq!(cf.model.as_deref(), Some("m"));
767 assert_eq!(cf.max_tokens, Some(10));
768 assert_eq!(cf.subscribe, vec!["http://x//path"]);
770 }
771
772 #[test]
773 fn non_ascii_round_trips_through_the_jsonc_stripper() {
774 let model = "Ünïcøde — 日本語 μοντέλο";
780 let src = format!(
781 "{{\n /* 日本語 block */\"model\": \"{model}\",/*é*/\n \"subscribe\": [\"fs:file:///wätch/收件箱\"] // — trailing 日本語\n}}"
782 );
783 let cf = ConfigFile::parse(&src).unwrap();
784 assert_eq!(cf.model.as_deref(), Some(model), "mojibake in the value");
785 assert_eq!(cf.subscribe, vec!["fs:file:///wätch/收件箱"]);
786 let plain = format!("{{ \"model\": \"{model}\" }}");
788 assert_eq!(strip_jsonc(&plain), plain);
789 let cf = ConfigFile::parse("{ \"model\": \"a\\\"—\\\\é\" }").unwrap();
791 assert_eq!(cf.model.as_deref(), Some("a\"—\\é"));
792 }
793
794 #[test]
795 fn schema_is_parseable_draft_2020_12() {
796 let s = config_schema();
797 assert_eq!(
798 s["$schema"],
799 json!("https://json-schema.org/draft/2020-12/schema")
800 );
801 assert_eq!(s["additionalProperties"], json!(false));
802 assert_eq!(
803 s["x-agentd-contract-version"],
804 json!(SCHEMA_CONTRACT_VERSION)
805 );
806 let text = serde_json::to_string(&s).unwrap();
808 let _: Value = serde_json::from_str(&text).unwrap();
809 }
810
811 #[test]
812 fn schema_properties_match_struct_fields() {
813 let s = config_schema();
816 let props = s["properties"].as_object().unwrap();
817 let schema_keys: std::collections::BTreeSet<&str> =
818 props.keys().map(String::as_str).collect();
819 let struct_keys: std::collections::BTreeSet<&str> =
820 CONFIG_FILE_FIELDS.iter().copied().collect();
821 assert_eq!(
822 schema_keys, struct_keys,
823 "schema properties drifted from ConfigFile fields"
824 );
825 }
826
827 #[test]
828 fn config_file_fields_const_matches_a_full_deser() {
829 let mut obj = serde_json::Map::new();
833 for k in CONFIG_FILE_FIELDS {
834 let v = match *k {
835 "config_version" | "model" | "log_level" | "intelligence" => json!("x"),
836 "model_swap" => json!("finish-on-old"),
837 "max_tokens" => json!(1),
838 "limits" => json!({}),
839 "mcp_servers" => json!([{ "name": "a", "endpoint": "unix:/a.sock" }]),
840 "subscribe" => json!(["u"]),
841 "a2a_peers" => json!([{ "name": "p", "endpoint": "unix:/x" }]),
842 "intelligence_headers" => json!({ "h": "v" }),
843 other => panic!("CONFIG_FILE_FIELDS has an unmapped key {other}"),
844 };
845 obj.insert((*k).to_string(), v);
846 }
847 let text = serde_json::to_string(&Value::Object(obj)).unwrap();
848 ConfigFile::parse(&text).expect("every CONFIG_FILE_FIELDS key must deserialize");
849 }
850}