use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Json,
Yaml,
}
impl Format {
pub fn as_str(self) -> &'static str {
match self {
Format::Json => "json",
Format::Yaml => "yaml",
}
}
pub fn detect(path: Option<&Path>, text: &str) -> Format {
if let Some(ext) = path.and_then(|p| p.extension()).and_then(|e| e.to_str()) {
match ext.to_ascii_lowercase().as_str() {
"yaml" | "yml" => return Format::Yaml,
"json" | "jsonc" => return Format::Json,
_ => {}
}
}
Format::sniff(text)
}
fn sniff(text: &str) -> Format {
let t = text.strip_prefix('\u{feff}').unwrap_or(text);
let bytes = t.as_bytes();
let mut i = 0;
loop {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i += 2;
continue;
}
break;
}
match bytes.get(i) {
Some(b'{') | Some(b'[') => Format::Json,
_ => Format::Yaml,
}
}
}
pub fn parse_document(text: &str, format: Format) -> Result<Value, String> {
let doc = match format {
Format::Json => {
let stripped = strip_jsonc(text);
serde_json::from_str::<Value>(&stripped)
.map_err(|e| format!("config file parse error (json): {e}"))?
}
Format::Yaml => {
super::yaml::parse(text).map_err(|e| format!("config file parse error (yaml): {e}"))?
}
};
match doc {
Value::Object(_) => Ok(doc),
Value::Null if format == Format::Yaml => Ok(Value::Object(serde_json::Map::new())),
other => Err(format!(
"config file must be a mapping (an object) at the top level, got {}",
kind_name(&other)
)),
}
}
pub fn read_document(path: &str) -> Result<(Value, Format), String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read config file {path}: {e}"))?;
let format = Format::detect(Some(Path::new(path)), &text);
let doc = parse_document(&text, format).map_err(|e| format!("{path}: {e}"))?;
Ok((doc, format))
}
pub fn read_documents(paths: &[String]) -> Result<(Value, Vec<(String, Format)>), String> {
read_documents_checked(paths, &|doc, source| {
ConfigFile::from_document(doc.clone(), source).map(|_| ())
})
}
pub fn read_documents_checked(
paths: &[String],
check: &dyn Fn(&Value, &str) -> Result<(), String>,
) -> Result<(Value, Vec<(String, Format)>), String> {
let mut merged = Value::Object(serde_json::Map::new());
let mut loaded = Vec::with_capacity(paths.len());
for path in paths {
let (doc, format) = read_document(path)?;
check(&doc, &format!("config file {path}"))?;
merge_into(&mut merged, doc);
loaded.push((path.clone(), format));
}
Ok((merged, loaded))
}
pub fn merge_into(base: &mut Value, overlay: Value) {
match overlay {
Value::Object(over) => {
if !base.is_object() {
*base = Value::Object(serde_json::Map::new());
}
let map = base.as_object_mut().expect("just ensured an object");
for (k, v) in over {
match v {
Value::Null => {
map.remove(&k);
}
Value::Object(_) => {
let slot = map
.entry(k)
.or_insert(Value::Object(serde_json::Map::new()));
merge_into(slot, v);
}
other => {
map.insert(k, other);
}
}
}
}
other => *base = other,
}
}
fn kind_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}
pub const SCHEMA_CONTRACT_VERSION: &str = "1.0";
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
pub config_version: Option<String>,
pub intelligence: Option<String>,
pub model_swap: Option<String>,
pub model: Option<String>,
pub max_tokens: Option<u64>,
pub limits: Option<LimitsFile>,
#[serde(default)]
pub mcp_servers: Vec<McpServerFile>,
#[serde(default)]
pub subscribe: Vec<String>,
#[serde(default)]
pub a2a_peers: Vec<A2aPeerFile>,
pub log_level: Option<String>,
#[serde(default)]
pub intelligence_headers: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LimitsFile {
pub max_steps: Option<u32>,
pub max_depth: Option<u32>,
pub deadline_secs: Option<u64>,
pub lifetime_tokens: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct McpServerFile {
pub name: String,
pub endpoint: Option<String>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub tags: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub aauth: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct A2aPeerFile {
pub name: String,
pub endpoint: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub client_cert: Option<String>,
#[serde(default)]
pub client_key: Option<String>,
}
pub const CONFIG_FILE_FIELDS: &[&str] = &[
"config_version",
"intelligence",
"model_swap",
"model",
"max_tokens",
"limits",
"mcp_servers",
"subscribe",
"a2a_peers",
"log_level",
"intelligence_headers",
];
impl ConfigFile {
pub fn parse(text: &str) -> Result<ConfigFile, String> {
let doc = parse_document(text, Format::detect(None, text))?;
Self::from_document(doc, "config file")
}
pub fn from_document(doc: Value, source: &str) -> Result<ConfigFile, String> {
serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
}
pub fn load(path: &str) -> Result<ConfigFile, String> {
let (doc, _format) = read_document(path)?;
Self::from_document(doc, "config file")
}
}
fn strip_jsonc(src: &str) -> String {
let bytes = src.as_bytes();
let mut out = String::with_capacity(src.len());
let mut i = 0;
let mut in_str = false;
let mut run = 0;
while i < bytes.len() {
let b = bytes[i];
if in_str {
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == b'"' {
in_str = false;
}
i += 1;
continue;
}
if b == b'"' {
in_str = true;
i += 1;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
out.push_str(&src[run..i]);
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
run = i;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
out.push_str(&src[run..i]);
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
run = i;
continue;
}
i += 1;
}
out.push_str(&src[run..]);
out
}
pub fn config_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": format!("https://agentd.dev/schema/internal/config-file-{SCHEMA_CONTRACT_VERSION}.json"),
"x-agentd-contract-version": SCHEMA_CONTRACT_VERSION,
"title": "agentd config file",
"type": "object",
"additionalProperties": false,
"properties": {
"config_version": { "type": "string" },
"intelligence": { "type": "string" },
"model_swap": { "enum": ["finish-on-old", "restart-turn"] },
"model": { "type": "string" },
"max_tokens": { "type": "integer", "minimum": 1 },
"limits": { "$ref": "#/$defs/Limits" },
"mcp_servers": { "type": "array", "items": { "$ref": "#/$defs/McpServer" } },
"subscribe": { "type": "array", "items": { "type": "string" } },
"a2a_peers": { "type": "array", "items": { "$ref": "#/$defs/A2aPeer" } },
"log_level": { "enum": ["trace", "debug", "info", "warn", "error"] },
"intelligence_headers": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"$defs": {
"Limits": {
"type": "object",
"additionalProperties": false,
"properties": {
"max_steps": { "type": "integer", "minimum": 1 },
"max_depth": { "type": "integer", "minimum": 0 },
"deadline_secs": { "type": "integer", "minimum": 0 },
"lifetime_tokens": { "type": "integer", "minimum": 0 }
}
},
"McpServer": {
"type": "object",
"additionalProperties": false,
"required": ["name", "endpoint"],
"properties": {
"name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
"endpoint": { "type": "string" },
"headers": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"tags": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "enum": ["untrusted_input", "sensitive", "egress"] }
}
},
"aauth": {
"type": "boolean",
"description": "sign requests to this server with the AAuth agent identity; omit to inherit the global default"
}
}
},
"A2aPeer": {
"type": "object",
"additionalProperties": false,
"required": ["name", "endpoint"],
"properties": {
"name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
"endpoint": { "type": "string" },
"headers": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "secret-free auth header templates presented to the peer ({{secret:NAME}} references)"
},
"client_cert": { "type": "string", "description": "client certificate PEM file path (mutual TLS to the peer; requires client_key)" },
"client_key": { "type": "string", "description": "client private-key PEM file path" }
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_full_file() {
let src = r#"{
"config_version": "1.0",
"model": "claude-opus-4",
"max_tokens": 2000000,
"limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
"mcp_servers": [
{ "name": "web", "endpoint": "https://web.example.com/mcp",
"headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
"tags": { "*": ["untrusted_input"] } }
],
"subscribe": ["fs:file:///watch/inbox"],
"a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
"log_level": "info",
"intelligence_headers": { "anthropic-version": "2023-06-01" }
}"#;
let cf = ConfigFile::parse(src).unwrap();
assert_eq!(cf.model.as_deref(), Some("claude-opus-4"));
assert_eq!(cf.max_tokens, Some(2_000_000));
assert_eq!(cf.limits.unwrap().max_steps, Some(200));
assert_eq!(cf.mcp_servers.len(), 1);
assert_eq!(
cf.mcp_servers[0].endpoint.as_deref(),
Some("https://web.example.com/mcp")
);
assert_eq!(cf.subscribe, vec!["fs:file:///watch/inbox"]);
assert_eq!(cf.a2a_peers[0].name, "mesh");
assert_eq!(cf.log_level.as_deref(), Some("info"));
}
#[test]
fn unknown_key_is_rejected() {
let e = ConfigFile::parse(r#"{ "max_token": 5 }"#).unwrap_err();
assert!(e.contains("parse error"), "got: {e}");
assert!(e.contains("max_token"), "names the key: {e}");
let e = ConfigFile::parse("max_token: 5\n").unwrap_err();
assert!(
e.contains("parse error") && e.contains("max_token"),
"got: {e}"
);
}
#[test]
fn yaml_and_json_documents_type_identically() {
let yaml = r#"
# the same document as parses_a_full_file, in YAML
config_version: "1.0"
model: claude-opus-4
max_tokens: 2000000
limits:
max_steps: 200
max_depth: 4
deadline_secs: 600
mcp_servers:
- name: web
endpoint: https://web.example.com/mcp
headers:
Authorization: "Bearer {{secret:WEB_TOKEN}}"
tags:
"*": [untrusted_input]
subscribe: [fs:file:///watch/inbox]
a2a_peers:
- name: mesh
endpoint: unix:/run/peer.sock
log_level: info
intelligence_headers:
anthropic-version: "2023-06-01"
"#;
let json = r#"{
"config_version": "1.0",
"model": "claude-opus-4",
"max_tokens": 2000000,
"limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
"mcp_servers": [
{ "name": "web", "endpoint": "https://web.example.com/mcp",
"headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
"tags": { "*": ["untrusted_input"] } }
],
"subscribe": ["fs:file:///watch/inbox"],
"a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
"log_level": "info",
"intelligence_headers": { "anthropic-version": "2023-06-01" }
}"#;
let from_yaml = ConfigFile::parse(yaml).expect("yaml parses");
let from_json = ConfigFile::parse(json).expect("json parses");
assert_eq!(from_yaml, from_json, "one document model, two syntaxes");
assert_eq!(from_yaml.limits.as_ref().unwrap().max_steps, Some(200));
assert_eq!(from_yaml.mcp_servers[0].tags["*"], vec!["untrusted_input"]);
}
#[test]
fn format_detection_by_extension_then_sniff() {
assert_eq!(
Format::detect(Some(Path::new("/etc/agentd/config.yaml")), "{}"),
Format::Yaml
);
assert_eq!(Format::detect(Some(Path::new("c.YML")), "{}"), Format::Yaml);
assert_eq!(
Format::detect(Some(Path::new("c.json")), "model: x"),
Format::Json
);
assert_eq!(
Format::detect(Some(Path::new("c.jsonc")), "model: x"),
Format::Json
);
assert_eq!(
Format::detect(Some(Path::new("agentd.conf")), " { \"a\": 1 }"),
Format::Json
);
assert_eq!(Format::detect(None, "// jsonc\n{ \"a\": 1 }"), Format::Json);
assert_eq!(Format::detect(None, "/* c */ [1]"), Format::Json);
assert_eq!(Format::detect(None, "# yaml\nmodel: x\n"), Format::Yaml);
assert_eq!(Format::detect(None, "model: x\n"), Format::Yaml);
assert_eq!(Format::detect(None, ""), Format::Yaml);
}
#[test]
fn merge_follows_json_merge_patch() {
let mut base = json!({
"model": "base",
"limits": {"max_steps": 1, "max_depth": 2},
"subscribe": ["a", "b"],
"intelligence_headers": {"h1": "v1"},
"log_level": "info"
});
merge_into(
&mut base,
json!({
"model": "over", "limits": {"max_steps": 9}, "subscribe": ["c"], "intelligence_headers": {"h2": "v2"}, "log_level": null }),
);
assert_eq!(
base,
json!({
"model": "over",
"limits": {"max_steps": 9, "max_depth": 2},
"subscribe": ["c"],
"intelligence_headers": {"h1": "v1", "h2": "v2"}
})
);
let mut base = json!({"limits": 5});
merge_into(&mut base, json!({"limits": {"max_steps": 1}}));
assert_eq!(base, json!({"limits": {"max_steps": 1}}));
}
#[test]
fn multiple_files_merge_in_order_later_wins() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("base.yaml");
let prod = dir.path().join("prod.yaml");
let extra = dir.path().join("extra.json");
std::fs::write(
&base,
"model: base\nlimits:\n max_steps: 1\n max_depth: 2\nsubscribe: [a, b]\n",
)
.unwrap();
std::fs::write(
&prod,
"model: prod\nlimits:\n max_steps: 9\nsubscribe: [c]\n",
)
.unwrap();
std::fs::write(
&extra,
r#"{ "log_level": "warn", "limits": { "max_depth": null } }"#,
)
.unwrap();
let paths: Vec<String> = [&base, &prod, &extra]
.iter()
.map(|p| p.to_str().unwrap().to_string())
.collect();
let (doc, loaded) = read_documents(&paths).unwrap();
assert_eq!(
doc,
json!({
"model": "prod",
"limits": {"max_steps": 9},
"subscribe": ["c"],
"log_level": "warn"
})
);
assert_eq!(loaded.len(), 3);
assert_eq!(loaded[0].1, Format::Yaml);
assert_eq!(loaded[2].1, Format::Json);
std::fs::write(&prod, "modle: typo\n").unwrap();
let e = read_documents(&paths).unwrap_err();
assert!(e.contains("prod.yaml") && e.contains("modle"), "{e}");
let e = read_documents(&["/no/such/agentd.yaml".to_string()]).unwrap_err();
assert!(e.contains("/no/such/agentd.yaml"), "{e}");
}
#[test]
fn a_non_mapping_document_is_rejected() {
let e = parse_document("- a\n- b\n", Format::Yaml).unwrap_err();
assert!(e.contains("mapping"), "{e}");
let e = parse_document("[1, 2]", Format::Json).unwrap_err();
assert!(e.contains("mapping"), "{e}");
assert_eq!(
parse_document("# nothing yet\n", Format::Yaml).unwrap(),
json!({})
);
let e = parse_document("a: 1\n\tb: 2\n", Format::Yaml).unwrap_err();
assert!(e.contains("(yaml)") && e.contains("line 2"), "{e}");
}
#[test]
fn malformed_json_is_an_error() {
assert!(ConfigFile::parse("{ not json").is_err());
}
#[test]
fn jsonc_comments_are_stripped() {
let src = r#"{
// a line comment
"model": "m", /* block */ "max_tokens": 10,
"subscribe": ["http://x//path"] // a // inside a string is data
}"#;
let cf = ConfigFile::parse(src).unwrap();
assert_eq!(cf.model.as_deref(), Some("m"));
assert_eq!(cf.max_tokens, Some(10));
assert_eq!(cf.subscribe, vec!["http://x//path"]);
}
#[test]
fn non_ascii_round_trips_through_the_jsonc_stripper() {
let model = "Ünïcøde — 日本語 μοντέλο";
let src = format!(
"{{\n /* 日本語 block */\"model\": \"{model}\",/*é*/\n \"subscribe\": [\"fs:file:///wätch/收件箱\"] // — trailing 日本語\n}}"
);
let cf = ConfigFile::parse(&src).unwrap();
assert_eq!(cf.model.as_deref(), Some(model), "mojibake in the value");
assert_eq!(cf.subscribe, vec!["fs:file:///wätch/收件箱"]);
let plain = format!("{{ \"model\": \"{model}\" }}");
assert_eq!(strip_jsonc(&plain), plain);
let cf = ConfigFile::parse("{ \"model\": \"a\\\"—\\\\é\" }").unwrap();
assert_eq!(cf.model.as_deref(), Some("a\"—\\é"));
}
#[test]
fn schema_is_parseable_draft_2020_12() {
let s = config_schema();
assert_eq!(
s["$schema"],
json!("https://json-schema.org/draft/2020-12/schema")
);
assert_eq!(s["additionalProperties"], json!(false));
assert_eq!(
s["x-agentd-contract-version"],
json!(SCHEMA_CONTRACT_VERSION)
);
let text = serde_json::to_string(&s).unwrap();
let _: Value = serde_json::from_str(&text).unwrap();
}
#[test]
fn schema_properties_match_struct_fields() {
let s = config_schema();
let props = s["properties"].as_object().unwrap();
let schema_keys: std::collections::BTreeSet<&str> =
props.keys().map(String::as_str).collect();
let struct_keys: std::collections::BTreeSet<&str> =
CONFIG_FILE_FIELDS.iter().copied().collect();
assert_eq!(
schema_keys, struct_keys,
"schema properties drifted from ConfigFile fields"
);
}
#[test]
fn config_file_fields_const_matches_a_full_deser() {
let mut obj = serde_json::Map::new();
for k in CONFIG_FILE_FIELDS {
let v = match *k {
"config_version" | "model" | "log_level" | "intelligence" => json!("x"),
"model_swap" => json!("finish-on-old"),
"max_tokens" => json!(1),
"limits" => json!({}),
"mcp_servers" => json!([{ "name": "a", "endpoint": "unix:/a.sock" }]),
"subscribe" => json!(["u"]),
"a2a_peers" => json!([{ "name": "p", "endpoint": "unix:/x" }]),
"intelligence_headers" => json!({ "h": "v" }),
other => panic!("CONFIG_FILE_FIELDS has an unmapped key {other}"),
};
obj.insert((*k).to_string(), v);
}
let text = serde_json::to_string(&Value::Object(obj)).unwrap();
ConfigFile::parse(&text).expect("every CONFIG_FILE_FIELDS key must deserialize");
}
}