tirea-agentos 0.4.0

Agent runtime with streaming LLM integration, sub-agent orchestration, and context window management
Documentation
use super::{
    A2aAgentBinding, AgentDefinition, AgentDefinitionSpec, AgentDescriptor, RemoteSecurityConfig,
    StopConditionSpec, ToolExecutionMode,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;

#[cfg(feature = "skills")]
#[derive(Debug, Clone)]
pub struct SkillsConfig {
    pub enabled: bool,
    pub advertise_catalog: bool,
    pub discovery_max_entries: usize,
    pub discovery_max_chars: usize,
}

#[cfg(feature = "skills")]
impl Default for SkillsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            advertise_catalog: true,
            discovery_max_entries: 32,
            discovery_max_chars: 16 * 1024,
        }
    }
}

#[derive(Debug, Clone)]
pub struct AgentToolsConfig {
    pub discovery_max_entries: usize,
    pub discovery_max_chars: usize,
}

impl Default for AgentToolsConfig {
    fn default() -> Self {
        Self {
            discovery_max_entries: 64,
            discovery_max_chars: 16 * 1024,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgentConfig {
    pub agents: Vec<AgentConfigEntry>,
}

impl AgentConfig {
    pub fn from_json_str(raw: &str) -> Result<Self, AgentConfigError> {
        serde_json::from_str(raw).map_err(AgentConfigError::ParseJson)
    }

    pub fn parse_specs_json(raw: &str) -> Result<Vec<AgentDefinitionSpec>, AgentConfigError> {
        Self::from_json_str(raw)?.into_specs()
    }

    #[must_use]
    pub fn json_schema() -> schemars::Schema {
        schemars::schema_for!(Self)
    }

    pub fn into_specs(self) -> Result<Vec<AgentDefinitionSpec>, AgentConfigError> {
        let mut seen = HashSet::new();
        let mut specs = Vec::with_capacity(self.agents.len());
        for entry in self.agents {
            let spec = entry.into_spec()?;
            let id = spec.id().to_string();
            if !seen.insert(id.clone()) {
                return Err(AgentConfigError::DuplicateAgentId(id));
            }
            specs.push(spec);
        }
        Ok(specs)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum AgentConfigEntry {
    Tagged(TaggedAgentConfigEntry),
    LegacyLocal(LocalAgentConfig),
}

impl AgentConfigEntry {
    pub fn into_spec(self) -> Result<AgentDefinitionSpec, AgentConfigError> {
        match self {
            Self::Tagged(TaggedAgentConfigEntry::Local(agent)) => agent.into_spec(),
            Self::Tagged(TaggedAgentConfigEntry::A2a(agent)) => agent.into_spec(),
            Self::LegacyLocal(agent) => agent.into_spec(),
        }
    }

    pub fn local_model(&self) -> Option<&str> {
        match self {
            Self::Tagged(TaggedAgentConfigEntry::Local(agent)) => agent.model.as_deref(),
            Self::Tagged(TaggedAgentConfigEntry::A2a(_)) => None,
            Self::LegacyLocal(agent) => agent.model.as_deref(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TaggedAgentConfigEntry {
    Local(LocalAgentConfig),
    A2a(A2aAgentConfig),
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LocalAgentConfig {
    pub id: String,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default)]
    pub system_prompt: String,
    #[serde(default)]
    pub max_rounds: Option<usize>,
    #[serde(default)]
    pub tool_execution_mode: ToolExecutionModeConfig,
    #[serde(default)]
    pub behavior_ids: Vec<String>,
    #[serde(default)]
    pub stop_condition_specs: Vec<StopConditionSpec>,
}

impl LocalAgentConfig {
    pub fn into_spec(self) -> Result<AgentDefinitionSpec, AgentConfigError> {
        let id = normalize_required_field(None, "id", self.id)?;
        let name = normalize_optional_text(self.name);
        let description = normalize_optional_text(self.description).unwrap_or_default();
        let model = normalize_optional_field(&id, "model", self.model)?;
        let behavior_ids = normalize_identifier_list(&id, "behavior_ids", self.behavior_ids)?;

        let mut definition = AgentDefinition {
            id,
            system_prompt: self.system_prompt,
            ..Default::default()
        };
        if let Some(name) = name {
            definition = definition.with_name(name);
        }
        definition = definition.with_description(description);
        if let Some(model) = model {
            definition.model = model;
        }
        if let Some(max_rounds) = self.max_rounds {
            definition.max_rounds = max_rounds;
        }
        definition.tool_execution_mode = self.tool_execution_mode.into();
        definition.behavior_ids = behavior_ids;
        definition.stop_condition_specs = self.stop_condition_specs;
        Ok(AgentDefinitionSpec::local(definition))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct A2aAgentConfig {
    pub id: String,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    pub endpoint: String,
    #[serde(default)]
    pub remote_agent_id: Option<String>,
    #[serde(default)]
    pub poll_interval_ms: Option<u64>,
    #[serde(default)]
    pub auth: Option<RemoteAuthConfig>,
}

impl A2aAgentConfig {
    pub fn into_spec(self) -> Result<AgentDefinitionSpec, AgentConfigError> {
        let id = normalize_required_field(None, "id", self.id)?;
        let endpoint = normalize_required_field(Some(&id), "endpoint", self.endpoint)?;
        let remote_agent_id =
            normalize_optional_field(&id, "remote_agent_id", self.remote_agent_id)?
                .unwrap_or_else(|| id.clone());
        let mut descriptor = AgentDescriptor::new(id.clone());
        if let Some(name) = normalize_optional_text(self.name) {
            descriptor = descriptor.with_name(name);
        }
        descriptor = descriptor
            .with_description(normalize_optional_text(self.description).unwrap_or_default());
        let mut binding = A2aAgentBinding::new(endpoint, remote_agent_id);
        if let Some(poll_interval_ms) = self.poll_interval_ms {
            binding = binding.with_poll_interval_ms(poll_interval_ms);
        }
        if let Some(auth) = self.auth {
            binding = binding.with_auth(auth.into_runtime_config(&id)?);
        }
        Ok(AgentDefinitionSpec::a2a(descriptor, binding))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RemoteAuthConfig {
    BearerToken { token: String },
    Header { name: String, value: String },
}

impl RemoteAuthConfig {
    fn into_runtime_config(self, agent_id: &str) -> Result<RemoteSecurityConfig, AgentConfigError> {
        match self {
            Self::BearerToken { token } => Ok(RemoteSecurityConfig::BearerToken(
                normalize_required_field(Some(agent_id), "auth.token", token)?,
            )),
            Self::Header { name, value } => Ok(RemoteSecurityConfig::Header {
                name: normalize_required_field(Some(agent_id), "auth.name", name)?,
                value: normalize_required_field(Some(agent_id), "auth.value", value)?,
            }),
        }
    }
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionModeConfig {
    Sequential,
    ParallelBatchApproval,
    #[default]
    ParallelStreaming,
}

impl From<ToolExecutionModeConfig> for ToolExecutionMode {
    fn from(value: ToolExecutionModeConfig) -> Self {
        match value {
            ToolExecutionModeConfig::Sequential => ToolExecutionMode::Sequential,
            ToolExecutionModeConfig::ParallelBatchApproval => {
                ToolExecutionMode::ParallelBatchApproval
            }
            ToolExecutionModeConfig::ParallelStreaming => ToolExecutionMode::ParallelStreaming,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum AgentConfigError {
    #[error("failed to parse agent config JSON: {0}")]
    ParseJson(#[from] serde_json::Error),
    #[error("agent id already configured: {0}")]
    DuplicateAgentId(String),
    #[error("field '{field}' must not be blank")]
    BlankField { field: &'static str },
    #[error("agent '{agent_id}' field '{field}' must not be blank")]
    BlankAgentField {
        agent_id: String,
        field: &'static str,
    },
}

fn normalize_optional_text(value: Option<String>) -> Option<String> {
    value
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn normalize_required_field(
    agent_id: Option<&str>,
    field: &'static str,
    value: String,
) -> Result<String, AgentConfigError> {
    let value = value.trim().to_string();
    if value.is_empty() {
        return Err(match agent_id {
            Some(agent_id) => AgentConfigError::BlankAgentField {
                agent_id: agent_id.to_string(),
                field,
            },
            None => AgentConfigError::BlankField { field },
        });
    }
    Ok(value)
}

fn normalize_optional_field(
    agent_id: &str,
    field: &'static str,
    value: Option<String>,
) -> Result<Option<String>, AgentConfigError> {
    match value {
        Some(value) => normalize_required_field(Some(agent_id), field, value).map(Some),
        None => Ok(None),
    }
}

fn normalize_identifier_list(
    agent_id: &str,
    field: &'static str,
    values: Vec<String>,
) -> Result<Vec<String>, AgentConfigError> {
    values
        .into_iter()
        .map(|value| normalize_required_field(Some(agent_id), field, value))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_legacy_local_and_tagged_a2a_into_specs() {
        let specs = AgentConfig::parse_specs_json(
            &serde_json::json!({
                "agents": [
                    {
                        "id": "assistant",
                        "name": "Assistant",
                        "model": "gpt-4o-mini"
                    },
                    {
                        "kind": "a2a",
                        "id": "researcher",
                        "endpoint": "https://example.test/v1/a2a"
                    }
                ]
            })
            .to_string(),
        )
        .expect("config should parse");

        assert_eq!(specs.len(), 2);
        assert_eq!(specs[0].id(), "assistant");
        assert_eq!(specs[1].id(), "researcher");
    }

    #[test]
    fn rejects_duplicate_agent_ids_after_normalization() {
        let err = AgentConfig::parse_specs_json(
            &serde_json::json!({
                "agents": [
                    { "id": "assistant" },
                    { "kind": "a2a", "id": "assistant", "endpoint": "https://example.test/v1/a2a" }
                ]
            })
            .to_string(),
        )
        .expect_err("duplicate ids should fail");

        assert!(matches!(err, AgentConfigError::DuplicateAgentId(id) if id == "assistant"));
    }

    #[test]
    fn rejects_blank_remote_endpoint_and_auth_header_values() {
        let err = AgentConfig::parse_specs_json(
            &serde_json::json!({
                "agents": [{
                    "kind": "a2a",
                    "id": "researcher",
                    "endpoint": "   ",
                    "auth": { "kind": "header", "name": "X-Key", "value": "secret" }
                }]
            })
            .to_string(),
        )
        .expect_err("blank endpoint should fail");
        assert!(
            matches!(err, AgentConfigError::BlankAgentField { agent_id, field } if agent_id == "researcher" && field == "endpoint")
        );

        let err = AgentConfig::parse_specs_json(
            &serde_json::json!({
                "agents": [{
                    "kind": "a2a",
                    "id": "researcher",
                    "endpoint": "https://example.test/v1/a2a",
                    "auth": { "kind": "header", "name": " ", "value": "secret" }
                }]
            })
            .to_string(),
        )
        .expect_err("blank auth header name should fail");
        assert!(
            matches!(err, AgentConfigError::BlankAgentField { agent_id, field } if agent_id == "researcher" && field == "auth.name")
        );
    }

    #[test]
    fn emits_json_schema_for_external_tooling() {
        let schema = AgentConfig::json_schema();
        let schema_json = serde_json::to_value(&schema).expect("schema should serialize");
        assert_eq!(schema_json["type"], serde_json::json!("object"));
        assert!(schema_json["properties"]["agents"].is_object());
    }
}