car-a2a 0.16.1

Bridge between Common Agent Runtime and the Linux Foundation Agent2Agent (A2A) v1.0 protocol
Documentation
//! Default Agent Card builder.
//!
//! Builds an A2A `AgentCard` from a live `Runtime` so peers see the
//! tools currently registered on this CAR instance as advertised
//! skills. Embedders that want to override identity, security
//! schemes, or extra metadata mutate the card after the call returns.

use car_a2ui::A2UI_MIME_TYPE;
use car_engine::Runtime;
use std::collections::HashMap;

use crate::types::{
    AgentCapabilities, AgentCard, AgentInterface, AgentProvider, AgentSkill, TransportProtocol,
};

/// Configuration for [`build_default_agent_card`].
///
/// Required: `name`, `description`, `url` (the public address
/// peers will reach this agent at), and the `provider`. Everything
/// else has reasonable defaults.
#[derive(Debug, Clone)]
pub struct AgentCardConfig {
    pub name: String,
    pub description: String,
    /// Public URL where this agent is reachable. Becomes the card's
    /// `url` field and the default interface entry.
    pub url: String,
    pub provider: AgentProvider,
    /// Override the default capabilities (`streaming` and
    /// `pushNotifications` both `true`, `stateTransitionHistory` `false`).
    pub capabilities: Option<AgentCapabilities>,
    /// Additional protocol interfaces beyond the JSON-RPC default.
    /// Useful when the same agent is also reachable via gRPC.
    pub extra_interfaces: Vec<AgentInterface>,
    /// Default input modes advertised on the card.
    pub default_input_modes: Vec<String>,
    /// Default output modes advertised on the card.
    pub default_output_modes: Vec<String>,
}

impl AgentCardConfig {
    /// Minimal config — the four required fields, everything else
    /// defaults.
    pub fn minimal(
        name: impl Into<String>,
        description: impl Into<String>,
        url: impl Into<String>,
        provider: AgentProvider,
    ) -> Self {
        Self {
            name: name.into(),
            description: description.into(),
            url: url.into(),
            provider,
            capabilities: None,
            extra_interfaces: Vec::new(),
            default_input_modes: vec!["text".into(), "data".into()],
            default_output_modes: vec!["text".into(), "data".into(), A2UI_MIME_TYPE.into()],
        }
    }
}

/// Build an `AgentCard` reflecting the runtime's currently registered
/// tools.
///
/// One [`AgentSkill`] is emitted per tool. The skill's `id` is the
/// tool name, its `description` comes from the tool schema, and its
/// `tags` carry the tool's idempotency / cache / rate-limit flags so
/// peer planners can reason about safe retries.
pub async fn build_default_agent_card(runtime: &Runtime, config: AgentCardConfig) -> AgentCard {
    let schemas = runtime.tool_schemas().await;
    let skills: Vec<AgentSkill> = schemas
        .into_iter()
        .map(|s| {
            let mut tags = Vec::new();
            if s.idempotent {
                tags.push("idempotent".into());
            }
            if s.cache_ttl_secs.is_some() {
                tags.push("cacheable".into());
            }
            if s.rate_limit.is_some() {
                tags.push("rate-limited".into());
            }
            AgentSkill {
                id: s.name.clone(),
                name: s.name,
                description: s.description,
                tags,
                examples: Vec::new(),
                input_modes: vec!["data".into()],
                output_modes: vec!["data".into(), A2UI_MIME_TYPE.into()],
            }
        })
        .collect();

    // Build the canonical v1.0 supported_interfaces list. First entry
    // is the preferred binding (mirrors the top-level v0.3 `url` /
    // `preferred_transport` for cross-version emission).
    let primary_interface = AgentInterface {
        url: config.url.clone(),
        protocol_binding: "JSONRPC".into(),
        transport: Some(TransportProtocol::JsonRpc),
        tenant: None,
        protocol_version: "1.0".into(),
    };
    let mut supported_interfaces = vec![primary_interface.clone()];
    supported_interfaces.extend(config.extra_interfaces.clone());
    // v0.3 alias = supported list minus the primary entry, by
    // convention. Empty when only the primary is supported.
    let additional_interfaces: Vec<AgentInterface> = config.extra_interfaces;

    AgentCard {
        name: config.name,
        description: config.description,
        url: config.url,
        version: "1.0.0".into(),
        // The bridge accepts BOTH A2A v1.0 PascalCase method names
        // (`SendMessage`, `GetTask`, ...) and v0.3 slash aliases
        // (`message/send`, `tasks/get`, ...). Advertise the higher
        // supported version. v0.3-only peers continue to work
        // unchanged because the slash methods stay wired.
        protocol_version: "1.0".into(),
        preferred_transport: Some("JSONRPC".into()),
        provider: config.provider,
        // Honest defaults.
        //
        // `streaming: true` is now accurate — the bridge serves
        // `message/stream` and `tasks/resubscribe` as JSON-RPC
        // methods that switch the response to SSE, plus the
        // out-of-band `GET /a2a/stream/:task_id` endpoint.
        //
        // `push_notifications: false` stays — the bundled
        // `PushDispatcher` is fire-and-forget with no retries / no
        // signed payloads / no replay protection. Embedders that
        // wrap those capabilities flip the flag via
        // `config.capabilities`.
        capabilities: config.capabilities.unwrap_or(AgentCapabilities {
            streaming: true,
            push_notifications: false,
            state_transition_history: false,
            // v1.0 mirror of the v0.3 top-level
            // `supportsAuthenticatedExtendedCard` — bridge serves
            // `GetExtendedAgentCard` so this is honestly true.
            extended_agent_card: false,
            extensions: vec![serde_json::json!({
                "uri": "https://a2ui.org/specification/v0.9-a2ui/",
                "description": "A2UI v0.9 declarative UI surfaces in data parts",
                "outputMode": A2UI_MIME_TYPE
            })],
        }),
        default_input_modes: config.default_input_modes,
        default_output_modes: config.default_output_modes,
        skills,
        documentation_url: None,
        icon_url: None,
        supported_interfaces,
        additional_interfaces,
        security_schemes: HashMap::new(),
        supports_authenticated_extended_card: false,
        security_requirements: vec![],
        signatures: vec![],
    }
}

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

    #[tokio::test]
    async fn skills_mirror_registered_tools() {
        let runtime = Runtime::new();
        runtime
            .register_tool_schema(ToolSchema {
                name: "echo".into(),
                description: "Echo input".into(),
                parameters: serde_json::json!({"type": "object"}),
                returns: None,
                idempotent: true,
                cache_ttl_secs: Some(60),
                rate_limit: None,
            })
            .await;

        let card = build_default_agent_card(
            &runtime,
            AgentCardConfig::minimal(
                "test",
                "test agent",
                "https://example.test",
                AgentProvider {
                    organization: "Parslee".into(),
                    url: None,
                },
            ),
        )
        .await;

        assert_eq!(card.skills.len(), 1);
        let skill = &card.skills[0];
        assert_eq!(skill.id, "echo");
        assert!(skill.tags.contains(&"idempotent".to_string()));
        assert!(skill.tags.contains(&"cacheable".to_string()));
        assert_eq!(card.version, "1.0.0");
        // streaming is on (message/stream + tasks/resubscribe land
        // honestly via the SSE response on POST). push_notifications
        // stays off until PushDispatcher gains retries / signing.
        assert!(card.capabilities.streaming);
        assert!(!card.capabilities.push_notifications);
    }
}