M5c-1 plan: task 10, the Honcho clients and the ensure steps in memoryd

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 08:05:57 -07:00
co-authored by Claude Opus 5.5
parent 2926031b4d
commit a359f34a73
7 changed files with 1693 additions and 0 deletions
@@ -0,0 +1,272 @@
//! 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-2's `ask_memory`).
pub const CHAT_READ: Duration = Duration::from_secs(180);
/// 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 {
// `Unreachable { kind, .. }` and `Refused { kind, .. }` (one arm, `|`): `kind`;
// `KeyRefused { .. }`: `"key-refused"`.
todo!()
}
pub fn status(&self) -> Option<u16> {
// `Unreachable { status, .. }` and `Refused { status, .. }` (one arm): `*status`;
// `KeyRefused { status }`: `Some(*status)`.
todo!()
}
}
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,
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> {
// A `Map::new()`; for each feature of `["reasoning", "summary", "dream"]`, insert
// `feature.to_string()` -> `json!({ "enabled": false })`. Return the map.
todo!()
}
/// 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> {
// `HashMap::new()`; insert `agent.to_string()` -> `SessionPeerConfig::new()
// .observe_me(false).observe_others(false)`; then for each speaker, `speaker.clone()` ->
// `SessionPeerConfig::new().observe_me(true).observe_others(false)`. Return the map.
todo!()
}
/// A create that found the thing there (409) did what it was for.
fn created<T>(result: Result<T, Error>) -> Result<(), HonchoError> {
// `Ok(_)` -> `Ok(())`. `Err(e)`: `match classify(&e)`: `HonchoError::Refused { status:
// Some(409), .. }` -> `Ok(())`; any other `other` -> `Err(other)`.
todo!()
}
/// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one.
fn client(
url: &str,
workspace: &str,
key: Option<&secrets::Secret>,
timeouts: Timeouts,
) -> Result<Client, String> {
// `let mut builder = Client::builder()` then, chained: `.without_config_file()`,
// `.base_url(url)`, `.workspace_id(workspace)`, `.connect_timeout(timeouts.connect)`,
// `.read_timeout(timeouts.read)`. When `key` is `Some(key)`: `builder = builder.api_key(
// key.expose())`. Then `builder.build()`, its error `e` mapped to
// `format!("the Honcho client for {workspace}: {e}")`.
todo!()
}
/// The key named `name` in `[secrets]`, if one is configured, read now.
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) };`. Then
// `secrets::load("memoryd", name, source, env)`, its error mapped with `e.to_string()` and `?`,
// into `loaded`. `warnings.extend(loaded.warning);` then `Ok(Some(loaded.secret))`.
todo!()
}
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(),
ensured: HashSet::new(),
};
Ok((clients, warnings))
}
/// The client of `workspace`.
pub fn client(&self, workspace: Workspace) -> &Client {
// `match workspace`: `Trusted` -> `&self.trusted`, `Untrusted` -> `&self.untrusted`,
// `Fleet` -> `&self.fleet`.
todo!()
}
/// 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);` and
// `let mut create = WorkspaceCreate::new(client.workspace_id());`;
// `if workspace == Workspace::Fleet`,
// `create = create.with_configuration(fleet_configuration());`. Then
// `created(client.get_or_create_workspace(&create))?;`, `self.ensured.insert(key);`,
// `Ok(())`.
todo!()
}
/// 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 already there, return
// `Ok(())`. `let mut create = PeerCreate::new(peer);`. `if peer == self.agent`: `let mut
// configuration = Map::new();`, insert `"observe_me".to_string()` -> `json!(false)`, and
// `create = create.with_configuration(configuration);`. Then
// `created(self.client(workspace).peer(&create))?;`, `self.ensured.insert(key);`, `Ok(())`.
todo!()
}
/// 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(())
}
/// 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(())`; any
// other `other` -> `Err(other)`.
todo!()
}
}
@@ -0,0 +1,39 @@
//! Which Honcho session a turn goes to (M5c spec, section 3). A Boxmaker session maps to the Honcho
//! session of its own id; a speaker new to it starts the next one, `<id>-2`, `<id>-3` and so on,
//! holding every speaker so far, since a Honcho session's peers are fixed when it is made. The
//! numbering is read from the ledger alone, so a restart numbers the same way.
use bxcore::SessionId;
use crate::honcho::ledger::{Action, Item};
/// A Honcho session: its name, and the speakers' peers it holds besides the agent's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HonchoSession {
pub name: String,
pub speakers: Vec<String>,
}
/// The name of the Honcho session for the `count`-th speaker of `session`: the id itself for the
/// first (and for none), then `<id>-<count>`.
pub fn session_name(session: &SessionId, count: usize) -> String {
// When `count <= 1`: `session.as_str().to_string()`; otherwise
// `format!("{}-{count}", session.as_str())`.
todo!()
}
/// The Honcho session of turn `turn` of `session`, from the ledger's decisions in their order:
/// the speakers of that session's `Turn` actions (in any workspace) up to and including this
/// turn, each counted once, in the order they first spoke. `None` when the turn has no `Turn`
/// action: undecided, or decided to go nowhere.
pub fn honcho_session<'a, I>(decisions: I, session: &SessionId, turn: u64) -> Option<HonchoSession>
where
I: IntoIterator<Item = (&'a Item, &'a Action)>,
{
// `let mut speakers: Vec<String> = Vec::new();`. For each `(item, action)`: `let
// Item::Turn { session: s, turn: t } = item else { continue; };`, then `let Action::Turn {
// speaker, .. } = action else { continue; };`; skip it when `s != session`. Push
// `speaker.clone()` unless `speakers.contains(speaker)`. When `*t == turn`, return `Some(
// HonchoSession { name: session_name(session, speakers.len()), speakers })`. After: `None`.
todo!()
}