diff --git a/crates/memoryd/src/honcho/ask.rs b/crates/memoryd/src/honcho/ask.rs index 32b21d8..451ebe7 100644 --- a/crates/memoryd/src/honcho/ask.rs +++ b/crates/memoryd/src/honcho/ask.rs @@ -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)"; diff --git a/crates/memoryd/src/serve.rs b/crates/memoryd/src/serve.rs index d2a3284..c712af1 100644 --- a/crates/memoryd/src/serve.rs +++ b/crates/memoryd/src/serve.rs @@ -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 { + 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. diff --git a/crates/memoryd/tests/honcho_ask_serve.rs b/crates/memoryd/tests/honcho_ask_serve.rs new file mode 100644 index 0000000..a2995d4 --- /dev/null +++ b/crates/memoryd/tests/honcho_ask_serve.rs @@ -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" + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index c871cb9..968690d 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5c2/12-memoryd-serve-ask-memory | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_ask_serve.rs` (new). Step 2 (`cargo test --no-fail-fast -p memoryd --test honcho_ask_serve`) showed exactly the six named failures: every `ask_memory` answered with `memoryd runs only recall and memory`, and the hang test after its 10 s deadline (`the chat was never sent`). Made A1 (`ask.rs`: `NOT_CONFIGURED` before `NO_ANSWER`, the sentence letter for letter), E1 (`use bxcore::AskMemoryArgs;`), E2 (`use crate::honcho::ask;`), E3 (`call` gains the third arm `bxcore::memory::ASK_MEMORY => self.ask_memory(&call)` and the unknown-tool sentence becomes `memoryd runs only recall, memory and ask_memory`), E4 (new `ask_memory`: decode `AskMemoryArgs`, `args.check()?`, the `[honcho]`-less branch returns `NOT_CONFIGURED` labelled private and trusted, else `ask::ask` then `ask::answer`). `cargo check -p memoryd` clean, no error or warning. `cargo test` passes: 6 in `honcho_ask_serve`, 10 in `honcho_ask`, 4 in `honcho_recall_serve`, 4 in `serve_admin`, 8 in `serve`. Step 5 all pass: `git diff --stat` lists only `ask.rs` and `serve.rs`; `wc -l crates/memoryd/src/serve.rs` prints 456. `make gate` printed `gate: ok` with 1454 tests (1453 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +6 over the 1448 before is exactly the tests of `honcho_ask_serve.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/11-memoryd-serve-admin | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/serve_admin.rs` (new) and the new module `crates/memoryd/src/serve/admin.rs` (given whole, `reindex` and `admin` moved unchanged from `serve.rs`, `fn` becoming `pub(super) fn`). Step 1 (`cargo test -p memoryd --test serve_admin`) passed all 4 before the move. Made E1 (dropped `MemoryAdminReply`, `MemoryCommand`, `Reindexed` from the `bxcore` use in `serve.rs`), E2 (dropped `use crate::honcho::report;`), E3 (added `mod admin;` after `use crate::{journal, recall, review};`), E4 (deleted `reindex` and `admin` from the end of `index_notes` to the end of the file). `cargo check -p memoryd` clean, no error or warning. `cargo test -p memoryd` passes: 4 in `serve_admin`, every other suite as before. Step 5 all pass: `wc -l crates/memoryd/src/serve.rs` prints 433; the `diff` of the moved `reindex` (the deleted one in `serve.rs` vs the `pub(super) fn` in `admin.rs`, `pub(super)` normalised) prints nothing; `git diff --stat` lists only `serve.rs` and `git status --short` shows `serve/` and `serve_admin.rs` as new. `make gate` printed `gate: ok` with 1448 tests (1447 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +4 over the 1444 before is exactly the tests of `serve_admin.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/10-memoryd-ask-memory | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_ask.rs` (new), a new version of `crates/memoryd/tests/support/honcho.rs` (from the plan's `files/`, replacing the tree's, adding the `Mode::Chat` variant and its arm), and the skeleton `crates/memoryd/src/honcho/ask.rs` (fill 2 bodies). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the one named failure: `honcho_ask` with one E0432 `unresolved import memoryd::honcho::ask`; every other target compiled. Made H1 (`pub mod ask;` before `pub mod client;` in `honcho/mod.rs`, keeping alphabetical order) and R1 (`recall.rs`: `snippet` becomes `one_line(text, SNIPPET)`, new `one_line(text, cap)` with the old body). Filled its two bodies from their comments, deleting each instruction comment and keeping the doc comment, each from its comment and `cargo check -p memoryd` after: `chat` (`DialecticOptions { query: question.to_string(), reasoning_level: Some(ReasoningLevel::Minimal), ..Default::default() }`; `reader.chat().workspace_chat(&options).map_err(|e| classify(&e))?`; `Ok(response.content().to_string())`); `answer` (`match asked`: `NotSent(sentence)` -> `Recalled { text: sentence, class: bxcore::DataClass::Private, untrusted: false }`; `Sent(chats)` walk by reference, `Some(content)` -> `Item { tag: label.tag(), from: label.name.clone(), class: label.class, untrusted: label.untrusted(), text: one_line(content, ANSWER_CAP) or NO_ANSWER when that is empty once trimmed }`, `None` -> push the name as `&str`; `render(1, &items)` when items non-empty then `unavailable(&names)` when names non-empty; `(class, untrusted) = label(&items)`; `Recalled { text: parts.join("\n"), class, untrusted }`). `cargo check -p memoryd` clean, no warning after both bodies. `cargo test` passes: 10 in `honcho_ask`, 12 in `honcho_search`, 6 in `recall`, 4 in `recall_form`, 11 in `honcho_recalled`. `git diff --stat` lists exactly `mod.rs`, `recall.rs`, `support/honcho.rs`; the two new files are `ask.rs` and `honcho_ask.rs`. `make gate` printed `gate: ok` with 1444 tests (1443 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +10 over the 1434 before is exactly the tests of `honcho_ask.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/09-memoryd-recall-honcho | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_recall_serve.rs` (new). Step 2 (`cargo test --no-fail-fast -p memoryd --test honcho_recall_serve`) showed exactly the two named failures: `a_fleet_hit_makes_the_answer_untrusted` (only the local hit, no Honcho hit shown) and `a_hanging_source_holds_up_no_memory_call` (`the fleet was never searched`); the other two passed already. Made the edits exactly as the task gives: S1 (`search.rs`, `Reading { readers, owner, wait }` and `Reading::recall` = `search` then `answer`, before the `Searched` doc); E1 (`use crate::honcho::search;`); E2 (`Memoryd` gains `reading: Option` after `mirror`, before `log`); E3 (`start` binds `(opened, owner)`, `owner = honcho.owner.clone()` or `String::new()`); E4 (`start` builds `search::Reading { readers: Arc::clone(&opened.readers), owner, wait: search::SOURCE_WAIT }` and the match yields `(honcho, mirror, reading)`); E5 (`reading` in the `Memoryd { .. }`); E6 (`call` drops the outer lock, `recall` now `self.recall(&call)`, `memory` `self.memory(&self.lock(), &call)`); E7 (`recall` without `[honcho]` answers the local index under the lock as before, with it runs `recall::local` in a lock block then `reading.recall(local, &args, &self.log)` with the lock released). `cargo check -p memoryd` clean, no warning. `cargo test` passes: 4 in `honcho_recall_serve`, 12 in `honcho_search`, 6 in `honcho_serve`, 8 in `serve`, 5 in `serve_sanitize`. `git diff --stat` lists exactly `search.rs` and `serve.rs`; `wc -l crates/memoryd/src/serve.rs` prints 495. `cargo fmt --all` put the `owner,` of E4's `Reading` on its own line (rustfmt-canonical, the task's block was one line); the edits otherwise match. `make gate` printed `gate: ok` with 1434 tests (1433 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +4 over the 1430 before is exactly the tests of `honcho_recall_serve.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |