use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
sync::Mutex,
};
use serde::{Deserialize, Serialize};
use crate::a2a::A2aError;
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) endpoint: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Waiting {
Agent,
Input,
}
pub(super) struct Book {
by_caller: Mutex<HashMap<String, Conversations>>,
remembered_in: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Conversations {
agents: BTreeMap<String, Conversation>,
}
impl Book {
pub(super) fn new(remembered_in: Option<PathBuf>) -> Self {
Self {
by_caller: Mutex::new(HashMap::new()),
remembered_in,
}
}
pub(super) fn get(&self, caller: &str, remote: &str, remembered: bool) -> Conversation {
let mut by_caller = self
.by_caller
.lock()
.expect("A2A conversation map poisoned");
if !by_caller.contains_key(caller) {
let loaded = self
.file(caller, remembered)
.map(|file| load(&file))
.unwrap_or_default();
by_caller.insert(caller.to_string(), loaded);
}
by_caller
.get(caller)
.and_then(|own| own.agents.get(remote))
.cloned()
.unwrap_or_default()
}
pub(super) fn record(
&self,
caller: &str,
remote: &str,
conversation: Conversation,
remembered: bool,
) -> std::io::Result<()> {
let mut by_caller = self
.by_caller
.lock()
.expect("A2A conversation map poisoned");
let own = by_caller.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 }),
}
}