systemprompt-agent 0.2.2

Agent-to-Agent (A2A) protocol for systemprompt.io AI governance: streaming, JSON-RPC models, task lifecycle, .well-known discovery, and governed agent orchestration.
Documentation
pub mod security;
pub mod skills;

use anyhow::{Result, anyhow};
use std::sync::Arc;
use systemprompt_loader::ConfigLoader;
use systemprompt_models::profile_bootstrap::ProfileBootstrap;
use systemprompt_models::{AgentConfig, ServicesConfig};
use tokio::sync::RwLock;

use crate::models::a2a::{
    AgentCapabilities, AgentCard, AgentExtension, AgentInterface, AgentProvider, TransportProtocol,
};
use security::{convert_json_security_to_struct, oauth_to_security_config, override_oauth_urls};
use skills::load_skill_from_disk;
use std::path::Path;

#[derive(Clone, Debug)]
pub struct AgentRegistry {
    config: Arc<RwLock<ServicesConfig>>,
}

impl AgentRegistry {
    pub fn new() -> Result<Self> {
        let config = ConfigLoader::load()?;
        Ok(Self {
            config: Arc::new(RwLock::new(config)),
        })
    }

    pub async fn get_agent(&self, name: &str) -> Result<AgentConfig> {
        let config = self.config.read().await;
        config
            .agents
            .get(name)
            .cloned()
            .ok_or_else(|| anyhow!("Agent not found: {}", name))
    }

    pub async fn list_agents(&self) -> Result<Vec<AgentConfig>> {
        let config = self.config.read().await;
        Ok(config.agents.values().cloned().collect())
    }

    pub async fn list_enabled_agents(&self) -> Result<Vec<AgentConfig>> {
        let config = self.config.read().await;
        let is_cloud = systemprompt_models::Config::get().is_ok_and(|c| c.is_cloud);
        Ok(config
            .agents
            .values()
            .filter(|a| a.enabled && !(a.dev_only && is_cloud))
            .cloned()
            .collect())
    }

    pub async fn get_default_agent(&self) -> Result<AgentConfig> {
        let config = self.config.read().await;
        let is_cloud = systemprompt_models::Config::get().is_ok_and(|c| c.is_cloud);
        config
            .agents
            .values()
            .find(|a| a.default && a.enabled && !(a.dev_only && is_cloud))
            .cloned()
            .ok_or_else(|| anyhow!("No default agent configured"))
    }

    pub async fn to_agent_card(
        &self,
        name: &str,
        api_external_url: &str,
        mcp_extensions: Vec<AgentExtension>,
        runtime_status: Option<(String, Option<u16>, Option<u32>)>,
    ) -> Result<AgentCard> {
        let agent = self.get_agent(name).await?;
        let url = agent.construct_url(api_external_url);

        let extensions = build_extensions(&agent, runtime_status.as_ref(), mcp_extensions);

        let (security_schemes, security) =
            if agent.card.security_schemes.is_some() || agent.card.security.is_some() {
                let (mut schemes, sec) = convert_json_security_to_struct(
                    agent.card.security_schemes.as_ref(),
                    agent.card.security.as_ref(),
                );
                if let Some(ref mut s) = schemes {
                    override_oauth_urls(s, api_external_url);
                }
                (schemes, sec)
            } else {
                oauth_to_security_config(&agent.oauth, api_external_url)
            };

        let all_skills = load_agent_skills(&agent);

        let protocol_binding = match agent.card.preferred_transport.as_str() {
            "GRPC" => TransportProtocol::Grpc,
            "HTTP+JSON" => TransportProtocol::HttpJson,
            _ => TransportProtocol::JsonRpc,
        };

        Ok(AgentCard {
            name: agent.name.clone(),
            description: agent.card.description.clone(),
            supported_interfaces: vec![AgentInterface {
                url,
                protocol_binding,
                protocol_version: agent.card.protocol_version.clone(),
            }],
            version: agent.card.version.clone(),
            icon_url: agent.card.icon_url.clone(),
            documentation_url: agent.card.documentation_url.clone(),
            provider: agent.card.provider.as_ref().map(|p| AgentProvider {
                organization: p.organization.clone(),
                url: p.url.clone(),
            }),
            capabilities: AgentCapabilities {
                streaming: Some(agent.card.capabilities.streaming),
                push_notifications: Some(agent.card.capabilities.push_notifications),
                state_transition_history: Some(agent.card.capabilities.state_transition_history),
                extensions: Some(extensions),
            },
            default_input_modes: agent.card.default_input_modes.clone(),
            default_output_modes: agent.card.default_output_modes.clone(),
            supports_authenticated_extended_card: Some(
                agent.card.supports_authenticated_extended_card,
            ),
            skills: all_skills,
            security_schemes,
            security,
            signatures: None,
        })
    }

    pub async fn reload(&self) -> Result<()> {
        let new_config = ConfigLoader::load()?;
        let mut config = self.config.write().await;
        *config = new_config;
        drop(config);
        Ok(())
    }

    pub async fn get_mcp_servers(&self, agent_name: &str) -> Result<Vec<String>> {
        let agent = self.get_agent(agent_name).await?;
        Ok(agent.metadata.mcp_servers)
    }

    pub async fn find_next_available_port(&self) -> Result<u16> {
        const BASE_PORT: u16 = 9000;
        const MAX_PORT: u16 = 9999;

        let agents = self.list_agents().await?;
        let used_ports: Vec<u16> = agents.iter().map(|a| a.port).collect();

        for port in BASE_PORT..=MAX_PORT {
            if !used_ports.contains(&port) {
                return Ok(port);
            }
        }

        Err(anyhow!(
            "No available ports in range {}-{}",
            BASE_PORT,
            MAX_PORT
        ))
    }
}

fn build_extensions(
    agent: &AgentConfig,
    runtime_status: Option<&(String, Option<u16>, Option<u32>)>,
    mcp_extensions: Vec<AgentExtension>,
) -> Vec<AgentExtension> {
    let mut extensions = vec![AgentExtension::agent_identity(&agent.name)];

    if let Some(prompt) = &agent.metadata.system_prompt {
        extensions.push(AgentExtension::system_instructions(prompt));
    }

    if let Some((status, port, pid)) = runtime_status {
        extensions.push(AgentExtension::service_status(
            status,
            *port,
            *pid,
            agent.default,
        ));
    }

    extensions.extend(mcp_extensions);
    extensions
}

fn load_agent_skills(agent: &AgentConfig) -> Vec<crate::models::a2a::AgentSkill> {
    let mut all_skills = Vec::new();

    let skills_path = ProfileBootstrap::get().map_or_else(|_| String::new(), |p| p.paths.skills());

    if !skills_path.is_empty() {
        let skills_dir = Path::new(&skills_path);
        for skill_id in &agent.metadata.skills {
            let skill_id_typed = systemprompt_identifiers::SkillId::new(skill_id);
            match load_skill_from_disk(skills_dir, &skill_id_typed) {
                Ok(skill) => all_skills.push(skill),
                Err(e) => {
                    tracing::warn!(
                        skill_id = %skill_id,
                        error = %e,
                        "Failed to load skill for agent card, skipping"
                    );
                },
            }
        }
    }

    all_skills
}