use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum HandlerType {
#[default]
Echo,
Llm,
Reimbursement,
Custom(String),
}
impl HandlerType {
pub fn as_str(&self) -> &str {
match self {
HandlerType::Echo => "echo",
HandlerType::Llm => "llm",
HandlerType::Reimbursement => "reimbursement",
HandlerType::Custom(name) => name,
}
}
}
impl From<&str> for HandlerType {
fn from(s: &str) -> Self {
match s {
"echo" => HandlerType::Echo,
"llm" => HandlerType::Llm,
"reimbursement" => HandlerType::Reimbursement,
other => HandlerType::Custom(other.to_string()),
}
}
}
impl From<String> for HandlerType {
fn from(s: String) -> Self {
match HandlerType::from(s.as_str()) {
HandlerType::Custom(_) => HandlerType::Custom(s),
known => known,
}
}
}
impl From<HandlerType> for String {
fn from(t: HandlerType) -> Self {
match t {
HandlerType::Custom(name) => name,
other => other.as_str().to_string(),
}
}
}
impl std::str::FromStr for HandlerType {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(HandlerType::from(s))
}
}
impl std::fmt::Display for HandlerType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct HandlerConfig {
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
pub r#type: HandlerType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmHandlerConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct LlmHandlerConfig {
#[serde(default = "default_llm_system_prompt")]
pub system_prompt: String,
#[serde(default = "default_max_tool_rounds")]
pub max_tool_rounds: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<RemoteAgentConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct RemoteAgentConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteAgentTarget<'a> {
Url(&'a str),
Skill(&'a str),
AgentId(&'a str),
}
impl RemoteAgentConfig {
pub fn target(&self) -> Result<RemoteAgentTarget<'_>, ConfigError> {
match (
self.url.as_deref(),
self.skill.as_deref(),
self.agent_id.as_deref(),
) {
(Some(url), None, None) => Ok(RemoteAgentTarget::Url(url)),
(None, Some(skill), None) => Ok(RemoteAgentTarget::Skill(skill)),
(None, None, Some(id)) => Ok(RemoteAgentTarget::AgentId(id)),
(None, None, None) => Err(ConfigError::ValidationError(format!(
"remote agent '{}' must set exactly one of `url`, `skill`, or `agent_id`",
self.name
))),
_ => Err(ConfigError::ValidationError(format!(
"remote agent '{}' sets more than one of `url`, `skill`, `agent_id`; pick one",
self.name
))),
}
}
}
fn default_llm_system_prompt() -> String {
"You are a helpful assistant. Use the available tools when they give a more precise answer than guessing, then reply concisely.".to_string()
}
fn default_max_tool_rounds() -> u32 {
4
}
impl Default for LlmHandlerConfig {
fn default() -> Self {
Self {
system_prompt: default_llm_system_prompt(),
max_tool_rounds: default_max_tool_rounds(),
agents: Vec::new(),
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse TOML: {0}")]
TomlError(#[from] toml::de::Error),
#[error("Environment variable not found: {0}")]
EnvVarError(String),
#[error("Invalid configuration: {0}")]
ValidationError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct AgentConfig {
pub agent: AgentMetadata,
#[serde(default)]
pub handler: HandlerConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub skills: Vec<SkillConfig>,
#[serde(default)]
pub features: FeaturesConfig,
#[serde(default)]
pub llm: Option<LlmConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct LlmConfig {
pub provider: String,
pub api_key: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
#[serde(default)]
pub http_referer: Option<String>,
#[serde(default)]
pub x_title: Option<String>,
}
impl AgentConfig {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path)?;
Self::from_toml(&content)
}
pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
let expanded = expand_env_vars(content)?;
let config: AgentConfig = toml::from_str(&expanded)?;
config.validate()?;
Ok(config)
}
pub fn handler_type(&self) -> HandlerType {
if self.handler.r#type != HandlerType::Echo {
return self.handler.r#type.clone();
}
match self
.agent
.implementation
.as_deref()
.filter(|s| !s.is_empty())
{
Some(implementation) => HandlerType::from(implementation),
None => HandlerType::Echo,
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.agent.name.is_empty() {
return Err(ConfigError::ValidationError(
"Agent name cannot be empty".to_string(),
));
}
if !self.features.mcp_server.enabled && self.server.http_port == 0 {
return Err(ConfigError::ValidationError(
"The HTTP server port must be configured when MCP server is disabled".to_string(),
));
}
for skill in &self.skills {
if skill.id.is_empty() {
return Err(ConfigError::ValidationError(
"Skill ID cannot be empty".to_string(),
));
}
}
if let Some(llm) = &self.handler.llm {
for agent in &llm.agents {
agent.target()?;
}
}
Ok(())
}
pub fn agent_url(&self) -> String {
format!("http://{}:{}", self.server.host, self.server.http_port)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct AgentMetadata {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub provider: Option<ProviderInfo>,
#[serde(default)]
pub documentation_url: Option<String>,
#[serde(default)]
pub implementation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ProviderInfo {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_http_port")]
pub http_port: u16,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub auth: AuthConfig,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
http_port: default_http_port(),
storage: StorageConfig::default(),
auth: AuthConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum StorageConfig {
#[default]
InMemory,
Sqlx {
url: String,
#[serde(default = "default_max_connections")]
max_connections: u32,
#[serde(default)]
enable_logging: bool,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AuthConfig {
#[default]
None,
Bearer {
tokens: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
ApiKey {
keys: Vec<String>,
#[serde(default = "default_api_key_location")]
location: String,
#[serde(default = "default_api_key_name")]
name: String,
},
Jwt {
#[serde(skip_serializing_if = "Option::is_none")]
secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rsa_pem_path: Option<String>,
#[serde(default = "default_jwt_algorithm")]
algorithm: String,
#[serde(skip_serializing_if = "Option::is_none")]
issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
audience: Option<String>,
},
OAuth2 {
client_id: String,
client_secret: String,
authorization_url: String,
token_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
redirect_url: Option<String>,
#[serde(default = "default_oauth2_flow")]
flow: String,
#[serde(default)]
scopes: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct SkillConfig {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub examples: Vec<String>,
#[serde(default = "default_formats")]
pub input_formats: Vec<String>,
#[serde(default = "default_formats")]
pub output_formats: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct FeaturesConfig {
#[serde(default)]
pub streaming: bool,
#[serde(default)]
pub push_notifications: bool,
#[serde(default)]
pub state_history: bool,
#[serde(default)]
pub authenticated_card: bool,
#[serde(default)]
pub extensions: ExtensionsConfig,
#[serde(default)]
pub mcp_server: McpServerConfig,
#[serde(default)]
pub mcp_client: McpClientConfig,
}
impl Default for FeaturesConfig {
fn default() -> Self {
Self {
streaming: true,
push_notifications: true,
state_history: true,
authenticated_card: false,
extensions: ExtensionsConfig::default(),
mcp_server: McpServerConfig::default(),
mcp_client: McpClientConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ExtensionsConfig {
#[serde(default)]
pub ap2: Option<Ap2ExtensionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct Ap2ExtensionConfig {
pub roles: Vec<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct McpServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub stdio: bool,
#[serde(default)]
pub http: McpHttpConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
impl Default for McpServerConfig {
fn default() -> Self {
Self {
enabled: false,
stdio: true,
http: McpHttpConfig::default(),
name: None,
version: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct McpHttpConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_mcp_http_host")]
pub host: String,
#[serde(default = "default_mcp_http_port")]
pub port: u16,
#[serde(default = "default_mcp_http_path")]
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_hosts: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_origins: Option<Vec<String>>,
}
impl Default for McpHttpConfig {
fn default() -> Self {
Self {
enabled: false,
host: default_mcp_http_host(),
port: default_mcp_http_port(),
path: default_mcp_http_path(),
allowed_hosts: None,
allowed_origins: None,
}
}
}
fn default_true() -> bool {
true
}
fn default_mcp_http_host() -> String {
"127.0.0.1".to_string()
}
fn default_mcp_http_port() -> u16 {
8000
}
fn default_mcp_http_path() -> String {
"/mcp".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct McpClientConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub servers: Vec<McpServerConnection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct McpServerConnection {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
fn default_host() -> String {
std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
}
fn default_http_port() -> u16 {
std::env::var("HTTP_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080)
}
fn default_max_connections() -> u32 {
10
}
fn default_jwt_algorithm() -> String {
"HS256".to_string()
}
fn default_oauth2_flow() -> String {
"authorization_code".to_string()
}
fn default_api_key_location() -> String {
"header".to_string()
}
fn default_api_key_name() -> String {
"X-API-Key".to_string()
}
fn default_formats() -> Vec<String> {
vec!["text".to_string(), "data".to_string()]
}
fn expand_env_vars(content: &str) -> Result<String, ConfigError> {
use std::sync::LazyLock;
static ENV_VAR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}").unwrap()
});
let mut result = content.to_string();
let re = &*ENV_VAR_RE;
for cap in re.captures_iter(content) {
let full_match = &cap[0];
let var_name = &cap[1];
let value = match std::env::var(var_name) {
Ok(value) => value,
Err(_) => match cap.get(2) {
Some(default) => default.as_str().to_string(),
None => return Err(ConfigError::EnvVarError(var_name.to_string())),
},
};
result = result.replace(full_match, &value);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_minimal_config() {
let toml = r#"
[agent]
name = "Test Agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(config.agent.name, "Test Agent");
assert_eq!(config.server.http_port, 8080);
}
#[test]
fn test_complete_config() {
let toml = r#"
[agent]
name = "Reimbursement Agent"
description = "Handles employee reimbursements"
version = "1.0.0"
[agent.provider]
name = "Example Corp"
url = "https://example.com"
[server]
host = "0.0.0.0"
http_port = 3000
[server.storage]
type = "sqlx"
url = "sqlite:test.db"
max_connections = 5
enable_logging = true
[server.auth]
type = "bearer"
tokens = ["token123"]
format = "JWT"
[[skills]]
id = "process_expense"
name = "Process Expense"
description = "Process expense reimbursements"
keywords = ["expense", "reimbursement"]
examples = ["Reimburse my $50 lunch"]
input_formats = ["text", "data"]
output_formats = ["text", "data"]
[features]
streaming = true
push_notifications = true
state_history = true
authenticated_card = false
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(config.agent.name, "Reimbursement Agent");
assert_eq!(config.server.http_port, 3000);
assert_eq!(config.skills.len(), 1);
assert_eq!(config.skills[0].id, "process_expense");
assert!(config.features.streaming);
}
#[test]
fn test_env_var_expansion() {
unsafe {
std::env::set_var("TEST_TOKEN", "secret123");
}
let content = r#"
[server.auth]
type = "bearer"
tokens = ["${TEST_TOKEN}"]
"#;
let expanded = expand_env_vars(content).unwrap();
assert!(expanded.contains("secret123"));
}
#[test]
fn test_env_var_default_used_when_unset() {
let content = r#"model = "${A2A_UNSET_MODEL_VAR:-gpt-4o}""#;
let expanded = expand_env_vars(content).unwrap();
assert_eq!(expanded, r#"model = "gpt-4o""#);
}
#[test]
fn test_env_var_default_ignored_when_set() {
unsafe {
std::env::set_var("A2A_SET_MODEL_VAR", "claude");
}
let content = r#"model = "${A2A_SET_MODEL_VAR:-gpt-4o}""#;
let expanded = expand_env_vars(content).unwrap();
assert_eq!(expanded, r#"model = "claude""#);
}
#[test]
fn test_env_var_empty_default_is_allowed() {
let content = r#"opt = "${A2A_UNSET_OPT_VAR:-}""#;
let expanded = expand_env_vars(content).unwrap();
assert_eq!(expanded, r#"opt = """#);
}
#[test]
fn test_env_var_without_default_still_errors() {
let content = r#"key = "${A2A_DEFINITELY_UNSET_VAR}""#;
assert!(matches!(
expand_env_vars(content),
Err(ConfigError::EnvVarError(_))
));
}
#[test]
fn test_env_var_lowercase_name_is_expanded() {
unsafe {
std::env::set_var("a2a_lower_var", "from_env");
}
let content = r#"url = "${a2a_lower_var}""#;
let expanded = expand_env_vars(content).unwrap();
assert_eq!(expanded, r#"url = "from_env""#);
}
#[test]
fn test_env_var_lowercase_unset_still_errors() {
let content = r#"url = "${a2a_unset_lower_var}""#;
assert!(matches!(
expand_env_vars(content),
Err(ConfigError::EnvVarError(_))
));
}
#[test]
#[cfg(feature = "auth")]
fn test_jwt_auth_config() {
let toml = r#"
[agent]
name = "JWT Agent"
[server.auth]
type = "jwt"
secret = "my-jwt-secret"
algorithm = "HS256"
issuer = "https://auth.example.com"
audience = "api://my-agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
match &config.server.auth {
AuthConfig::Jwt {
secret,
algorithm,
issuer,
audience,
..
} => {
assert_eq!(secret.as_ref().unwrap(), "my-jwt-secret");
assert_eq!(algorithm, "HS256");
assert_eq!(issuer.as_ref().unwrap(), "https://auth.example.com");
assert_eq!(audience.as_ref().unwrap(), "api://my-agent");
}
_ => panic!("Expected JWT auth config"),
}
}
#[test]
#[cfg(feature = "auth")]
fn test_oauth2_auth_config() {
let toml = r#"
[agent]
name = "OAuth2 Agent"
[server.auth]
type = "oauth2"
client_id = "my-client-id"
client_secret = "my-client-secret"
authorization_url = "https://provider.com/auth"
token_url = "https://provider.com/token"
flow = "authorization_code"
scopes = ["read", "write"]
"#;
let config = AgentConfig::from_toml(toml).unwrap();
match &config.server.auth {
AuthConfig::OAuth2 {
client_id,
client_secret,
flow,
scopes,
..
} => {
assert_eq!(client_id, "my-client-id");
assert_eq!(client_secret, "my-client-secret");
assert_eq!(flow, "authorization_code");
assert_eq!(scopes.len(), 2);
assert_eq!(scopes[0], "read");
}
_ => panic!("Expected OAuth2 auth config"),
}
}
#[test]
fn test_validation_empty_name() {
let toml = r#"
[agent]
name = ""
"#;
let result = AgentConfig::from_toml(toml);
assert!(result.is_err());
}
#[test]
fn test_ap2_extension_config() {
let toml = r#"
[agent]
name = "Merchant Agent"
[features.extensions.ap2]
roles = ["merchant", "payment-processor"]
required = true
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let ap2 = config.features.extensions.ap2.unwrap();
assert_eq!(ap2.roles, vec!["merchant", "payment-processor"]);
assert!(ap2.required);
}
#[test]
fn test_mcp_http_config() {
let toml = r#"
[agent]
name = "HTTP MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
stdio = false
[features.mcp_server.http]
enabled = true
host = "0.0.0.0"
port = 9000
path = "/rpc"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let http = &config.features.mcp_server.http;
assert!(http.enabled);
assert_eq!(http.host, "0.0.0.0");
assert_eq!(http.port, 9000);
assert_eq!(http.path, "/rpc");
assert!(http.allowed_hosts.is_none());
assert!(http.allowed_origins.is_none());
}
#[test]
fn test_mcp_http_security_knobs() {
let toml = r#"
[agent]
name = "Public MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
[features.mcp_server.http]
enabled = true
allowed_hosts = ["mcp.example.com", "mcp.example.com:8000"]
allowed_origins = ["https://app.example.com"]
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let http = &config.features.mcp_server.http;
assert_eq!(
http.allowed_hosts.as_deref(),
Some(
[
"mcp.example.com".to_string(),
"mcp.example.com:8000".to_string()
]
.as_slice()
)
);
assert_eq!(
http.allowed_origins.as_deref(),
Some(["https://app.example.com".to_string()].as_slice())
);
}
#[test]
fn test_mcp_http_disable_host_validation() {
let toml = r#"
[agent]
name = "Open MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
[features.mcp_server.http]
enabled = true
allowed_hosts = []
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(
config.features.mcp_server.http.allowed_hosts.as_deref(),
Some([].as_slice())
);
}
#[test]
fn test_mcp_http_config_defaults() {
let toml = r#"
[agent]
name = "Stdio MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let mcp = &config.features.mcp_server;
assert!(mcp.stdio);
assert!(!mcp.http.enabled);
assert_eq!(mcp.http.host, "127.0.0.1");
assert_eq!(mcp.http.port, 8000);
assert_eq!(mcp.http.path, "/mcp");
}
#[test]
fn test_ap2_extension_config_optional() {
let toml = r#"
[agent]
name = "Plain Agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert!(config.features.extensions.ap2.is_none());
}
#[test]
fn test_handler_block_llm() {
let toml = r#"
[agent]
name = "LLM Agent"
[handler]
type = "llm"
[handler.llm]
system_prompt = "be brief"
max_tool_rounds = 2
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(config.handler_type(), HandlerType::Llm);
let llm = config.handler.llm.unwrap();
assert_eq!(llm.system_prompt, "be brief");
assert_eq!(llm.max_tool_rounds, 2);
}
#[test]
fn test_handler_llm_remote_agents() {
let toml = r#"
[agent]
name = "Orchestrator"
[handler]
type = "llm"
[handler.llm]
system_prompt = "route work to peers"
[[handler.llm.agents]]
name = "Weather Agent"
url = "http://localhost:8081"
description = "Knows the weather"
[[handler.llm.agents]]
name = "billing"
url = "http://localhost:8082"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let llm = config.handler.llm.unwrap();
assert_eq!(llm.agents.len(), 2);
assert_eq!(llm.agents[0].name, "Weather Agent");
assert_eq!(llm.agents[0].url.as_deref(), Some("http://localhost:8081"));
assert_eq!(
llm.agents[0].target().unwrap(),
RemoteAgentTarget::Url("http://localhost:8081")
);
assert_eq!(
llm.agents[0].description.as_deref(),
Some("Knows the weather")
);
assert_eq!(llm.agents[1].name, "billing");
assert!(llm.agents[1].description.is_none());
}
#[test]
fn test_remote_agent_skill_and_id_refs() {
let toml = r#"
[agent]
name = "Orchestrator"
[handler]
type = "llm"
[[handler.llm.agents]]
name = "Weather"
skill = "weather-lookup"
[[handler.llm.agents]]
name = "Billing"
agent_id = "billing-agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let agents = &config.handler.llm.as_ref().unwrap().agents;
assert_eq!(
agents[0].target().unwrap(),
RemoteAgentTarget::Skill("weather-lookup")
);
assert_eq!(
agents[1].target().unwrap(),
RemoteAgentTarget::AgentId("billing-agent")
);
}
#[test]
fn test_remote_agent_rejects_zero_or_multiple_refs() {
let none = r#"
[agent]
name = "Orchestrator"
[handler]
type = "llm"
[[handler.llm.agents]]
name = "Nameless"
"#;
assert!(AgentConfig::from_toml(none).is_err());
let both = r#"
[agent]
name = "Orchestrator"
[handler]
type = "llm"
[[handler.llm.agents]]
name = "Ambiguous"
url = "http://localhost:8081"
skill = "weather-lookup"
"#;
assert!(AgentConfig::from_toml(both).is_err());
}
#[test]
fn test_handler_falls_back_to_implementation() {
let toml = r#"
[agent]
name = "Legacy Agent"
implementation = "reimbursement"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(config.handler_type(), HandlerType::Reimbursement);
}
#[test]
fn test_handler_defaults_to_echo() {
let toml = r#"
[agent]
name = "Plain Agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(config.handler_type(), HandlerType::Echo);
}
#[test]
fn test_handler_custom_type_round_trips() {
let toml = r#"
[agent]
name = "Custom Agent"
[handler]
type = "weather"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(
config.handler_type(),
HandlerType::Custom("weather".to_string())
);
assert_eq!(config.handler.r#type.as_str(), "weather");
}
}