use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use mentra::a2a::A2aClient;
use serde::{Deserialize, Serialize};
use super::{A2aAgent, A2aError};
pub(crate) struct RemoteAgent {
name: String,
client: Option<Arc<A2aClient>>,
}
impl RemoteAgent {
pub(crate) fn client(&self) -> Option<&Arc<A2aClient>> {
self.client.as_ref()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Conversation {
pub(crate) context_id: Option<String>,
pub(crate) outstanding: Option<Outstanding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Outstanding {
pub(crate) task_id: String,
pub(crate) waiting_for: Waiting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Waiting {
Agent,
Input,
}
pub(crate) struct RemoteAgents {
agents: Vec<RemoteAgent>,
conversations: Mutex<HashMap<String, Conversations>>,
remembered_in: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Conversations {
agents: BTreeMap<String, Conversation>,
}
impl RemoteAgents {
pub(crate) async fn connect(agents: Vec<A2aAgent>, remembered_in: Option<PathBuf>) -> Self {
let mut connected = Vec::with_capacity(agents.len());
for agent in agents {
let config = agent.into_config();
let client = match A2aClient::connect(&config).await {
Ok(client) => Some(Arc::new(client)),
Err(error) => {
eprintln!(
"Warning: A2A agent '{}' failed to connect: {error}",
config.name
);
None
}
};
connected.push(RemoteAgent {
name: config.name,
client,
});
}
Self {
agents: connected,
conversations: Mutex::new(HashMap::new()),
remembered_in,
}
}
pub(crate) fn names(&self) -> Vec<String> {
self.agents.iter().map(|agent| agent.name.clone()).collect()
}
pub(crate) fn get(&self, name: &str) -> Option<&RemoteAgent> {
self.agents.iter().find(|agent| agent.name == name)
}
pub(crate) fn reachable(&self) -> impl Iterator<Item = (&str, &Arc<A2aClient>)> {
self.agents
.iter()
.filter_map(|agent| Some((agent.name.as_str(), agent.client.as_ref()?)))
}
pub(crate) fn conversation(
&self,
caller: &str,
remote: &str,
remembered: bool,
) -> Conversation {
let mut conversations = self
.conversations
.lock()
.expect("A2A conversation map poisoned");
if !conversations.contains_key(caller) {
let loaded = self
.file(caller, remembered)
.map(|file| load(&file))
.unwrap_or_default();
conversations.insert(caller.to_string(), loaded);
}
conversations
.get(caller)
.and_then(|own| own.agents.get(remote))
.cloned()
.unwrap_or_default()
}
pub(crate) fn record(
&self,
caller: &str,
remote: &str,
conversation: Conversation,
remembered: bool,
) -> std::io::Result<()> {
let mut conversations = self
.conversations
.lock()
.expect("A2A conversation map poisoned");
let own = conversations.entry(caller.to_string()).or_default();
own.agents.insert(remote.to_string(), conversation);
match self.file(caller, remembered) {
Some(file) => store(&file, own),
None => Ok(()),
}
}
fn file(&self, caller: &str, remembered: bool) -> Option<PathBuf> {
if !remembered || !is_file_safe(caller) {
return None;
}
Some(self.remembered_in.as_ref()?.join(format!("{caller}.json")))
}
}
fn is_file_safe(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn load(file: &Path) -> Conversations {
match std::fs::read(file) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
eprintln!(
"Warning: ignoring unreadable A2A conversation record {}: {error}",
file.display()
);
Conversations::default()
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Conversations::default(),
Err(error) => {
eprintln!(
"Warning: ignoring unreadable A2A conversation record {}: {error}",
file.display()
);
Conversations::default()
}
}
}
fn store(file: &Path, conversations: &Conversations) -> std::io::Result<()> {
let dir = file.parent().expect("a record file has a directory");
std::fs::create_dir_all(dir)?;
let bytes = serde_json::to_vec_pretty(conversations).map_err(std::io::Error::other)?;
let staged = file.with_extension("json.tmp");
std::fs::write(&staged, bytes)?;
std::fs::rename(&staged, file)
}
pub(crate) fn forget(dir: &Path, agent_id: &str) -> Result<(), A2aError> {
if !is_file_safe(agent_id) {
return Ok(());
}
let path = dir.join(format!("{agent_id}.json"));
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(A2aError::Forget { path, source }),
}
}
impl std::fmt::Debug for RemoteAgents {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_list()
.entries(
self.agents
.iter()
.map(|agent| (&agent.name, agent.client.is_some())),
)
.finish()
}
}
#[cfg(test)]
impl RemoteAgents {
pub(crate) fn unreachable(names: &[&str]) -> Self {
Self {
agents: names
.iter()
.map(|name| RemoteAgent {
name: (*name).to_string(),
client: None,
})
.collect(),
conversations: Mutex::new(HashMap::new()),
remembered_in: None,
}
}
pub(crate) fn unreachable_in(names: &[&str], dir: &Path) -> Self {
Self {
remembered_in: Some(dir.to_path_buf()),
..Self::unreachable(names)
}
}
}
#[cfg(test)]
mod tests;