memoryd: ask_memory served beside recall and memory, outside the lock

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-29 10:01:39 -07:00
co-authored by ornith-1.5-35b-a3b
parent 165145edb5
commit e0f0057ac8
4 changed files with 264 additions and 4 deletions
+4
View File
@@ -27,6 +27,10 @@ pub const ANSWER_CAP: usize = 2_000;
/// The answer when the question holds a known-prefix secret.
pub const SECRET: &str = "ask_memory: not asked; the question looks like it holds a secret";
/// The answer without `[honcho]`: a result, not an error, so that the model reads it and moves on.
pub const NOT_CONFIGURED: &str =
"ask_memory: Honcho is not configured for this agent; recall searches its own memory";
/// The text of an answer with no content.
pub const NO_ANSWER: &str = "(no answer)";
+27 -4
View File
@@ -7,6 +7,7 @@ use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use bxcore::AskMemoryArgs;
use bxcore::{
DataClass, Empty, Envelope, ErrorCode, MemoryArgs, MemoryBlockReply, MemoryCall, Message,
PROTOCOL_VERSION, RecallArgs, SessionId, SessionUpdated, Timestamp, ToolResponse, WireError,
@@ -16,6 +17,7 @@ use bxcore::{
use crate::config::Config;
use crate::edit::{self, Context};
use crate::files::Store;
use crate::honcho::ask;
use crate::honcho::ledger::{self, LedgerError};
use crate::honcho::mirror::Mirror;
use crate::honcho::search;
@@ -318,16 +320,17 @@ impl Memoryd {
Message::Ok(Empty {})
}
/// A decided call from `brokerd`: `recall` or `memory`. `memory` runs under the one lock;
/// `recall` takes it for the local index only (`recall`). `Failed` carries the sentence for
/// the model.
/// A decided call from `brokerd`: `recall`, `memory` or `ask_memory`. `memory` runs under
/// the one lock; `recall` takes it for the local index only (`recall`); `ask_memory` never
/// takes it. `Failed` carries the sentence for the model.
fn call(&self, call: MemoryCall) -> ToolResponse {
let result = match call.request.tool.as_str() {
bxcore::memory::RECALL => self.recall(&call),
bxcore::memory::MEMORY => self.memory(&self.lock(), &call),
bxcore::memory::ASK_MEMORY => self.ask_memory(&call),
_ => {
return ToolResponse::Failed {
message: "memoryd runs only recall and memory".to_string(),
message: "memoryd runs only recall, memory and ask_memory".to_string(),
};
}
};
@@ -362,6 +365,26 @@ impl Memoryd {
Ok(reading.recall(local, &args, &self.log))
}
/// `ask_memory` (M5c spec, section 9): the workspaces named, asked by Honcho's chat, never
/// under the lock. Its arguments are checked again, as `recall`'s are. Without `[honcho]`, a
/// sentence that says so, labelled private and trusted.
fn ask_memory(&self, call: &MemoryCall) -> Result<Recalled, String> {
let args: AskMemoryArgs = match serde_json::from_str(&call.request.arguments) {
Ok(args) => args,
Err(e) => return Err(format!("ask_memory's arguments: {e}")),
};
args.check()?;
let Some(reading) = &self.reading else {
return Ok(Recalled {
text: ask::NOT_CONFIGURED.to_string(),
class: DataClass::Private,
untrusted: false,
});
};
let asked = ask::ask(&reading.readers, &args, ask::CHAT_WAIT, &self.log);
Ok(ask::answer(asked))
}
/// `memory`: refused, staged for review, or applied and journalled; then what it appended to
/// `notes.md` is indexed. Its answer is labelled private and trusted: it is `memoryd`'s own
/// sentence. A refusal and a flag are logged with the rule's name, never with the text.
+232
View File
@@ -0,0 +1,232 @@
//! `memoryd` answering `ask_memory` through its `brokerd` socket (M5c spec, section 9), against a
//! fake Honcho: `own`'s answer with its labels, a fleet answer making the result untrusted (T58);
//! a chat that hangs holds up no memory call, because `ask_memory` never takes the state lock;
//! without `[honcho]`, a sentence that says so; bad arguments refused with `bxcore`'s sentences;
//! an unknown name's sentence as the result; an unknown tool refused. Every wait has a deadline.
//! Do not edit.
#[path = "support/honcho.rs"]
mod honcho;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use bxcore::{
CallId, DataClass, Envelope, MemoryCall, Message, SessionId, ToolRequest, ToolResponse,
read_frame, write_frame,
};
use honcho::{FakeHoncho, Mode};
use memoryd::config::Config;
use memoryd::serve;
use tmp::TempDir;
const QUESTION: &str = r#"{"question":"What does the owner drink in the afternoon?"}"#;
/// `[honcho]` for `url`, reading `boxmaker1`'s workspace.
fn section(url: &str) -> String {
format!(
"[honcho]\nurl = \"{url}\"\nagent = \"boxmaker0\"\nowner = \"kyle\"\ntrusted = \"boxmaker0-trusted\"\nuntrusted = \"boxmaker0-untrusted\"\nfleet = \"fleet\"\n[[honcho.read]]\nagent = \"boxmaker1\"\nworkspace = \"boxmaker1-trusted\"\n"
)
}
struct Home {
config: Config,
/// Removed when the test ends; declared last, so it outlives what writes into it (T44).
_dir: TempDir,
}
impl Home {
/// A `memoryd` serving on a thread, with `[honcho]` at `url` when there is one.
fn serve(tag: &str, url: Option<&str>) -> Home {
let dir = TempDir::new(tag);
let soul = dir.write("etc/soul.md", "You are Boxmaker.\n");
let text = format!(
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{}",
dir.path().display(),
soul.display(),
url.map(section).unwrap_or_default()
);
let config = Config::parse(&text).unwrap();
let log: serve::Log = Arc::new(|_: &str| {});
let running = serve::start(config.clone(), log).unwrap();
std::thread::spawn(move || running.run());
Home { config, _dir: dir }
}
}
/// One call on `brokerd`'s socket and its answer, within 20 s; how long it took.
fn call(socket: &Path, tool: &str, arguments: &str) -> (ToolResponse, Duration) {
let started = Instant::now();
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(20)))
.unwrap();
let env = Envelope {
v: 1,
id: 5,
r#final: true,
msg: Message::MemoryCall(MemoryCall {
request: ToolRequest {
session: SessionId::new("mm-now").unwrap(),
call: CallId(5),
tool: tool.to_string(),
arguments: arguments.to_string(),
},
taint: DataClass::Private,
untrusted: false,
}),
};
write_frame(&mut stream, &env).unwrap();
match read_frame(&mut stream).unwrap().msg {
Message::ToolResponse(response) => (response, started.elapsed()),
other => panic!("{other:?}"),
}
}
fn result(response: ToolResponse) -> (String, DataClass, bool) {
match response {
ToolResponse::Result {
content,
class,
untrusted,
truncated,
} => {
assert!(!truncated);
(content, class, untrusted)
}
other => panic!("{other:?}"),
}
}
fn failed(response: ToolResponse) -> String {
match response {
ToolResponse::Failed { message } => message,
other => panic!("{other:?}"),
}
}
/// `own`'s answer, trusted; a fleet answer, untrusted, as `brokerd` will label the session
/// (T58, end to end).
#[test]
fn each_answer_comes_back_with_its_workspaces_label() {
let fake = FakeHoncho::start();
let home = Home::serve("ask-serve-labels", Some(&fake.url));
let socket = home.config.broker_socket();
let (content, class, untrusted) = result(call(&socket, "ask_memory", QUESTION).0);
assert!(
content.starts_with("[1] memory · own · private\n Based on what I found in memory, "),
"{content}"
);
assert_eq!((class, untrusted), (DataClass::Private, false));
let fleet = r#"{"question":"What does the owner drink?","workspaces":["fleet"]}"#;
let (content, class, untrusted) = result(call(&socket, "ask_memory", fleet).0);
assert!(
content.starts_with("[1] memory-tainted · fleet · private\n "),
"{content}"
);
assert_eq!((class, untrusted), (DataClass::Private, true));
}
/// `ask_memory` never takes the state lock: while its chat hangs, a memory call is answered at
/// once. Stopping the fake Honcho ends the chat, and the answer says the workspace was
/// unavailable.
#[test]
fn a_hanging_chat_holds_up_no_memory_call() {
let fake = FakeHoncho::start();
fake.set(Mode::Hang);
let home = Home::serve("ask-serve-hang", Some(&fake.url));
let socket = home.config.broker_socket();
let for_ask = socket.clone();
let asking = std::thread::spawn(move || call(&for_ask, "ask_memory", QUESTION));
let deadline = Instant::now() + Duration::from_secs(10);
while !fake.requests().iter().any(|r| r.path.ends_with("/chat")) {
assert!(Instant::now() < deadline, "the chat was never sent");
std::thread::sleep(Duration::from_millis(10));
}
let note = r#"{"action":"note","text":"tea is kept in the blue tin"}"#;
let (answer, took) = call(&socket, "memory", note);
assert!(matches!(answer, ToolResponse::Result { .. }), "{answer:?}");
assert!(
took < Duration::from_secs(2),
"the memory call waited {took:?}"
);
assert!(!asking.is_finished(), "the chat is still waiting");
drop(fake);
let (answer, _) = asking.join().unwrap();
let (content, class, untrusted) = result(answer);
assert_eq!(content, "own: unavailable");
assert_eq!((class, untrusted), (DataClass::Private, false));
}
/// Without `[honcho]`, a result the model reads and moves on from, private and trusted.
#[test]
fn without_honcho_the_answer_says_so() {
let home = Home::serve("ask-serve-off", None);
let (content, class, untrusted) =
result(call(&home.config.broker_socket(), "ask_memory", QUESTION).0);
assert_eq!(
content,
"ask_memory: Honcho is not configured for this agent; recall searches its own memory"
);
assert_eq!((class, untrusted), (DataClass::Private, false));
}
/// `memoryd` checks the arguments again, as it does `recall`'s: a refusal is `bxcore`'s sentence.
#[test]
fn bad_arguments_are_refused_with_the_checks_sentences() {
let fake = FakeHoncho::start();
let home = Home::serve("ask-serve-bad", Some(&fake.url));
let socket = home.config.broker_socket();
assert_eq!(
failed(call(&socket, "ask_memory", r#"{"question":" "}"#).0),
"question is empty; write the question to ask"
);
let three = r#"{"question":"q","workspaces":["own","untrusted","fleet"]}"#;
assert_eq!(
failed(call(&socket, "ask_memory", three).0),
"workspaces names 3, over the limit of 2; ask about the others in another call"
);
let unknown = failed(
call(
&socket,
"ask_memory",
r#"{"question":"q","reasoning":"low"}"#,
)
.0,
);
assert!(
unknown.starts_with("ask_memory's arguments: unknown field `reasoning`"),
"{unknown}"
);
assert_eq!(fake.requests().len(), 0, "nothing was sent");
}
/// A name that is none of this agent's workspaces: the sentence listing the names it may use,
/// as the result, and nothing sent.
#[test]
fn an_unknown_names_sentence_is_the_result() {
let fake = FakeHoncho::start();
let home = Home::serve("ask-serve-unknown", Some(&fake.url));
let asked = r#"{"question":"q","workspaces":["boxmaker9"]}"#;
let (content, class, untrusted) =
result(call(&home.config.broker_socket(), "ask_memory", asked).0);
assert_eq!(
content,
"no workspace is named boxmaker9; ask own, untrusted, fleet or boxmaker1"
);
assert_eq!((class, untrusted), (DataClass::Private, false));
assert_eq!(fake.requests().len(), 0);
}
#[test]
fn an_unknown_tool_is_refused() {
let home = Home::serve("ask-serve-tool", None);
assert_eq!(
failed(call(&home.config.broker_socket(), "forget", "{}").0),
"memoryd runs only recall, memory and ask_memory"
);
}