355 lines
13 KiB
Rust
355 lines
13 KiB
Rust
//! 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<u16>,
|
|
},
|
|
/// 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<u16>,
|
|
},
|
|
/// 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<u16> {
|
|
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<String, Value> {
|
|
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<String, SessionPeerConfig> {
|
|
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<T>(result: Result<T, Error>) -> 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<Client, String> {
|
|
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<OsString>,
|
|
warnings: &mut Vec<String>,
|
|
) -> Result<Option<secrets::Secret>, 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<OsString>,
|
|
timeouts: Timeouts,
|
|
) -> Result<(Clients, Vec<String>), 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 `<id>_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),
|
|
},
|
|
}
|
|
}
|
|
}
|