use crate::nats_utils::OrchestratorEntry;
use serde::{Deserialize, Serialize, Serializer};
use std::collections::HashMap;
use std::path::PathBuf;
use utoipa::ToSchema;
fn serialize_redacted_env<S>(
env: &HashMap<String, String>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeMap;
const SENSITIVE: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL"];
let mut map = serializer.serialize_map(Some(env.len()))?;
for (k, v) in env {
let upper = k.to_uppercase();
let redacted = SENSITIVE.iter().any(|s| upper.contains(s));
map.serialize_entry(k, if redacted { "<redacted>" } else { v.as_str() })?;
}
map.end()
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PersonaLayer {
Text {
prompt: String,
},
Md {
prompt: PathBuf,
},
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PersonaInput {
Inline(String),
Layered(Vec<PersonaLayer>),
}
fn deserialize_persona<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let opt: Option<PersonaInput> = Option::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(PersonaInput::Inline(s)) => Ok(Some(s)),
Some(PersonaInput::Layered(layers)) => {
let mut parts: Vec<String> = Vec::with_capacity(layers.len());
for layer in layers {
match layer {
PersonaLayer::Text { prompt } => parts.push(prompt),
PersonaLayer::Md { prompt } => {
let content = std::fs::read_to_string(&prompt).map_err(|e| {
D::Error::custom(format!(
"persona md layer at `{}` could not be read: {e}",
prompt.display()
))
})?;
parts.push(content);
}
}
}
Ok(Some(parts.join("\n\n")))
}
}
}
#[derive(Debug, Deserialize, Clone, Serialize, ToSchema)]
pub struct AgentConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub provider_id: String,
#[serde(default)]
pub model_name: String,
#[serde(default)]
pub temperature: f32,
#[serde(default)]
pub max_tokens: i32,
#[serde(default)]
pub system_prompt_override: Option<String>,
#[serde(default, deserialize_with = "deserialize_persona")]
pub persona: Option<String>,
#[serde(default = "default_max_react_iterations")]
pub max_react_iterations: Option<i32>,
#[serde(default = "default_max_scratchpad_size")]
pub max_scratchpad_size: Option<i32>,
#[serde(default = "default_max_retries")]
pub max_retries: Option<i32>,
#[serde(default)]
pub supports_native_thinking: bool,
#[serde(default)]
pub frequency_penalty: Option<f32>,
#[serde(default = "default_presence_penalty")]
pub presence_penalty: Option<f32>,
#[serde(default = "default_textual_feedback")]
pub textual_feedback: bool,
#[serde(default = "default_use_streaming")]
pub use_streaming: bool,
#[serde(default)]
pub merge_system_prompt: bool,
#[serde(default)]
pub unwrap_hallucinated_tool_calls: bool,
#[serde(default = "default_repair_invalid_escapes")]
pub repair_invalid_escapes: bool,
#[serde(default = "default_scratchpad_limit")]
pub scratchpad_limit: i32,
#[serde(default = "default_scratchpad_squeeze_fraction")]
pub scratchpad_squeeze_fraction: f64,
#[serde(default = "default_compact_history_keep")]
pub compact_history_default_keep: usize,
#[serde(default)]
pub json_mode: bool,
#[serde(default)]
pub disable_native_tools: bool,
#[serde(default = "default_context_window")]
pub context_window: i32,
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub tool_format: Option<String>,
#[serde(default)]
pub input_price_per_mtok: Option<f64>,
#[serde(default)]
pub output_price_per_mtok: Option<f64>,
#[serde(default)]
pub chars_per_token: Option<f64>,
#[serde(default, skip_serializing)]
#[schema(ignore)]
pub orchestrators: Vec<OrchestratorEntry>,
#[serde(default)]
pub task_precision: Option<HashMap<String, TaskPrecision>>,
#[serde(default = "default_failure_dumps")]
pub failure_dumps: Option<String>,
#[serde(default = "default_response_sla_secs")]
pub response_sla_secs: u64,
#[serde(default = "default_propagate_payment_error")]
pub propagate_payment_error: bool,
#[serde(default)]
pub capability_tags: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub signing_schemes: Vec<String>,
#[serde(default)]
pub auto_stop: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<ExecProviderConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpProviderConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude: Option<ClaudeProviderConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[schema(value_type = Object)]
pub provider_config: HashMap<String, serde_yaml::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openrouter: Option<OpenRouterConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub builtin_tools: Vec<BuiltinToolGrant>,
#[serde(default)]
pub prompt_exposure_guard: bool,
#[serde(default, skip_serializing)]
#[schema(value_type = Vec<String>)]
pub read_file_roots: Vec<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BuiltinToolGrant {
ReadFile {
roots: Vec<String>,
#[serde(default = "default_read_file_max_bytes")]
max_bytes: usize,
},
Grep {
roots: Vec<String>,
#[serde(default = "default_read_file_max_bytes")]
max_bytes: usize,
#[serde(default = "default_grep_max_results")]
max_results: usize,
#[serde(default = "default_grep_timeout_secs")]
timeout_secs: u64,
},
PdfQuery {
trees_root: String,
script_path: String,
#[serde(default = "default_pdf_query_python_bin")]
python_bin: String,
#[serde(default = "default_read_file_max_bytes")]
max_bytes: usize,
#[serde(default = "default_pdf_query_max_results")]
max_results: usize,
#[serde(default = "default_pdf_query_timeout_secs")]
timeout_secs: u64,
},
}
fn default_read_file_max_bytes() -> usize {
1024 * 1024
}
fn default_grep_max_results() -> usize {
200
}
fn default_grep_timeout_secs() -> u64 {
10
}
fn default_pdf_query_python_bin() -> String {
"python3".to_string()
}
fn default_pdf_query_max_results() -> usize {
10
}
fn default_pdf_query_timeout_secs() -> u64 {
60
}
impl AgentConfig {
pub fn provider_config_as<T: serde::de::DeserializeOwned>(
&self,
) -> Result<T, serde_yaml::Error> {
let mapping: serde_yaml::Mapping = self
.provider_config
.iter()
.map(|(k, v)| (serde_yaml::Value::String(k.clone()), v.clone()))
.collect();
serde_yaml::from_value(serde_yaml::Value::Mapping(mapping))
}
pub fn validate_provider_sections(
&self,
resolved_provider_type: Option<&str>,
) -> Result<(), String> {
let sections: Vec<&str> = [
self.exec.as_ref().map(|_| "exec"),
self.mcp.as_ref().map(|_| "mcp"),
self.claude.as_ref().map(|_| "claude"),
]
.into_iter()
.flatten()
.collect();
if sections.len() > 1 {
return Err(format!(
"agent '{}': multiple provider sections present ({}); exactly one is allowed",
self.name,
sections.join(", ")
));
}
if let Some(ptype) = resolved_provider_type {
if let Some(§ion) = sections.first() {
if section != ptype {
return Err(format!(
"agent '{}': provider_type '{}' does not match config section '{}'",
self.name, ptype, section
));
}
}
}
Ok(())
}
pub fn validate_compaction_knobs(&self) -> Result<(), String> {
if !(self.scratchpad_squeeze_fraction > 0.0 && self.scratchpad_squeeze_fraction <= 1.0) {
return Err(format!(
"agent '{}': scratchpad_squeeze_fraction must be in (0.0, 1.0], got {}",
self.name, self.scratchpad_squeeze_fraction
));
}
if self.compact_history_default_keep == 0 {
return Err(format!(
"agent '{}': compact_history_default_keep must be >= 1",
self.name
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct ExecProviderConfig {
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>)]
pub working_dir: Option<PathBuf>,
#[serde(
default,
skip_serializing_if = "HashMap::is_empty",
serialize_with = "serialize_redacted_env"
)]
#[schema(value_type = HashMap<String, String>)]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct McpProviderConfig {
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>)]
pub working_dir: Option<PathBuf>,
#[serde(
default,
skip_serializing_if = "HashMap::is_empty",
serialize_with = "serialize_redacted_env"
)]
#[schema(value_type = HashMap<String, String>)]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct ClaudeProviderConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>)]
pub working_dir: Option<PathBuf>,
#[serde(
default,
skip_serializing_if = "HashMap::is_empty",
serialize_with = "serialize_redacted_env"
)]
#[schema(value_type = HashMap<String, String>)]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default = "default_claude_permission_mode")]
pub permission_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_budget_usd: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[schema(value_type = Vec<String>)]
pub mcp_config: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disallowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[schema(value_type = Vec<String>)]
pub context_files: Vec<PathBuf>,
#[serde(default)]
pub writable: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[schema(value_type = Vec<String>)]
pub add_dirs: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub agents: HashMap<String, ClaudeSubAgentDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_args: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct ClaudeSubAgentDef {
pub description: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[serde(rename = "disallowedTools")]
pub disallowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "permissionMode")]
pub permission_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "maxTurns")]
pub max_turns: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[serde(rename = "mcpServers")]
pub mcp_servers: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isolation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "initialPrompt")]
pub initial_prompt: Option<String>,
}
fn default_claude_permission_mode() -> String {
"bypassPermissions".to_string()
}
impl Default for ClaudeProviderConfig {
fn default() -> Self {
Self {
model: None,
working_dir: None,
env: HashMap::new(),
timeout_secs: None,
permission_mode: default_claude_permission_mode(),
max_budget_usd: None,
mcp_config: Vec::new(),
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
context_files: Vec::new(),
add_dirs: Vec::new(),
agents: HashMap::new(),
extra_args: Vec::new(),
writable: false,
}
}
}
#[derive(Debug, Deserialize, Clone, Serialize, Default, PartialEq, ToSchema)]
pub struct OpenRouterConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_sort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zdr: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_fallbacks: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub only: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_reasoning: Option<bool>,
}
#[derive(Debug, Deserialize, Clone, Serialize, Default, ToSchema)]
pub struct TaskPrecision {
pub pg: f64,
pub pv: f64,
}
pub fn default_context_window() -> i32 {
128_000
}
pub fn default_scratchpad_limit() -> i32 {
2000
}
pub fn default_scratchpad_squeeze_fraction() -> f64 {
0.95
}
pub fn default_compact_history_keep() -> usize {
2
}
pub fn default_repair_invalid_escapes() -> bool {
true
}
pub fn default_textual_feedback() -> bool {
true
}
pub fn default_use_streaming() -> bool {
true
}
pub fn default_presence_penalty() -> Option<f32> {
Some(1.5)
}
pub fn default_max_retries() -> Option<i32> {
Some(3)
}
pub fn default_max_react_iterations() -> Option<i32> {
Some(20)
}
pub fn default_max_scratchpad_size() -> Option<i32> {
Some(32_768)
}
pub fn default_failure_dumps() -> Option<String> {
Some("on".to_string())
}
pub fn default_response_sla_secs() -> u64 {
3600
}
pub fn default_propagate_payment_error() -> bool {
true
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
name: String::new(),
model: None,
provider_id: String::new(),
model_name: String::new(),
temperature: 0.0,
max_tokens: 0,
system_prompt_override: None,
persona: None,
max_react_iterations: default_max_react_iterations(),
max_scratchpad_size: default_max_scratchpad_size(),
max_retries: default_max_retries(),
supports_native_thinking: false,
frequency_penalty: None,
presence_penalty: default_presence_penalty(),
textual_feedback: default_textual_feedback(),
use_streaming: default_use_streaming(),
merge_system_prompt: false,
unwrap_hallucinated_tool_calls: false,
repair_invalid_escapes: default_repair_invalid_escapes(),
scratchpad_limit: default_scratchpad_limit(),
scratchpad_squeeze_fraction: default_scratchpad_squeeze_fraction(),
compact_history_default_keep: default_compact_history_keep(),
json_mode: false,
disable_native_tools: false,
context_window: default_context_window(),
reasoning_effort: None,
tool_format: None,
input_price_per_mtok: None,
output_price_per_mtok: None,
chars_per_token: None,
orchestrators: Vec::new(),
task_precision: None,
failure_dumps: default_failure_dumps(),
response_sla_secs: default_response_sla_secs(),
propagate_payment_error: default_propagate_payment_error(),
capability_tags: Vec::new(),
description: None,
signing_schemes: Vec::new(),
auto_stop: false,
exec: None,
mcp: None,
claude: None,
provider_config: HashMap::new(),
openrouter: None,
builtin_tools: Vec::new(),
prompt_exposure_guard: false,
read_file_roots: Vec::new(),
}
}
}
pub fn is_openai_family_provider(config: &AgentConfig) -> bool {
config.exec.is_none() && config.mcp.is_none() && config.claude.is_none()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn provider_config_deserializes_into_typed_struct() {
#[derive(Debug, serde::Deserialize, PartialEq)]
struct CodexConfig {
permission_mode: String,
sandbox: bool,
#[serde(default)]
extra_args: Vec<String>,
}
let cfg: AgentConfig = serde_yaml::from_str(
r#"
name: codex-a
provider_id: my_codex
model_name: codex-mini
provider_config:
permission_mode: "auto"
sandbox: true
extra_args: ["--yolo"]
"#,
)
.expect("agent yaml must parse");
let codex: CodexConfig = cfg.provider_config_as().expect("typed read");
assert_eq!(
codex,
CodexConfig {
permission_mode: "auto".into(),
sandbox: true,
extra_args: vec!["--yolo".into()],
}
);
}
#[test]
fn provider_config_empty_yields_all_defaults() {
#[derive(Debug, serde::Deserialize)]
struct AllDefault {
#[serde(default)]
flag: bool,
}
let cfg = AgentConfig::default();
assert!(cfg.provider_config.is_empty());
let parsed: AllDefault = cfg.provider_config_as().expect("empty map → defaults");
assert!(!parsed.flag);
}
#[test]
fn provider_config_omitted_from_serialization_when_empty() {
let cfg = AgentConfig {
name: "x".into(),
..Default::default()
};
let yaml = serde_yaml::to_string(&cfg).unwrap();
assert!(
!yaml.contains("provider_config"),
"empty provider_config must be skipped in serialization"
);
}
#[test]
fn test_builtin_tools_roundtrip() {
let json = json!({
"name": "test",
"provider_id": "openrouter",
"model_name": "glm-5.1",
"builtin_tools": [
{
"type": "read_file",
"roots": ["/work/corpus", "/work/linux"],
"max_bytes": 2097152
}
]
});
let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
assert_eq!(config.builtin_tools.len(), 1);
match &config.builtin_tools[0] {
BuiltinToolGrant::ReadFile { roots, max_bytes } => {
assert_eq!(
roots,
&vec!["/work/corpus".to_string(), "/work/linux".to_string()]
);
assert_eq!(*max_bytes, 2097152);
}
other => panic!("expected ReadFile, got {other:?}"),
}
let default_json = json!({
"name": "test2",
"provider_id": "openrouter",
"model_name": "glm-5.1",
"builtin_tools": [{"type": "read_file", "roots": ["/tmp"]}]
});
let cfg: AgentConfig = serde_json::from_value(default_json).expect("deserialize");
match &cfg.builtin_tools[0] {
BuiltinToolGrant::ReadFile { max_bytes, .. } => {
assert_eq!(*max_bytes, 1024 * 1024);
}
other => panic!("expected ReadFile, got {other:?}"),
}
let bare_json = json!({
"name": "test3",
"provider_id": "p",
"model_name": "m"
});
let bare: AgentConfig = serde_json::from_value(bare_json).expect("deserialize");
assert!(bare.builtin_tools.is_empty());
}
#[test]
fn test_pdf_query_grant_roundtrip() {
let json = json!({
"name": "agg",
"provider_id": "openrouter",
"model_name": "glm-5.1",
"builtin_tools": [
{
"type": "pdf_query",
"trees_root": "/work/corpus/trees",
"script_path": "/work/scripts/pdf_query.py",
"python_bin": "/opt/pageindex/.venv/bin/python3",
"max_bytes": 524288,
"max_results": 8,
"timeout_secs": 90
}
]
});
let cfg: AgentConfig = serde_json::from_value(json).expect("deserialize");
match &cfg.builtin_tools[0] {
BuiltinToolGrant::PdfQuery {
trees_root,
script_path,
python_bin,
max_bytes,
max_results,
timeout_secs,
} => {
assert_eq!(trees_root, "/work/corpus/trees");
assert_eq!(script_path, "/work/scripts/pdf_query.py");
assert_eq!(python_bin, "/opt/pageindex/.venv/bin/python3");
assert_eq!(*max_bytes, 524288);
assert_eq!(*max_results, 8);
assert_eq!(*timeout_secs, 90);
}
other => panic!("expected PdfQuery, got {other:?}"),
}
let defaults_json = json!({
"name": "agg2",
"provider_id": "openrouter",
"model_name": "glm-5.1",
"builtin_tools": [
{
"type": "pdf_query",
"trees_root": "/work/corpus/trees",
"script_path": "/work/scripts/pdf_query.py"
}
]
});
let cfg: AgentConfig = serde_json::from_value(defaults_json).expect("deserialize");
match &cfg.builtin_tools[0] {
BuiltinToolGrant::PdfQuery {
python_bin,
max_bytes,
max_results,
timeout_secs,
..
} => {
assert_eq!(python_bin, "python3");
assert_eq!(*max_bytes, 1024 * 1024);
assert_eq!(*max_results, 10);
assert_eq!(*timeout_secs, 60);
}
other => panic!("expected PdfQuery, got {other:?}"),
}
}
#[test]
fn test_agent_config_defaults() {
let json = json!({
"name": "test-agent",
"provider_id": "ollama_local",
"model_name": "model",
});
let config: AgentConfig = serde_json::from_value(json).expect("Deserialization failed");
assert_eq!(config.max_react_iterations, Some(20));
}
#[test]
fn test_agent_config_model_field_deserialization() {
let json = json!({
"name": "dotpath-agent",
"model": "together_ai.llama-70b",
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.model, Some("together_ai.llama-70b".to_string()));
assert!(config.provider_id.is_empty());
assert!(config.model_name.is_empty());
}
#[test]
fn test_agent_config_model_field_default_none() {
let json = json!({
"name": "no-model-field",
"provider_id": "p1",
"model_name": "m1",
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert!(config.model.is_none());
}
#[test]
fn test_agent_config_model_field_not_serialized_when_none() {
let config = AgentConfig::default();
let serialized = serde_json::to_value(&config).unwrap();
let obj = serialized.as_object().unwrap();
assert!(
!obj.contains_key("model"),
"model: None should be omitted from serialization"
);
}
#[test]
fn test_agent_config_pricing_fields_default_none() {
let json = json!({
"name": "test-agent",
"provider_id": "p1",
"model_name": "m1",
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.input_price_per_mtok, None);
assert_eq!(config.output_price_per_mtok, None);
assert_eq!(config.chars_per_token, None);
}
#[test]
fn test_agent_config_pricing_fields_roundtrip() {
let json = json!({
"name": "priced-agent",
"provider_id": "openai",
"model_name": "gpt-4",
"input_price_per_mtok": 10.0,
"output_price_per_mtok": 30.0,
"chars_per_token": 3.5
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.input_price_per_mtok, Some(10.0));
assert_eq!(config.output_price_per_mtok, Some(30.0));
assert_eq!(config.chars_per_token, Some(3.5));
let serialized = serde_json::to_value(&config).unwrap();
let deserialized: AgentConfig = serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized.input_price_per_mtok, Some(10.0));
assert_eq!(deserialized.output_price_per_mtok, Some(30.0));
assert_eq!(deserialized.chars_per_token, Some(3.5));
}
#[test]
fn test_agent_config_chars_per_token_override() {
let json = json!({
"name": "cjk-agent",
"provider_id": "ollama",
"model_name": "qwen",
"chars_per_token": 1.5
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.chars_per_token, Some(1.5));
}
#[test]
fn test_agent_config_all_defaults() {
let json = json!({
"name": "minimal",
"provider_id": "p",
"model_name": "m",
});
let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
assert_eq!(config.name, "minimal");
assert_eq!(config.provider_id, "p");
assert_eq!(config.model_name, "m");
assert_eq!(config.temperature, 0.0);
assert_eq!(config.max_tokens, 0);
assert!(config.system_prompt_override.is_none());
assert!(config.persona.is_none());
assert_eq!(config.max_react_iterations, Some(20));
assert_eq!(config.max_scratchpad_size, Some(32768));
assert_eq!(config.max_retries, Some(3));
assert!(!config.supports_native_thinking);
assert!(config.frequency_penalty.is_none());
assert_eq!(config.presence_penalty, Some(1.5));
assert!(config.textual_feedback);
assert!(config.use_streaming);
assert!(!config.merge_system_prompt);
assert!(!config.unwrap_hallucinated_tool_calls);
assert!(config.repair_invalid_escapes);
assert_eq!(config.scratchpad_limit, 2000);
assert!(!config.json_mode);
assert!(!config.disable_native_tools);
assert_eq!(config.context_window, 128_000);
assert!(config.reasoning_effort.is_none());
assert!(config.tool_format.is_none());
assert!(config.input_price_per_mtok.is_none());
assert!(config.output_price_per_mtok.is_none());
assert!(config.chars_per_token.is_none());
assert!(config.task_precision.is_none());
assert_eq!(config.failure_dumps, Some("on".to_string()));
assert_eq!(config.response_sla_secs, 3600);
assert!(config.propagate_payment_error);
}
#[test]
fn test_agent_config_full_roundtrip() {
let json = json!({
"name": "full-agent",
"provider_id": "openai",
"model_name": "gpt-4o",
"temperature": 0.7,
"max_tokens": 4096,
"system_prompt_override": "You are helpful.",
"persona": "expert analyst",
"max_react_iterations": 5,
"max_scratchpad_size": 16384,
"max_retries": 2,
"supports_native_thinking": true,
"frequency_penalty": 0.5,
"presence_penalty": 0.8,
"textual_feedback": false,
"use_streaming": false,
"merge_system_prompt": true,
"unwrap_hallucinated_tool_calls": true,
"repair_invalid_escapes": false,
"scratchpad_limit": 500,
"json_mode": true,
"disable_native_tools": true,
"context_window": 64000,
"reasoning_effort": "high",
"tool_format": "json",
"input_price_per_mtok": 2.5,
"output_price_per_mtok": 10.0,
"chars_per_token": 1.5,
"task_precision": {
"supply": { "pg": 0.3, "pv": 0.8 },
"audit": { "pg": 0.5, "pv": 0.9 }
},
"failure_dumps": "full",
"response_sla_secs": 120,
"propagate_payment_error": false
});
let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
assert_eq!(config.name, "full-agent");
assert_eq!(config.temperature, 0.7);
assert_eq!(config.max_tokens, 4096);
assert_eq!(
config.system_prompt_override,
Some("You are helpful.".to_string())
);
assert_eq!(config.persona, Some("expert analyst".to_string()));
assert_eq!(config.max_react_iterations, Some(5));
assert_eq!(config.max_scratchpad_size, Some(16384));
assert_eq!(config.max_retries, Some(2));
assert!(config.supports_native_thinking);
assert_eq!(config.frequency_penalty, Some(0.5));
assert_eq!(config.presence_penalty, Some(0.8));
assert!(!config.textual_feedback);
assert!(!config.use_streaming);
assert!(config.merge_system_prompt);
assert!(config.unwrap_hallucinated_tool_calls);
assert!(!config.repair_invalid_escapes);
assert_eq!(config.scratchpad_limit, 500);
assert!(config.json_mode);
assert!(config.disable_native_tools);
assert_eq!(config.context_window, 64000);
assert_eq!(config.reasoning_effort, Some("high".to_string()));
assert_eq!(config.tool_format, Some("json".to_string()));
assert_eq!(config.input_price_per_mtok, Some(2.5));
assert_eq!(config.output_price_per_mtok, Some(10.0));
assert_eq!(config.chars_per_token, Some(1.5));
assert_eq!(config.failure_dumps, Some("full".to_string()));
let tp = config
.task_precision
.as_ref()
.expect("task_precision present");
assert_eq!(tp.len(), 2);
let supply = tp.get("supply").expect("supply key");
assert!((supply.pg - 0.3).abs() < f64::EPSILON);
assert!((supply.pv - 0.8).abs() < f64::EPSILON);
let audit = tp.get("audit").expect("audit key");
assert!((audit.pg - 0.5).abs() < f64::EPSILON);
assert!((audit.pv - 0.9).abs() < f64::EPSILON);
let serialized = serde_json::to_value(&config).expect("serialize");
let roundtripped: AgentConfig =
serde_json::from_value(serialized).expect("deserialize roundtrip");
assert_eq!(roundtripped.name, "full-agent");
assert_eq!(roundtripped.temperature, 0.7);
assert_eq!(roundtripped.max_tokens, 4096);
assert_eq!(roundtripped.tool_format, Some("json".to_string()));
assert_eq!(roundtripped.failure_dumps, Some("full".to_string()));
assert_eq!(roundtripped.reasoning_effort, Some("high".to_string()));
assert_eq!(roundtripped.context_window, 64000);
assert_eq!(config.response_sla_secs, 120);
assert_eq!(roundtripped.response_sla_secs, 120);
assert!(!config.propagate_payment_error);
assert!(!roundtripped.propagate_payment_error);
let rt_tp = roundtripped.task_precision.as_ref().unwrap();
assert_eq!(rt_tp.len(), 2);
assert!((rt_tp["supply"].pg - 0.3).abs() < f64::EPSILON);
}
#[test]
fn test_agent_config_orchestrators_skip_serializing() {
let mut config = AgentConfig {
name: "orch-test".to_string(),
provider_id: "p".to_string(),
model_name: "m".to_string(),
..AgentConfig::default()
};
config.orchestrators = vec![OrchestratorEntry {
id: Some("local".to_string()),
url: "http://localhost:8080".to_string(),
bearer_token: None,
invite_code: None,
}];
let serialized = serde_json::to_value(&config).expect("serialize");
let obj = serialized.as_object().expect("should be object");
assert!(
!obj.contains_key("orchestrators"),
"orchestrators should be skipped during serialization"
);
}
#[test]
fn test_task_precision_serde() {
let tp = TaskPrecision { pg: 0.3, pv: 0.8 };
let serialized = serde_json::to_value(&tp).expect("serialize");
assert_eq!(serialized["pg"], 0.3);
assert_eq!(serialized["pv"], 0.8);
let roundtripped: TaskPrecision = serde_json::from_value(serialized).expect("deserialize");
assert!((roundtripped.pg - 0.3).abs() < f64::EPSILON);
assert!((roundtripped.pv - 0.8).abs() < f64::EPSILON);
}
#[test]
fn test_task_precision_default() {
let tp = TaskPrecision::default();
assert!((tp.pg - 0.0).abs() < f64::EPSILON);
assert!((tp.pv - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_default_function_values() {
assert_eq!(default_context_window(), 128_000);
assert_eq!(default_scratchpad_limit(), 2000);
assert!(default_repair_invalid_escapes());
assert!(default_textual_feedback());
assert!(default_use_streaming());
assert_eq!(default_presence_penalty(), Some(1.5));
assert_eq!(default_max_retries(), Some(3));
assert_eq!(default_max_react_iterations(), Some(20));
assert_eq!(default_max_scratchpad_size(), Some(32_768));
assert_eq!(default_failure_dumps(), Some("on".to_string()));
assert_eq!(default_response_sla_secs(), 3600);
assert!(default_propagate_payment_error());
}
#[test]
fn test_agent_config_propagate_payment_error_default_true() {
let json = json!({
"name": "no-config",
"provider_id": "p",
"model_name": "m",
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert!(config.propagate_payment_error, "should default to true");
}
#[test]
fn test_agent_config_propagate_payment_error_false() {
let json = json!({
"name": "silent-agent",
"provider_id": "p",
"model_name": "m",
"propagate_payment_error": false
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert!(!config.propagate_payment_error);
}
#[test]
fn test_agent_config_response_sla_default() {
let json = json!({
"name": "no-sla",
"provider_id": "p",
"model_name": "m",
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.response_sla_secs, 3600, "should default to 3600s");
}
#[test]
fn test_agent_config_response_sla_explicit() {
let json = json!({
"name": "fast-agent",
"provider_id": "openai",
"model_name": "gpt-4o",
"response_sla_secs": 60
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.response_sla_secs, 60);
let serialized = serde_json::to_value(&config).unwrap();
let roundtripped: AgentConfig = serde_json::from_value(serialized).unwrap();
assert_eq!(roundtripped.response_sla_secs, 60);
}
#[test]
fn test_agent_config_failure_dumps_values() {
let json_on = json!({
"name": "a", "provider_id": "p", "model_name": "m",
"failure_dumps": "on"
});
let cfg: AgentConfig = serde_json::from_value(json_on).unwrap();
assert_eq!(cfg.failure_dumps, Some("on".to_string()));
let json_full = json!({
"name": "a", "provider_id": "p", "model_name": "m",
"failure_dumps": "full"
});
let cfg: AgentConfig = serde_json::from_value(json_full).unwrap();
assert_eq!(cfg.failure_dumps, Some("full".to_string()));
let json_off = json!({
"name": "a", "provider_id": "p", "model_name": "m",
"failure_dumps": "off"
});
let cfg: AgentConfig = serde_json::from_value(json_off).unwrap();
assert_eq!(cfg.failure_dumps, Some("off".to_string()));
let json_null = json!({
"name": "a", "provider_id": "p", "model_name": "m",
"failure_dumps": null
});
let cfg: AgentConfig = serde_json::from_value(json_null).unwrap();
assert!(cfg.failure_dumps.is_none());
}
#[test]
fn test_agent_config_default_parity_with_serde() {
let from_default = AgentConfig::default();
let from_serde: AgentConfig = serde_json::from_value(json!({
"name": "",
"provider_id": "",
"model_name": "",
"temperature": 0.0,
"max_tokens": 0,
}))
.expect("minimal JSON should deserialize with serde defaults");
assert_eq!(from_default.model, from_serde.model, "model");
assert_eq!(
from_default.max_react_iterations, from_serde.max_react_iterations,
"max_react_iterations"
);
assert_eq!(
from_default.max_scratchpad_size, from_serde.max_scratchpad_size,
"max_scratchpad_size"
);
assert_eq!(
from_default.max_retries, from_serde.max_retries,
"max_retries"
);
assert_eq!(
from_default.presence_penalty, from_serde.presence_penalty,
"presence_penalty"
);
assert_eq!(
from_default.textual_feedback, from_serde.textual_feedback,
"textual_feedback"
);
assert_eq!(
from_default.use_streaming, from_serde.use_streaming,
"use_streaming"
);
assert_eq!(
from_default.repair_invalid_escapes, from_serde.repair_invalid_escapes,
"repair_invalid_escapes"
);
assert_eq!(
from_default.scratchpad_limit, from_serde.scratchpad_limit,
"scratchpad_limit"
);
assert_eq!(
from_default.context_window, from_serde.context_window,
"context_window"
);
assert_eq!(
from_default.failure_dumps, from_serde.failure_dumps,
"failure_dumps"
);
assert_eq!(
from_default.response_sla_secs, from_serde.response_sla_secs,
"response_sla_secs"
);
assert_eq!(
from_default.propagate_payment_error, from_serde.propagate_payment_error,
"propagate_payment_error"
);
assert_eq!(
from_default.capability_tags, from_serde.capability_tags,
"capability_tags"
);
assert_eq!(
from_default.description, from_serde.description,
"description"
);
assert_eq!(
from_default.signing_schemes, from_serde.signing_schemes,
"signing_schemes"
);
assert_eq!(from_default.exec, from_serde.exec, "exec");
assert_eq!(from_default.mcp, from_serde.mcp, "mcp");
assert_eq!(from_default.claude, from_serde.claude, "claude");
assert_eq!(from_default.openrouter, from_serde.openrouter, "openrouter");
assert_eq!(
from_default.prompt_exposure_guard, from_serde.prompt_exposure_guard,
"prompt_exposure_guard"
);
}
#[test]
fn test_openrouter_field_omitted_when_none() {
let cfg = AgentConfig {
name: "test".into(),
provider_id: "openai".into(),
model_name: "gpt-4".into(),
temperature: 0.0,
max_tokens: 0,
..Default::default()
};
assert!(cfg.openrouter.is_none(), "sanity: default is None");
let serialized = serde_json::to_string(&cfg).unwrap();
assert!(
!serialized.contains("openrouter"),
"openrouter=None should be omitted, got: {serialized}"
);
let json = json!({
"name": "test",
"provider_id": "openai",
"model_name": "gpt-4",
"temperature": 0.0,
"max_tokens": 0,
});
let parsed: AgentConfig = serde_json::from_value(json).unwrap();
assert!(parsed.openrouter.is_none());
}
#[test]
fn test_openrouter_field_roundtrip_populated() {
let cfg = AgentConfig {
name: "test".into(),
provider_id: "openrouter".into(),
model_name: "google/gemma-4-26b-a4b-it".into(),
temperature: 0.7,
max_tokens: 16384,
openrouter: Some(OpenRouterConfig {
provider_sort: Some("throughput".into()),
zdr: Some(true),
allow_fallbacks: Some(false),
ignore: vec!["nextbit".into()],
only: vec!["akashml/fp8".into()],
exclude_reasoning: Some(true),
}),
..Default::default()
};
let serialized = serde_json::to_string(&cfg).unwrap();
assert!(serialized.contains(r#""openrouter""#));
let parsed: AgentConfig = serde_json::from_str(&serialized).unwrap();
let or = parsed.openrouter.expect("openrouter must round-trip");
assert_eq!(or.provider_sort.as_deref(), Some("throughput"));
assert_eq!(or.zdr, Some(true));
assert_eq!(or.allow_fallbacks, Some(false));
assert_eq!(or.ignore, vec!["nextbit".to_string()]);
assert_eq!(or.only, vec!["akashml/fp8".to_string()]);
assert_eq!(or.exclude_reasoning, Some(true));
}
#[test]
fn test_capability_tags_roundtrip() {
let json = json!({
"name": "test",
"provider_id": "openai",
"model_name": "gpt-4",
"temperature": 0.7,
"max_tokens": 1000,
"capability_tags": ["legal", "audit", "compliance"],
"description": "Legal audit specialist",
"signing_schemes": ["eip712", "ed25519"]
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.capability_tags, vec!["legal", "audit", "compliance"]);
assert_eq!(
config.description.as_deref(),
Some("Legal audit specialist")
);
assert_eq!(config.signing_schemes, vec!["eip712", "ed25519"]);
let serialized = serde_json::to_string(&config).unwrap();
let deserialized: AgentConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.capability_tags, config.capability_tags);
assert_eq!(deserialized.description, config.description);
assert_eq!(deserialized.signing_schemes, config.signing_schemes);
}
#[test]
fn test_new_fields_default_to_empty() {
let json = json!({
"name": "minimal",
"provider_id": "test",
"model_name": "test",
"temperature": 0.0,
"max_tokens": 0,
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert!(config.capability_tags.is_empty());
assert!(config.description.is_none());
assert!(config.signing_schemes.is_empty());
assert!(config.exec.is_none());
}
#[test]
fn test_agent_config_exec_roundtrip() {
let json = json!({
"name": "py-agent",
"provider_id": "exec_local",
"model_name": "custom",
"exec": {
"command": ["python3", "agent.py"],
"working_dir": "/opt/agents",
"env": {"MY_VAR": "value"},
"timeout_secs": 120
}
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
let exec = config.exec.as_ref().expect("exec should be present");
assert_eq!(exec.command, vec!["python3", "agent.py"]);
assert_eq!(
exec.working_dir.as_ref().map(|p| p.to_str().unwrap()),
Some("/opt/agents")
);
assert_eq!(exec.env.get("MY_VAR").unwrap(), "value");
assert_eq!(exec.timeout_secs, Some(120));
let serialized = serde_json::to_value(&config).unwrap();
let deserialized: AgentConfig = serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized.exec, config.exec);
}
#[test]
fn test_agent_config_exec_none_omitted_from_json() {
let config = AgentConfig::default();
let serialized = serde_json::to_value(&config).unwrap();
let obj = serialized.as_object().unwrap();
assert!(
!obj.contains_key("exec"),
"exec: None should be omitted from serialization"
);
assert!(
!obj.contains_key("mcp"),
"mcp: None should be omitted from serialization"
);
assert!(
!obj.contains_key("claude"),
"claude: None should be omitted from serialization"
);
}
#[test]
fn redacted_env_hides_sensitive_keys() {
let mut env = HashMap::new();
env.insert("SAFE_VAR".to_string(), "visible".to_string());
env.insert("API_KEY".to_string(), "super-secret".to_string());
env.insert("db_password".to_string(), "pass123".to_string());
env.insert("AUTH_TOKEN".to_string(), "tok_abc".to_string());
env.insert("MY_CREDENTIAL_ID".to_string(), "cred".to_string());
env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "aws123".to_string());
let config = ExecProviderConfig {
command: vec!["test".into()],
working_dir: None,
env,
timeout_secs: None,
};
let serialized = serde_json::to_value(&config).unwrap();
let env_obj = serialized["env"].as_object().unwrap();
assert_eq!(env_obj["SAFE_VAR"], "visible");
assert_eq!(env_obj["API_KEY"], "<redacted>");
assert_eq!(env_obj["db_password"], "<redacted>");
assert_eq!(env_obj["AUTH_TOKEN"], "<redacted>");
assert_eq!(env_obj["MY_CREDENTIAL_ID"], "<redacted>");
assert_eq!(env_obj["AWS_SECRET_ACCESS_KEY"], "<redacted>");
}
#[test]
fn validate_provider_sections_rejects_multiple() {
let config: AgentConfig = serde_json::from_value(json!({
"name": "a", "provider_id": "p", "model_name": "m",
"exec": { "command": ["test"] },
"mcp": { "command": ["test"] }
}))
.unwrap();
let err = config.validate_provider_sections(None).unwrap_err();
assert!(err.contains("multiple provider sections"), "{err}");
}
#[test]
fn validate_provider_sections_rejects_mismatch() {
let config: AgentConfig = serde_json::from_value(json!({
"name": "a", "provider_id": "p", "model_name": "m",
"exec": { "command": ["test"] }
}))
.unwrap();
let err = config.validate_provider_sections(Some("mcp")).unwrap_err();
assert!(err.contains("does not match"), "{err}");
}
#[test]
fn validate_provider_sections_accepts_matching() {
let config: AgentConfig = serde_json::from_value(json!({
"name": "a", "provider_id": "p", "model_name": "m",
"exec": { "command": ["test"] }
}))
.unwrap();
config.validate_provider_sections(Some("exec")).unwrap();
}
#[test]
fn validate_provider_sections_accepts_none() {
let config: AgentConfig = serde_json::from_value(json!({
"name": "a", "provider_id": "p", "model_name": "m"
}))
.unwrap();
config.validate_provider_sections(Some("exec")).unwrap();
}
#[test]
fn validate_compaction_knobs_accepts_defaults() {
AgentConfig::default().validate_compaction_knobs().unwrap();
}
#[test]
fn validate_compaction_knobs_rejects_zero_keep() {
let cfg = AgentConfig {
compact_history_default_keep: 0,
..AgentConfig::default()
};
let err = cfg.validate_compaction_knobs().unwrap_err();
assert!(err.contains("compact_history_default_keep"));
}
#[test]
fn validate_compaction_knobs_rejects_out_of_range_fraction() {
for bad in [0.0, 1.5, -0.1] {
let cfg = AgentConfig {
scratchpad_squeeze_fraction: bad,
..AgentConfig::default()
};
assert!(
cfg.validate_compaction_knobs().is_err(),
"fraction {bad} must be rejected"
);
}
}
#[test]
fn validate_compaction_knobs_accepts_boundary_one() {
let cfg = AgentConfig {
scratchpad_squeeze_fraction: 1.0,
compact_history_default_keep: 1,
..AgentConfig::default()
};
cfg.validate_compaction_knobs().unwrap();
}
fn parse_agent_persona(yaml_fragment: &str) -> Option<String> {
let yaml = format!("name: test\n{yaml_fragment}");
let cfg: AgentConfig = serde_yaml::from_str(&yaml).expect("agent yaml must parse");
cfg.persona
}
#[test]
fn persona_inline_string_back_compat() {
let persona = parse_agent_persona("persona: \"you are a careful reviewer\"");
assert_eq!(persona.as_deref(), Some("you are a careful reviewer"));
}
#[test]
fn persona_absent_stays_none() {
let persona = parse_agent_persona("");
assert!(persona.is_none());
}
#[test]
fn persona_layered_text_joins_with_double_newline() {
let persona = parse_agent_persona(
"persona:\n\
- type: text\n prompt: \"a\"\n\
- type: text\n prompt: \"b\"\n",
);
assert_eq!(persona.as_deref(), Some("a\n\nb"));
}
#[test]
fn persona_layered_md_reads_file_and_stacks_with_text() {
let sandbox_dir = tempfile::tempdir().unwrap();
let md_path = sandbox_dir.path().join("body.md");
std::fs::write(&md_path, "from-md\n").unwrap();
let yaml = format!(
"persona:\n\
- type: text\n prompt: \"lead\"\n\
- type: md\n prompt: \"{}\"\n\
- type: text\n prompt: \"tail\"\n",
md_path.display()
);
let persona = parse_agent_persona(&yaml);
assert_eq!(persona.as_deref(), Some("lead\n\nfrom-md\n\n\ntail"));
}
#[test]
fn persona_layered_md_missing_file_errors_with_path() {
let yaml = "name: test\n\
persona:\n\
- type: md\n prompt: \"/path/does/not/exist/persona.md\"\n";
let err = serde_yaml::from_str::<AgentConfig>(yaml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("/path/does/not/exist/persona.md") && msg.contains("could not be read"),
"error must name the missing path; got: {msg}"
);
}
}