pub(crate) mod configured;
mod file;
pub(crate) mod live;
mod peer;
mod reload;
mod watch;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::context::ContextScope;
pub use configured::A2A_RETRY_BACKOFF;
pub use mentra::a2a::{A2aAgentConfig, A2aLimits};
pub use mentra::mcp::SecretString;
pub use peer::A2aPeer;
pub use reload::A2aReload;
pub use watch::{A2aWatch, A2aWatchError, DEFAULT_A2A_WATCH_INTERVAL};
pub const HOPS_EXTENSION: &str = "https://github.com/oops-rs/basis/blob/main/docs/a2a/hops-v1.md";
pub const DEFAULT_WORKSPACE_A2A_FILE: &str = ".basis/a2a.json";
pub const DEFAULT_GLOBAL_A2A_FILE: &str = "a2a.json";
#[derive(Clone)]
pub struct A2aAgent(A2aAgentConfig);
impl A2aAgent {
pub fn new(config: A2aAgentConfig) -> Self {
Self(config)
}
pub fn name(&self) -> &str {
&self.0.name
}
pub fn config(&self) -> &A2aAgentConfig {
&self.0
}
pub fn into_config(self) -> A2aAgentConfig {
self.0
}
pub(crate) fn endpoint(&self) -> String {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let parts = [self.0.name.as_str(), self.0.url.as_str()]
.into_iter()
.chain(self.0.headers.keys().map(String::as_str));
let mut hash = OFFSET;
for part in parts {
for byte in part.bytes().chain([0xff]) {
hash = (hash ^ u64::from(byte)).wrapping_mul(PRIME);
}
}
format!("{hash:016x}")
}
pub(crate) fn connects_like(&self, other: &Self) -> bool {
let A2aAgentConfig {
name,
url,
headers,
allow_plaintext_credentials,
limits,
} = &self.0;
*name == other.0.name
&& *url == other.0.url
&& *headers == other.0.headers
&& *allow_plaintext_credentials == other.0.allow_plaintext_credentials
&& *limits == other.0.limits
}
}
impl From<A2aAgentConfig> for A2aAgent {
fn from(config: A2aAgentConfig) -> Self {
Self(config)
}
}
impl std::fmt::Debug for A2aAgent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aAgent")
.field("name", &self.0.name)
.field("url", &crate::redaction::redacted_url(&self.0.url))
.field("headers", &self.0.headers)
.field(
"allow_plaintext_credentials",
&self.0.allow_plaintext_credentials,
)
.field("limits", &self.0.limits)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct A2aConfig {
pub workspace_file: PathBuf,
pub global_dir: Option<PathBuf>,
pub supplied: Vec<A2aAgent>,
}
impl Default for A2aConfig {
fn default() -> Self {
Self {
workspace_file: PathBuf::from(DEFAULT_WORKSPACE_A2A_FILE),
global_dir: crate::context::default_global_dir(),
supplied: Vec::new(),
}
}
}
impl A2aConfig {
pub fn with_supplied(self, supplied: Vec<A2aAgent>) -> Self {
Self { supplied, ..self }
}
pub fn supplied_only(self) -> Self {
Self {
workspace_file: PathBuf::new(),
global_dir: None,
..self
}
}
}
#[derive(Debug, Clone)]
pub struct A2aSource {
pub path: PathBuf,
pub scope: ContextScope,
pub agents: Vec<A2aAgent>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum A2aError {
#[error("failed to read A2A configuration {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is not a valid A2A configuration: {problem} at line {line}, column {column}")]
Parse {
path: PathBuf,
problem: &'static str,
line: usize,
column: usize,
},
#[error("{path} has no `agents` object")]
NoAgents { path: PathBuf },
#[error("{origin}: A2A agent `{name}` {reason}")]
Invalid {
origin: String,
name: String,
reason: String,
},
}
pub fn discover(workspace: &Path, config: &A2aConfig) -> Result<Vec<A2aSource>, A2aError> {
let mut sources = Vec::new();
if let Some(workspace_file) = crate::paths::candidate(workspace, &config.workspace_file)
&& workspace_file.is_file()
{
sources.push(read(workspace_file, ContextScope::Workspace)?);
}
if let Some(global) = &config.global_dir {
let global_file = global.join(DEFAULT_GLOBAL_A2A_FILE);
if global_file.is_file()
&& !sources
.iter()
.any(|source| crate::paths::same_dir(&source.path, &global_file))
{
sources.push(read(global_file, ContextScope::Global)?);
}
}
Ok(sources)
}
pub fn agents(workspace: &Path, config: &A2aConfig) -> Result<Vec<A2aAgent>, A2aError> {
Ok(layered(supplied(config)?, discover(workspace, config)?))
}
pub(crate) fn layered(supplied: Vec<A2aAgent>, discovered: Vec<A2aSource>) -> Vec<A2aAgent> {
layer(
supplied
.into_iter()
.chain(discovered.into_iter().flat_map(|source| source.agents)),
)
}
pub(crate) fn supplied(config: &A2aConfig) -> Result<Vec<A2aAgent>, A2aError> {
for agent in &config.supplied {
agent
.config()
.validate()
.map_err(|error| A2aError::Invalid {
origin: "the supplied A2A agent list".to_string(),
name: agent.name().to_string(),
reason: file::refused(&error),
})?;
}
Ok(layer(config.supplied.iter().cloned()))
}
fn layer(agents: impl IntoIterator<Item = A2aAgent>) -> Vec<A2aAgent> {
let mut seen = std::collections::BTreeSet::new();
agents
.into_iter()
.filter(|agent| seen.insert(agent.name().to_string()))
.collect()
}
fn read(path: PathBuf, scope: ContextScope) -> Result<A2aSource, A2aError> {
let text = std::fs::read_to_string(&path).map_err(|source| A2aError::Read {
path: path.clone(),
source,
})?;
let agents = file::parse(&path, &text)?;
Ok(A2aSource {
path,
scope,
agents,
})
}
#[cfg(test)]
mod tests;