microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use serde::{Deserialize, Serialize};

use crate::config::Config;

pub const A2A_PROTOCOL_VERSION: &str = "microclaw-a2a/v1";
pub const A2A_AGENT_CARD_PATH: &str = "/api/a2a/agent-card";
pub const A2A_MESSAGE_PATH: &str = "/api/a2a/message";

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct A2AAgentCard {
    pub protocol_version: String,
    pub agent_id: String,
    pub agent_name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub public_base_url: Option<String>,
    pub endpoints: A2AEndpoints,
    pub capabilities: Vec<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct A2AEndpoints {
    pub agent_card: String,
    pub message: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2AMessageRequest {
    #[serde(default)]
    pub session_key: Option<String>,
    #[serde(default)]
    pub sender_name: Option<String>,
    #[serde(default)]
    pub source_agent: Option<String>,
    #[serde(default)]
    pub source_url: Option<String>,
    pub message: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct A2AMessageResponse {
    pub ok: bool,
    pub protocol_version: String,
    pub agent_name: String,
    pub session_key: String,
    pub response: String,
}

pub fn normalize_peer_name(name: &str) -> Option<String> {
    let trimmed = name.trim().to_ascii_lowercase();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

pub fn normalize_base_url(raw: &str) -> Option<String> {
    let trimmed = raw.trim().trim_end_matches('/');
    if trimmed.is_empty() {
        return None;
    }
    let parsed = reqwest::Url::parse(trimmed).ok()?;
    match parsed.scheme() {
        "http" | "https" => Some(trimmed.to_string()),
        _ => None,
    }
}

pub fn local_agent_name(config: &Config) -> String {
    config
        .a2a
        .agent_name
        .as_deref()
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| {
            let bot = config.bot_username.trim();
            if bot.is_empty() {
                None
            } else {
                Some(bot.to_string())
            }
        })
        .unwrap_or_else(|| "MicroClaw".to_string())
}

pub fn default_session_key_for_source(source_agent: Option<&str>) -> String {
    source_agent
        .and_then(normalize_peer_name)
        .map(|v| format!("a2a:{v}"))
        .unwrap_or_else(|| "a2a:remote".to_string())
}

pub fn build_agent_card(config: &Config) -> A2AAgentCard {
    let base_url = config
        .a2a
        .public_base_url
        .as_deref()
        .and_then(normalize_base_url);
    let agent_name = local_agent_name(config);
    let agent_id = agent_name
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() {
                ch.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect::<String>()
        .trim_matches('-')
        .to_string();
    let prefix = base_url.clone().unwrap_or_default();
    A2AAgentCard {
        protocol_version: A2A_PROTOCOL_VERSION.to_string(),
        agent_id: if agent_id.is_empty() {
            "microclaw".to_string()
        } else {
            agent_id
        },
        agent_name,
        description: config.a2a.agent_description.clone(),
        public_base_url: base_url,
        endpoints: A2AEndpoints {
            agent_card: format!("{prefix}{A2A_AGENT_CARD_PATH}"),
            message: format!("{prefix}{A2A_MESSAGE_PATH}"),
        },
        capabilities: vec!["sync-message".to_string()],
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{A2APeerConfig, Config};

    #[test]
    fn test_normalize_base_url() {
        assert_eq!(
            normalize_base_url(" https://example.com/base/ "),
            Some("https://example.com/base".to_string())
        );
        assert_eq!(normalize_base_url("file:///tmp"), None);
    }

    #[test]
    fn test_build_agent_card_uses_config() {
        let mut cfg = Config::test_defaults();
        cfg.a2a.enabled = true;
        cfg.a2a.agent_name = Some("Planner".into());
        cfg.a2a.agent_description = Some("Routes work".into());
        cfg.a2a.public_base_url = Some("https://mc.example.com/".into());
        cfg.a2a.peers.insert(
            "worker".into(),
            A2APeerConfig {
                enabled: true,
                base_url: "https://worker.example.com".into(),
                bearer_token: None,
                description: None,
                default_session_key: None,
                trust: Default::default(),
            },
        );

        let card = build_agent_card(&cfg);
        assert_eq!(card.agent_name, "Planner");
        assert_eq!(card.agent_id, "planner");
        assert_eq!(
            card.endpoints.message,
            "https://mc.example.com/api/a2a/message"
        );
    }
}