tirea_agentos/composition/
config.rs

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}