//! This agent's three Honcho workspaces through `roncho`'s blocking client (M5c spec, section 6): //! one client per workspace, with its own key, and the steps that make sure a workspace, a peer //! and a session exist before anything is written to them. Nothing here keeps or logs a response //! body or a key: an error is its kind and its status. use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::time::Duration; use bxcore::secrets; use roncho::blocking::{Client, Error}; use roncho::{PeerCreate, SessionCreate, SessionPeerConfig, WorkspaceCreate}; use serde_json::{Map, Value, json}; use crate::honcho::config::{FLEET_KEY, HonchoConfig, TRUSTED_KEY, UNTRUSTED_KEY}; use crate::honcho::ledger::Workspace; use crate::honcho::naming::HonchoSession; /// How long a connection may take, and a read may go without a byte. pub const CONNECT: Duration = Duration::from_secs(5); pub const READ: Duration = Duration::from_secs(30); /// A chat's read: a non-streamed chat sends nothing until it is done (M5c spec, section 9). pub const CHAT_READ: Duration = Duration::from_secs(150); /// The timeouts a client is built with. Tests shorten them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Timeouts { pub connect: Duration, pub read: Duration, } impl Timeouts { pub const WRITES: Timeouts = Timeouts { connect: CONNECT, read: READ, }; } /// Why a call to Honcho failed: its kind and status only, never the response's body. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HonchoError { /// No answer that counts: no connection, a timeout, a closed socket, or a 5xx. Try again later. Unreachable { kind: &'static str, status: Option, }, /// Honcho answered, and will answer the same again: a 4xx, a redirect, or a body that is not /// what `roncho` expects. Refused { kind: &'static str, status: Option, }, /// The workspace's key was refused (401 or 403). KeyRefused { status: u16 }, } impl HonchoError { pub fn kind(&self) -> &'static str { match self { HonchoError::Unreachable { kind, .. } | HonchoError::Refused { kind, .. } => kind, HonchoError::KeyRefused { .. } => "key-refused", } } pub fn status(&self) -> Option { match self { HonchoError::Unreachable { status, .. } | HonchoError::Refused { status, .. } => { *status } HonchoError::KeyRefused { status } => Some(*status), } } } impl std::fmt::Display for HonchoError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let what = match self { HonchoError::Unreachable { .. } => "Honcho is unreachable", HonchoError::Refused { .. } => "Honcho refused the request", HonchoError::KeyRefused { .. } => "Honcho refused the key", }; match self.status() { Some(status) => write!(f, "{what} ({}, HTTP {status})", self.kind()), None => write!(f, "{what} ({})", self.kind()), } } } impl std::error::Error for HonchoError {} /// `roncho`'s error as a `HonchoError`. Its text is dropped: a `Status` or `Decode` error carries /// up to 4 KiB of the response, which can echo memory text (a 422 echoes the whole input). pub fn classify(error: &Error) -> HonchoError { let unreachable = |kind, status| HonchoError::Unreachable { kind, status }; let refused = |kind, status| HonchoError::Refused { kind, status }; match error { Error::Connect(_) => unreachable("connect", None), Error::Timeout => unreachable("timeout", None), Error::Closed => unreachable("closed", None), Error::Io(_) => unreachable("io", None), Error::Status { status, .. } if *status >= 500 => unreachable("server", Some(*status)), Error::Status { status, .. } => refused("status", Some(*status)), Error::Unauthorized { status } => HonchoError::KeyRefused { status: *status }, Error::Redirect { status } => refused("redirect", Some(*status)), Error::ActiveSessions { .. } => refused("active-sessions", Some(409)), Error::TooLarge => refused("too-large", None), Error::Decode(_) => refused("decode", None), Error::Stream(_) => refused("stream", None), Error::Config(_) => refused("config", None), _ => refused("other", None), } } /// What has been made sure of in one workspace during this run. #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum Ensured { Workspace, Peer(String), Session(String), } /// The three clients, and what they have made sure of since `memoryd` started. After a restart /// everything is made sure of again: each step is get-or-create, so that costs a request, no more. #[derive(Debug)] pub struct Clients { trusted: Client, untrusted: Client, fleet: Client, agent: String, owner: String, ensured: HashSet<(Workspace, Ensured)>, } /// The fleet workspace's configuration: nothing reasons over the fleet's notes (M5c spec, /// section 6). All three are set, since Honcho checks each on its own. pub fn fleet_configuration() -> Map { let mut map = Map::new(); for feature in ["reasoning", "summary", "dream"] { map.insert(feature.to_string(), json!({ "enabled": false })); } map } /// A session's peers: the agent's, unobserved, and each speaker's, observed; no peer forms a /// picture of the others. pub fn session_peers(agent: &str, speakers: &[String]) -> HashMap { let mut peers = HashMap::new(); peers.insert( agent.to_string(), SessionPeerConfig::new() .observe_me(false) .observe_others(false), ); for speaker in speakers { peers.insert( speaker.clone(), SessionPeerConfig::new() .observe_me(true) .observe_others(false), ); } peers } /// A create that found the thing there (409) did what it was for. fn created(result: Result) -> Result<(), HonchoError> { match result { Ok(_) => Ok(()), Err(e) => match classify(&e) { HonchoError::Refused { status: Some(409), .. } => Ok(()), other => Err(other), }, } } /// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one. pub(crate) fn client( url: &str, workspace: &str, key: Option<&secrets::Secret>, timeouts: Timeouts, ) -> Result { let mut builder = Client::builder() .without_config_file() .base_url(url) .workspace_id(workspace) .connect_timeout(timeouts.connect) .read_timeout(timeouts.read); if let Some(key) = key { builder = builder.api_key(key.expose()); } builder .build() .map_err(|e| format!("the Honcho client for {workspace}: {e}")) } /// The key named `name` in `[secrets]`, if one is configured, read now. pub(crate) fn key( config: &HonchoConfig, name: &str, env: &dyn Fn(&str) -> Option, warnings: &mut Vec, ) -> Result, String> { let Some(source) = config.keys.get(name) else { return Ok(None); }; let loaded = secrets::load("memoryd", name, source, env).map_err(|e| e.to_string())?; warnings.extend(loaded.warning); Ok(Some(loaded.secret)) } impl Clients { /// A client for each of the three workspaces, each with its key when one is configured. The /// warnings are the key files' plaintext warnings, for the caller to log. An error names the /// key or the workspace, never a key's value. pub fn new( config: &HonchoConfig, env: &dyn Fn(&str) -> Option, timeouts: Timeouts, ) -> Result<(Clients, Vec), String> { let mut warnings = Vec::new(); let trusted_key = key(config, TRUSTED_KEY, env, &mut warnings)?; let untrusted_key = key(config, UNTRUSTED_KEY, env, &mut warnings)?; let fleet_key = key(config, FLEET_KEY, env, &mut warnings)?; let clients = Clients { trusted: client(&config.url, &config.trusted, trusted_key.as_ref(), timeouts)?, untrusted: client( &config.url, &config.untrusted, untrusted_key.as_ref(), timeouts, )?, fleet: client(&config.url, &config.fleet, fleet_key.as_ref(), timeouts)?, agent: config.agent.clone(), owner: config.owner.clone(), ensured: HashSet::new(), }; Ok((clients, warnings)) } /// The client of `workspace`. pub fn client(&self, workspace: Workspace) -> &Client { match workspace { Workspace::Trusted => &self.trusted, Workspace::Untrusted => &self.untrusted, Workspace::Fleet => &self.fleet, } } /// Make sure `workspace` exists; the fleet one is made with `fleet_configuration`, the other /// two with Honcho's defaults. pub fn ensure_workspace(&mut self, workspace: Workspace) -> Result<(), HonchoError> { let key = (workspace, Ensured::Workspace); if self.ensured.contains(&key) { return Ok(()); } let client = self.client(workspace); let mut create = WorkspaceCreate::new(client.workspace_id()); if workspace == Workspace::Fleet { create = create.with_configuration(fleet_configuration()); } created(client.get_or_create_workspace(&create))?; self.ensured.insert(key); Ok(()) } /// Make sure peer `peer` exists in `workspace`. The agent's own peer is made unobserved /// (`observe_me` false): Honcho draws no conclusions from the agent's own output. pub fn ensure_peer(&mut self, workspace: Workspace, peer: &str) -> Result<(), HonchoError> { let key = (workspace, Ensured::Peer(peer.to_string())); if self.ensured.contains(&key) { return Ok(()); } let mut create = PeerCreate::new(peer); if peer == self.agent { let mut configuration = Map::new(); configuration.insert("observe_me".to_string(), json!(false)); create = create.with_configuration(configuration); } created(self.client(workspace).peer(&create))?; self.ensured.insert(key); Ok(()) } /// Make sure `session` exists in `workspace`, with the agent's peer and its speakers' peers, /// after the workspace and every peer. A session must exist before a message is added to /// it: Honcho makes a missing one on the fly, with no peers configured. pub fn ensure_session( &mut self, workspace: Workspace, session: &HonchoSession, ) -> Result<(), HonchoError> { self.ensure_workspace(workspace)?; let agent = self.agent.clone(); self.ensure_peer(workspace, &agent)?; for speaker in &session.speakers { self.ensure_peer(workspace, speaker)?; } let key = (workspace, Ensured::Session(session.name.clone())); if self.ensured.contains(&key) { return Ok(()); } let peers = session_peers(&agent, &session.speakers); let create = SessionCreate::new(session.name.as_str()).with_peers(peers); created(self.client(workspace).session(&create))?; self.ensured.insert(key); Ok(()) } /// Make sure memory session `name` (a Boxmaker session's `_memory`) exists in /// `workspace`, after the workspace, the agent's peer and the owner's. It holds conclusions, /// no messages, so neither peer observes (M5c spec, section 4). pub fn ensure_memory_session( &mut self, workspace: Workspace, name: &str, ) -> Result<(), HonchoError> { self.ensure_workspace(workspace)?; let (agent, owner) = (self.agent.clone(), self.owner.clone()); self.ensure_peer(workspace, &agent)?; self.ensure_peer(workspace, &owner)?; let key = (workspace, Ensured::Session(name.to_string())); if self.ensured.contains(&key) { return Ok(()); } let unobserved = || { SessionPeerConfig::new() .observe_me(false) .observe_others(false) }; let mut peers = HashMap::new(); peers.insert(agent, unobserved()); peers.insert(owner, unobserved()); let create = SessionCreate::new(name).with_peers(peers); created(self.client(workspace).session(&create))?; self.ensured.insert(key); Ok(()) } /// Delete conclusion `id` in `workspace`. Gone already (404) counts as done. pub fn delete_conclusion(&self, workspace: Workspace, id: &str) -> Result<(), HonchoError> { match self.client(workspace).delete_conclusion(id) { Ok(()) => Ok(()), Err(e) => match classify(&e) { HonchoError::Refused { status: Some(404), .. } => Ok(()), other => Err(other), }, } } }