1use crate::nats_utils::OrchestratorEntry;
2use serde::{Deserialize, Serialize, Serializer};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use utoipa::ToSchema;
6
7fn serialize_redacted_env<S>(
12 env: &HashMap<String, String>,
13 serializer: S,
14) -> Result<S::Ok, S::Error>
15where
16 S: Serializer,
17{
18 use serde::ser::SerializeMap;
19 const SENSITIVE: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL"];
20 let mut map = serializer.serialize_map(Some(env.len()))?;
21 for (k, v) in env {
22 let upper = k.to_uppercase();
23 let redacted = SENSITIVE.iter().any(|s| upper.contains(s));
24 map.serialize_entry(k, if redacted { "<redacted>" } else { v.as_str() })?;
25 }
26 map.end()
27}
28
29#[derive(Debug, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33enum PersonaLayer {
34 Text {
35 prompt: String,
36 },
37 Md {
42 prompt: PathBuf,
43 },
44}
45
46#[derive(Debug, Deserialize)]
51#[serde(untagged)]
52enum PersonaInput {
53 Inline(String),
54 Layered(Vec<PersonaLayer>),
55}
56
57fn deserialize_persona<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
75where
76 D: serde::Deserializer<'de>,
77{
78 use serde::de::Error;
79 let opt: Option<PersonaInput> = Option::deserialize(deserializer)?;
80 match opt {
81 None => Ok(None),
82 Some(PersonaInput::Inline(s)) => Ok(Some(s)),
83 Some(PersonaInput::Layered(layers)) => {
84 let mut parts: Vec<String> = Vec::with_capacity(layers.len());
85 for layer in layers {
86 match layer {
87 PersonaLayer::Text { prompt } => parts.push(prompt),
88 PersonaLayer::Md { prompt } => {
89 let content = std::fs::read_to_string(&prompt).map_err(|e| {
90 D::Error::custom(format!(
91 "persona md layer at `{}` could not be read: {e}",
92 prompt.display()
93 ))
94 })?;
95 parts.push(content);
96 }
97 }
98 }
99 Ok(Some(parts.join("\n\n")))
100 }
101 }
102}
103
104#[derive(Debug, Deserialize, Clone, Serialize, ToSchema)]
106pub struct AgentConfig {
107 pub name: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub model: Option<String>,
114 #[serde(default)]
117 pub provider_id: String,
118 #[serde(default)]
119 pub model_name: String,
120 #[serde(default)]
121 pub temperature: f32,
122 #[serde(default)]
123 pub max_tokens: i32,
124 #[serde(default)]
125 pub system_prompt_override: Option<String>,
126 #[serde(default, deserialize_with = "deserialize_persona")]
127 pub persona: Option<String>,
128 #[serde(default = "default_max_react_iterations")]
129 pub max_react_iterations: Option<i32>,
130 #[serde(default = "default_max_scratchpad_size")]
131 pub max_scratchpad_size: Option<i32>,
132 #[serde(default = "default_max_retries")]
133 pub max_retries: Option<i32>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub max_concurrent_jobs: Option<usize>,
141 #[serde(default)]
142 pub supports_native_thinking: bool,
143 #[serde(default)]
144 pub frequency_penalty: Option<f32>,
145 #[serde(default = "default_presence_penalty")]
149 pub presence_penalty: Option<f32>,
150 #[serde(default = "default_textual_feedback")]
151 pub textual_feedback: bool,
152 #[serde(default = "default_use_streaming")]
153 pub use_streaming: bool,
154 #[serde(default)]
155 pub merge_system_prompt: bool,
156 #[serde(default)]
157 pub unwrap_hallucinated_tool_calls: bool,
158 #[serde(default = "default_repair_invalid_escapes")]
159 pub repair_invalid_escapes: bool,
160 #[serde(default = "default_scratchpad_limit")]
161 pub scratchpad_limit: i32,
162
163 #[serde(default = "default_scratchpad_squeeze_fraction")]
168 pub scratchpad_squeeze_fraction: f64,
169
170 #[serde(default = "default_compact_history_keep")]
175 pub compact_history_default_keep: usize,
176 #[serde(default)]
177 pub json_mode: bool,
178 #[serde(default)]
179 pub disable_native_tools: bool,
180 #[serde(default = "default_context_window")]
181 pub context_window: i32,
182 #[serde(default)]
183 pub reasoning_effort: Option<String>,
184 #[serde(default)]
185 pub tool_format: Option<String>,
186 #[serde(default)]
188 pub input_price_per_mtok: Option<f64>,
189 #[serde(default)]
191 pub output_price_per_mtok: Option<f64>,
192 #[serde(default)]
197 pub chars_per_token: Option<f64>,
198 #[serde(default, skip_serializing)]
202 #[schema(ignore)]
203 pub orchestrators: Vec<OrchestratorEntry>,
204 #[serde(default)]
211 pub task_precision: Option<HashMap<String, TaskPrecision>>,
212 #[serde(default = "default_failure_dumps")]
219 pub failure_dumps: Option<String>,
220 #[serde(default = "default_response_sla_secs")]
225 pub response_sla_secs: u64,
226 #[serde(default = "default_propagate_payment_error")]
230 pub propagate_payment_error: bool,
231
232 #[serde(default)]
236 pub capability_tags: Vec<String>,
237
238 #[serde(default)]
241 pub description: Option<String>,
242
243 #[serde(default)]
247 pub signing_schemes: Vec<String>,
248
249 #[serde(default)]
254 pub auto_stop: bool,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub exec: Option<ExecProviderConfig>,
261
262 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub mcp: Option<McpProviderConfig>,
268
269 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub claude: Option<ClaudeProviderConfig>,
276
277 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
288 #[schema(value_type = Object)]
289 pub provider_config: HashMap<String, serde_yaml::Value>,
290
291 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub openrouter: Option<OpenRouterConfig>,
297
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub builtin_tools: Vec<BuiltinToolGrant>,
313
314 #[serde(default)]
324 pub prompt_exposure_guard: bool,
325
326 #[serde(default, skip_serializing)]
332 #[schema(ignore)]
333 pub middleware: crate::middleware::MiddlewareConfig,
334
335 #[serde(default, skip_serializing)]
345 #[schema(value_type = Vec<String>)]
346 pub read_file_roots: Vec<PathBuf>,
347}
348
349#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, PartialEq)]
358#[serde(tag = "type", rename_all = "snake_case")]
359pub enum BuiltinToolGrant {
360 ReadFile {
368 roots: Vec<String>,
374 #[serde(default = "default_read_file_max_bytes")]
379 max_bytes: usize,
380 },
381 Grep {
389 roots: Vec<String>,
391 #[serde(default = "default_read_file_max_bytes")]
394 max_bytes: usize,
395 #[serde(default = "default_grep_max_results")]
397 max_results: usize,
398 #[serde(default = "default_grep_timeout_secs")]
402 timeout_secs: u64,
403 },
404 PdfQuery {
418 trees_root: String,
422 script_path: String,
427 #[serde(default = "default_pdf_query_python_bin")]
430 python_bin: String,
431 #[serde(default = "default_read_file_max_bytes")]
434 max_bytes: usize,
435 #[serde(default = "default_pdf_query_max_results")]
439 max_results: usize,
440 #[serde(default = "default_pdf_query_timeout_secs")]
445 timeout_secs: u64,
446 },
447}
448
449fn default_read_file_max_bytes() -> usize {
450 1024 * 1024
451}
452
453fn default_grep_max_results() -> usize {
454 200
455}
456
457fn default_grep_timeout_secs() -> u64 {
458 10
459}
460
461fn default_pdf_query_python_bin() -> String {
462 "python3".to_string()
463}
464
465fn default_pdf_query_max_results() -> usize {
466 10
467}
468
469fn default_pdf_query_timeout_secs() -> u64 {
470 60
471}
472
473impl AgentConfig {
474 pub fn provider_config_as<T: serde::de::DeserializeOwned>(
490 &self,
491 ) -> Result<T, serde_yaml::Error> {
492 let mapping: serde_yaml::Mapping = self
493 .provider_config
494 .iter()
495 .map(|(k, v)| (serde_yaml::Value::String(k.clone()), v.clone()))
496 .collect();
497 serde_yaml::from_value(serde_yaml::Value::Mapping(mapping))
498 }
499
500 pub fn validate_provider_sections(
503 &self,
504 resolved_provider_type: Option<&str>,
505 ) -> Result<(), String> {
506 let sections: Vec<&str> = [
507 self.exec.as_ref().map(|_| "exec"),
508 self.mcp.as_ref().map(|_| "mcp"),
509 self.claude.as_ref().map(|_| "claude"),
510 ]
511 .into_iter()
512 .flatten()
513 .collect();
514
515 if sections.len() > 1 {
516 return Err(format!(
517 "agent '{}': multiple provider sections present ({}); exactly one is allowed",
518 self.name,
519 sections.join(", ")
520 ));
521 }
522
523 if let Some(ptype) = resolved_provider_type {
524 if let Some(§ion) = sections.first() {
525 if section != ptype {
526 return Err(format!(
527 "agent '{}': provider_type '{}' does not match config section '{}'",
528 self.name, ptype, section
529 ));
530 }
531 }
532 }
533 Ok(())
534 }
535
536 pub fn validate_compaction_knobs(&self) -> Result<(), String> {
541 if !(self.scratchpad_squeeze_fraction > 0.0 && self.scratchpad_squeeze_fraction <= 1.0) {
542 return Err(format!(
543 "agent '{}': scratchpad_squeeze_fraction must be in (0.0, 1.0], got {}",
544 self.name, self.scratchpad_squeeze_fraction
545 ));
546 }
547 if self.compact_history_default_keep == 0 {
548 return Err(format!(
549 "agent '{}': compact_history_default_keep must be >= 1",
550 self.name
551 ));
552 }
553 Ok(())
554 }
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
561pub struct ExecProviderConfig {
562 pub command: Vec<String>,
565
566 #[serde(default, skip_serializing_if = "Option::is_none")]
568 #[schema(value_type = Option<String>)]
569 pub working_dir: Option<PathBuf>,
570
571 #[serde(
574 default,
575 skip_serializing_if = "HashMap::is_empty",
576 serialize_with = "serialize_redacted_env"
577 )]
578 #[schema(value_type = HashMap<String, String>)]
579 pub env: HashMap<String, String>,
580
581 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub timeout_secs: Option<u64>,
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
593pub struct McpProviderConfig {
594 pub command: Vec<String>,
597
598 #[serde(default, skip_serializing_if = "Option::is_none")]
600 #[schema(value_type = Option<String>)]
601 pub working_dir: Option<PathBuf>,
602
603 #[serde(
606 default,
607 skip_serializing_if = "HashMap::is_empty",
608 serialize_with = "serialize_redacted_env"
609 )]
610 #[schema(value_type = HashMap<String, String>)]
611 pub env: HashMap<String, String>,
612
613 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub timeout_secs: Option<u64>,
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
625pub struct ClaudeProviderConfig {
626 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub model: Option<String>,
630
631 #[serde(default, skip_serializing_if = "Option::is_none")]
633 #[schema(value_type = Option<String>)]
634 pub working_dir: Option<PathBuf>,
635
636 #[serde(
639 default,
640 skip_serializing_if = "HashMap::is_empty",
641 serialize_with = "serialize_redacted_env"
642 )]
643 #[schema(value_type = HashMap<String, String>)]
644 pub env: HashMap<String, String>,
645
646 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub timeout_secs: Option<u64>,
650
651 #[serde(default = "default_claude_permission_mode")]
654 pub permission_mode: String,
655
656 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub max_budget_usd: Option<f64>,
659
660 #[serde(default, skip_serializing_if = "Vec::is_empty")]
663 #[schema(value_type = Vec<String>)]
664 pub mcp_config: Vec<PathBuf>,
665
666 #[serde(default, skip_serializing_if = "Vec::is_empty")]
669 pub allowed_tools: Vec<String>,
670
671 #[serde(default, skip_serializing_if = "Vec::is_empty")]
676 pub disallowed_tools: Vec<String>,
677
678 #[serde(default, skip_serializing_if = "Vec::is_empty")]
684 #[schema(value_type = Vec<String>)]
685 pub context_files: Vec<PathBuf>,
686
687 #[serde(default)]
691 pub writable: bool,
692
693 #[serde(default, skip_serializing_if = "Vec::is_empty")]
697 #[schema(value_type = Vec<String>)]
698 pub add_dirs: Vec<PathBuf>,
699
700 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
704 pub agents: HashMap<String, ClaudeSubAgentDef>,
705
706 #[serde(default, skip_serializing_if = "Vec::is_empty")]
709 pub extra_args: Vec<String>,
710}
711
712#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, ToSchema)]
718pub struct ClaudeSubAgentDef {
719 pub description: String,
721
722 pub prompt: String,
724
725 #[serde(default, skip_serializing_if = "Vec::is_empty")]
728 pub tools: Vec<String>,
729
730 #[serde(default, skip_serializing_if = "Vec::is_empty")]
733 #[serde(rename = "disallowedTools")]
734 pub disallowed_tools: Vec<String>,
735
736 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub model: Option<String>,
739
740 #[serde(default, skip_serializing_if = "Option::is_none")]
743 #[serde(rename = "permissionMode")]
744 pub permission_mode: Option<String>,
745
746 #[serde(default, skip_serializing_if = "Option::is_none")]
748 #[serde(rename = "maxTurns")]
749 pub max_turns: Option<u32>,
750
751 #[serde(default, skip_serializing_if = "Vec::is_empty")]
755 #[serde(rename = "mcpServers")]
756 pub mcp_servers: Vec<serde_json::Value>,
757
758 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub effort: Option<String>,
761
762 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub background: Option<bool>,
765
766 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub isolation: Option<String>,
770
771 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub memory: Option<String>,
774
775 #[serde(default, skip_serializing_if = "Vec::is_empty")]
777 pub skills: Vec<String>,
778
779 #[serde(default, skip_serializing_if = "Option::is_none")]
782 #[serde(rename = "initialPrompt")]
783 pub initial_prompt: Option<String>,
784}
785
786fn default_claude_permission_mode() -> String {
787 "bypassPermissions".to_string()
788}
789
790impl Default for ClaudeProviderConfig {
791 fn default() -> Self {
792 Self {
793 model: None,
794 working_dir: None,
795 env: HashMap::new(),
796 timeout_secs: None,
797 permission_mode: default_claude_permission_mode(),
798 max_budget_usd: None,
799 mcp_config: Vec::new(),
800 allowed_tools: Vec::new(),
801 disallowed_tools: Vec::new(),
802 context_files: Vec::new(),
803 add_dirs: Vec::new(),
804 agents: HashMap::new(),
805 extra_args: Vec::new(),
806 writable: false,
807 }
808 }
809}
810
811#[derive(Debug, Deserialize, Clone, Serialize, Default, PartialEq, ToSchema)]
818pub struct OpenRouterConfig {
819 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub provider_sort: Option<String>,
823
824 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub zdr: Option<bool>,
828
829 #[serde(default, skip_serializing_if = "Option::is_none")]
833 pub allow_fallbacks: Option<bool>,
834
835 #[serde(default, skip_serializing_if = "Vec::is_empty")]
841 pub ignore: Vec<String>,
842
843 #[serde(default, skip_serializing_if = "Vec::is_empty")]
853 pub only: Vec<String>,
854
855 #[serde(default, skip_serializing_if = "Option::is_none")]
865 pub exclude_reasoning: Option<bool>,
866
867 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub web_search: Option<WebSearchConfig>,
872}
873
874#[derive(Debug, Deserialize, Clone, Serialize, Default, PartialEq, ToSchema)]
879pub struct WebSearchConfig {
880 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub engine: Option<String>,
886
887 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub max_results: Option<u32>,
890
891 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub search_prompt: Option<String>,
894}
895
896#[derive(Debug, Deserialize, Clone, Serialize, Default, ToSchema)]
900pub struct TaskPrecision {
901 pub pg: f64,
903 pub pv: f64,
905}
906
907pub fn default_context_window() -> i32 {
908 128_000
909}
910
911pub fn default_scratchpad_limit() -> i32 {
912 2000
913}
914
915pub fn default_scratchpad_squeeze_fraction() -> f64 {
916 0.95
917}
918
919pub fn default_compact_history_keep() -> usize {
920 2
921}
922
923pub fn default_repair_invalid_escapes() -> bool {
924 true
925}
926
927pub fn default_textual_feedback() -> bool {
928 true
929}
930
931pub fn default_use_streaming() -> bool {
932 true
933}
934
935pub fn default_presence_penalty() -> Option<f32> {
936 Some(1.5)
937}
938
939pub fn default_max_retries() -> Option<i32> {
940 Some(3)
941}
942
943pub fn default_max_react_iterations() -> Option<i32> {
944 Some(20)
953}
954
955pub fn default_max_scratchpad_size() -> Option<i32> {
956 Some(32_768)
957}
958
959pub fn default_failure_dumps() -> Option<String> {
960 Some("on".to_string())
961}
962
963pub fn default_response_sla_secs() -> u64 {
964 3600
965}
966
967pub fn default_propagate_payment_error() -> bool {
968 true
969}
970
971impl Default for AgentConfig {
976 fn default() -> Self {
977 Self {
978 name: String::new(),
979 model: None,
980 provider_id: String::new(),
981 model_name: String::new(),
982 temperature: 0.0,
983 max_tokens: 0,
984 system_prompt_override: None,
985 persona: None,
986 max_react_iterations: default_max_react_iterations(),
987 max_scratchpad_size: default_max_scratchpad_size(),
988 max_retries: default_max_retries(),
989 max_concurrent_jobs: None,
990 supports_native_thinking: false,
991 frequency_penalty: None,
992 presence_penalty: default_presence_penalty(),
993 textual_feedback: default_textual_feedback(),
994 use_streaming: default_use_streaming(),
995 merge_system_prompt: false,
996 unwrap_hallucinated_tool_calls: false,
997 repair_invalid_escapes: default_repair_invalid_escapes(),
998 scratchpad_limit: default_scratchpad_limit(),
999 scratchpad_squeeze_fraction: default_scratchpad_squeeze_fraction(),
1000 compact_history_default_keep: default_compact_history_keep(),
1001 json_mode: false,
1002 disable_native_tools: false,
1003 context_window: default_context_window(),
1004 reasoning_effort: None,
1005 tool_format: None,
1006 input_price_per_mtok: None,
1007 output_price_per_mtok: None,
1008 chars_per_token: None,
1009 orchestrators: Vec::new(),
1010 task_precision: None,
1011 failure_dumps: default_failure_dumps(),
1012 response_sla_secs: default_response_sla_secs(),
1013 propagate_payment_error: default_propagate_payment_error(),
1014 capability_tags: Vec::new(),
1015 description: None,
1016 signing_schemes: Vec::new(),
1017 auto_stop: false,
1018 exec: None,
1019 mcp: None,
1020 claude: None,
1021 provider_config: HashMap::new(),
1022 openrouter: None,
1023 builtin_tools: Vec::new(),
1024 prompt_exposure_guard: false,
1025 read_file_roots: Vec::new(),
1026 middleware: Default::default(),
1027 }
1028 }
1029}
1030
1031pub fn is_openai_family_provider(config: &AgentConfig) -> bool {
1036 config.exec.is_none() && config.mcp.is_none() && config.claude.is_none()
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::*;
1042 use serde_json::json;
1043
1044 #[test]
1045 fn agent_middleware_block_deserializes() {
1046 let yaml = r#"
1049name: PropductBot
1050provider_id: claude
1051middleware:
1052 before_prompt:
1053 - dylib: ./libpatch_deliberation.dylib
1054 config:
1055 patch_deliberation: { upstream: epic }
1056 on_completion:
1057 - dylib: ./libpatch_deliberation.dylib
1058 on_job_complete:
1059 - dylib: ./libpatch_deliberation.dylib
1060"#;
1061 let cfg: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1062 assert_eq!(cfg.middleware.before_prompt.len(), 1);
1063 assert_eq!(cfg.middleware.on_completion.len(), 1);
1064 assert_eq!(cfg.middleware.on_job_complete.len(), 1);
1065 assert!(cfg.middleware.on_provider_response.is_empty());
1066 assert!(AgentConfig::default().middleware.is_empty());
1068 }
1069
1070 #[test]
1071 fn provider_config_deserializes_into_typed_struct() {
1072 #[derive(Debug, serde::Deserialize, PartialEq)]
1073 struct CodexConfig {
1074 permission_mode: String,
1075 sandbox: bool,
1076 #[serde(default)]
1077 extra_args: Vec<String>,
1078 }
1079
1080 let cfg: AgentConfig = serde_yaml::from_str(
1081 r#"
1082name: codex-a
1083provider_id: my_codex
1084model_name: codex-mini
1085provider_config:
1086 permission_mode: "auto"
1087 sandbox: true
1088 extra_args: ["--yolo"]
1089"#,
1090 )
1091 .expect("agent yaml must parse");
1092
1093 let codex: CodexConfig = cfg.provider_config_as().expect("typed read");
1094 assert_eq!(
1095 codex,
1096 CodexConfig {
1097 permission_mode: "auto".into(),
1098 sandbox: true,
1099 extra_args: vec!["--yolo".into()],
1100 }
1101 );
1102 }
1103
1104 #[test]
1105 fn provider_config_empty_yields_all_defaults() {
1106 #[derive(Debug, serde::Deserialize)]
1107 struct AllDefault {
1108 #[serde(default)]
1109 flag: bool,
1110 }
1111 let cfg = AgentConfig::default();
1112 assert!(cfg.provider_config.is_empty());
1113 let parsed: AllDefault = cfg.provider_config_as().expect("empty map → defaults");
1114 assert!(!parsed.flag);
1115 }
1116
1117 #[test]
1118 fn provider_config_omitted_from_serialization_when_empty() {
1119 let cfg = AgentConfig {
1120 name: "x".into(),
1121 ..Default::default()
1122 };
1123 let yaml = serde_yaml::to_string(&cfg).unwrap();
1124 assert!(
1125 !yaml.contains("provider_config"),
1126 "empty provider_config must be skipped in serialization"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_builtin_tools_roundtrip() {
1132 let json = json!({
1134 "name": "test",
1135 "provider_id": "openrouter",
1136 "model_name": "glm-5.1",
1137 "builtin_tools": [
1138 {
1139 "type": "read_file",
1140 "roots": ["/work/corpus", "/work/linux"],
1141 "max_bytes": 2097152
1142 }
1143 ]
1144 });
1145 let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
1146 assert_eq!(config.builtin_tools.len(), 1);
1147 match &config.builtin_tools[0] {
1148 BuiltinToolGrant::ReadFile { roots, max_bytes } => {
1149 assert_eq!(
1150 roots,
1151 &vec!["/work/corpus".to_string(), "/work/linux".to_string()]
1152 );
1153 assert_eq!(*max_bytes, 2097152);
1154 }
1155 other => panic!("expected ReadFile, got {other:?}"),
1156 }
1157
1158 let default_json = json!({
1160 "name": "test2",
1161 "provider_id": "openrouter",
1162 "model_name": "glm-5.1",
1163 "builtin_tools": [{"type": "read_file", "roots": ["/tmp"]}]
1164 });
1165 let cfg: AgentConfig = serde_json::from_value(default_json).expect("deserialize");
1166 match &cfg.builtin_tools[0] {
1167 BuiltinToolGrant::ReadFile { max_bytes, .. } => {
1168 assert_eq!(*max_bytes, 1024 * 1024);
1169 }
1170 other => panic!("expected ReadFile, got {other:?}"),
1171 }
1172
1173 let bare_json = json!({
1175 "name": "test3",
1176 "provider_id": "p",
1177 "model_name": "m"
1178 });
1179 let bare: AgentConfig = serde_json::from_value(bare_json).expect("deserialize");
1180 assert!(bare.builtin_tools.is_empty());
1181 }
1182
1183 #[test]
1184 fn test_pdf_query_grant_roundtrip() {
1185 let json = json!({
1187 "name": "agg",
1188 "provider_id": "openrouter",
1189 "model_name": "glm-5.1",
1190 "builtin_tools": [
1191 {
1192 "type": "pdf_query",
1193 "trees_root": "/work/corpus/trees",
1194 "script_path": "/work/scripts/pdf_query.py",
1195 "python_bin": "/opt/pageindex/.venv/bin/python3",
1196 "max_bytes": 524288,
1197 "max_results": 8,
1198 "timeout_secs": 90
1199 }
1200 ]
1201 });
1202 let cfg: AgentConfig = serde_json::from_value(json).expect("deserialize");
1203 match &cfg.builtin_tools[0] {
1204 BuiltinToolGrant::PdfQuery {
1205 trees_root,
1206 script_path,
1207 python_bin,
1208 max_bytes,
1209 max_results,
1210 timeout_secs,
1211 } => {
1212 assert_eq!(trees_root, "/work/corpus/trees");
1213 assert_eq!(script_path, "/work/scripts/pdf_query.py");
1214 assert_eq!(python_bin, "/opt/pageindex/.venv/bin/python3");
1215 assert_eq!(*max_bytes, 524288);
1216 assert_eq!(*max_results, 8);
1217 assert_eq!(*timeout_secs, 90);
1218 }
1219 other => panic!("expected PdfQuery, got {other:?}"),
1220 }
1221
1222 let defaults_json = json!({
1224 "name": "agg2",
1225 "provider_id": "openrouter",
1226 "model_name": "glm-5.1",
1227 "builtin_tools": [
1228 {
1229 "type": "pdf_query",
1230 "trees_root": "/work/corpus/trees",
1231 "script_path": "/work/scripts/pdf_query.py"
1232 }
1233 ]
1234 });
1235 let cfg: AgentConfig = serde_json::from_value(defaults_json).expect("deserialize");
1236 match &cfg.builtin_tools[0] {
1237 BuiltinToolGrant::PdfQuery {
1238 python_bin,
1239 max_bytes,
1240 max_results,
1241 timeout_secs,
1242 ..
1243 } => {
1244 assert_eq!(python_bin, "python3");
1245 assert_eq!(*max_bytes, 1024 * 1024);
1246 assert_eq!(*max_results, 10);
1247 assert_eq!(*timeout_secs, 60);
1248 }
1249 other => panic!("expected PdfQuery, got {other:?}"),
1250 }
1251 }
1252
1253 #[test]
1254 fn test_agent_config_defaults() {
1255 let json = json!({
1256 "name": "test-agent",
1257 "provider_id": "ollama_local",
1258 "model_name": "model",
1259 });
1260
1261 let config: AgentConfig = serde_json::from_value(json).expect("Deserialization failed");
1262 assert_eq!(config.max_react_iterations, Some(20));
1263 }
1264
1265 #[test]
1266 fn test_agent_config_model_field_deserialization() {
1267 let json = json!({
1268 "name": "dotpath-agent",
1269 "model": "together_ai.llama-70b",
1270 });
1271 let config: AgentConfig = serde_json::from_value(json).unwrap();
1272 assert_eq!(config.model, Some("together_ai.llama-70b".to_string()));
1273 assert!(config.provider_id.is_empty());
1275 assert!(config.model_name.is_empty());
1276 }
1277
1278 #[test]
1279 fn test_agent_config_model_field_default_none() {
1280 let json = json!({
1281 "name": "no-model-field",
1282 "provider_id": "p1",
1283 "model_name": "m1",
1284 });
1285 let config: AgentConfig = serde_json::from_value(json).unwrap();
1286 assert!(config.model.is_none());
1287 }
1288
1289 #[test]
1290 fn test_agent_config_model_field_not_serialized_when_none() {
1291 let config = AgentConfig::default();
1292 let serialized = serde_json::to_value(&config).unwrap();
1293 let obj = serialized.as_object().unwrap();
1294 assert!(
1295 !obj.contains_key("model"),
1296 "model: None should be omitted from serialization"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_agent_config_pricing_fields_default_none() {
1302 let json = json!({
1303 "name": "test-agent",
1304 "provider_id": "p1",
1305 "model_name": "m1",
1306 });
1307 let config: AgentConfig = serde_json::from_value(json).unwrap();
1308 assert_eq!(config.input_price_per_mtok, None);
1309 assert_eq!(config.output_price_per_mtok, None);
1310 assert_eq!(config.chars_per_token, None);
1311 }
1312
1313 #[test]
1314 fn test_agent_config_pricing_fields_roundtrip() {
1315 let json = json!({
1316 "name": "priced-agent",
1317 "provider_id": "openai",
1318 "model_name": "gpt-4",
1319 "input_price_per_mtok": 10.0,
1320 "output_price_per_mtok": 30.0,
1321 "chars_per_token": 3.5
1322 });
1323 let config: AgentConfig = serde_json::from_value(json).unwrap();
1324 assert_eq!(config.input_price_per_mtok, Some(10.0));
1325 assert_eq!(config.output_price_per_mtok, Some(30.0));
1326 assert_eq!(config.chars_per_token, Some(3.5));
1327
1328 let serialized = serde_json::to_value(&config).unwrap();
1330 let deserialized: AgentConfig = serde_json::from_value(serialized).unwrap();
1331 assert_eq!(deserialized.input_price_per_mtok, Some(10.0));
1332 assert_eq!(deserialized.output_price_per_mtok, Some(30.0));
1333 assert_eq!(deserialized.chars_per_token, Some(3.5));
1334 }
1335
1336 #[test]
1337 fn test_agent_config_chars_per_token_override() {
1338 let json = json!({
1339 "name": "cjk-agent",
1340 "provider_id": "ollama",
1341 "model_name": "qwen",
1342 "chars_per_token": 1.5
1343 });
1344 let config: AgentConfig = serde_json::from_value(json).unwrap();
1345 assert_eq!(config.chars_per_token, Some(1.5));
1346 }
1347
1348 #[test]
1349 fn test_agent_config_all_defaults() {
1350 let json = json!({
1351 "name": "minimal",
1352 "provider_id": "p",
1353 "model_name": "m",
1354 });
1355 let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
1356
1357 assert_eq!(config.name, "minimal");
1358 assert_eq!(config.provider_id, "p");
1359 assert_eq!(config.model_name, "m");
1360 assert_eq!(config.temperature, 0.0);
1361 assert_eq!(config.max_tokens, 0);
1362 assert!(config.system_prompt_override.is_none());
1363 assert!(config.persona.is_none());
1364 assert_eq!(config.max_react_iterations, Some(20));
1365 assert_eq!(config.max_scratchpad_size, Some(32768));
1366 assert_eq!(config.max_retries, Some(3));
1367 assert!(!config.supports_native_thinking);
1368 assert!(config.frequency_penalty.is_none());
1369 assert_eq!(config.presence_penalty, Some(1.5));
1370 assert!(config.textual_feedback);
1371 assert!(config.use_streaming);
1372 assert!(!config.merge_system_prompt);
1373 assert!(!config.unwrap_hallucinated_tool_calls);
1374 assert!(config.repair_invalid_escapes);
1375 assert_eq!(config.scratchpad_limit, 2000);
1376 assert!(!config.json_mode);
1377 assert!(!config.disable_native_tools);
1378 assert_eq!(config.context_window, 128_000);
1379 assert!(config.reasoning_effort.is_none());
1380 assert!(config.tool_format.is_none());
1381 assert!(config.input_price_per_mtok.is_none());
1382 assert!(config.output_price_per_mtok.is_none());
1383 assert!(config.chars_per_token.is_none());
1384 assert!(config.task_precision.is_none());
1385 assert_eq!(config.failure_dumps, Some("on".to_string()));
1386 assert_eq!(config.response_sla_secs, 3600);
1387 assert!(config.propagate_payment_error);
1388 }
1389
1390 #[test]
1391 fn test_agent_config_full_roundtrip() {
1392 let json = json!({
1393 "name": "full-agent",
1394 "provider_id": "openai",
1395 "model_name": "gpt-4o",
1396 "temperature": 0.7,
1397 "max_tokens": 4096,
1398 "system_prompt_override": "You are helpful.",
1399 "persona": "expert analyst",
1400 "max_react_iterations": 5,
1401 "max_scratchpad_size": 16384,
1402 "max_retries": 2,
1403 "supports_native_thinking": true,
1404 "frequency_penalty": 0.5,
1405 "presence_penalty": 0.8,
1406 "textual_feedback": false,
1407 "use_streaming": false,
1408 "merge_system_prompt": true,
1409 "unwrap_hallucinated_tool_calls": true,
1410 "repair_invalid_escapes": false,
1411 "scratchpad_limit": 500,
1412 "json_mode": true,
1413 "disable_native_tools": true,
1414 "context_window": 64000,
1415 "reasoning_effort": "high",
1416 "tool_format": "json",
1417 "input_price_per_mtok": 2.5,
1418 "output_price_per_mtok": 10.0,
1419 "chars_per_token": 1.5,
1420 "task_precision": {
1421 "supply": { "pg": 0.3, "pv": 0.8 },
1422 "audit": { "pg": 0.5, "pv": 0.9 }
1423 },
1424 "failure_dumps": "full",
1425 "response_sla_secs": 120,
1426 "propagate_payment_error": false
1427 });
1428
1429 let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
1430
1431 assert_eq!(config.name, "full-agent");
1433 assert_eq!(config.temperature, 0.7);
1434 assert_eq!(config.max_tokens, 4096);
1435 assert_eq!(
1436 config.system_prompt_override,
1437 Some("You are helpful.".to_string())
1438 );
1439 assert_eq!(config.persona, Some("expert analyst".to_string()));
1440 assert_eq!(config.max_react_iterations, Some(5));
1441 assert_eq!(config.max_scratchpad_size, Some(16384));
1442 assert_eq!(config.max_retries, Some(2));
1443 assert!(config.supports_native_thinking);
1444 assert_eq!(config.frequency_penalty, Some(0.5));
1445 assert_eq!(config.presence_penalty, Some(0.8));
1446 assert!(!config.textual_feedback);
1447 assert!(!config.use_streaming);
1448 assert!(config.merge_system_prompt);
1449 assert!(config.unwrap_hallucinated_tool_calls);
1450 assert!(!config.repair_invalid_escapes);
1451 assert_eq!(config.scratchpad_limit, 500);
1452 assert!(config.json_mode);
1453 assert!(config.disable_native_tools);
1454 assert_eq!(config.context_window, 64000);
1455 assert_eq!(config.reasoning_effort, Some("high".to_string()));
1456 assert_eq!(config.tool_format, Some("json".to_string()));
1457 assert_eq!(config.input_price_per_mtok, Some(2.5));
1458 assert_eq!(config.output_price_per_mtok, Some(10.0));
1459 assert_eq!(config.chars_per_token, Some(1.5));
1460 assert_eq!(config.failure_dumps, Some("full".to_string()));
1461
1462 let tp = config
1464 .task_precision
1465 .as_ref()
1466 .expect("task_precision present");
1467 assert_eq!(tp.len(), 2);
1468 let supply = tp.get("supply").expect("supply key");
1469 assert!((supply.pg - 0.3).abs() < f64::EPSILON);
1470 assert!((supply.pv - 0.8).abs() < f64::EPSILON);
1471 let audit = tp.get("audit").expect("audit key");
1472 assert!((audit.pg - 0.5).abs() < f64::EPSILON);
1473 assert!((audit.pv - 0.9).abs() < f64::EPSILON);
1474
1475 let serialized = serde_json::to_value(&config).expect("serialize");
1477 let roundtripped: AgentConfig =
1478 serde_json::from_value(serialized).expect("deserialize roundtrip");
1479
1480 assert_eq!(roundtripped.name, "full-agent");
1481 assert_eq!(roundtripped.temperature, 0.7);
1482 assert_eq!(roundtripped.max_tokens, 4096);
1483 assert_eq!(roundtripped.tool_format, Some("json".to_string()));
1484 assert_eq!(roundtripped.failure_dumps, Some("full".to_string()));
1485 assert_eq!(roundtripped.reasoning_effort, Some("high".to_string()));
1486 assert_eq!(roundtripped.context_window, 64000);
1487 assert_eq!(config.response_sla_secs, 120);
1488 assert_eq!(roundtripped.response_sla_secs, 120);
1489 assert!(!config.propagate_payment_error);
1490 assert!(!roundtripped.propagate_payment_error);
1491 let rt_tp = roundtripped.task_precision.as_ref().unwrap();
1492 assert_eq!(rt_tp.len(), 2);
1493 assert!((rt_tp["supply"].pg - 0.3).abs() < f64::EPSILON);
1494 }
1495
1496 #[test]
1497 fn test_agent_config_orchestrators_skip_serializing() {
1498 let mut config = AgentConfig {
1499 name: "orch-test".to_string(),
1500 provider_id: "p".to_string(),
1501 model_name: "m".to_string(),
1502 ..AgentConfig::default()
1503 };
1504 config.orchestrators = vec![OrchestratorEntry {
1505 id: Some("local".to_string()),
1506 url: "http://localhost:8080".to_string(),
1507 bearer_token: None,
1508 invite_code: None,
1509 }];
1510
1511 let serialized = serde_json::to_value(&config).expect("serialize");
1512 let obj = serialized.as_object().expect("should be object");
1513 assert!(
1514 !obj.contains_key("orchestrators"),
1515 "orchestrators should be skipped during serialization"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_task_precision_serde() {
1521 let tp = TaskPrecision { pg: 0.3, pv: 0.8 };
1522 let serialized = serde_json::to_value(&tp).expect("serialize");
1523 assert_eq!(serialized["pg"], 0.3);
1524 assert_eq!(serialized["pv"], 0.8);
1525
1526 let roundtripped: TaskPrecision = serde_json::from_value(serialized).expect("deserialize");
1527 assert!((roundtripped.pg - 0.3).abs() < f64::EPSILON);
1528 assert!((roundtripped.pv - 0.8).abs() < f64::EPSILON);
1529 }
1530
1531 #[test]
1532 fn test_task_precision_default() {
1533 let tp = TaskPrecision::default();
1534 assert!((tp.pg - 0.0).abs() < f64::EPSILON);
1535 assert!((tp.pv - 0.0).abs() < f64::EPSILON);
1536 }
1537
1538 #[test]
1539 fn test_default_function_values() {
1540 assert_eq!(default_context_window(), 128_000);
1541 assert_eq!(default_scratchpad_limit(), 2000);
1542 assert!(default_repair_invalid_escapes());
1543 assert!(default_textual_feedback());
1544 assert!(default_use_streaming());
1545 assert_eq!(default_presence_penalty(), Some(1.5));
1546 assert_eq!(default_max_retries(), Some(3));
1547 assert_eq!(default_max_react_iterations(), Some(20));
1548 assert_eq!(default_max_scratchpad_size(), Some(32_768));
1549 assert_eq!(default_failure_dumps(), Some("on".to_string()));
1550 assert_eq!(default_response_sla_secs(), 3600);
1551 assert!(default_propagate_payment_error());
1552 }
1553
1554 #[test]
1555 fn test_agent_config_propagate_payment_error_default_true() {
1556 let json = json!({
1557 "name": "no-config",
1558 "provider_id": "p",
1559 "model_name": "m",
1560 });
1561 let config: AgentConfig = serde_json::from_value(json).unwrap();
1562 assert!(config.propagate_payment_error, "should default to true");
1563 }
1564
1565 #[test]
1566 fn test_agent_config_propagate_payment_error_false() {
1567 let json = json!({
1568 "name": "silent-agent",
1569 "provider_id": "p",
1570 "model_name": "m",
1571 "propagate_payment_error": false
1572 });
1573 let config: AgentConfig = serde_json::from_value(json).unwrap();
1574 assert!(!config.propagate_payment_error);
1575 }
1576
1577 #[test]
1578 fn test_agent_config_response_sla_default() {
1579 let json = json!({
1580 "name": "no-sla",
1581 "provider_id": "p",
1582 "model_name": "m",
1583 });
1584 let config: AgentConfig = serde_json::from_value(json).unwrap();
1585 assert_eq!(config.response_sla_secs, 3600, "should default to 3600s");
1586 }
1587
1588 #[test]
1589 fn test_agent_config_response_sla_explicit() {
1590 let json = json!({
1591 "name": "fast-agent",
1592 "provider_id": "openai",
1593 "model_name": "gpt-4o",
1594 "response_sla_secs": 60
1595 });
1596 let config: AgentConfig = serde_json::from_value(json).unwrap();
1597 assert_eq!(config.response_sla_secs, 60);
1598
1599 let serialized = serde_json::to_value(&config).unwrap();
1601 let roundtripped: AgentConfig = serde_json::from_value(serialized).unwrap();
1602 assert_eq!(roundtripped.response_sla_secs, 60);
1603 }
1604
1605 #[test]
1606 fn test_agent_config_failure_dumps_values() {
1607 let json_on = json!({
1609 "name": "a", "provider_id": "p", "model_name": "m",
1610 "failure_dumps": "on"
1611 });
1612 let cfg: AgentConfig = serde_json::from_value(json_on).unwrap();
1613 assert_eq!(cfg.failure_dumps, Some("on".to_string()));
1614
1615 let json_full = json!({
1617 "name": "a", "provider_id": "p", "model_name": "m",
1618 "failure_dumps": "full"
1619 });
1620 let cfg: AgentConfig = serde_json::from_value(json_full).unwrap();
1621 assert_eq!(cfg.failure_dumps, Some("full".to_string()));
1622
1623 let json_off = json!({
1625 "name": "a", "provider_id": "p", "model_name": "m",
1626 "failure_dumps": "off"
1627 });
1628 let cfg: AgentConfig = serde_json::from_value(json_off).unwrap();
1629 assert_eq!(cfg.failure_dumps, Some("off".to_string()));
1630
1631 let json_null = json!({
1633 "name": "a", "provider_id": "p", "model_name": "m",
1634 "failure_dumps": null
1635 });
1636 let cfg: AgentConfig = serde_json::from_value(json_null).unwrap();
1637 assert!(cfg.failure_dumps.is_none());
1638 }
1639
1640 #[test]
1644 fn test_agent_config_default_parity_with_serde() {
1645 let from_default = AgentConfig::default();
1646
1647 let from_serde: AgentConfig = serde_json::from_value(json!({
1649 "name": "",
1650 "provider_id": "",
1651 "model_name": "",
1652 "temperature": 0.0,
1653 "max_tokens": 0,
1654 }))
1655 .expect("minimal JSON should deserialize with serde defaults");
1656
1657 assert_eq!(from_default.model, from_serde.model, "model");
1659 assert_eq!(
1660 from_default.max_react_iterations, from_serde.max_react_iterations,
1661 "max_react_iterations"
1662 );
1663 assert_eq!(
1664 from_default.max_scratchpad_size, from_serde.max_scratchpad_size,
1665 "max_scratchpad_size"
1666 );
1667 assert_eq!(
1668 from_default.max_retries, from_serde.max_retries,
1669 "max_retries"
1670 );
1671 assert_eq!(
1672 from_default.presence_penalty, from_serde.presence_penalty,
1673 "presence_penalty"
1674 );
1675 assert_eq!(
1676 from_default.textual_feedback, from_serde.textual_feedback,
1677 "textual_feedback"
1678 );
1679 assert_eq!(
1680 from_default.use_streaming, from_serde.use_streaming,
1681 "use_streaming"
1682 );
1683 assert_eq!(
1684 from_default.repair_invalid_escapes, from_serde.repair_invalid_escapes,
1685 "repair_invalid_escapes"
1686 );
1687 assert_eq!(
1688 from_default.scratchpad_limit, from_serde.scratchpad_limit,
1689 "scratchpad_limit"
1690 );
1691 assert_eq!(
1692 from_default.context_window, from_serde.context_window,
1693 "context_window"
1694 );
1695 assert_eq!(
1696 from_default.failure_dumps, from_serde.failure_dumps,
1697 "failure_dumps"
1698 );
1699 assert_eq!(
1700 from_default.response_sla_secs, from_serde.response_sla_secs,
1701 "response_sla_secs"
1702 );
1703 assert_eq!(
1704 from_default.propagate_payment_error, from_serde.propagate_payment_error,
1705 "propagate_payment_error"
1706 );
1707 assert_eq!(
1708 from_default.capability_tags, from_serde.capability_tags,
1709 "capability_tags"
1710 );
1711 assert_eq!(
1712 from_default.description, from_serde.description,
1713 "description"
1714 );
1715 assert_eq!(
1716 from_default.signing_schemes, from_serde.signing_schemes,
1717 "signing_schemes"
1718 );
1719 assert_eq!(from_default.exec, from_serde.exec, "exec");
1720 assert_eq!(from_default.mcp, from_serde.mcp, "mcp");
1721 assert_eq!(from_default.claude, from_serde.claude, "claude");
1722 assert_eq!(from_default.openrouter, from_serde.openrouter, "openrouter");
1723 assert_eq!(
1724 from_default.prompt_exposure_guard, from_serde.prompt_exposure_guard,
1725 "prompt_exposure_guard"
1726 );
1727 }
1728
1729 #[test]
1734 fn test_openrouter_field_omitted_when_none() {
1735 let cfg = AgentConfig {
1736 name: "test".into(),
1737 provider_id: "openai".into(),
1738 model_name: "gpt-4".into(),
1739 temperature: 0.0,
1740 max_tokens: 0,
1741 ..Default::default()
1742 };
1743 assert!(cfg.openrouter.is_none(), "sanity: default is None");
1744 let serialized = serde_json::to_string(&cfg).unwrap();
1745 assert!(
1746 !serialized.contains("openrouter"),
1747 "openrouter=None should be omitted, got: {serialized}"
1748 );
1749
1750 let json = json!({
1753 "name": "test",
1754 "provider_id": "openai",
1755 "model_name": "gpt-4",
1756 "temperature": 0.0,
1757 "max_tokens": 0,
1758 });
1759 let parsed: AgentConfig = serde_json::from_value(json).unwrap();
1760 assert!(parsed.openrouter.is_none());
1761 }
1762
1763 #[test]
1767 fn test_openrouter_field_roundtrip_populated() {
1768 let cfg = AgentConfig {
1769 name: "test".into(),
1770 provider_id: "openrouter".into(),
1771 model_name: "google/gemma-4-26b-a4b-it".into(),
1772 temperature: 0.7,
1773 max_tokens: 16384,
1774 openrouter: Some(OpenRouterConfig {
1775 provider_sort: Some("throughput".into()),
1776 zdr: Some(true),
1777 allow_fallbacks: Some(false),
1778 ignore: vec!["nextbit".into()],
1779 only: vec!["akashml/fp8".into()],
1780 exclude_reasoning: Some(true),
1781 web_search: None,
1782 }),
1783 ..Default::default()
1784 };
1785 let serialized = serde_json::to_string(&cfg).unwrap();
1786 assert!(serialized.contains(r#""openrouter""#));
1787 let parsed: AgentConfig = serde_json::from_str(&serialized).unwrap();
1788 let or = parsed.openrouter.expect("openrouter must round-trip");
1789 assert_eq!(or.provider_sort.as_deref(), Some("throughput"));
1790 assert_eq!(or.zdr, Some(true));
1791 assert_eq!(or.allow_fallbacks, Some(false));
1792 assert_eq!(or.ignore, vec!["nextbit".to_string()]);
1793 assert_eq!(or.only, vec!["akashml/fp8".to_string()]);
1794 assert_eq!(or.exclude_reasoning, Some(true));
1795 }
1796
1797 #[test]
1798 fn test_capability_tags_roundtrip() {
1799 let json = json!({
1800 "name": "test",
1801 "provider_id": "openai",
1802 "model_name": "gpt-4",
1803 "temperature": 0.7,
1804 "max_tokens": 1000,
1805 "capability_tags": ["legal", "audit", "compliance"],
1806 "description": "Legal audit specialist",
1807 "signing_schemes": ["eip712", "ed25519"]
1808 });
1809 let config: AgentConfig = serde_json::from_value(json).unwrap();
1810 assert_eq!(config.capability_tags, vec!["legal", "audit", "compliance"]);
1811 assert_eq!(
1812 config.description.as_deref(),
1813 Some("Legal audit specialist")
1814 );
1815 assert_eq!(config.signing_schemes, vec!["eip712", "ed25519"]);
1816
1817 let serialized = serde_json::to_string(&config).unwrap();
1819 let deserialized: AgentConfig = serde_json::from_str(&serialized).unwrap();
1820 assert_eq!(deserialized.capability_tags, config.capability_tags);
1821 assert_eq!(deserialized.description, config.description);
1822 assert_eq!(deserialized.signing_schemes, config.signing_schemes);
1823 }
1824
1825 #[test]
1826 fn test_new_fields_default_to_empty() {
1827 let json = json!({
1828 "name": "minimal",
1829 "provider_id": "test",
1830 "model_name": "test",
1831 "temperature": 0.0,
1832 "max_tokens": 0,
1833 });
1834 let config: AgentConfig = serde_json::from_value(json).unwrap();
1835 assert!(config.capability_tags.is_empty());
1836 assert!(config.description.is_none());
1837 assert!(config.signing_schemes.is_empty());
1838 assert!(config.exec.is_none());
1839 }
1840
1841 #[test]
1842 fn test_agent_config_exec_roundtrip() {
1843 let json = json!({
1844 "name": "py-agent",
1845 "provider_id": "exec_local",
1846 "model_name": "custom",
1847 "exec": {
1848 "command": ["python3", "agent.py"],
1849 "working_dir": "/opt/agents",
1850 "env": {"MY_VAR": "value"},
1851 "timeout_secs": 120
1852 }
1853 });
1854 let config: AgentConfig = serde_json::from_value(json).unwrap();
1855 let exec = config.exec.as_ref().expect("exec should be present");
1856 assert_eq!(exec.command, vec!["python3", "agent.py"]);
1857 assert_eq!(
1858 exec.working_dir.as_ref().map(|p| p.to_str().unwrap()),
1859 Some("/opt/agents")
1860 );
1861 assert_eq!(exec.env.get("MY_VAR").unwrap(), "value");
1862 assert_eq!(exec.timeout_secs, Some(120));
1863
1864 let serialized = serde_json::to_value(&config).unwrap();
1866 let deserialized: AgentConfig = serde_json::from_value(serialized).unwrap();
1867 assert_eq!(deserialized.exec, config.exec);
1868 }
1869
1870 #[test]
1871 fn test_agent_config_exec_none_omitted_from_json() {
1872 let config = AgentConfig::default();
1873 let serialized = serde_json::to_value(&config).unwrap();
1874 let obj = serialized.as_object().unwrap();
1875 assert!(
1876 !obj.contains_key("exec"),
1877 "exec: None should be omitted from serialization"
1878 );
1879 assert!(
1880 !obj.contains_key("mcp"),
1881 "mcp: None should be omitted from serialization"
1882 );
1883 assert!(
1884 !obj.contains_key("claude"),
1885 "claude: None should be omitted from serialization"
1886 );
1887 }
1888
1889 #[test]
1890 fn redacted_env_hides_sensitive_keys() {
1891 let mut env = HashMap::new();
1892 env.insert("SAFE_VAR".to_string(), "visible".to_string());
1893 env.insert("API_KEY".to_string(), "super-secret".to_string());
1894 env.insert("db_password".to_string(), "pass123".to_string());
1895 env.insert("AUTH_TOKEN".to_string(), "tok_abc".to_string());
1896 env.insert("MY_CREDENTIAL_ID".to_string(), "cred".to_string());
1897 env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "aws123".to_string());
1898
1899 let config = ExecProviderConfig {
1900 command: vec!["test".into()],
1901 working_dir: None,
1902 env,
1903 timeout_secs: None,
1904 };
1905 let serialized = serde_json::to_value(&config).unwrap();
1906 let env_obj = serialized["env"].as_object().unwrap();
1907
1908 assert_eq!(env_obj["SAFE_VAR"], "visible");
1909 assert_eq!(env_obj["API_KEY"], "<redacted>");
1910 assert_eq!(env_obj["db_password"], "<redacted>");
1911 assert_eq!(env_obj["AUTH_TOKEN"], "<redacted>");
1912 assert_eq!(env_obj["MY_CREDENTIAL_ID"], "<redacted>");
1913 assert_eq!(env_obj["AWS_SECRET_ACCESS_KEY"], "<redacted>");
1914 }
1915
1916 #[test]
1917 fn validate_provider_sections_rejects_multiple() {
1918 let config: AgentConfig = serde_json::from_value(json!({
1919 "name": "a", "provider_id": "p", "model_name": "m",
1920 "exec": { "command": ["test"] },
1921 "mcp": { "command": ["test"] }
1922 }))
1923 .unwrap();
1924 let err = config.validate_provider_sections(None).unwrap_err();
1925 assert!(err.contains("multiple provider sections"), "{err}");
1926 }
1927
1928 #[test]
1929 fn validate_provider_sections_rejects_mismatch() {
1930 let config: AgentConfig = serde_json::from_value(json!({
1931 "name": "a", "provider_id": "p", "model_name": "m",
1932 "exec": { "command": ["test"] }
1933 }))
1934 .unwrap();
1935 let err = config.validate_provider_sections(Some("mcp")).unwrap_err();
1936 assert!(err.contains("does not match"), "{err}");
1937 }
1938
1939 #[test]
1940 fn validate_provider_sections_accepts_matching() {
1941 let config: AgentConfig = serde_json::from_value(json!({
1942 "name": "a", "provider_id": "p", "model_name": "m",
1943 "exec": { "command": ["test"] }
1944 }))
1945 .unwrap();
1946 config.validate_provider_sections(Some("exec")).unwrap();
1947 }
1948
1949 #[test]
1950 fn validate_provider_sections_accepts_none() {
1951 let config: AgentConfig = serde_json::from_value(json!({
1952 "name": "a", "provider_id": "p", "model_name": "m"
1953 }))
1954 .unwrap();
1955 config.validate_provider_sections(Some("exec")).unwrap();
1956 }
1957
1958 #[test]
1959 fn validate_compaction_knobs_accepts_defaults() {
1960 AgentConfig::default().validate_compaction_knobs().unwrap();
1961 }
1962
1963 #[test]
1964 fn validate_compaction_knobs_rejects_zero_keep() {
1965 let cfg = AgentConfig {
1966 compact_history_default_keep: 0,
1967 ..AgentConfig::default()
1968 };
1969 let err = cfg.validate_compaction_knobs().unwrap_err();
1970 assert!(err.contains("compact_history_default_keep"));
1971 }
1972
1973 #[test]
1974 fn validate_compaction_knobs_rejects_out_of_range_fraction() {
1975 for bad in [0.0, 1.5, -0.1] {
1976 let cfg = AgentConfig {
1977 scratchpad_squeeze_fraction: bad,
1978 ..AgentConfig::default()
1979 };
1980 assert!(
1981 cfg.validate_compaction_knobs().is_err(),
1982 "fraction {bad} must be rejected"
1983 );
1984 }
1985 }
1986
1987 #[test]
1988 fn validate_compaction_knobs_accepts_boundary_one() {
1989 let cfg = AgentConfig {
1990 scratchpad_squeeze_fraction: 1.0,
1991 compact_history_default_keep: 1,
1992 ..AgentConfig::default()
1993 };
1994 cfg.validate_compaction_knobs().unwrap();
1995 }
1996
1997 fn parse_agent_persona(yaml_fragment: &str) -> Option<String> {
2000 let yaml = format!("name: test\n{yaml_fragment}");
2001 let cfg: AgentConfig = serde_yaml::from_str(&yaml).expect("agent yaml must parse");
2002 cfg.persona
2003 }
2004
2005 #[test]
2008 fn persona_inline_string_back_compat() {
2009 let persona = parse_agent_persona("persona: \"you are a careful reviewer\"");
2010 assert_eq!(persona.as_deref(), Some("you are a careful reviewer"));
2011 }
2012
2013 #[test]
2014 fn persona_absent_stays_none() {
2015 let persona = parse_agent_persona("");
2016 assert!(persona.is_none());
2017 }
2018
2019 #[test]
2020 fn persona_layered_text_joins_with_double_newline() {
2021 let persona = parse_agent_persona(
2022 "persona:\n\
2023 - type: text\n prompt: \"a\"\n\
2024 - type: text\n prompt: \"b\"\n",
2025 );
2026 assert_eq!(persona.as_deref(), Some("a\n\nb"));
2027 }
2028
2029 #[test]
2032 fn persona_layered_md_reads_file_and_stacks_with_text() {
2033 let sandbox_dir = tempfile::tempdir().unwrap();
2034 let md_path = sandbox_dir.path().join("body.md");
2035 std::fs::write(&md_path, "from-md\n").unwrap();
2036 let yaml = format!(
2037 "persona:\n\
2038 - type: text\n prompt: \"lead\"\n\
2039 - type: md\n prompt: \"{}\"\n\
2040 - type: text\n prompt: \"tail\"\n",
2041 md_path.display()
2042 );
2043 let persona = parse_agent_persona(&yaml);
2044 assert_eq!(persona.as_deref(), Some("lead\n\nfrom-md\n\n\ntail"));
2045 }
2046
2047 #[test]
2048 fn max_concurrent_jobs_parses_and_defaults_none() {
2049 let with =
2050 serde_yaml::from_str::<AgentConfig>("name: a\nmax_concurrent_jobs: 1\n").unwrap();
2051 assert_eq!(with.max_concurrent_jobs, Some(1));
2052 let without = serde_yaml::from_str::<AgentConfig>("name: a\n").unwrap();
2053 assert_eq!(without.max_concurrent_jobs, None);
2054 }
2055
2056 #[test]
2059 fn persona_layered_md_missing_file_errors_with_path() {
2060 let yaml = "name: test\n\
2061 persona:\n\
2062 - type: md\n prompt: \"/path/does/not/exist/persona.md\"\n";
2063 let err = serde_yaml::from_str::<AgentConfig>(yaml).unwrap_err();
2064 let msg = err.to_string();
2065 assert!(
2066 msg.contains("/path/does/not/exist/persona.md") && msg.contains("could not be read"),
2067 "error must name the missing path; got: {msg}"
2068 );
2069 }
2070}