memoryd: the Honcho workspaces it reads, with their clients and labels

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-29 08:56:50 -07:00
co-authored by ornith-1.5-35b-a3b
parent b33341c53a
commit b370c60bd4
10 changed files with 651 additions and 9 deletions
+4 -4
View File
@@ -19,8 +19,8 @@ use crate::honcho::naming::HonchoSession;
/// How long a connection may take, and a read may go without a byte. /// How long a connection may take, and a read may go without a byte.
pub const CONNECT: Duration = Duration::from_secs(5); pub const CONNECT: Duration = Duration::from_secs(5);
pub const READ: Duration = Duration::from_secs(30); 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`). /// 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(180); pub const CHAT_READ: Duration = Duration::from_secs(150);
/// The timeouts a client is built with. Tests shorten them. /// The timeouts a client is built with. Tests shorten them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -176,7 +176,7 @@ fn created<T>(result: Result<T, Error>) -> Result<(), HonchoError> {
} }
/// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one. /// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one.
fn client( pub(crate) fn client(
url: &str, url: &str,
workspace: &str, workspace: &str,
key: Option<&secrets::Secret>, key: Option<&secrets::Secret>,
@@ -197,7 +197,7 @@ fn client(
} }
/// The key named `name` in `[secrets]`, if one is configured, read now. /// The key named `name` in `[secrets]`, if one is configured, read now.
fn key( pub(crate) fn key(
config: &HonchoConfig, config: &HonchoConfig,
name: &str, name: &str,
env: &dyn Fn(&str) -> Option<OsString>, env: &dyn Fn(&str) -> Option<OsString>,
+11
View File
@@ -14,6 +14,10 @@ pub const RUNBOOK: &str = "see docs/runbook.md#honcho-config-invalid";
/// The longest name Honcho accepts for a workspace, a peer or a session (M5c spec, section 6). /// The longest name Honcho accepts for a workspace, a peer or a session (M5c spec, section 6).
pub const MAX_NAME: usize = 512; pub const MAX_NAME: usize = 512;
/// The names `ask_memory` gives this agent's own workspaces and the fleet's (M5c spec, section 9),
/// which no `[[honcho.read]]` agent may have.
pub const RESERVED: [&str; 3] = ["own", "untrusted", "fleet"];
/// The names of the keys in `[secrets.<name>]`, one per workspace. A read workspace's key is /// The names of the keys in `[secrets.<name>]`, one per workspace. A read workspace's key is
/// `READ_KEY` and its agent's name (`read_key`). /// `READ_KEY` and its agent's name (`read_key`).
pub const TRUSTED_KEY: &str = "honcho_trusted"; pub const TRUSTED_KEY: &str = "honcho_trusted";
@@ -233,6 +237,13 @@ fn read_workspace(
entry.agent entry.agent
)); ));
} }
if RESERVED.contains(&entry.agent.as_str()) {
return Err(format!(
"[[honcho.read]] agent {:?} is a name ask_memory gives this agent's own workspaces: \
own, untrusted and fleet cannot be agents' names",
entry.agent
));
}
if earlier.iter().any(|r| r.agent == entry.agent) { if earlier.iter().any(|r| r.agent == entry.agent) {
return Err(format!( return Err(format!(
"[[honcho.read]] agent {:?} is listed twice", "[[honcho.read]] agent {:?} is listed twice",
+1
View File
@@ -9,6 +9,7 @@ pub mod ledger;
pub mod messages; pub mod messages;
pub mod mirror; pub mod mirror;
pub mod naming; pub mod naming;
pub mod read;
pub mod report; pub mod report;
pub mod start; pub mod start;
pub mod state; pub mod state;
+226
View File
@@ -0,0 +1,226 @@
//! The Honcho workspaces this agent reads (M5c spec, sections 3, 8 and 9): each `[[honcho.read]]`
//! workspace, the fleet's, and the agent's own two, which only `ask_memory` asks. Each has a
//! client for searches and one for chat, whose answers take far longer, and the label its results
//! carry. Nothing here writes to Honcho, keeps a response or shows a key.
use std::ffi::OsString;
use std::time::Duration;
use bxcore::DataClass;
use bxcore::memory::OWN_WORKSPACE;
use roncho::blocking::Client;
use crate::honcho::client::{CHAT_READ, CONNECT, Timeouts, client, key};
use crate::honcho::config::{FLEET_KEY, HonchoConfig, TRUSTED_KEY, Trust, UNTRUSTED_KEY, read_key};
/// How long a search may go without a byte (M5c spec, section 8).
pub const SEARCH_READ: Duration = Duration::from_secs(5);
/// The names `ask_memory` gives the agent's own untrusted workspace and the fleet's. The own
/// trusted one is `bxcore::memory::OWN_WORKSPACE`, `own`.
pub const UNTRUSTED: &str = "untrusted";
pub const FLEET: &str = "fleet";
/// The timeouts the read clients are built with. Tests shorten them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadTimeouts {
pub connect: Duration,
pub search: Duration,
pub chat: Duration,
}
impl ReadTimeouts {
pub const DEFAULT: ReadTimeouts = ReadTimeouts {
connect: CONNECT,
search: SEARCH_READ,
chat: CHAT_READ,
};
}
/// What a reader reads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
/// Another agent's workspace, from `[[honcho.read]]`, by the agent's name.
Agent(String),
Fleet,
/// This agent's own trusted workspace, and its own untrusted one: `ask_memory` only.
Own,
OwnUntrusted,
}
/// What a reader's results are labelled with: the name each hit shows, whether the owner trusts
/// the workspace, and its class.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Label {
pub name: String,
pub trust: Trust,
pub class: DataClass,
}
impl Label {
/// The tag on each hit, as a local `recall` hit has: `memory` when trusted, else
/// `memory-tainted`.
pub fn tag(&self) -> &'static str {
match self.trust {
Trust::Trusted => "memory",
Trust::Untrusted => "memory-tainted",
}
}
/// Whether a result makes the session untrusted.
pub fn untrusted(&self) -> bool {
self.trust == Trust::Untrusted
}
}
/// One workspace this agent reads: what it is, its label, and its two clients.
#[derive(Debug, Clone)]
pub struct Reader {
pub source: Source,
pub label: Label,
search: Client,
chat: Client,
}
impl Reader {
/// The client for searches and conclusion queries, with the short read timeout.
pub fn search(&self) -> &Client {
&self.search
}
/// The client for workspace chat, with the long read timeout.
pub fn chat(&self) -> &Client {
&self.chat
}
}
/// Every workspace this agent reads, built at start with `[honcho]`. Shared by the threads that
/// search and ask (`roncho`'s blocking client is `Send + Sync`).
#[derive(Debug)]
pub struct Readers {
read: Vec<Reader>,
fleet: Reader,
own: Reader,
own_untrusted: Reader,
}
/// What every reader is built from, and the key files' warnings so far.
struct Build<'a> {
config: &'a HonchoConfig,
env: &'a dyn Fn(&str) -> Option<OsString>,
timeouts: ReadTimeouts,
warnings: Vec<String>,
}
impl Build<'_> {
/// A reader of `workspace`: both clients, with the key named `key_name` when one is
/// configured, read once.
fn reader(
&mut self,
workspace: &str,
key_name: &str,
source: Source,
label: Label,
) -> Result<Reader, String> {
let secret = key(self.config, key_name, self.env, &mut self.warnings)?;
let search = Timeouts {
connect: self.timeouts.connect,
read: self.timeouts.search,
};
let chat = Timeouts {
connect: self.timeouts.connect,
read: self.timeouts.chat,
};
let url = &self.config.url;
Ok(Reader {
source,
label,
search: client(url, workspace, secret.as_ref(), search)?,
chat: client(url, workspace, secret.as_ref(), chat)?,
})
}
}
/// A `private` label.
fn private(name: &str, trust: Trust) -> Label {
Label {
name: name.to_string(),
trust,
class: DataClass::Private,
}
}
impl Readers {
/// A reader for each `[[honcho.read]]` workspace, in the order written, then the fleet's and
/// the agent's own two. 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: ReadTimeouts,
) -> Result<(Readers, Vec<String>), String> {
let mut build = Build {
config,
env,
timeouts,
warnings: Vec::new(),
};
let mut read = Vec::new();
for entry in &config.read {
let label = Label {
name: entry.agent.clone(),
trust: entry.trust,
class: entry.class,
};
let source = Source::Agent(entry.agent.clone());
read.push(build.reader(&entry.workspace, &read_key(&entry.agent), source, label)?);
}
let fleet = private(FLEET, Trust::Untrusted);
let fleet = build.reader(&config.fleet, FLEET_KEY, Source::Fleet, fleet)?;
let own = private(OWN_WORKSPACE, Trust::Trusted);
let own = build.reader(&config.trusted, TRUSTED_KEY, Source::Own, own)?;
let own_untrusted = private(UNTRUSTED, Trust::Untrusted);
let own_untrusted = build.reader(
&config.untrusted,
UNTRUSTED_KEY,
Source::OwnUntrusted,
own_untrusted,
)?;
let readers = Readers {
read,
fleet,
own,
own_untrusted,
};
Ok((readers, build.warnings))
}
/// What `recall` searches: the `[[honcho.read]]` workspaces in the order written, then the
/// fleet's. Never the agent's own (M5c spec, section 8): the local index holds their text.
pub fn recall_sources(&self) -> Vec<&Reader> {
self.read
.iter()
.chain(std::iter::once(&self.fleet))
.collect()
}
/// The reader `ask_memory` names `name`: `own`, `untrusted`, `fleet`, or a `[[honcho.read]]`
/// agent. Any other name is the sentence the model is shown, listing the names it may use.
pub fn resolve(&self, name: &str) -> Result<&Reader, String> {
match name {
OWN_WORKSPACE => return Ok(&self.own),
UNTRUSTED => return Ok(&self.own_untrusted),
FLEET => return Ok(&self.fleet),
_ => {}
}
if let Some(reader) = self.read.iter().find(|r| r.label.name == name) {
return Ok(reader);
}
let mut names = vec![OWN_WORKSPACE, UNTRUSTED, FLEET];
names.extend(self.read.iter().map(|r| r.label.name.as_str()));
let (last, rest) = names.split_last().unwrap_or((&FLEET, &[]));
Err(format!(
"no workspace is named {name}; ask {} or {last}",
rest.join(", ")
))
}
}
+12 -1
View File
@@ -10,6 +10,7 @@ use crate::honcho::client::{Clients, Timeouts};
use crate::honcho::config::HonchoConfig; use crate::honcho::config::HonchoConfig;
use crate::honcho::ledger::{Ledger, Record}; use crate::honcho::ledger::{Ledger, Record};
use crate::honcho::mirror::Mirror; use crate::honcho::mirror::Mirror;
use crate::honcho::read::{ReadTimeouts, Readers};
use crate::honcho::state; use crate::honcho::state;
use crate::honcho::turns::{LEDGER, TurnSync}; use crate::honcho::turns::{LEDGER, TurnSync};
use crate::honcho::worker::{AskBox, Tuning, Wake, Worker}; use crate::honcho::worker::{AskBox, Tuning, Wake, Worker};
@@ -19,6 +20,8 @@ use crate::serve::{Log, StartError};
/// clients with their keys read. Nothing is bound or started yet. /// clients with their keys read. Nothing is bound or started yet.
pub struct Opened { pub struct Opened {
pub sync: Arc<Mutex<TurnSync>>, pub sync: Arc<Mutex<TurnSync>>,
/// The workspaces `recall` and `ask_memory` read (M5c spec, sections 8 and 9).
pub readers: Arc<Readers>,
clients: Clients, clients: Clients,
agent: String, agent: String,
owner: String, owner: String,
@@ -65,11 +68,19 @@ pub fn open(config: &Config, honcho: &HonchoConfig, log: &Log) -> Result<Opened,
let env = |name: &str| std::env::var_os(name); let env = |name: &str| std::env::var_os(name);
let (clients, warnings) = let (clients, warnings) =
Clients::new(honcho, &env, Timeouts::WRITES).map_err(StartError::Secret)?; Clients::new(honcho, &env, Timeouts::WRITES).map_err(StartError::Secret)?;
for warning in warnings { let (readers, read_warnings) =
Readers::new(honcho, &env, ReadTimeouts::DEFAULT).map_err(StartError::Secret)?;
// The readers load the keys of the three written workspaces again: each warning once.
let read_warnings: Vec<String> = read_warnings
.into_iter()
.filter(|w| !warnings.contains(w))
.collect();
for warning in warnings.into_iter().chain(read_warnings) {
(log)(&warning); (log)(&warning);
} }
Ok(Opened { Ok(Opened {
sync: Arc::new(Mutex::new(sync)), sync: Arc::new(Mutex::new(sync)),
readers: Arc::new(readers),
clients, clients,
agent: honcho.agent.clone(), agent: honcho.agent.clone(),
owner: honcho.owner.clone(), owner: honcho.owner.clone(),
+1 -1
View File
@@ -74,7 +74,7 @@ fn post(path: &str, body: Value) -> (String, String, Value) {
fn the_timeouts() { fn the_timeouts() {
assert_eq!(CONNECT, Duration::from_secs(5)); assert_eq!(CONNECT, Duration::from_secs(5));
assert_eq!(READ, Duration::from_secs(30)); assert_eq!(READ, Duration::from_secs(30));
assert_eq!(CHAT_READ, Duration::from_secs(180)); assert_eq!(CHAT_READ, Duration::from_secs(150));
assert_eq!( assert_eq!(
Timeouts::WRITES, Timeouts::WRITES,
Timeouts { Timeouts {
+367
View File
@@ -0,0 +1,367 @@
//! The workspaces `memoryd` reads (M5c spec, sections 3, 8 and 9): a reader for each
//! `[[honcho.read]]` workspace, the fleet's and the agent's own two, each with its own key, a
//! short wait for searches and a long one for chat; the label its results carry; what `recall`
//! searches and the names `ask_memory` may use. Against a fake Honcho. Do not edit.
#[path = "support/honcho.rs"]
mod honcho;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::time::Duration;
use bxcore::DataClass;
use bxcore::secrets::SecretSource;
use honcho::{FakeHoncho, Mode};
use memoryd::honcho::client::{CHAT_READ, CONNECT, classify};
use memoryd::honcho::config::{
FLEET_KEY, HonchoConfig, HonchoSection, RESERVED, ReadSection, ReadWorkspace, TRUSTED_KEY,
Trust, check, read_key,
};
use memoryd::honcho::read::{
FLEET, Label, ReadTimeouts, Reader, Readers, SEARCH_READ, Source, UNTRUSTED,
};
use roncho::MessageSearch;
const FAST: ReadTimeouts = ReadTimeouts {
connect: Duration::from_secs(5),
search: Duration::from_millis(300),
chat: Duration::from_millis(900),
};
/// Key values built from pieces, so that no file holds one whole.
fn key(which: &str) -> String {
["hk", which, "7a31c0"].join("-")
}
fn read(agent: &str, trust: Trust, class: DataClass) -> ReadWorkspace {
ReadWorkspace {
agent: agent.to_string(),
workspace: format!("{agent}-trusted"),
trust,
class,
}
}
/// `boxmaker0`, reading `boxmaker1` (labelled trusted, `secret`) and `boxmaker2` (the defaults).
/// Keys for `boxmaker1`, the fleet and the own trusted workspace; none for the others.
fn config(url: &str) -> HonchoConfig {
let mut keys = BTreeMap::new();
for (name, var) in [
(read_key("boxmaker1"), "K_READ_1"),
(FLEET_KEY.to_string(), "K_FLEET"),
(TRUSTED_KEY.to_string(), "K_TRUSTED"),
] {
keys.insert(name, SecretSource::Env(var.to_string()));
}
HonchoConfig {
url: url.to_string(),
agent: "boxmaker0".to_string(),
owner: "kyle".to_string(),
trusted: "boxmaker0-trusted".to_string(),
untrusted: "boxmaker0-untrusted".to_string(),
fleet: "fleet".to_string(),
read: vec![
read("boxmaker1", Trust::Trusted, DataClass::Secret),
read("boxmaker2", Trust::Untrusted, DataClass::Public),
],
keys,
}
}
fn env(name: &str) -> Option<OsString> {
match name {
"K_READ_1" => Some(OsString::from(key("read1"))),
"K_FLEET" => Some(OsString::from(key("fleet"))),
"K_TRUSTED" => Some(OsString::from(key("trusted"))),
_ => None,
}
}
fn readers(url: &str) -> Readers {
Readers::new(&config(url), &env, FAST).unwrap().0
}
/// Every reader: `recall`'s, then the own two.
fn all(r: &Readers) -> Vec<&Reader> {
let mut all = r.recall_sources();
all.push(r.resolve("own").unwrap());
all.push(r.resolve("untrusted").unwrap());
all
}
#[test]
fn a_reader_client_can_be_shared_across_threads() {
fn shared<T: Send + Sync>() {}
shared::<roncho::blocking::Client>();
shared::<Reader>();
shared::<Readers>();
}
#[test]
fn the_timeouts() {
assert_eq!(SEARCH_READ, Duration::from_secs(5));
assert_eq!(CHAT_READ, Duration::from_secs(150));
assert_eq!(
ReadTimeouts::DEFAULT,
ReadTimeouts {
connect: CONNECT,
search: SEARCH_READ,
chat: CHAT_READ
}
);
let fake = FakeHoncho::start();
let r = Readers::new(&config(&fake.url), &env, ReadTimeouts::DEFAULT)
.unwrap()
.0;
for reader in all(&r) {
// `roncho`'s `Debug` shows the timeouts, and whether there is a key, never the key.
let search = format!("{:?}", reader.search());
let chat = format!("{:?}", reader.chat());
for shown in [&search, &chat] {
assert!(shown.contains("connect_timeout: 5s"), "{shown}");
}
assert!(search.contains("read_timeout: 5s"), "{search}");
assert!(chat.contains("read_timeout: 150s"), "{chat}");
}
}
#[test]
fn each_reader_reads_its_own_workspace_with_its_own_key() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
let want = [
("boxmaker1-trusted", Some(key("read1"))),
("boxmaker2-trusted", None),
("fleet", Some(key("fleet"))),
("boxmaker0-trusted", Some(key("trusted"))),
("boxmaker0-untrusted", None),
];
let readers = all(&r);
assert_eq!(readers.len(), want.len());
for (reader, (workspace, _)) in readers.iter().zip(&want) {
assert_eq!(reader.search().workspace_id(), *workspace);
assert_eq!(reader.chat().workspace_id(), *workspace);
// The fake answers a search it has no recording for with 404: only the request matters.
let _ = reader
.search()
.search_workspace(&MessageSearch::new("launch"));
let _ = reader
.chat()
.search_workspace(&MessageSearch::new("launch"));
}
let requests = fake.requests();
assert_eq!(requests.len(), 2 * want.len());
for (n, request) in requests.iter().enumerate() {
let (workspace, secret) = &want[n / 2];
assert_eq!(request.path, format!("/v3/workspaces/{workspace}/search"));
let bearer = secret.as_ref().map(|k| format!("Bearer {k}"));
assert_eq!(
request.header("authorization").map(str::to_string),
bearer,
"{workspace}"
);
}
}
#[test]
fn no_key_is_shown_by_a_reader_or_an_error() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
fake.set(Mode::Status(401));
let mut shown = vec![format!("{r:?}")];
for reader in all(&r) {
let err = reader
.search()
.search_workspace(&MessageSearch::new("launch"))
.unwrap_err();
let err = classify(&err);
shown.push(format!("{err:?}"));
shown.push(err.to_string());
}
for text in shown {
for which in ["read1", "fleet", "trusted"] {
assert!(!text.contains(&key(which)), "{text}");
}
}
// A key that cannot be read stops the build, naming the key and never a value.
let mut config = config(&fake.url);
config.keys.insert(
read_key("boxmaker2"),
SecretSource::Env("K_MISSING".to_string()),
);
let err = Readers::new(&config, &env, FAST).unwrap_err();
assert!(err.contains("honcho_read_boxmaker2"), "{err}");
assert!(
err.contains("see docs/runbook.md#secret-unavailable"),
"{err}"
);
for which in ["read1", "fleet", "trusted"] {
assert!(!err.contains(&key(which)), "{err}");
}
}
/// `recall` searches the read workspaces, in the order written, then the fleet's; never the
/// agent's own two (M5c spec, section 8).
#[test]
fn recall_never_searches_the_agents_own_workspaces() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
let sources: Vec<Source> = r
.recall_sources()
.iter()
.map(|s| s.source.clone())
.collect();
assert_eq!(
sources,
[
Source::Agent("boxmaker1".to_string()),
Source::Agent("boxmaker2".to_string()),
Source::Fleet
]
);
let workspaces: Vec<&str> = r
.recall_sources()
.iter()
.map(|s| s.search().workspace_id())
.collect();
assert_eq!(
workspaces,
["boxmaker1-trusted", "boxmaker2-trusted", "fleet"]
);
let mut none = config(&fake.url);
none.read.clear();
let r = Readers::new(&none, &env, FAST).unwrap().0;
let sources: Vec<Source> = r
.recall_sources()
.iter()
.map(|s| s.source.clone())
.collect();
assert_eq!(sources, [Source::Fleet]);
}
fn label(name: &str, trust: Trust, class: DataClass) -> Label {
Label {
name: name.to_string(),
trust,
class,
}
}
/// A result is tagged `memory` only from a workspace the owner labelled trusted, or the agent's
/// own trusted one; the fleet and everything else is `memory-tainted`.
#[test]
fn each_source_has_its_label() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
let got: Vec<(Label, &str, bool)> = all(&r)
.iter()
.map(|reader| {
(
reader.label.clone(),
reader.label.tag(),
reader.label.untrusted(),
)
})
.collect();
assert_eq!(
got,
[
(
label("boxmaker1", Trust::Trusted, DataClass::Secret),
"memory",
false
),
(
label("boxmaker2", Trust::Untrusted, DataClass::Public),
"memory-tainted",
true
),
(
label("fleet", Trust::Untrusted, DataClass::Private),
"memory-tainted",
true
),
(
label("own", Trust::Trusted, DataClass::Private),
"memory",
false
),
(
label("untrusted", Trust::Untrusted, DataClass::Private),
"memory-tainted",
true
),
]
);
}
#[test]
fn ask_memory_names_resolve_to_their_readers() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
for (name, source, workspace) in [
("own", Source::Own, "boxmaker0-trusted"),
(UNTRUSTED, Source::OwnUntrusted, "boxmaker0-untrusted"),
(FLEET, Source::Fleet, "fleet"),
(
"boxmaker1",
Source::Agent("boxmaker1".to_string()),
"boxmaker1-trusted",
),
(
"boxmaker2",
Source::Agent("boxmaker2".to_string()),
"boxmaker2-trusted",
),
] {
let reader = r.resolve(name).unwrap();
assert_eq!(reader.source, source, "{name}");
assert_eq!(reader.chat().workspace_id(), workspace, "{name}");
}
for unknown in ["boxmaker9", "boxmaker0", "Own", "boxmaker1-trusted"] {
assert_eq!(
r.resolve(unknown).unwrap_err(),
format!(
"no workspace is named {unknown}; ask own, untrusted, fleet, boxmaker1 or boxmaker2"
)
);
}
let mut none = config(&fake.url);
none.read.clear();
let r = Readers::new(&none, &env, FAST).unwrap().0;
assert_eq!(
r.resolve("boxmaker1").unwrap_err(),
"no workspace is named boxmaker1; ask own, untrusted or fleet"
);
}
/// `own`, `untrusted` and `fleet` name this agent's workspaces in `ask_memory`, so no
/// `[[honcho.read]]` agent may be called that: the fleet can never be labelled trusted.
#[test]
fn no_read_agent_takes_a_reserved_name() {
assert_eq!(RESERVED, ["own", "untrusted", "fleet"]);
for agent in RESERVED {
let section = HonchoSection {
url: "http://127.0.0.1:9".to_string(),
agent: "boxmaker0".to_string(),
owner: "kyle".to_string(),
trusted: "boxmaker0-trusted".to_string(),
untrusted: "boxmaker0-untrusted".to_string(),
fleet: "fleet".to_string(),
read: vec![ReadSection {
agent: agent.to_string(),
workspace: "someone-trusted".to_string(),
trust: Some("trusted".to_string()),
class: None,
}],
};
assert_eq!(
check(Some(&section), &BTreeMap::new()).unwrap_err(),
format!(
"[[honcho.read]] agent {agent:?} is a name ask_memory gives this agent's own \
workspaces: own, untrusted and fleet cannot be agents' names"
)
);
}
}
+24
View File
@@ -204,6 +204,22 @@ fn a_key_for_an_agent_not_read_stops_memoryd() {
assert_no_key(&stderr); assert_no_key(&stderr);
} }
/// The readers are built at start (M5c-2): a read workspace's key that cannot be read stops
/// `memoryd`, as a written workspace's does.
#[test]
fn a_read_key_that_cannot_be_read_stops_memoryd() {
let extra = format!("{SECTION}[secrets.honcho_read_boxmaker1]\nenv = \"HONCHO_NOT_SET\"\n");
let home = Home::new("honcho-read-key", &extra);
let (code, stderr) = home.run_to_end();
assert_eq!(code, Some(1), "{stderr}");
assert!(stderr.contains("honcho_read_boxmaker1"), "{stderr}");
assert!(
stderr.contains("see docs/runbook.md#secret-unavailable"),
"{stderr}"
);
assert_no_key(&stderr);
}
#[test] #[test]
fn an_unknown_key_in_the_section_is_still_a_start_failure() { fn an_unknown_key_in_the_section_is_still_a_start_failure() {
let extra = SECTION.replace("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\""); let extra = SECTION.replace("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\"");
@@ -234,5 +250,13 @@ fn a_valid_section_with_keys_starts_memoryd_as_before() {
let stderr = home.stderr(); let stderr = home.stderr();
assert!(stderr.contains("memoryd: serving\n"), "{stderr}"); assert!(stderr.contains("memoryd: serving\n"), "{stderr}");
assert_no_key(&stderr); assert_no_key(&stderr);
// The writers and the readers both load the fleet's key file; its warning is logged once.
assert_eq!(
stderr
.matches("secret honcho_fleet is read in plaintext")
.count(),
1,
"{stderr}"
);
assert!(!stderr.contains(POINTER), "{stderr}"); assert!(!stderr.contains(POINTER), "{stderr}");
} }
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| M5c2/05-memoryd-honcho-readers | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_read.rs` (new), new versions of `crates/memoryd/tests/honcho_client.rs` and `honcho_start.rs` (they replace the tree's), and the new source `crates/memoryd/src/honcho/read.rs` (complete, no `todo!`). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the two named failures: `honcho_read` with one E0432 `unresolved import memoryd::honcho::config::RESERVED` and one E0432 `unresolved import memoryd::honcho::read`; every other target compiled. `cargo test --no-fail-fast` on `honcho_client` and `honcho_start` failed the two named tests: `the_timeouts` (`CHAT_READ` is 180 s) and `a_read_key_that_cannot_be_read_stops_memoryd` (memoryd starts instead of stopping). Made the edits exactly as the task gives: H1 (`pub mod read;` after `pub mod naming;` in `honcho/mod.rs`); C1 (`CHAT_READ` = 150 s, doc now `M5c spec, section 9`); C2 (`client` `pub(crate)`); C3 (`key` `pub(crate)`); G1 (`RESERVED` `["own", "untrusted", "fleet"]` before the key names, doc naming section 9); G2 (the reserved-names check in `read_workspace` after the this-agent check, its sentence split with `\` as shown); O1 (`use ...read::{ReadTimeouts, Readers}`); O2 (`Opened::readers` `Arc<Readers>` after `sync`, before `clients`); O3 (`Readers::new` beside `Clients::new`, `StartError::Secret`, the readers' warnings filtered to those not among the writers' then logged once); D1 (the reserved names among the `honcho-config-invalid` reasons in `docs/runbook.md`). `cargo check -p memoryd` clean, no warning. `cargo test` passes: 8 in `honcho_read`, 13 in `honcho_client`, 8 in `honcho_config`, 3 in `honcho_keys` (1 ignored), 5 in `honcho_start`. `git diff --stat -- crates/memoryd/src` lists only `client.rs`, `config.rs`, `mod.rs` and `start.rs`; `git diff --stat -- crates/memoryd/src/serve.rs crates/memoryd/src/recall.rs crates/memoryd/src/worker.rs Cargo.toml Cargo.lock` prints nothing. `make gate` printed `gate: ok` with 1403 tests (1402 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately; the +9 over the 1394 before is exactly the 8 `honcho_read` tests and the 1 new `honcho_start` test). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M5c2/04-loopd-ask-memory | 2026-09-28 | done | 1 | pass | none | Copied the three given tests from `docs/plans/M5c2/files/crates/loopd/tests/`: `ask_memory_tool.rs` (new) and `broker_port_ask_wait.rs` (new), and a new version of `device.rs` (replaces the tree's). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the 14 named failures: `ask_memory_tool` with one E0432 `unresolved imports loopd::memory::ASK_MEMORY`, `loopd::memory::ask_memory_schema` and three E0599 for `m5c` on `Registry`; `broker_port_ask_wait` with one E0432 `unresolved import loopd::broker_port::ASK_MEMORY_WAIT`, five E0599 for `timeout_for` and three for `with_ask_wait`; `device` with one E0599 for `m5c`; every other target compiled. Made the edits exactly as the task gives: S1 (`ASK_MEMORY` = `bxcore::memory::ASK_MEMORY`), S2 (`ask_memory_schema` with `maxItems: 2`, description one string split with `\` as shown); T1 (`m5a()` comment drops `loopd serve`); T2 (`m5c()` inserts `ask_memory` core at index 3 after `m5a()`); N1 (`main.rs` uses `Registry::m5c()`); Y1 (`config/system.md` last sentence names `ask_memory`); P1 (`ASK_MEMORY_WAIT` 180 s before `POINTER`), P2 (`BrokerPort` gains `ask_timeout` after `timeout`), P3 (`new` sets `ask_timeout: ASK_MEMORY_WAIT`, adds `with_ask_wait` via struct update and `timeout_for` matching `ASK_MEMORY` -> `timeout.max(ask_timeout)` else `timeout`), P4 (`with_log` sets `ask_timeout: ASK_MEMORY_WAIT`), P5 (`call` binds `timeout = self.timeout_for(&request.tool)`, uses it for the first deadline), P6 (write timeout), P7 (the pending-frame extension). `cargo check -p loopd` clean, and `grep -n 'self.timeout'` prints the three expected lines. `cargo test` passes: 6 in `ask_memory_tool`, 4 in `broker_port_ask_wait`, 15 in `broker_port`, 2 in `broker_port_bad`, 2 in `broker_port_cap`, 4 in `memory`, 6 in `turn_broker`. `git diff --stat` lists only the five edited files and `device.rs`; `git diff config/system.md | grep -c '^[-+]You are'` prints 2. `make gate` printed `gate: ok` with 1394 tests (1393 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +10 over the 1384 before is exactly the 6 in `ask_memory_tool.rs` and 4 in `broker_port_ask_wait.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/04-loopd-ask-memory | 2026-09-28 | done | 1 | pass | none | Copied the three given tests from `docs/plans/M5c2/files/crates/loopd/tests/`: `ask_memory_tool.rs` (new) and `broker_port_ask_wait.rs` (new), and a new version of `device.rs` (replaces the tree's). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the 14 named failures: `ask_memory_tool` with one E0432 `unresolved imports loopd::memory::ASK_MEMORY`, `loopd::memory::ask_memory_schema` and three E0599 for `m5c` on `Registry`; `broker_port_ask_wait` with one E0432 `unresolved import loopd::broker_port::ASK_MEMORY_WAIT`, five E0599 for `timeout_for` and three for `with_ask_wait`; `device` with one E0599 for `m5c`; every other target compiled. Made the edits exactly as the task gives: S1 (`ASK_MEMORY` = `bxcore::memory::ASK_MEMORY`), S2 (`ask_memory_schema` with `maxItems: 2`, description one string split with `\` as shown); T1 (`m5a()` comment drops `loopd serve`); T2 (`m5c()` inserts `ask_memory` core at index 3 after `m5a()`); N1 (`main.rs` uses `Registry::m5c()`); Y1 (`config/system.md` last sentence names `ask_memory`); P1 (`ASK_MEMORY_WAIT` 180 s before `POINTER`), P2 (`BrokerPort` gains `ask_timeout` after `timeout`), P3 (`new` sets `ask_timeout: ASK_MEMORY_WAIT`, adds `with_ask_wait` via struct update and `timeout_for` matching `ASK_MEMORY` -> `timeout.max(ask_timeout)` else `timeout`), P4 (`with_log` sets `ask_timeout: ASK_MEMORY_WAIT`), P5 (`call` binds `timeout = self.timeout_for(&request.tool)`, uses it for the first deadline), P6 (write timeout), P7 (the pending-frame extension). `cargo check -p loopd` clean, and `grep -n 'self.timeout'` prints the three expected lines. `cargo test` passes: 6 in `ask_memory_tool`, 4 in `broker_port_ask_wait`, 15 in `broker_port`, 2 in `broker_port_bad`, 2 in `broker_port_cap`, 4 in `memory`, 6 in `turn_broker`. `git diff --stat` lists only the five edited files and `device.rs`; `git diff config/system.md | grep -c '^[-+]You are'` prints 2. `make gate` printed `gate: ok` with 1394 tests (1393 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +10 over the 1384 before is exactly the 6 in `ask_memory_tool.rs` and 4 in `broker_port_ask_wait.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M5c2/03-brokerd-ask-memory-wait | 2026-09-28 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/memory_wait.rs` from `docs/plans/M5c2/files/crates/brokerd/tests/`. Step 2 (`cargo build --tests --keep-going -p brokerd`) showed exactly the 10 named failures: one E0432 `unresolved import brokerd::memory_port::ASK_MEMORY_WAIT`, six E0599 for `wait_for`, three E0599 for `with_ask_wait`; every other target compiled. Made edits W1 (module doc naming `ask_memory` and the `ask_memory` wait of its own), W2 (`use crate::args::ToolName`), W3 (`MEMORY_WAIT` doc now says `recall` or `memory`; added `ASK_MEMORY_WAIT` 170 s after it with no blank line between, `MemoryPort` gains `ask_wait` last, `new` sets `ask_wait: ASK_MEMORY_WAIT`, `with_ask_wait` via struct update, `wait_for` matches `AskMemory` -> `ask_wait` else `wait`), W4 (ask comment `self.wait_for(spec.tool())`), W5 (`ask` binds `wait = self.wait_for(spec.tool())` and uses it for both timeouts). `cargo check -p brokerd` clean. `cargo test -p brokerd --test memory_wait --test memory_port --test ask_memory_tool` passes: 3 in `memory_wait`, 5 in `memory_port`, 9 in `ask_memory_tool`. `git diff --stat` lists only `memory_port.rs`; `grep -n 'MEMORY_WAIT' crates/brokerd/src/main.rs` prints the `use` line and `MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT)`. `make gate` printed `gate: ok` with 1384 tests (1383 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +3 over the 1381 before is exactly the 3 new `memory_wait` tests). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/03-brokerd-ask-memory-wait | 2026-09-28 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/memory_wait.rs` from `docs/plans/M5c2/files/crates/brokerd/tests/`. Step 2 (`cargo build --tests --keep-going -p brokerd`) showed exactly the 10 named failures: one E0432 `unresolved import brokerd::memory_port::ASK_MEMORY_WAIT`, six E0599 for `wait_for`, three E0599 for `with_ask_wait`; every other target compiled. Made edits W1 (module doc naming `ask_memory` and the `ask_memory` wait of its own), W2 (`use crate::args::ToolName`), W3 (`MEMORY_WAIT` doc now says `recall` or `memory`; added `ASK_MEMORY_WAIT` 170 s after it with no blank line between, `MemoryPort` gains `ask_wait` last, `new` sets `ask_wait: ASK_MEMORY_WAIT`, `with_ask_wait` via struct update, `wait_for` matches `AskMemory` -> `ask_wait` else `wait`), W4 (ask comment `self.wait_for(spec.tool())`), W5 (`ask` binds `wait = self.wait_for(spec.tool())` and uses it for both timeouts). `cargo check -p brokerd` clean. `cargo test -p brokerd --test memory_wait --test memory_port --test ask_memory_tool` passes: 3 in `memory_wait`, 5 in `memory_port`, 9 in `ask_memory_tool`. `git diff --stat` lists only `memory_port.rs`; `grep -n 'MEMORY_WAIT' crates/brokerd/src/main.rs` prints the `use` line and `MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT)`. `make gate` printed `gate: ok` with 1384 tests (1383 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +3 over the 1381 before is exactly the 3 new `memory_wait` tests). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M5c2/02-brokerd-ask-memory | 2026-09-28 | done | 1 | pass | none | Copied the given tests from `docs/plans/M5c2/files/crates/brokerd/tests/`: `ask_memory_tool.rs` (new) and new versions of `grants.rs` and `memory_tools.rs`. Step 2 (`cargo build --tests --keep-going -p brokerd`) showed exactly the 11 named failures: one E0432 `unresolved import brokerd::builtin::ASK_MEMORY`, one E0599 for `AskMemory` on `ToolArgs`, nine E0599 for `AskMemory` on `ToolName`; every other target compiled. `cargo test --no-fail-fast` on `grants` and `memory_tools` failed the two named lines (`an_empty_directory_is_a_valid_empty_set`, `grant_files_cannot_name_the_memory_tools_or_take_a_built_in_id`). Made edits A1 (`use bxcore::AskMemoryArgs`), A2 (`ToolName::AskMemory`), A3 (`ask_memory` in `parse`), A4 (`ask_memory` in `as_str`), A5 (`is_memory`), A6 (`ToolArgs::AskMemory`), A7 (`tool()`), A8 (`canonical_json`), A9 (`parse` decodes then runs `check`); B1 (module doc naming the three tools), B2 (`ASK_MEMORY` grant text, `BUILT_IN` sentence naming the three, `sources` with `builtin-ask-memory` last), C1 (`time_limit`), P1 (`matching` takes no constraints), R1 (`runner` no mounts/egress). `cargo check -p brokerd` clean. `cargo test` passes: 9 in `ask_memory_tool`, 17 in `grants`, 4 in `memory_tools`, 5 in `memory_port`, 8 in `runner`. `git diff --stat -- crates/brokerd/src` lists only the five files, not `grants.rs`/`container.rs`/`memory_port.rs`; the other-crates diff is empty. `make gate` printed `gate: ok` with 1381 tests (1380 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1372 before is exactly the 9 new `ask_memory_tool` tests). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/02-brokerd-ask-memory | 2026-09-28 | done | 1 | pass | none | Copied the given tests from `docs/plans/M5c2/files/crates/brokerd/tests/`: `ask_memory_tool.rs` (new) and new versions of `grants.rs` and `memory_tools.rs`. Step 2 (`cargo build --tests --keep-going -p brokerd`) showed exactly the 11 named failures: one E0432 `unresolved import brokerd::builtin::ASK_MEMORY`, one E0599 for `AskMemory` on `ToolArgs`, nine E0599 for `AskMemory` on `ToolName`; every other target compiled. `cargo test --no-fail-fast` on `grants` and `memory_tools` failed the two named lines (`an_empty_directory_is_a_valid_empty_set`, `grant_files_cannot_name_the_memory_tools_or_take_a_built_in_id`). Made edits A1 (`use bxcore::AskMemoryArgs`), A2 (`ToolName::AskMemory`), A3 (`ask_memory` in `parse`), A4 (`ask_memory` in `as_str`), A5 (`is_memory`), A6 (`ToolArgs::AskMemory`), A7 (`tool()`), A8 (`canonical_json`), A9 (`parse` decodes then runs `check`); B1 (module doc naming the three tools), B2 (`ASK_MEMORY` grant text, `BUILT_IN` sentence naming the three, `sources` with `builtin-ask-memory` last), C1 (`time_limit`), P1 (`matching` takes no constraints), R1 (`runner` no mounts/egress). `cargo check -p brokerd` clean. `cargo test` passes: 9 in `ask_memory_tool`, 17 in `grants`, 4 in `memory_tools`, 5 in `memory_port`, 8 in `runner`. `git diff --stat -- crates/brokerd/src` lists only the five files, not `grants.rs`/`container.rs`/`memory_port.rs`; the other-crates diff is empty. `make gate` printed `gate: ok` with 1381 tests (1380 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1372 before is exactly the 9 new `ask_memory_tool` tests). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
+4 -3
View File
@@ -916,9 +916,10 @@ they do not belong in, so `memoryd` checks the section before it starts. The rea
or a space is not allowed. or a space is not allowed.
- `agent` and `owner` are the same peer, or two of `trusted`, `untrusted` and `fleet` are the same - `agent` and `owner` are the same peer, or two of `trusted`, `untrusted` and `fleet` are the same
workspace. workspace.
- A `[[honcho.read]]` names this agent, names an agent twice, or names one of this agent's three - A `[[honcho.read]]` names this agent, names an agent twice, names an agent `own`, `untrusted`
workspaces; or its `trust` is not `trusted` or `untrusted`, or its `class` not `public`, or `fleet` (the names `ask_memory` gives this agent's own workspaces), or names one of this
`private` or `secret` (all lower case). agent's three workspaces; or its `trust` is not `trusted` or `untrusted`, or its `class` not
`public`, `private` or `secret` (all lower case).
- A `[secrets.<name>]` is not `honcho_trusted`, `honcho_untrusted`, `honcho_fleet` or - A `[secrets.<name>]` is not `honcho_trusted`, `honcho_untrusted`, `honcho_fleet` or
`honcho_read_<agent>` for an agent in `[[honcho.read]]`; or it does not name exactly one `honcho_read_<agent>` for an agent in `[[honcho.read]]`; or it does not name exactly one
well-formed `credential`, `env` or `file` (as in `secret-unavailable`); or there is no well-formed `credential`, `env` or `file` (as in `secret-unavailable`); or there is no