1use a2a_agents_common::llm::{LlmSettings, Reasoning};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use thiserror::Error;
10
11#[cfg(feature = "schema")]
12use schemars::JsonSchema;
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(from = "String", into = "String")]
21pub enum HandlerType {
22 #[default]
24 Echo,
25 Llm,
27 Reimbursement,
29 Custom(String),
31}
32
33impl HandlerType {
34 pub fn as_str(&self) -> &str {
36 match self {
37 HandlerType::Echo => "echo",
38 HandlerType::Llm => "llm",
39 HandlerType::Reimbursement => "reimbursement",
40 HandlerType::Custom(name) => name,
41 }
42 }
43}
44
45impl From<&str> for HandlerType {
46 fn from(s: &str) -> Self {
47 match s {
48 "echo" => HandlerType::Echo,
49 "llm" => HandlerType::Llm,
50 "reimbursement" => HandlerType::Reimbursement,
51 other => HandlerType::Custom(other.to_string()),
52 }
53 }
54}
55
56impl From<String> for HandlerType {
57 fn from(s: String) -> Self {
58 match HandlerType::from(s.as_str()) {
60 HandlerType::Custom(_) => HandlerType::Custom(s),
61 known => known,
62 }
63 }
64}
65
66impl From<HandlerType> for String {
67 fn from(t: HandlerType) -> Self {
68 match t {
69 HandlerType::Custom(name) => name,
70 other => other.as_str().to_string(),
71 }
72 }
73}
74
75impl std::str::FromStr for HandlerType {
76 type Err = std::convert::Infallible;
77 fn from_str(s: &str) -> Result<Self, Self::Err> {
78 Ok(HandlerType::from(s))
79 }
80}
81
82impl std::fmt::Display for HandlerType {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str(self.as_str())
85 }
86}
87
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[cfg_attr(feature = "schema", derive(JsonSchema))]
95#[serde(deny_unknown_fields)]
96pub struct HandlerConfig {
97 #[serde(default)]
100 #[cfg_attr(feature = "schema", schemars(with = "String"))]
101 pub r#type: HandlerType,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub llm: Option<LlmHandlerConfig>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[cfg_attr(feature = "schema", derive(JsonSchema))]
111#[serde(deny_unknown_fields)]
112pub struct LlmHandlerConfig {
113 #[serde(default = "default_llm_system_prompt")]
115 pub system_prompt: String,
116
117 #[serde(default = "default_max_tool_rounds")]
119 pub max_tool_rounds: u32,
120
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub agents: Vec<RemoteAgentConfig>,
125
126 #[serde(default)]
129 pub context: ContextConfig,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[cfg_attr(feature = "schema", derive(JsonSchema))]
139#[serde(deny_unknown_fields)]
140pub struct ContextConfig {
141 #[serde(default)]
143 pub mode: ContextMode,
144
145 #[serde(default = "default_max_input_tokens")]
148 pub max_input_tokens: usize,
149
150 #[serde(default = "default_reserve_for_output")]
152 pub reserve_for_output: usize,
153
154 #[serde(default = "default_compact_at_percent")]
159 pub compact_at_percent: u8,
160
161 #[serde(default = "default_keep_recent_turns")]
164 pub keep_recent_turns: usize,
165
166 #[serde(default = "default_max_tool_result_chars")]
169 pub max_tool_result_chars: usize,
170}
171
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
174#[cfg_attr(feature = "schema", derive(JsonSchema))]
175#[serde(rename_all = "lowercase")]
176pub enum ContextMode {
177 #[default]
180 None,
181 Task,
183 Context,
187}
188
189impl ContextMode {
190 pub fn reads_history(self) -> bool {
192 !matches!(self, ContextMode::None)
193 }
194}
195
196fn default_max_input_tokens() -> usize {
197 100_000
198}
199
200fn default_reserve_for_output() -> usize {
201 8_000
202}
203
204fn default_compact_at_percent() -> u8 {
205 80
206}
207
208fn default_keep_recent_turns() -> usize {
209 4
210}
211
212fn default_max_tool_result_chars() -> usize {
213 8_000
214}
215
216impl Default for ContextConfig {
217 fn default() -> Self {
218 Self {
219 mode: ContextMode::default(),
220 max_input_tokens: default_max_input_tokens(),
221 reserve_for_output: default_reserve_for_output(),
222 compact_at_percent: default_compact_at_percent(),
223 keep_recent_turns: default_keep_recent_turns(),
224 max_tool_result_chars: default_max_tool_result_chars(),
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "schema", derive(JsonSchema))]
237#[serde(deny_unknown_fields)]
238pub struct RemoteAgentConfig {
239 pub name: String,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub url: Option<String>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub skill: Option<String>,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub agent_id: Option<String>,
257
258 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub description: Option<String>,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum RemoteAgentTarget<'a> {
267 Url(&'a str),
269 Skill(&'a str),
271 AgentId(&'a str),
273}
274
275impl std::fmt::Display for RemoteAgentTarget<'_> {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 match self {
280 Self::Url(url) => write!(f, "{url}"),
281 Self::Skill(skill) => write!(f, "skill '{skill}'"),
282 Self::AgentId(id) => write!(f, "agent id '{id}'"),
283 }
284 }
285}
286
287impl RemoteAgentConfig {
288 pub fn target(&self) -> Result<RemoteAgentTarget<'_>, ConfigError> {
292 match (
293 self.url.as_deref(),
294 self.skill.as_deref(),
295 self.agent_id.as_deref(),
296 ) {
297 (Some(url), None, None) => Ok(RemoteAgentTarget::Url(url)),
298 (None, Some(skill), None) => Ok(RemoteAgentTarget::Skill(skill)),
299 (None, None, Some(id)) => Ok(RemoteAgentTarget::AgentId(id)),
300 (None, None, None) => Err(ConfigError::ValidationError(format!(
301 "remote agent '{}' must set exactly one of `url`, `skill`, or `agent_id`",
302 self.name
303 ))),
304 _ => Err(ConfigError::ValidationError(format!(
305 "remote agent '{}' sets more than one of `url`, `skill`, `agent_id`; pick one",
306 self.name
307 ))),
308 }
309 }
310}
311
312fn default_llm_system_prompt() -> String {
313 "You are a helpful assistant. Use the available tools when they give a more precise answer than guessing, then reply concisely.".to_string()
314}
315
316fn default_max_tool_rounds() -> u32 {
317 4
318}
319
320impl Default for LlmHandlerConfig {
321 fn default() -> Self {
322 Self {
323 system_prompt: default_llm_system_prompt(),
324 max_tool_rounds: default_max_tool_rounds(),
325 agents: Vec::new(),
326 context: ContextConfig::default(),
327 }
328 }
329}
330
331#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
340#[cfg_attr(feature = "schema", derive(JsonSchema))]
341#[serde(deny_unknown_fields)]
342pub struct RuntimeConfig {
343 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub image: Option<String>,
358}
359
360#[derive(Debug, Error)]
361pub enum ConfigError {
362 #[error("Failed to read config file: {0}")]
363 IoError(#[from] std::io::Error),
364 #[error("Failed to parse TOML: {0}")]
365 TomlError(#[from] toml::de::Error),
366 #[error("Environment variable not found: {0}")]
367 EnvVarError(String),
368 #[error("Invalid configuration: {0}")]
369 ValidationError(String),
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374#[cfg_attr(feature = "schema", derive(JsonSchema))]
375#[serde(deny_unknown_fields)]
376pub struct AgentConfig {
377 pub agent: AgentMetadata,
379
380 #[serde(default)]
383 pub handler: HandlerConfig,
384
385 #[serde(default)]
388 pub runtime: RuntimeConfig,
389
390 #[serde(default)]
392 pub server: ServerConfig,
393
394 #[serde(default)]
396 pub skills: Vec<SkillConfig>,
397
398 #[serde(default)]
400 pub features: FeaturesConfig,
401
402 #[serde(default)]
404 pub llm: Option<LlmConfig>,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409#[cfg_attr(feature = "schema", derive(JsonSchema))]
410#[serde(deny_unknown_fields)]
411pub struct LlmConfig {
412 pub provider: String,
414 pub api_key: Option<String>,
416 pub model: Option<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
427 #[cfg_attr(feature = "schema", schemars(with = "Option<ReasoningSchema>"))]
428 pub reasoning: Option<Reasoning>,
429 pub base_url: Option<String>,
431 #[serde(default)]
433 pub http_referer: Option<String>,
434 #[serde(default)]
436 pub x_title: Option<String>,
437}
438
439impl From<&LlmConfig> for LlmSettings {
440 fn from(config: &LlmConfig) -> Self {
443 Self {
444 provider: config.provider.clone(),
445 api_key: config.api_key.clone(),
446 model: config.model.clone(),
447 base_url: config.base_url.clone(),
448 http_referer: config.http_referer.clone(),
449 x_title: config.x_title.clone(),
450 reasoning: config.reasoning,
451 }
452 }
453}
454
455#[cfg(feature = "schema")]
462#[derive(JsonSchema)]
463#[serde(untagged)]
464pub enum ReasoningSchema {
465 Level(ReasoningLevelSchema),
468 Budget(u32),
470}
471
472#[cfg(feature = "schema")]
474#[derive(JsonSchema)]
475#[serde(rename_all = "lowercase")]
476pub enum ReasoningLevelSchema {
477 Off,
478 Low,
479 Medium,
480 High,
481}
482
483impl AgentConfig {
484 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
486 let content = std::fs::read_to_string(path)?;
487 Self::from_toml(&content)
488 }
489
490 pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
492 let expanded = expand_env_vars(content)?;
494 let config: AgentConfig = toml::from_str(&expanded)?;
495 config.validate()?;
496 Ok(config)
497 }
498
499 pub fn check_toml(content: &str) -> Result<(Self, Vec<String>), ConfigError> {
510 let (expanded, unset) = expand_env_vars_lenient(content);
511 let config: AgentConfig = toml::from_str(&expanded)?;
512 config.validate()?;
513 Ok((config, unset))
514 }
515
516 pub fn check_file<P: AsRef<Path>>(path: P) -> Result<(Self, Vec<String>), ConfigError> {
518 let content = std::fs::read_to_string(path)?;
519 Self::check_toml(&content)
520 }
521
522 pub fn handler_type(&self) -> HandlerType {
526 if self.handler.r#type != HandlerType::Echo {
527 return self.handler.r#type.clone();
528 }
529 match self
530 .agent
531 .implementation
532 .as_deref()
533 .filter(|s| !s.is_empty())
534 {
535 Some(implementation) => HandlerType::from(implementation),
536 None => HandlerType::Echo,
537 }
538 }
539
540 pub fn image(&self) -> Option<&str> {
545 self.runtime.image.as_deref()
546 }
547
548 pub fn validate(&self) -> Result<(), ConfigError> {
550 if self.agent.name.is_empty() {
551 return Err(ConfigError::ValidationError(
552 "Agent name cannot be empty".to_string(),
553 ));
554 }
555
556 if !self.features.mcp_server.enabled && self.server.http_port == 0 {
557 return Err(ConfigError::ValidationError(
558 "The HTTP server port must be configured when MCP server is disabled".to_string(),
559 ));
560 }
561
562 for skill in &self.skills {
564 if skill.id.is_empty() {
565 return Err(ConfigError::ValidationError(
566 "Skill ID cannot be empty".to_string(),
567 ));
568 }
569 }
570
571 if self
575 .runtime
576 .image
577 .as_deref()
578 .is_some_and(|image| image.trim().is_empty())
579 {
580 return Err(ConfigError::ValidationError(
581 "[runtime] image cannot be empty — name an image or omit the key".to_string(),
582 ));
583 }
584
585 if let Some(llm) = &self.handler.llm {
588 for agent in &llm.agents {
589 agent.target()?;
590 }
591 }
592
593 Ok(())
594 }
595
596 pub fn agent_url(&self) -> String {
598 format!("http://{}:{}", self.server.host, self.server.http_port)
599 }
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
604#[cfg_attr(feature = "schema", derive(JsonSchema))]
605#[serde(deny_unknown_fields)]
606pub struct AgentMetadata {
607 pub name: String,
609
610 #[serde(default)]
612 pub description: Option<String>,
613
614 #[serde(default)]
616 pub version: Option<String>,
617
618 #[serde(default)]
620 pub provider: Option<ProviderInfo>,
621
622 #[serde(default)]
624 pub documentation_url: Option<String>,
625
626 #[serde(default)]
629 pub implementation: Option<String>,
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
634#[cfg_attr(feature = "schema", derive(JsonSchema))]
635#[serde(deny_unknown_fields)]
636pub struct ProviderInfo {
637 pub name: String,
638 pub url: String,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize)]
643#[cfg_attr(feature = "schema", derive(JsonSchema))]
644#[serde(deny_unknown_fields)]
645pub struct ServerConfig {
646 #[serde(default = "default_host")]
648 pub host: String,
649
650 #[serde(default = "default_http_port")]
652 pub http_port: u16,
653
654 #[serde(default)]
656 pub storage: StorageConfig,
657
658 #[serde(default)]
660 pub auth: AuthConfig,
661}
662
663impl Default for ServerConfig {
664 fn default() -> Self {
665 Self {
666 host: default_host(),
667 http_port: default_http_port(),
668 storage: StorageConfig::default(),
669 auth: AuthConfig::default(),
670 }
671 }
672}
673
674#[derive(Debug, Clone, Default, Serialize, Deserialize)]
676#[cfg_attr(feature = "schema", derive(JsonSchema))]
677#[serde(tag = "type", rename_all = "lowercase")]
678pub enum StorageConfig {
679 #[default]
681 InMemory,
682
683 Sqlx {
685 url: String,
687
688 #[serde(default = "default_max_connections")]
690 max_connections: u32,
691
692 #[serde(default)]
694 enable_logging: bool,
695 },
696}
697
698#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
700#[cfg_attr(feature = "schema", derive(JsonSchema))]
701#[serde(tag = "type", rename_all = "lowercase")]
702pub enum AuthConfig {
703 #[default]
705 None,
706
707 Bearer {
709 tokens: Vec<String>,
711
712 #[serde(skip_serializing_if = "Option::is_none")]
714 format: Option<String>,
715 },
716
717 ApiKey {
719 keys: Vec<String>,
721
722 #[serde(default = "default_api_key_location")]
724 location: String,
725
726 #[serde(default = "default_api_key_name")]
728 name: String,
729 },
730
731 Jwt {
733 #[serde(skip_serializing_if = "Option::is_none")]
736 secret: Option<String>,
737
738 #[serde(skip_serializing_if = "Option::is_none")]
740 rsa_pem_path: Option<String>,
741
742 #[serde(default = "default_jwt_algorithm")]
744 algorithm: String,
745
746 #[serde(skip_serializing_if = "Option::is_none")]
748 issuer: Option<String>,
749
750 #[serde(skip_serializing_if = "Option::is_none")]
752 audience: Option<String>,
753 },
754
755 OAuth2 {
757 client_id: String,
759
760 client_secret: String,
762
763 authorization_url: String,
765
766 token_url: String,
768
769 #[serde(skip_serializing_if = "Option::is_none")]
771 redirect_url: Option<String>,
772
773 #[serde(default = "default_oauth2_flow")]
775 flow: String,
776
777 #[serde(default)]
779 scopes: Vec<String>,
780 },
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
785#[cfg_attr(feature = "schema", derive(JsonSchema))]
786#[serde(deny_unknown_fields)]
787pub struct SkillConfig {
788 pub id: String,
790
791 pub name: String,
793
794 #[serde(default)]
796 pub description: Option<String>,
797
798 #[serde(default)]
800 pub keywords: Vec<String>,
801
802 #[serde(default)]
804 pub examples: Vec<String>,
805
806 #[serde(default = "default_formats")]
808 pub input_formats: Vec<String>,
809
810 #[serde(default = "default_formats")]
812 pub output_formats: Vec<String>,
813}
814
815#[derive(Debug, Clone, Serialize, Deserialize)]
817#[cfg_attr(feature = "schema", derive(JsonSchema))]
818#[serde(deny_unknown_fields)]
819pub struct FeaturesConfig {
820 #[serde(default)]
822 pub streaming: bool,
823
824 #[serde(default)]
826 pub push_notifications: bool,
827
828 #[serde(default)]
830 pub state_history: bool,
831
832 #[serde(default)]
834 pub authenticated_card: bool,
835
836 #[serde(default)]
838 pub extensions: ExtensionsConfig,
839
840 #[serde(default)]
842 pub mcp_server: McpServerConfig,
843
844 #[serde(default)]
846 pub mcp_client: McpClientConfig,
847}
848
849impl Default for FeaturesConfig {
850 fn default() -> Self {
851 Self {
852 streaming: true,
853 push_notifications: true,
854 state_history: true,
855 authenticated_card: false,
856 extensions: ExtensionsConfig::default(),
857 mcp_server: McpServerConfig::default(),
858 mcp_client: McpClientConfig::default(),
859 }
860 }
861}
862
863#[derive(Debug, Clone, Default, Serialize, Deserialize)]
865#[cfg_attr(feature = "schema", derive(JsonSchema))]
866#[serde(deny_unknown_fields)]
867pub struct ExtensionsConfig {
868 #[serde(default)]
870 pub ap2: Option<Ap2ExtensionConfig>,
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize)]
875#[cfg_attr(feature = "schema", derive(JsonSchema))]
876#[serde(deny_unknown_fields)]
877pub struct Ap2ExtensionConfig {
878 pub roles: Vec<String>,
880
881 #[serde(default)]
883 pub required: bool,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
888#[cfg_attr(feature = "schema", derive(JsonSchema))]
889#[serde(deny_unknown_fields)]
890pub struct McpServerConfig {
891 #[serde(default)]
893 pub enabled: bool,
894
895 #[serde(default = "default_true")]
901 pub stdio: bool,
902
903 #[serde(default)]
905 pub http: McpHttpConfig,
906
907 #[serde(skip_serializing_if = "Option::is_none")]
909 pub name: Option<String>,
910
911 #[serde(skip_serializing_if = "Option::is_none")]
913 pub version: Option<String>,
914}
915
916impl Default for McpServerConfig {
917 fn default() -> Self {
918 Self {
919 enabled: false,
920 stdio: true,
921 http: McpHttpConfig::default(),
922 name: None,
923 version: None,
924 }
925 }
926}
927
928#[derive(Debug, Clone, Serialize, Deserialize)]
934#[cfg_attr(feature = "schema", derive(JsonSchema))]
935#[serde(deny_unknown_fields)]
936pub struct McpHttpConfig {
937 #[serde(default)]
939 pub enabled: bool,
940
941 #[serde(default = "default_mcp_http_host")]
943 pub host: String,
944
945 #[serde(default = "default_mcp_http_port")]
947 pub port: u16,
948
949 #[serde(default = "default_mcp_http_path")]
951 pub path: String,
952
953 #[serde(default, skip_serializing_if = "Option::is_none")]
962 pub allowed_hosts: Option<Vec<String>>,
963
964 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub allowed_origins: Option<Vec<String>>,
972}
973
974impl Default for McpHttpConfig {
975 fn default() -> Self {
976 Self {
977 enabled: false,
978 host: default_mcp_http_host(),
979 port: default_mcp_http_port(),
980 path: default_mcp_http_path(),
981 allowed_hosts: None,
982 allowed_origins: None,
983 }
984 }
985}
986
987fn default_true() -> bool {
988 true
989}
990
991fn default_mcp_http_host() -> String {
992 "127.0.0.1".to_string()
993}
994
995fn default_mcp_http_port() -> u16 {
996 8000
997}
998
999fn default_mcp_http_path() -> String {
1000 "/mcp".to_string()
1001}
1002
1003#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1005#[cfg_attr(feature = "schema", derive(JsonSchema))]
1006#[serde(deny_unknown_fields)]
1007pub struct McpClientConfig {
1008 #[serde(default)]
1010 pub enabled: bool,
1011
1012 #[serde(default)]
1014 pub servers: Vec<McpServerConnection>,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1019#[cfg_attr(feature = "schema", derive(JsonSchema))]
1020#[serde(deny_unknown_fields)]
1021pub struct McpServerConnection {
1022 pub name: String,
1024
1025 pub command: String,
1027
1028 #[serde(default)]
1030 pub args: Vec<String>,
1031
1032 #[serde(default)]
1034 pub env: std::collections::HashMap<String, String>,
1035
1036 #[serde(skip_serializing_if = "Option::is_none")]
1038 pub cwd: Option<String>,
1039}
1040
1041fn default_host() -> String {
1044 std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
1045}
1046
1047fn default_http_port() -> u16 {
1048 std::env::var("HTTP_PORT")
1049 .ok()
1050 .and_then(|s| s.parse().ok())
1051 .unwrap_or(8080)
1052}
1053
1054fn default_max_connections() -> u32 {
1055 10
1056}
1057
1058fn default_jwt_algorithm() -> String {
1059 "HS256".to_string()
1060}
1061
1062fn default_oauth2_flow() -> String {
1063 "authorization_code".to_string()
1064}
1065
1066fn default_api_key_location() -> String {
1067 "header".to_string()
1068}
1069
1070fn default_api_key_name() -> String {
1071 "X-API-Key".to_string()
1072}
1073
1074fn default_formats() -> Vec<String> {
1075 vec!["text".to_string(), "data".to_string()]
1076}
1077
1078fn env_var_regex() -> &'static regex::Regex {
1082 use std::sync::LazyLock;
1083 static ENV_VAR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
1084 regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}").unwrap()
1085 });
1086 &ENV_VAR_RE
1087}
1088
1089pub fn referenced_env_vars(content: &str) -> std::collections::BTreeSet<String> {
1097 env_var_regex()
1098 .captures_iter(content)
1099 .map(|cap| cap[1].to_string())
1100 .collect()
1101}
1102
1103fn expand_env_vars(content: &str) -> Result<String, ConfigError> {
1109 let mut unset = Vec::new();
1110 let expanded = expand_env_vars_inner(content, &mut unset);
1111 match unset.first() {
1112 Some(var) => Err(ConfigError::EnvVarError(var.clone())),
1113 None => Ok(expanded),
1114 }
1115}
1116
1117const UNSET_ENV_PLACEHOLDER: &str = "<unset>";
1120
1121fn expand_env_vars_lenient(content: &str) -> (String, Vec<String>) {
1131 let mut unset = Vec::new();
1132 let expanded = expand_env_vars_inner(content, &mut unset);
1133 (expanded, unset)
1134}
1135
1136fn expand_env_vars_inner(content: &str, unset: &mut Vec<String>) -> String {
1139 let mut result = content.to_string();
1140
1141 for cap in env_var_regex().captures_iter(content) {
1142 let full_match = &cap[0];
1143 let var_name = &cap[1];
1144
1145 let value = match std::env::var(var_name) {
1146 Ok(value) => value,
1147 Err(_) => match cap.get(2) {
1148 Some(default) => default.as_str().to_string(),
1149 None => {
1150 if !unset.iter().any(|v| v == var_name) {
1151 unset.push(var_name.to_string());
1152 }
1153 UNSET_ENV_PLACEHOLDER.to_string()
1154 }
1155 },
1156 };
1157
1158 result = result.replace(full_match, &value);
1159 }
1160
1161 result
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use a2a_agents_common::llm::ReasoningEffort;
1167
1168 use super::*;
1169
1170 #[test]
1174 fn unknown_field_is_rejected_and_suggests_the_real_ones() {
1175 let toml = r#"
1176 [agent]
1177 name = "Typo Agent"
1178
1179 [server]
1180 http_prot = 9999
1181 "#;
1182 let err = AgentConfig::from_toml(toml)
1183 .expect_err("a mistyped key must not be silently ignored")
1184 .to_string();
1185 assert!(err.contains("http_prot"), "must name the bad key: {err}");
1186 assert!(
1187 err.contains("http_port"),
1188 "must list the valid keys so the fix is obvious: {err}"
1189 );
1190 }
1191
1192 #[test]
1194 fn unknown_field_is_rejected_in_nested_tables() {
1195 let toml = r#"
1196 [agent]
1197 name = "Nested"
1198
1199 [handler]
1200 type = "llm"
1201
1202 [handler.llm]
1203 sytsem_prompt = "oops"
1204 "#;
1205 let err = AgentConfig::from_toml(toml)
1206 .expect_err("nested typo must be caught")
1207 .to_string();
1208 assert!(err.contains("sytsem_prompt"), "{err}");
1209 }
1210
1211 fn llm_config(reasoning_line: &str) -> Result<Option<LlmConfig>, ConfigError> {
1212 let toml = format!(
1213 r#"
1214 [agent]
1215 name = "Thinker"
1216
1217 [llm]
1218 provider = "openrouter"
1219 model = "z-ai/glm-4.6"
1220 {reasoning_line}
1221 "#
1222 );
1223 AgentConfig::from_toml(&toml).map(|config| config.llm)
1224 }
1225
1226 #[test]
1231 fn reasoning_accepts_a_level_or_a_token_budget() {
1232 for (line, expected) in [
1233 (r#"reasoning = "off""#, Reasoning::Off),
1234 (
1235 r#"reasoning = "low""#,
1236 Reasoning::Effort(ReasoningEffort::Low),
1237 ),
1238 (
1239 r#"reasoning = "medium""#,
1240 Reasoning::Effort(ReasoningEffort::Medium),
1241 ),
1242 (
1243 r#"reasoning = "high""#,
1244 Reasoning::Effort(ReasoningEffort::High),
1245 ),
1246 ("reasoning = 2000", Reasoning::Budget(2000)),
1247 ] {
1248 let llm = llm_config(line)
1249 .unwrap_or_else(|e| panic!("`{line}` must parse: {e}"))
1250 .expect("[llm] present");
1251 assert_eq!(llm.reasoning, Some(expected), "for `{line}`");
1252 }
1253 }
1254
1255 #[cfg(feature = "schema")]
1259 #[test]
1260 fn reasoning_schema_matches_the_parser() {
1261 let schema = serde_json::to_value(schemars::schema_for!(ReasoningLevelSchema))
1262 .expect("schema serializes");
1263 let levels = schema["enum"]
1264 .as_array()
1265 .expect("a string enum of levels")
1266 .iter()
1267 .map(|v| v.as_str().expect("string level").to_string())
1268 .collect::<Vec<_>>();
1269
1270 assert_eq!(levels, ["off", "low", "medium", "high"]);
1271 for level in &levels {
1272 level.parse::<Reasoning>().unwrap_or_else(|e| {
1273 panic!("schema offers `{level}` but the parser refuses it: {e}")
1274 });
1275 }
1276 }
1277
1278 #[test]
1281 fn reasoning_is_absent_unless_asked_for() {
1282 let llm = llm_config("").expect("parses").expect("[llm] present");
1283 assert_eq!(llm.reasoning, None);
1284 }
1285
1286 #[test]
1290 fn a_misspelt_reasoning_level_is_a_config_error() {
1291 let err = llm_config(r#"reasoning = "hihg""#)
1292 .expect_err("a bad level must not be ignored")
1293 .to_string();
1294 assert!(err.contains("reasoning"), "must name the field: {err}");
1295 assert!(
1296 err.contains(r#""off", "low", "medium", "high""#),
1297 "must say what is accepted, not just that this was not: {err}"
1298 );
1299 }
1300
1301 #[test]
1302 fn check_toml_reports_unset_env_vars_instead_of_failing() {
1303 let toml = r#"
1304 [agent]
1305 name = "Needs Secrets"
1306 description = "${A2A_DEFINITELY_UNSET_ONE}"
1307
1308 [server]
1309 http_port = 8080
1310 [server.auth]
1311 type = "bearer"
1312 tokens = ["${A2A_DEFINITELY_UNSET_TWO}"]
1313 "#;
1314
1315 assert!(AgentConfig::from_toml(toml).is_err());
1317
1318 let (config, unset) = AgentConfig::check_toml(toml).expect("shape is valid");
1320 assert_eq!(config.agent.name, "Needs Secrets");
1321 assert_eq!(
1322 unset,
1323 ["A2A_DEFINITELY_UNSET_ONE", "A2A_DEFINITELY_UNSET_TWO"]
1324 );
1325 }
1326
1327 #[test]
1330 fn check_toml_still_rejects_unknown_fields() {
1331 let toml = r#"
1332 [agent]
1333 name = "Lenient But Not Lax"
1334
1335 [server]
1336 http_prot = 1
1337 "#;
1338 assert!(AgentConfig::check_toml(toml).is_err());
1339 }
1340
1341 #[test]
1342 fn check_toml_reports_nothing_when_every_ref_has_a_default() {
1343 let toml = r#"
1344 [agent]
1345 name = "Defaulted"
1346 description = "${A2A_ALSO_UNSET:-a default}"
1347 "#;
1348 let (config, unset) = AgentConfig::check_toml(toml).unwrap();
1349 assert_eq!(config.agent.description.as_deref(), Some("a default"));
1350 assert!(unset.is_empty(), "a defaulted ref is not 'unset'");
1351 }
1352
1353 #[test]
1354 fn test_minimal_config() {
1355 let toml = r#"
1356 [agent]
1357 name = "Test Agent"
1358 "#;
1359
1360 let config = AgentConfig::from_toml(toml).unwrap();
1361 assert_eq!(config.agent.name, "Test Agent");
1362 assert_eq!(config.server.http_port, 8080);
1363 }
1364
1365 #[test]
1366 fn test_complete_config() {
1367 let toml = r#"
1368 [agent]
1369 name = "Reimbursement Agent"
1370 description = "Handles employee reimbursements"
1371 version = "1.0.0"
1372
1373 [agent.provider]
1374 name = "Example Corp"
1375 url = "https://example.com"
1376
1377 [server]
1378 host = "0.0.0.0"
1379 http_port = 3000
1380
1381 [server.storage]
1382 type = "sqlx"
1383 url = "sqlite:test.db"
1384 max_connections = 5
1385 enable_logging = true
1386
1387 [server.auth]
1388 type = "bearer"
1389 tokens = ["token123"]
1390 format = "JWT"
1391
1392 [[skills]]
1393 id = "process_expense"
1394 name = "Process Expense"
1395 description = "Process expense reimbursements"
1396 keywords = ["expense", "reimbursement"]
1397 examples = ["Reimburse my $50 lunch"]
1398 input_formats = ["text", "data"]
1399 output_formats = ["text", "data"]
1400
1401 [features]
1402 streaming = true
1403 push_notifications = true
1404 state_history = true
1405 authenticated_card = false
1406 "#;
1407
1408 let config = AgentConfig::from_toml(toml).unwrap();
1409 assert_eq!(config.agent.name, "Reimbursement Agent");
1410 assert_eq!(config.server.http_port, 3000);
1411 assert_eq!(config.skills.len(), 1);
1412 assert_eq!(config.skills[0].id, "process_expense");
1413 assert!(config.features.streaming);
1414 }
1415
1416 #[test]
1417 fn test_env_var_expansion() {
1418 unsafe {
1421 std::env::set_var("TEST_TOKEN", "secret123");
1422 }
1423
1424 let content = r#"
1425 [server.auth]
1426 type = "bearer"
1427 tokens = ["${TEST_TOKEN}"]
1428 "#;
1429
1430 let expanded = expand_env_vars(content).unwrap();
1431 assert!(expanded.contains("secret123"));
1432 }
1433
1434 #[test]
1435 fn test_referenced_env_vars_dedups_and_sorts() {
1436 let content = r#"
1437 key = "${ZED_KEY}"
1438 url = "${db_url:-sqlite:dev.db}"
1439 token = "${ZED_KEY}"
1440 plain = "no refs here"
1441 "#;
1442 let refs: Vec<String> = referenced_env_vars(content).into_iter().collect();
1443 assert_eq!(refs, ["ZED_KEY", "db_url"]);
1444 }
1445
1446 #[test]
1447 fn test_referenced_env_vars_empty_when_no_refs() {
1448 assert!(referenced_env_vars(r#"name = "plain""#).is_empty());
1449 }
1450
1451 #[test]
1452 fn test_env_var_default_used_when_unset() {
1453 let content = r#"model = "${A2A_UNSET_MODEL_VAR:-gpt-4o}""#;
1455 let expanded = expand_env_vars(content).unwrap();
1456 assert_eq!(expanded, r#"model = "gpt-4o""#);
1457 }
1458
1459 #[test]
1460 fn test_env_var_default_ignored_when_set() {
1461 unsafe {
1463 std::env::set_var("A2A_SET_MODEL_VAR", "claude");
1464 }
1465 let content = r#"model = "${A2A_SET_MODEL_VAR:-gpt-4o}""#;
1466 let expanded = expand_env_vars(content).unwrap();
1467 assert_eq!(expanded, r#"model = "claude""#);
1468 }
1469
1470 #[test]
1471 fn test_env_var_empty_default_is_allowed() {
1472 let content = r#"opt = "${A2A_UNSET_OPT_VAR:-}""#;
1473 let expanded = expand_env_vars(content).unwrap();
1474 assert_eq!(expanded, r#"opt = """#);
1475 }
1476
1477 #[test]
1478 fn test_env_var_without_default_still_errors() {
1479 let content = r#"key = "${A2A_DEFINITELY_UNSET_VAR}""#;
1480 assert!(matches!(
1481 expand_env_vars(content),
1482 Err(ConfigError::EnvVarError(_))
1483 ));
1484 }
1485
1486 #[test]
1487 fn test_env_var_lowercase_name_is_expanded() {
1488 unsafe {
1490 std::env::set_var("a2a_lower_var", "from_env");
1491 }
1492 let content = r#"url = "${a2a_lower_var}""#;
1493 let expanded = expand_env_vars(content).unwrap();
1494 assert_eq!(expanded, r#"url = "from_env""#);
1495 }
1496
1497 #[test]
1498 fn test_env_var_lowercase_unset_still_errors() {
1499 let content = r#"url = "${a2a_unset_lower_var}""#;
1502 assert!(matches!(
1503 expand_env_vars(content),
1504 Err(ConfigError::EnvVarError(_))
1505 ));
1506 }
1507
1508 #[test]
1509 #[cfg(feature = "auth")]
1510 fn test_jwt_auth_config() {
1511 let toml = r#"
1512 [agent]
1513 name = "JWT Agent"
1514
1515 [server.auth]
1516 type = "jwt"
1517 secret = "my-jwt-secret"
1518 algorithm = "HS256"
1519 issuer = "https://auth.example.com"
1520 audience = "api://my-agent"
1521 "#;
1522
1523 let config = AgentConfig::from_toml(toml).unwrap();
1524 match &config.server.auth {
1525 AuthConfig::Jwt {
1526 secret,
1527 algorithm,
1528 issuer,
1529 audience,
1530 ..
1531 } => {
1532 assert_eq!(secret.as_ref().unwrap(), "my-jwt-secret");
1533 assert_eq!(algorithm, "HS256");
1534 assert_eq!(issuer.as_ref().unwrap(), "https://auth.example.com");
1535 assert_eq!(audience.as_ref().unwrap(), "api://my-agent");
1536 }
1537 _ => panic!("Expected JWT auth config"),
1538 }
1539 }
1540
1541 #[test]
1542 #[cfg(feature = "auth")]
1543 fn test_oauth2_auth_config() {
1544 let toml = r#"
1545 [agent]
1546 name = "OAuth2 Agent"
1547
1548 [server.auth]
1549 type = "oauth2"
1550 client_id = "my-client-id"
1551 client_secret = "my-client-secret"
1552 authorization_url = "https://provider.com/auth"
1553 token_url = "https://provider.com/token"
1554 flow = "authorization_code"
1555 scopes = ["read", "write"]
1556 "#;
1557
1558 let config = AgentConfig::from_toml(toml).unwrap();
1559 match &config.server.auth {
1560 AuthConfig::OAuth2 {
1561 client_id,
1562 client_secret,
1563 flow,
1564 scopes,
1565 ..
1566 } => {
1567 assert_eq!(client_id, "my-client-id");
1568 assert_eq!(client_secret, "my-client-secret");
1569 assert_eq!(flow, "authorization_code");
1570 assert_eq!(scopes.len(), 2);
1571 assert_eq!(scopes[0], "read");
1572 }
1573 _ => panic!("Expected OAuth2 auth config"),
1574 }
1575 }
1576
1577 #[test]
1578 fn test_validation_empty_name() {
1579 let toml = r#"
1580 [agent]
1581 name = ""
1582 "#;
1583
1584 let result = AgentConfig::from_toml(toml);
1585 assert!(result.is_err());
1586 }
1587
1588 #[test]
1589 fn test_ap2_extension_config() {
1590 let toml = r#"
1591 [agent]
1592 name = "Merchant Agent"
1593
1594 [features.extensions.ap2]
1595 roles = ["merchant", "payment-processor"]
1596 required = true
1597 "#;
1598
1599 let config = AgentConfig::from_toml(toml).unwrap();
1600 let ap2 = config.features.extensions.ap2.unwrap();
1601 assert_eq!(ap2.roles, vec!["merchant", "payment-processor"]);
1602 assert!(ap2.required);
1603 }
1604
1605 #[test]
1606 fn test_mcp_http_config() {
1607 let toml = r#"
1608 [agent]
1609 name = "HTTP MCP Agent"
1610
1611 [server]
1612 http_port = 0
1613
1614 [features.mcp_server]
1615 enabled = true
1616 stdio = false
1617
1618 [features.mcp_server.http]
1619 enabled = true
1620 host = "0.0.0.0"
1621 port = 9000
1622 path = "/rpc"
1623 "#;
1624
1625 let config = AgentConfig::from_toml(toml).unwrap();
1626 let http = &config.features.mcp_server.http;
1627 assert!(http.enabled);
1628 assert_eq!(http.host, "0.0.0.0");
1629 assert_eq!(http.port, 9000);
1630 assert_eq!(http.path, "/rpc");
1631 assert!(http.allowed_hosts.is_none());
1633 assert!(http.allowed_origins.is_none());
1634 }
1635
1636 #[test]
1637 fn test_mcp_http_security_knobs() {
1638 let toml = r#"
1639 [agent]
1640 name = "Public MCP Agent"
1641
1642 [server]
1643 http_port = 0
1644
1645 [features.mcp_server]
1646 enabled = true
1647
1648 [features.mcp_server.http]
1649 enabled = true
1650 allowed_hosts = ["mcp.example.com", "mcp.example.com:8000"]
1651 allowed_origins = ["https://app.example.com"]
1652 "#;
1653
1654 let config = AgentConfig::from_toml(toml).unwrap();
1655 let http = &config.features.mcp_server.http;
1656 assert_eq!(
1657 http.allowed_hosts.as_deref(),
1658 Some(
1659 [
1660 "mcp.example.com".to_string(),
1661 "mcp.example.com:8000".to_string()
1662 ]
1663 .as_slice()
1664 )
1665 );
1666 assert_eq!(
1667 http.allowed_origins.as_deref(),
1668 Some(["https://app.example.com".to_string()].as_slice())
1669 );
1670 }
1671
1672 #[test]
1673 fn test_mcp_http_disable_host_validation() {
1674 let toml = r#"
1677 [agent]
1678 name = "Open MCP Agent"
1679
1680 [server]
1681 http_port = 0
1682
1683 [features.mcp_server]
1684 enabled = true
1685
1686 [features.mcp_server.http]
1687 enabled = true
1688 allowed_hosts = []
1689 "#;
1690
1691 let config = AgentConfig::from_toml(toml).unwrap();
1692 assert_eq!(
1693 config.features.mcp_server.http.allowed_hosts.as_deref(),
1694 Some([].as_slice())
1695 );
1696 }
1697
1698 #[test]
1699 fn test_mcp_http_config_defaults() {
1700 let toml = r#"
1702 [agent]
1703 name = "Stdio MCP Agent"
1704
1705 [server]
1706 http_port = 0
1707
1708 [features.mcp_server]
1709 enabled = true
1710 "#;
1711
1712 let config = AgentConfig::from_toml(toml).unwrap();
1713 let mcp = &config.features.mcp_server;
1714 assert!(mcp.stdio);
1715 assert!(!mcp.http.enabled);
1716 assert_eq!(mcp.http.host, "127.0.0.1");
1717 assert_eq!(mcp.http.port, 8000);
1718 assert_eq!(mcp.http.path, "/mcp");
1719 }
1720
1721 #[test]
1722 fn test_ap2_extension_config_optional() {
1723 let toml = r#"
1724 [agent]
1725 name = "Plain Agent"
1726 "#;
1727
1728 let config = AgentConfig::from_toml(toml).unwrap();
1729 assert!(config.features.extensions.ap2.is_none());
1730 }
1731
1732 #[test]
1733 fn test_handler_block_llm() {
1734 let toml = r#"
1735 [agent]
1736 name = "LLM Agent"
1737
1738 [handler]
1739 type = "llm"
1740
1741 [handler.llm]
1742 system_prompt = "be brief"
1743 max_tool_rounds = 2
1744 "#;
1745 let config = AgentConfig::from_toml(toml).unwrap();
1746 assert_eq!(config.handler_type(), HandlerType::Llm);
1747 let llm = config.handler.llm.unwrap();
1748 assert_eq!(llm.system_prompt, "be brief");
1749 assert_eq!(llm.max_tool_rounds, 2);
1750 }
1751
1752 #[test]
1753 fn test_handler_llm_remote_agents() {
1754 let toml = r#"
1755 [agent]
1756 name = "Orchestrator"
1757
1758 [handler]
1759 type = "llm"
1760
1761 [handler.llm]
1762 system_prompt = "route work to peers"
1763
1764 [[handler.llm.agents]]
1765 name = "Weather Agent"
1766 url = "http://localhost:8081"
1767 description = "Knows the weather"
1768
1769 [[handler.llm.agents]]
1770 name = "billing"
1771 url = "http://localhost:8082"
1772 "#;
1773 let config = AgentConfig::from_toml(toml).unwrap();
1774 let llm = config.handler.llm.unwrap();
1775 assert_eq!(llm.agents.len(), 2);
1776 assert_eq!(llm.agents[0].name, "Weather Agent");
1777 assert_eq!(llm.agents[0].url.as_deref(), Some("http://localhost:8081"));
1778 assert_eq!(
1779 llm.agents[0].target().unwrap(),
1780 RemoteAgentTarget::Url("http://localhost:8081")
1781 );
1782 assert_eq!(
1783 llm.agents[0].description.as_deref(),
1784 Some("Knows the weather")
1785 );
1786 assert_eq!(llm.agents[1].name, "billing");
1787 assert!(llm.agents[1].description.is_none());
1788 }
1789
1790 #[test]
1791 fn test_remote_agent_skill_and_id_refs() {
1792 let toml = r#"
1793 [agent]
1794 name = "Orchestrator"
1795
1796 [handler]
1797 type = "llm"
1798
1799 [[handler.llm.agents]]
1800 name = "Weather"
1801 skill = "weather-lookup"
1802
1803 [[handler.llm.agents]]
1804 name = "Billing"
1805 agent_id = "billing-agent"
1806 "#;
1807 let config = AgentConfig::from_toml(toml).unwrap();
1808 let agents = &config.handler.llm.as_ref().unwrap().agents;
1809 assert_eq!(
1810 agents[0].target().unwrap(),
1811 RemoteAgentTarget::Skill("weather-lookup")
1812 );
1813 assert_eq!(
1814 agents[1].target().unwrap(),
1815 RemoteAgentTarget::AgentId("billing-agent")
1816 );
1817 }
1818
1819 #[test]
1820 fn test_remote_agent_rejects_zero_or_multiple_refs() {
1821 let none = r#"
1823 [agent]
1824 name = "Orchestrator"
1825 [handler]
1826 type = "llm"
1827 [[handler.llm.agents]]
1828 name = "Nameless"
1829 "#;
1830 assert!(AgentConfig::from_toml(none).is_err());
1831
1832 let both = r#"
1834 [agent]
1835 name = "Orchestrator"
1836 [handler]
1837 type = "llm"
1838 [[handler.llm.agents]]
1839 name = "Ambiguous"
1840 url = "http://localhost:8081"
1841 skill = "weather-lookup"
1842 "#;
1843 assert!(AgentConfig::from_toml(both).is_err());
1844 }
1845
1846 #[test]
1847 fn test_handler_falls_back_to_implementation() {
1848 let toml = r#"
1849 [agent]
1850 name = "Legacy Agent"
1851 implementation = "reimbursement"
1852 "#;
1853 let config = AgentConfig::from_toml(toml).unwrap();
1854 assert_eq!(config.handler_type(), HandlerType::Reimbursement);
1855 }
1856
1857 #[test]
1858 fn test_handler_defaults_to_echo() {
1859 let toml = r#"
1860 [agent]
1861 name = "Plain Agent"
1862 "#;
1863 let config = AgentConfig::from_toml(toml).unwrap();
1864 assert_eq!(config.handler_type(), HandlerType::Echo);
1865 }
1866
1867 #[test]
1868 fn test_handler_custom_type_round_trips() {
1869 let toml = r#"
1870 [agent]
1871 name = "Custom Agent"
1872
1873 [handler]
1874 type = "weather"
1875 "#;
1876 let config = AgentConfig::from_toml(toml).unwrap();
1877 assert_eq!(
1878 config.handler_type(),
1879 HandlerType::Custom("weather".to_string())
1880 );
1881 assert_eq!(config.handler.r#type.as_str(), "weather");
1882 }
1883
1884 #[test]
1885 fn an_agent_can_bring_its_own_image() {
1886 let toml = r#"
1887 [agent]
1888 name = "Billing Agent"
1889
1890 [handler]
1891 type = "billing"
1892
1893 [runtime]
1894 image = "ghcr.io/acme/billing:1.4"
1895 "#;
1896 let config = AgentConfig::from_toml(toml).unwrap();
1897 assert_eq!(config.image(), Some("ghcr.io/acme/billing:1.4"));
1898 assert_eq!(
1901 config.handler_type(),
1902 HandlerType::Custom("billing".to_string())
1903 );
1904 }
1905
1906 #[test]
1907 fn most_agents_bring_no_image() {
1908 let config = AgentConfig::from_toml("[agent]\nname = \"Plain\"\n").unwrap();
1909 assert_eq!(config.image(), None);
1910 }
1911
1912 #[test]
1915 fn a_blank_image_is_a_config_error() {
1916 for image in ["", " "] {
1917 let toml = format!("[agent]\nname = \"Blank\"\n\n[runtime]\nimage = \"{image}\"\n");
1918 let err = AgentConfig::from_toml(&toml).expect_err("a blank image must not parse");
1919 assert!(err.to_string().contains("image"), "{err}");
1920 }
1921 }
1922}