Skip to main content

a2a_agents/core/
config.rs

1//! Agent configuration with TOML support
2//!
3//! This module provides declarative configuration for A2A agents via TOML files.
4//! It supports environment variable interpolation and sensible defaults.
5
6use 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/// Which built-in handler drives an agent (parse, don't validate).
15///
16/// The typed replacement for the stringly-typed `[handler].type` /
17/// `agent.implementation`. Known selectors map to their variant; anything else
18/// is a [`Custom`](HandlerType::Custom) name resolved by the host binary.
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(from = "String", into = "String")]
21pub enum HandlerType {
22    /// Echoes the request back (the default).
23    #[default]
24    Echo,
25    /// Generic config-driven LLM handler (`type = "llm"`).
26    Llm,
27    /// The reimbursement reference agent (`type = "reimbursement"`).
28    Reimbursement,
29    /// A host-resolved custom handler keyed by name.
30    Custom(String),
31}
32
33impl HandlerType {
34    /// The wire string for this selector (round-trips through `from`).
35    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        // Reuse the &str mapping; only `Custom` keeps the owned string.
59        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/// How a handler is selected for an agent.
89///
90/// The typed replacement for the stringly-typed `agent.implementation`. When
91/// absent, the legacy `agent.implementation` field is honoured, so existing
92/// configs keep working unchanged.
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[cfg_attr(feature = "schema", derive(JsonSchema))]
95#[serde(deny_unknown_fields)]
96pub struct HandlerConfig {
97    /// Built-in handler selector: `echo`, `llm`, `reimbursement`, or any custom
98    /// name resolved by the host binary.
99    #[serde(default)]
100    #[cfg_attr(feature = "schema", schemars(with = "String"))]
101    pub r#type: HandlerType,
102
103    /// Options for the generic LLM-driven handler (`type = "llm"`).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub llm: Option<LlmHandlerConfig>,
106}
107
108/// Options for the generic config-driven LLM handler.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[cfg_attr(feature = "schema", derive(JsonSchema))]
111#[serde(deny_unknown_fields)]
112pub struct LlmHandlerConfig {
113    /// System prompt steering the model's behaviour.
114    #[serde(default = "default_llm_system_prompt")]
115    pub system_prompt: String,
116
117    /// Maximum LLM <-> tool round-trips before the handler gives up.
118    #[serde(default = "default_max_tool_rounds")]
119    pub max_tool_rounds: u32,
120
121    /// Remote A2A agents exposed to the model as tools (`[[handler.llm.agents]]`),
122    /// enabling agent-to-agent delegation. Each becomes an `ask_<slug>` tool.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub agents: Vec<RemoteAgentConfig>,
125
126    /// How much of the conversation this agent carries between turns, and how it
127    /// keeps that inside the model's window (`[handler.llm.context]`).
128    #[serde(default)]
129    pub context: ContextConfig,
130}
131
132/// What an agent remembers between messages, and what it does when that no
133/// longer fits.
134///
135/// Off by default. Carrying history changes what an existing agent costs and how
136/// it answers, so it is opted into rather than switched on under everyone.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[cfg_attr(feature = "schema", derive(JsonSchema))]
139#[serde(deny_unknown_fields)]
140pub struct ContextConfig {
141    /// Which messages a turn is given.
142    #[serde(default)]
143    pub mode: ContextMode,
144
145    /// Ceiling on the request, in tokens, counted by an estimator rather than a
146    /// real tokenizer — leave headroom. Zero means no ceiling and no trimming.
147    #[serde(default = "default_max_input_tokens")]
148    pub max_input_tokens: usize,
149
150    /// Held back from `max_input_tokens` for the reply.
151    #[serde(default = "default_reserve_for_output")]
152    pub reserve_for_output: usize,
153
154    /// Percentage of the usable budget at which the conversation is summarized,
155    /// before anything has to be dropped. A percentage rather than a fraction so
156    /// this config stays comparable by equality, which the surrounding structs
157    /// derive.
158    #[serde(default = "default_compact_at_percent")]
159    pub compact_at_percent: u8,
160
161    /// Turns kept verbatim at the end of the conversation; compaction may only
162    /// fold what precedes them.
163    #[serde(default = "default_keep_recent_turns")]
164    pub keep_recent_turns: usize,
165
166    /// Longest a single tool result may be, in characters, before it is trimmed
167    /// to its head and tail. The first thing given up, and usually enough.
168    #[serde(default = "default_max_tool_result_chars")]
169    pub max_tool_result_chars: usize,
170}
171
172/// Which messages a turn is given.
173#[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    /// Only the incoming message. Every turn starts fresh, which is how this
178    /// handler has always behaved.
179    #[default]
180    None,
181    /// The messages of the current task.
182    Task,
183    /// Every message in the context — the conversation this message belongs to,
184    /// across all of its tasks. Needs storage that outlives the process to be
185    /// worth anything, so pair it with `[storage] type = "sqlx"`.
186    Context,
187}
188
189impl ContextMode {
190    /// Whether this mode reads anything back from storage.
191    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/// A remote A2A agent the LLM handler can delegate to, exposed as one tool.
230///
231/// The peer is named by **exactly one** of `url`, `skill`, or `agent_id`:
232/// a raw `url` dials directly, while `skill`/`agent_id` are resolved against the
233/// [`AgentRegistry`](crate::registry::AgentRegistry) at startup. Use
234/// [`target`](Self::target) to read the resolved one-of.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "schema", derive(JsonSchema))]
237#[serde(deny_unknown_fields)]
238pub struct RemoteAgentConfig {
239    /// Friendly name; the tool the model sees is `ask_<slug-of-name>`.
240    pub name: String,
241
242    /// Base URL of the remote agent (its transport is auto-negotiated from the
243    /// agent card, falling back to a direct client). Mutually exclusive with
244    /// `skill`/`agent_id`.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub url: Option<String>,
247
248    /// Resolve the peer by a skill it advertises (matched against registered
249    /// agent cards). Mutually exclusive with `url`/`agent_id`.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub skill: Option<String>,
252
253    /// Resolve the peer by its registry id (slug of its name). Mutually
254    /// exclusive with `url`/`skill`.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub agent_id: Option<String>,
257
258    /// Optional override for the tool description shown to the model. When
259    /// omitted, a description is derived from the agent card at startup.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub description: Option<String>,
262}
263
264/// The resolved one-of selecting how a [`RemoteAgentConfig`] names its peer.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum RemoteAgentTarget<'a> {
267    /// A raw base URL, dialed directly.
268    Url(&'a str),
269    /// A skill to resolve against the registry.
270    Skill(&'a str),
271    /// A registry id to resolve against the registry.
272    AgentId(&'a str),
273}
274
275impl std::fmt::Display for RemoteAgentTarget<'_> {
276    /// How the peer was named, for a log line that has to say which reference
277    /// resolved (or did not).
278    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    /// Resolve the peer reference, enforcing that **exactly one** of `url`,
289    /// `skill`, or `agent_id` is set (parse, don't validate). A missing or
290    /// ambiguous reference is a [`ConfigError::ValidationError`].
291    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/// How the platform packages this agent when it runs it as a managed unit
332/// (`[runtime]`).
333///
334/// Only meaningful to a runtime that can honour it — [`ContainerRuntime`] today.
335/// `a2a run` reads the config in-process and has no image to pull, so it says so
336/// and runs the built-in handler instead.
337///
338/// [`ContainerRuntime`]: crate::ContainerRuntime
339#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
340#[cfg_attr(feature = "schema", derive(JsonSchema))]
341#[serde(deny_unknown_fields)]
342pub struct RuntimeConfig {
343    /// Container image running this agent, instead of the platform's base image.
344    ///
345    /// This is what keeps the declarative layer from being closed: an agent that
346    /// needs a handler no TOML can express ships as its own image, and the
347    /// platform goes back to caring only about the config, the port and the
348    /// container. The image is passed to the engine verbatim, so any reference
349    /// it accepts works (`ghcr.io/acme/billing:1.4`, a digest, a local tag).
350    ///
351    /// The image gets the same contract as the base one: its config bind-mounted
352    /// read-only and named by `A2A_CONFIG`, `HOST=0.0.0.0`, its `http_port`
353    /// published, and the config's `${VAR}` references passed through. Unlike
354    /// the base image it is started with **no command override**, so its own
355    /// `ENTRYPOINT`/`CMD` decides how to boot.
356    #[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/// Complete agent configuration from TOML
373#[derive(Debug, Clone, Serialize, Deserialize)]
374#[cfg_attr(feature = "schema", derive(JsonSchema))]
375#[serde(deny_unknown_fields)]
376pub struct AgentConfig {
377    /// Agent metadata
378    pub agent: AgentMetadata,
379
380    /// Typed handler selection (`[handler]`). When omitted, the legacy
381    /// `agent.implementation` string is honoured via [`handler_type`].
382    #[serde(default)]
383    pub handler: HandlerConfig,
384
385    /// How the platform packages this agent (`[runtime]`) — a custom image, or
386    /// nothing, meaning the platform's base image runs it.
387    #[serde(default)]
388    pub runtime: RuntimeConfig,
389
390    /// Server configuration
391    #[serde(default)]
392    pub server: ServerConfig,
393
394    /// Skills exposed by the agent
395    #[serde(default)]
396    pub skills: Vec<SkillConfig>,
397
398    /// Features enabled for the agent
399    #[serde(default)]
400    pub features: FeaturesConfig,
401
402    /// LLM Configuration
403    #[serde(default)]
404    pub llm: Option<LlmConfig>,
405}
406
407/// LLM Configuration
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[cfg_attr(feature = "schema", derive(JsonSchema))]
410#[serde(deny_unknown_fields)]
411pub struct LlmConfig {
412    /// LLM Provider (e.g. "openrouter", "openai", "gemini")
413    pub provider: String,
414    /// API key for the LLM
415    pub api_key: Option<String>,
416    /// Model to use
417    pub model: Option<String>,
418    /// What to ask this model to do with its thinking: `"off"`, `"low"`,
419    /// `"medium"`, `"high"`, or a token budget (`reasoning = 2000`).
420    ///
421    /// Omitted, nothing is sent and the model's own default stands — a
422    /// reasoning model still reasons. It sits beside `model` because that is
423    /// what it belongs to: a flash model answering in one line and a frontier
424    /// model doing analysis want different answers, and the agent's *handler*
425    /// has no way to know which it was pointed at. OpenRouter only today.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    #[cfg_attr(feature = "schema", schemars(with = "Option<ReasoningSchema>"))]
428    pub reasoning: Option<Reasoning>,
429    /// Base URL (for providers like openai that support local LLMs like ollama)
430    pub base_url: Option<String>,
431    /// OpenRouter `HTTP-Referer` attribution header (ignored by other providers)
432    #[serde(default)]
433    pub http_referer: Option<String>,
434    /// OpenRouter `X-Title` attribution header (ignored by other providers)
435    #[serde(default)]
436    pub x_title: Option<String>,
437}
438
439impl From<&LlmConfig> for LlmSettings {
440    /// Lives here so `a2a run` and `a2a doctor` build a provider from the same
441    /// values; two hand-written copies of this drift.
442    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/// Schema-only mirror of what `[llm] reasoning` accepts.
456///
457/// [`Reasoning`] carries its own TOML surface (one parser, one error message),
458/// but it lives in `a2a-agents-common`, which has no `schemars` dependency —
459/// so the JSON Schema export needs a type to point `schemars(with = …)` at.
460/// `reasoning_schema_matches_the_parser` keeps this honest.
461#[cfg(feature = "schema")]
462#[derive(JsonSchema)]
463#[serde(untagged)]
464pub enum ReasoningSchema {
465    /// A named level, `"off"` included — the setting a small fast model usually
466    /// wants, and the one an effort-only knob cannot express.
467    Level(ReasoningLevelSchema),
468    /// A hard cap on reasoning tokens.
469    Budget(u32),
470}
471
472/// The named levels of [`ReasoningSchema`].
473#[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    /// Load configuration from a TOML file
485    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    /// Parse configuration from TOML string
491    pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
492        // Expand environment variables
493        let expanded = expand_env_vars(content)?;
494        let config: AgentConfig = toml::from_str(&expanded)?;
495        config.validate()?;
496        Ok(config)
497    }
498
499    /// Parse for **validation**, tolerating unset `${VAR}` references.
500    ///
501    /// Returns the config alongside the names of variables that had no value and
502    /// no `:-default` — they were substituted with a placeholder so the rest of
503    /// the config could still be checked. Everything else (unknown keys, wrong
504    /// types, failed [`validate`](Self::validate)) errors exactly as in
505    /// [`from_toml`](Self::from_toml).
506    ///
507    /// This is what `a2a validate` uses: a config's shape should be checkable
508    /// without the production secrets it will eventually run with.
509    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    /// [`check_toml`](Self::check_toml) for a file on disk.
517    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    /// Resolve the handler selector, preferring `[handler].type` and falling
523    /// back to the legacy `agent.implementation` (so existing configs keep
524    /// working) and finally [`HandlerType::Echo`].
525    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    /// The image this agent runs from, if it brings its own.
541    ///
542    /// `None` means the runtime's base image (or, under `a2a run`, this binary's
543    /// built-in handlers).
544    pub fn image(&self) -> Option<&str> {
545        self.runtime.image.as_deref()
546    }
547
548    /// Validate the configuration
549    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        // Validate skills
563        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        // A blank image would reach the engine as an empty argument, which fails
572        // as an unreadable `docker create` error rather than as the config
573        // mistake it is.
574        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        // Validate remote-agent references fail fast at load (exactly one of
586        // url/skill/agent_id) rather than at resolve time.
587        if let Some(llm) = &self.handler.llm {
588            for agent in &llm.agents {
589                agent.target()?;
590            }
591        }
592
593        Ok(())
594    }
595
596    /// Build agent card URL from server config
597    pub fn agent_url(&self) -> String {
598        format!("http://{}:{}", self.server.host, self.server.http_port)
599    }
600}
601
602/// Agent metadata and identity
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[cfg_attr(feature = "schema", derive(JsonSchema))]
605#[serde(deny_unknown_fields)]
606pub struct AgentMetadata {
607    /// Agent name
608    pub name: String,
609
610    /// Agent description
611    #[serde(default)]
612    pub description: Option<String>,
613
614    /// Agent version
615    #[serde(default)]
616    pub version: Option<String>,
617
618    /// Provider information
619    #[serde(default)]
620    pub provider: Option<ProviderInfo>,
621
622    /// Documentation URL
623    #[serde(default)]
624    pub documentation_url: Option<String>,
625
626    /// The implementation handler to use for this agent (e.g. 'reimbursement', 'echo')
627    /// Used primarily by the generic a2a binary.
628    #[serde(default)]
629    pub implementation: Option<String>,
630}
631
632/// Provider information
633#[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/// Server configuration
642#[derive(Debug, Clone, Serialize, Deserialize)]
643#[cfg_attr(feature = "schema", derive(JsonSchema))]
644#[serde(deny_unknown_fields)]
645pub struct ServerConfig {
646    /// Host to bind to
647    #[serde(default = "default_host")]
648    pub host: String,
649
650    /// HTTP server port (0 to disable)
651    #[serde(default = "default_http_port")]
652    pub http_port: u16,
653
654    /// Storage configuration
655    #[serde(default)]
656    pub storage: StorageConfig,
657
658    /// Authentication configuration
659    #[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/// Storage backend configuration
675#[derive(Debug, Clone, Default, Serialize, Deserialize)]
676#[cfg_attr(feature = "schema", derive(JsonSchema))]
677#[serde(tag = "type", rename_all = "lowercase")]
678pub enum StorageConfig {
679    /// In-memory storage (default)
680    #[default]
681    InMemory,
682
683    /// SQLx-based persistent storage
684    Sqlx {
685        /// Database URL (supports env vars like ${DATABASE_URL})
686        url: String,
687
688        /// Maximum number of connections in the pool
689        #[serde(default = "default_max_connections")]
690        max_connections: u32,
691
692        /// Enable SQL query logging
693        #[serde(default)]
694        enable_logging: bool,
695    },
696}
697
698/// Authentication configuration
699#[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    /// No authentication (default for development)
704    #[default]
705    None,
706
707    /// Bearer token authentication
708    Bearer {
709        /// List of valid tokens (supports env vars)
710        tokens: Vec<String>,
711
712        /// Optional bearer format description (e.g., "JWT")
713        #[serde(skip_serializing_if = "Option::is_none")]
714        format: Option<String>,
715    },
716
717    /// API Key authentication
718    ApiKey {
719        /// Valid API keys
720        keys: Vec<String>,
721
722        /// Location of the API key: "header", "query", or "cookie"
723        #[serde(default = "default_api_key_location")]
724        location: String,
725
726        /// Name of the header/query param/cookie
727        #[serde(default = "default_api_key_name")]
728        name: String,
729    },
730
731    /// JWT (JSON Web Token) authentication
732    Jwt {
733        /// JWT secret for HMAC algorithms (HS256, HS384, HS512)
734        /// Use ${ENV_VAR} for environment variables
735        #[serde(skip_serializing_if = "Option::is_none")]
736        secret: Option<String>,
737
738        /// RSA public key in PEM format for RSA algorithms (RS256, RS384, RS512)
739        #[serde(skip_serializing_if = "Option::is_none")]
740        rsa_pem_path: Option<String>,
741
742        /// Algorithm to use (HS256, HS384, HS512, RS256, RS384, RS512)
743        #[serde(default = "default_jwt_algorithm")]
744        algorithm: String,
745
746        /// Required issuer (iss claim)
747        #[serde(skip_serializing_if = "Option::is_none")]
748        issuer: Option<String>,
749
750        /// Required audience (aud claim)
751        #[serde(skip_serializing_if = "Option::is_none")]
752        audience: Option<String>,
753    },
754
755    /// OAuth2 authentication
756    OAuth2 {
757        /// Client ID
758        client_id: String,
759
760        /// Client secret (use ${ENV_VAR} for environment variables)
761        client_secret: String,
762
763        /// Authorization URL
764        authorization_url: String,
765
766        /// Token URL
767        token_url: String,
768
769        /// Redirect URL for authorization code flow
770        #[serde(skip_serializing_if = "Option::is_none")]
771        redirect_url: Option<String>,
772
773        /// OAuth2 flow type: "authorization_code" or "client_credentials"
774        #[serde(default = "default_oauth2_flow")]
775        flow: String,
776
777        /// Required scopes
778        #[serde(default)]
779        scopes: Vec<String>,
780    },
781}
782
783/// Skill configuration
784#[derive(Debug, Clone, Serialize, Deserialize)]
785#[cfg_attr(feature = "schema", derive(JsonSchema))]
786#[serde(deny_unknown_fields)]
787pub struct SkillConfig {
788    /// Unique skill identifier
789    pub id: String,
790
791    /// Human-readable skill name
792    pub name: String,
793
794    /// Skill description
795    #[serde(default)]
796    pub description: Option<String>,
797
798    /// Keywords for skill discovery
799    #[serde(default)]
800    pub keywords: Vec<String>,
801
802    /// Example queries for this skill
803    #[serde(default)]
804    pub examples: Vec<String>,
805
806    /// Supported input formats
807    #[serde(default = "default_formats")]
808    pub input_formats: Vec<String>,
809
810    /// Supported output formats
811    #[serde(default = "default_formats")]
812    pub output_formats: Vec<String>,
813}
814
815/// Features configuration
816#[derive(Debug, Clone, Serialize, Deserialize)]
817#[cfg_attr(feature = "schema", derive(JsonSchema))]
818#[serde(deny_unknown_fields)]
819pub struct FeaturesConfig {
820    /// Enable streaming updates
821    #[serde(default)]
822    pub streaming: bool,
823
824    /// Enable push notifications
825    #[serde(default)]
826    pub push_notifications: bool,
827
828    /// Enable state transition history
829    #[serde(default)]
830    pub state_history: bool,
831
832    /// Enable authenticated extended card
833    #[serde(default)]
834    pub authenticated_card: bool,
835
836    /// Protocol extensions (AP2, etc.)
837    #[serde(default)]
838    pub extensions: ExtensionsConfig,
839
840    /// MCP server configuration (expose agent as MCP server)
841    #[serde(default)]
842    pub mcp_server: McpServerConfig,
843
844    /// MCP client configuration (connect to MCP servers to use their tools)
845    #[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/// Protocol extensions configuration
864#[derive(Debug, Clone, Default, Serialize, Deserialize)]
865#[cfg_attr(feature = "schema", derive(JsonSchema))]
866#[serde(deny_unknown_fields)]
867pub struct ExtensionsConfig {
868    /// AP2 (Agent Payments Protocol) extension
869    #[serde(default)]
870    pub ap2: Option<Ap2ExtensionConfig>,
871}
872
873/// AP2 extension configuration
874#[derive(Debug, Clone, Serialize, Deserialize)]
875#[cfg_attr(feature = "schema", derive(JsonSchema))]
876#[serde(deny_unknown_fields)]
877pub struct Ap2ExtensionConfig {
878    /// AP2 roles this agent performs (merchant, shopper, credentials-provider, payment-processor)
879    pub roles: Vec<String>,
880
881    /// Whether clients must understand AP2 to interact with this agent
882    #[serde(default)]
883    pub required: bool,
884}
885
886/// MCP server configuration
887#[derive(Debug, Clone, Serialize, Deserialize)]
888#[cfg_attr(feature = "schema", derive(JsonSchema))]
889#[serde(deny_unknown_fields)]
890pub struct McpServerConfig {
891    /// Enable MCP server (expose agent as MCP tools)
892    #[serde(default)]
893    pub enabled: bool,
894
895    /// Use stdio transport (for Claude Desktop integration).
896    ///
897    /// Ignored when [`http.enabled`](McpHttpConfig::enabled) is set — the HTTP
898    /// (Streamable HTTP) transport takes precedence, since a single process
899    /// cannot own stdin/stdout for stdio and bind a socket at the same time.
900    #[serde(default = "default_true")]
901    pub stdio: bool,
902
903    /// Streamable HTTP transport (for networked MCP clients).
904    #[serde(default)]
905    pub http: McpHttpConfig,
906
907    /// Server name (defaults to agent name)
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub name: Option<String>,
910
911    /// Server version (defaults to agent version)
912    #[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/// Streamable HTTP transport configuration for the MCP server.
929///
930/// When [`enabled`](Self::enabled), the agent is served over MCP's Streamable
931/// HTTP transport (`rmcp`'s `StreamableHttpService`) instead of stdio, mounted
932/// at [`path`](Self::path) on `host:port`.
933#[derive(Debug, Clone, Serialize, Deserialize)]
934#[cfg_attr(feature = "schema", derive(JsonSchema))]
935#[serde(deny_unknown_fields)]
936pub struct McpHttpConfig {
937    /// Serve the MCP server over Streamable HTTP rather than stdio.
938    #[serde(default)]
939    pub enabled: bool,
940
941    /// Host/interface to bind to.
942    #[serde(default = "default_mcp_http_host")]
943    pub host: String,
944
945    /// TCP port to bind to.
946    #[serde(default = "default_mcp_http_port")]
947    pub port: u16,
948
949    /// URL path the Streamable HTTP endpoint is mounted at.
950    #[serde(default = "default_mcp_http_path")]
951    pub path: String,
952
953    /// Hostnames / `host:port` authorities accepted in the inbound `Host`
954    /// header (DNS-rebinding protection).
955    ///
956    /// * Omitted → the secure default: loopback only (`localhost`, `127.0.0.1`,
957    ///   `::1`).
958    /// * `[]` → disable `Host` validation entirely (allow any host — required
959    ///   for public binds, but **not recommended** without an upstream proxy).
960    /// * Non-empty → only the listed authorities are accepted.
961    #[serde(default, skip_serializing_if = "Option::is_none")]
962    pub allowed_hosts: Option<Vec<String>>,
963
964    /// Browser `Origin` values accepted on inbound requests.
965    ///
966    /// * Omitted (or `[]`) → `Origin` validation disabled (the rmcp default).
967    /// * Non-empty → requests carrying an `Origin` must match one of these per
968    ///   RFC 6454 `(scheme, host, port)`; entries must include a scheme (e.g.
969    ///   `https://app.example.com`). Requests without an `Origin` still pass.
970    #[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/// MCP client configuration
1004#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1005#[cfg_attr(feature = "schema", derive(JsonSchema))]
1006#[serde(deny_unknown_fields)]
1007pub struct McpClientConfig {
1008    /// Enable MCP client (connect to MCP servers to use their tools)
1009    #[serde(default)]
1010    pub enabled: bool,
1011
1012    /// MCP servers to connect to
1013    #[serde(default)]
1014    pub servers: Vec<McpServerConnection>,
1015}
1016
1017/// Configuration for connecting to an MCP server
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1019#[cfg_attr(feature = "schema", derive(JsonSchema))]
1020#[serde(deny_unknown_fields)]
1021pub struct McpServerConnection {
1022    /// Unique name for this MCP server
1023    pub name: String,
1024
1025    /// Command to run to start the MCP server
1026    pub command: String,
1027
1028    /// Arguments to pass to the command
1029    #[serde(default)]
1030    pub args: Vec<String>,
1031
1032    /// Environment variables to set
1033    #[serde(default)]
1034    pub env: std::collections::HashMap<String, String>,
1035
1036    /// Working directory for the command
1037    #[serde(skip_serializing_if = "Option::is_none")]
1038    pub cwd: Option<String>,
1039}
1040
1041// Default value functions
1042
1043fn 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
1078/// The `${VAR}` / `${VAR:-default}` reference syntax shared by
1079/// [`expand_env_vars`] and [`referenced_env_vars`].
1080/// Group 1 = var name; group 2 (optional) = `:-default` fallback.
1081fn 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
1089/// The set of environment-variable names a raw config string references via
1090/// `${VAR}` / `${VAR:-default}`, deduplicated and sorted.
1091///
1092/// This is the *inspection* counterpart to [`expand_env_vars`]: a runtime that
1093/// defers expansion to another environment (e.g. a container where `a2a run`
1094/// re-parses the TOML) uses it to know which variables to inject there, so
1095/// secrets can stay out of the on-disk TOML.
1096pub 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
1103/// Expand environment variables in the config string.
1104///
1105/// Supports `${VAR_NAME}` and `${VAR_NAME:-default}` syntax. An unset variable
1106/// with no default is a hard [`ConfigError::EnvVarError`]; with a default, the
1107/// default (which may be empty) is substituted.
1108fn 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
1117/// Substituted for an unset variable when expanding leniently. Non-empty so
1118/// fields with `String` types still parse, and recognizable if it ever leaks.
1119const UNSET_ENV_PLACEHOLDER: &str = "<unset>";
1120
1121/// Expand for **validation**: an unset `${VAR}` becomes [`UNSET_ENV_PLACEHOLDER`]
1122/// instead of failing, and every such name is collected (deduplicated, in first-
1123/// seen order).
1124///
1125/// Checking a config's *shape* should not require holding the secrets it will
1126/// run with — otherwise `a2a validate` is unusable in CI and on a fresh
1127/// checkout, which is exactly where it is most useful. Callers that genuinely
1128/// need the values (running an agent) use [`expand_env_vars`] and get the hard
1129/// error.
1130fn 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
1136/// Shared expansion: substitutes every reference, recording the names that had
1137/// neither a value nor a `:-default`.
1138fn 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    /// A mistyped key must be an error, not a silent default. This is the worst
1171    /// failure mode a declarative config can have: `http_prot = 9999` used to
1172    /// validate clean and then serve on 8080.
1173    #[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    /// Strictness has to reach nested tables too, not just the top level.
1193    #[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    /// Both spellings of `[llm] reasoning` reach the same request knob, and each
1227    /// TOML token is pinned to the variant it must produce — the tokens live
1228    /// here while the wire lives in `a2a-agents-common`, so nothing but a test
1229    /// holds the two together.
1230    #[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    /// The JSON Schema export describes `reasoning` through a mirror type, so
1256    /// the mirror has to list exactly the levels the parser takes — a schema
1257    /// that has drifted rejects configs the agent would have run.
1258    #[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    /// Omitting it must mean "leave the model alone", not a default effort the
1279    /// caller never asked for and pays for on every request.
1280    #[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    /// A misspelt level must fail at load. Silently ignoring it would leave the
1287    /// agent thinking as hard as the model's default while the config claims
1288    /// otherwise — the exact silence this setting exists to end.
1289    #[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        // Strict parsing refuses — that is right for actually running an agent.
1316        assert!(AgentConfig::from_toml(toml).is_err());
1317
1318        // Validation still checks the shape and tells you what is missing.
1319        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    /// Leniency is scoped to *missing values* — it must not weaken structural
1328    /// checking, or `validate` would go back to rubber-stamping typos.
1329    #[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        // SAFETY: This is a test function run in a controlled environment
1419        // We're setting an environment variable that won't affect other tests
1420        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        // An unset var with a `:-default` falls back to the default.
1454        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        // SAFETY: test-only var, unique name, controlled environment.
1462        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        // SAFETY: test-only var, unique name, controlled environment.
1489        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        // A lowercase ref must obey the same "unset with no default is a hard
1500        // error" contract as uppercase — not pass through literally.
1501        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        // Security knobs omitted → None (keep rmcp's loopback-only default).
1632        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        // An explicit empty list parses as Some([]) — distinct from omission —
1675        // and disables Host validation at the transport layer.
1676        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        // Omitting [features.mcp_server.http] leaves HTTP disabled with sane defaults.
1701        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        // Zero refs.
1822        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        // Two refs.
1833        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        // The handler name is still the config's own: what resolves it is the
1899        // image, not this binary's table of built-ins.
1900        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    /// A blank image reaches the engine as an empty argument, where it fails as
1913    /// an unreadable `create` error instead of as the typo it is.
1914    #[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}