1use super::{
2 A2aAgentBinding, AgentDefinition, AgentDefinitionSpec, AgentDescriptor, RemoteSecurityConfig,
3 StopConditionSpec, ToolExecutionMode,
4};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9#[cfg(feature = "skills")]
10#[derive(Debug, Clone)]
11pub struct SkillsConfig {
12 pub enabled: bool,
13 pub advertise_catalog: bool,
14 pub discovery_max_entries: usize,
15 pub discovery_max_chars: usize,
16}
17
18#[cfg(feature = "skills")]
19impl Default for SkillsConfig {
20 fn default() -> Self {
21 Self {
22 enabled: false,
23 advertise_catalog: true,
24 discovery_max_entries: 32,
25 discovery_max_chars: 16 * 1024,
26 }
27 }
28}
29
30#[derive(Debug, Clone)]
31pub struct AgentToolsConfig {
32 pub discovery_max_entries: usize,
33 pub discovery_max_chars: usize,
34}
35
36impl Default for AgentToolsConfig {
37 fn default() -> Self {
38 Self {
39 discovery_max_entries: 64,
40 discovery_max_chars: 16 * 1024,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct AgentConfig {
47 pub agents: Vec<AgentConfigEntry>,
48}
49
50impl AgentConfig {
51 pub fn from_json_str(raw: &str) -> Result<Self, AgentConfigError> {
52 serde_json::from_str(raw).map_err(AgentConfigError::ParseJson)
53 }
54
55 pub fn parse_specs_json(raw: &str) -> Result<Vec<AgentDefinitionSpec>, AgentConfigError> {
56 Self::from_json_str(raw)?.into_specs()
57 }
58
59 #[must_use]
60 pub fn json_schema() -> schemars::Schema {
61 schemars::schema_for!(Self)
62 }
63
64 pub fn into_specs(self) -> Result<Vec<AgentDefinitionSpec>, AgentConfigError> {
65 let mut seen = HashSet::new();
66 let mut specs = Vec::with_capacity(self.agents.len());
67 for entry in self.agents {
68 let spec = entry.into_spec()?;
69 let id = spec.id().to_string();
70 if !seen.insert(id.clone()) {
71 return Err(AgentConfigError::DuplicateAgentId(id));
72 }
73 specs.push(spec);
74 }
75 Ok(specs)
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
80#[serde(untagged)]
81pub enum AgentConfigEntry {
82 Tagged(TaggedAgentConfigEntry),
83 LegacyLocal(LocalAgentConfig),
84}
85
86impl AgentConfigEntry {
87 pub fn into_spec(self) -> Result<AgentDefinitionSpec, AgentConfigError> {
88 match self {
89 Self::Tagged(TaggedAgentConfigEntry::Local(agent)) => agent.into_spec(),
90 Self::Tagged(TaggedAgentConfigEntry::A2a(agent)) => agent.into_spec(),
91 Self::LegacyLocal(agent) => agent.into_spec(),
92 }
93 }
94
95 pub fn local_model(&self) -> Option<&str> {
96 match self {
97 Self::Tagged(TaggedAgentConfigEntry::Local(agent)) => agent.model.as_deref(),
98 Self::Tagged(TaggedAgentConfigEntry::A2a(_)) => None,
99 Self::LegacyLocal(agent) => agent.model.as_deref(),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
105#[serde(tag = "kind", rename_all = "snake_case")]
106pub enum TaggedAgentConfigEntry {
107 Local(LocalAgentConfig),
108 A2a(A2aAgentConfig),
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
112pub struct LocalAgentConfig {
113 pub id: String,
114 #[serde(default)]
115 pub name: Option<String>,
116 #[serde(default)]
117 pub description: Option<String>,
118 #[serde(default)]
119 pub model: Option<String>,
120 #[serde(default)]
121 pub system_prompt: String,
122 #[serde(default)]
123 pub max_rounds: Option<usize>,
124 #[serde(default)]
125 pub tool_execution_mode: ToolExecutionModeConfig,
126 #[serde(default)]
127 pub behavior_ids: Vec<String>,
128 #[serde(default)]
129 pub stop_condition_specs: Vec<StopConditionSpec>,
130}
131
132impl LocalAgentConfig {
133 pub fn into_spec(self) -> Result<AgentDefinitionSpec, AgentConfigError> {
134 let id = normalize_required_field(None, "id", self.id)?;
135 let name = normalize_optional_text(self.name);
136 let description = normalize_optional_text(self.description).unwrap_or_default();
137 let model = normalize_optional_field(&id, "model", self.model)?;
138 let behavior_ids = normalize_identifier_list(&id, "behavior_ids", self.behavior_ids)?;
139
140 let mut definition = AgentDefinition {
141 id,
142 system_prompt: self.system_prompt,
143 ..Default::default()
144 };
145 if let Some(name) = name {
146 definition = definition.with_name(name);
147 }
148 definition = definition.with_description(description);
149 if let Some(model) = model {
150 definition.model = model;
151 }
152 if let Some(max_rounds) = self.max_rounds {
153 definition.max_rounds = max_rounds;
154 }
155 definition.tool_execution_mode = self.tool_execution_mode.into();
156 definition.behavior_ids = behavior_ids;
157 definition.stop_condition_specs = self.stop_condition_specs;
158 Ok(AgentDefinitionSpec::local(definition))
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
163pub struct A2aAgentConfig {
164 pub id: String,
165 #[serde(default)]
166 pub name: Option<String>,
167 #[serde(default)]
168 pub description: Option<String>,
169 pub endpoint: String,
170 #[serde(default)]
171 pub remote_agent_id: Option<String>,
172 #[serde(default)]
173 pub poll_interval_ms: Option<u64>,
174 #[serde(default)]
175 pub auth: Option<RemoteAuthConfig>,
176}
177
178impl A2aAgentConfig {
179 pub fn into_spec(self) -> Result<AgentDefinitionSpec, AgentConfigError> {
180 let id = normalize_required_field(None, "id", self.id)?;
181 let endpoint = normalize_required_field(Some(&id), "endpoint", self.endpoint)?;
182 let remote_agent_id =
183 normalize_optional_field(&id, "remote_agent_id", self.remote_agent_id)?
184 .unwrap_or_else(|| id.clone());
185 let mut descriptor = AgentDescriptor::new(id.clone());
186 if let Some(name) = normalize_optional_text(self.name) {
187 descriptor = descriptor.with_name(name);
188 }
189 descriptor = descriptor
190 .with_description(normalize_optional_text(self.description).unwrap_or_default());
191 let mut binding = A2aAgentBinding::new(endpoint, remote_agent_id);
192 if let Some(poll_interval_ms) = self.poll_interval_ms {
193 binding = binding.with_poll_interval_ms(poll_interval_ms);
194 }
195 if let Some(auth) = self.auth {
196 binding = binding.with_auth(auth.into_runtime_config(&id)?);
197 }
198 Ok(AgentDefinitionSpec::a2a(descriptor, binding))
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum RemoteAuthConfig {
205 BearerToken { token: String },
206 Header { name: String, value: String },
207}
208
209impl RemoteAuthConfig {
210 fn into_runtime_config(self, agent_id: &str) -> Result<RemoteSecurityConfig, AgentConfigError> {
211 match self {
212 Self::BearerToken { token } => Ok(RemoteSecurityConfig::BearerToken(
213 normalize_required_field(Some(agent_id), "auth.token", token)?,
214 )),
215 Self::Header { name, value } => Ok(RemoteSecurityConfig::Header {
216 name: normalize_required_field(Some(agent_id), "auth.name", name)?,
217 value: normalize_required_field(Some(agent_id), "auth.value", value)?,
218 }),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
224#[serde(rename_all = "snake_case")]
225pub enum ToolExecutionModeConfig {
226 Sequential,
227 ParallelBatchApproval,
228 #[default]
229 ParallelStreaming,
230}
231
232impl From<ToolExecutionModeConfig> for ToolExecutionMode {
233 fn from(value: ToolExecutionModeConfig) -> Self {
234 match value {
235 ToolExecutionModeConfig::Sequential => ToolExecutionMode::Sequential,
236 ToolExecutionModeConfig::ParallelBatchApproval => {
237 ToolExecutionMode::ParallelBatchApproval
238 }
239 ToolExecutionModeConfig::ParallelStreaming => ToolExecutionMode::ParallelStreaming,
240 }
241 }
242}
243
244#[derive(Debug, thiserror::Error)]
245pub enum AgentConfigError {
246 #[error("failed to parse agent config JSON: {0}")]
247 ParseJson(#[from] serde_json::Error),
248 #[error("agent id already configured: {0}")]
249 DuplicateAgentId(String),
250 #[error("field '{field}' must not be blank")]
251 BlankField { field: &'static str },
252 #[error("agent '{agent_id}' field '{field}' must not be blank")]
253 BlankAgentField {
254 agent_id: String,
255 field: &'static str,
256 },
257}
258
259fn normalize_optional_text(value: Option<String>) -> Option<String> {
260 value
261 .map(|value| value.trim().to_string())
262 .filter(|value| !value.is_empty())
263}
264
265fn normalize_required_field(
266 agent_id: Option<&str>,
267 field: &'static str,
268 value: String,
269) -> Result<String, AgentConfigError> {
270 let value = value.trim().to_string();
271 if value.is_empty() {
272 return Err(match agent_id {
273 Some(agent_id) => AgentConfigError::BlankAgentField {
274 agent_id: agent_id.to_string(),
275 field,
276 },
277 None => AgentConfigError::BlankField { field },
278 });
279 }
280 Ok(value)
281}
282
283fn normalize_optional_field(
284 agent_id: &str,
285 field: &'static str,
286 value: Option<String>,
287) -> Result<Option<String>, AgentConfigError> {
288 match value {
289 Some(value) => normalize_required_field(Some(agent_id), field, value).map(Some),
290 None => Ok(None),
291 }
292}
293
294fn normalize_identifier_list(
295 agent_id: &str,
296 field: &'static str,
297 values: Vec<String>,
298) -> Result<Vec<String>, AgentConfigError> {
299 values
300 .into_iter()
301 .map(|value| normalize_required_field(Some(agent_id), field, value))
302 .collect()
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn parses_legacy_local_and_tagged_a2a_into_specs() {
311 let specs = AgentConfig::parse_specs_json(
312 &serde_json::json!({
313 "agents": [
314 {
315 "id": "assistant",
316 "name": "Assistant",
317 "model": "gpt-4o-mini"
318 },
319 {
320 "kind": "a2a",
321 "id": "researcher",
322 "endpoint": "https://example.test/v1/a2a"
323 }
324 ]
325 })
326 .to_string(),
327 )
328 .expect("config should parse");
329
330 assert_eq!(specs.len(), 2);
331 assert_eq!(specs[0].id(), "assistant");
332 assert_eq!(specs[1].id(), "researcher");
333 }
334
335 #[test]
336 fn rejects_duplicate_agent_ids_after_normalization() {
337 let err = AgentConfig::parse_specs_json(
338 &serde_json::json!({
339 "agents": [
340 { "id": "assistant" },
341 { "kind": "a2a", "id": "assistant", "endpoint": "https://example.test/v1/a2a" }
342 ]
343 })
344 .to_string(),
345 )
346 .expect_err("duplicate ids should fail");
347
348 assert!(matches!(err, AgentConfigError::DuplicateAgentId(id) if id == "assistant"));
349 }
350
351 #[test]
352 fn rejects_blank_remote_endpoint_and_auth_header_values() {
353 let err = AgentConfig::parse_specs_json(
354 &serde_json::json!({
355 "agents": [{
356 "kind": "a2a",
357 "id": "researcher",
358 "endpoint": " ",
359 "auth": { "kind": "header", "name": "X-Key", "value": "secret" }
360 }]
361 })
362 .to_string(),
363 )
364 .expect_err("blank endpoint should fail");
365 assert!(
366 matches!(err, AgentConfigError::BlankAgentField { agent_id, field } if agent_id == "researcher" && field == "endpoint")
367 );
368
369 let err = AgentConfig::parse_specs_json(
370 &serde_json::json!({
371 "agents": [{
372 "kind": "a2a",
373 "id": "researcher",
374 "endpoint": "https://example.test/v1/a2a",
375 "auth": { "kind": "header", "name": " ", "value": "secret" }
376 }]
377 })
378 .to_string(),
379 )
380 .expect_err("blank auth header name should fail");
381 assert!(
382 matches!(err, AgentConfigError::BlankAgentField { agent_id, field } if agent_id == "researcher" && field == "auth.name")
383 );
384 }
385
386 #[test]
387 fn emits_json_schema_for_external_tooling() {
388 let schema = AgentConfig::json_schema();
389 let schema_json = serde_json::to_value(&schema).expect("schema should serialize");
390 assert_eq!(schema_json["type"], serde_json::json!("object"));
391 assert!(schema_json["properties"]["agents"].is_object());
392 }
393}