diff --git a/docs/plans/M5c2/01-wire-ask-memory.md b/docs/plans/M5c2/01-wire-ask-memory.md index 13cfacc..ccbdc0b 100644 --- a/docs/plans/M5c2/01-wire-ask-memory.md +++ b/docs/plans/M5c2/01-wire-ask-memory.md @@ -12,7 +12,7 @@ agents, a question about one or two of its workspaces (M5c spec, section 9). Thi the tool's arguments to `bxcore`, with their shape checks, the way `recall`'s are there (`RecallArgs`, its name constant `RECALL`, its limit `MAX_QUERY`, and `RecallArgs::check`). Later tasks use them: task 02 (`brokerd` parses the arguments with them), task 04 (`loopd` declares the -tool), tasks 10 and 11 (`memoryd` answers it). +tool), tasks 10 and 12 (`memoryd` answers it). ```json {"question":"Who wrote the launch note?","workspaces":["fleet","boxmaker1"]} diff --git a/docs/plans/M5c2/02-brokerd-ask-memory.md b/docs/plans/M5c2/02-brokerd-ask-memory.md index ca8a134..329b830 100644 --- a/docs/plans/M5c2/02-brokerd-ask-memory.md +++ b/docs/plans/M5c2/02-brokerd-ask-memory.md @@ -25,7 +25,7 @@ section 9): `brokerd`'s wait for `memoryd` stays 30 s for every memory tool in this task; task 03 gives `ask_memory` its own. Nothing yet asks `brokerd` for `ask_memory`: `loopd` declares it in task 04 -and `memoryd` answers it from task 11. Until then, a call reaching `memoryd` is answered +and `memoryd` answers it from task 12. Until then, a call reaching `memoryd` is answered `memoryd runs only recall and memory`, a failed result. ## Every place that changes diff --git a/docs/plans/M5c2/10-memoryd-ask-memory.md b/docs/plans/M5c2/10-memoryd-ask-memory.md index 22f1ecd..8cfa10a 100644 --- a/docs/plans/M5c2/10-memoryd-ask-memory.md +++ b/docs/plans/M5c2/10-memoryd-ask-memory.md @@ -10,7 +10,7 @@ is empty; otherwise stop. Never switch branch. `ask_memory { question, workspaces }` (task 01) asks Honcho's dialectic about one or two workspaces (M5c spec, section 9). `brokerd` sends it to `memoryd` (tasks 02, 03), and `loopd` declares it (task 04). This task writes what `memoryd` does with it, as a new module -`crates/memoryd/src/honcho/ask.rs`, modelled on task 08's `search.rs`. Task 11 wires it into +`crates/memoryd/src/honcho/ask.rs`, modelled on task 08's `search.rs`. Task 12 wires it into `memoryd`'s serving; until then nothing calls it. - **`ask(readers, args, wait, log) -> Asked`**: @@ -59,7 +59,7 @@ This is the complete list. | `crates/memoryd/src/recall.rs` | `one_line`, and `snippet` calls it (edit R1) | | `crates/memoryd/tests/honcho_ask.rs` | new, given | | `crates/memoryd/tests/support/honcho.rs` | new version, given | -| everything else | **no change**: not `serve.rs` (task 11), not `search.rs`, no `Cargo.toml` | +| everything else | **no change**: not `serve.rs` (task 12), not `search.rs`, no `Cargo.toml` | ## Files diff --git a/docs/plans/M5c2/12-memoryd-serve-ask-memory.md b/docs/plans/M5c2/12-memoryd-serve-ask-memory.md new file mode 100644 index 0000000..ca09419 --- /dev/null +++ b/docs/plans/M5c2/12-memoryd-serve-ask-memory.md @@ -0,0 +1,276 @@ +# M5c-2 task 12: `memoryd` serves `ask_memory`, never under the state lock + +**Branch:** `m5c2`. Check it: `git branch --show-current` prints `m5c2` and `git status --short` +is empty; otherwise stop. Never switch branch. +**Commit subject:** `memoryd: ask_memory served beside recall and memory, outside the lock` +**Log row:** the Task cell is `M5c2/12-memoryd-serve-ask-memory`. + +## Goal + +The last piece of `ask_memory` (M5c spec, section 9). `brokerd` decides it and sends it to +`memoryd` (tasks 02, 03), `loopd` declares it (task 04), and task 10 wrote what `memoryd` does +with it (`honcho/ask.rs`: `ask`, then `answer`). This task makes `memoryd`'s `call` answer it. + +- **`call`** gets a third arm, `ask_memory`, which **never takes the state lock**: its chats may + take 150 s, and nothing else may wait behind them. `memory` keeps the lock for the whole call, + and `recall` for its local part (task 09), as before. +- **Its arguments are checked again**, as `recall`'s are (its `recall::local` calls + `RecallArgs::check` first): `AskMemoryArgs` is decoded from the call's arguments (an error is + `ask_memory's arguments: {serde's error}`), then `check()`, whose error is `bxcore`'s sentence. + Either is a failed result, as for `recall`. `brokerd` has checked them already; `memoryd` does + not rely on that. +- **With `[honcho]`**: `ask::ask(&reading.readers, &args, ask::CHAT_WAIT, &self.log)`, then + `ask::answer`, which becomes a `ToolResponse::Result` with its text and its label, as + `recall`'s answer does. An unknown name or a question holding a secret is `answer`'s sentence, + a result too. +- **Without `[honcho]`**: a result, not an error, labelled `private` and trusted, which the model + reads and moves on from: `ask_memory: Honcho is not configured for this agent; recall searches + its own memory` (`ask::NOT_CONFIGURED`). +- **Another tool**: the sentence becomes `memoryd runs only recall, memory and ask_memory`. No + given test or fixture pins the old one. +- **No new fail-closed state.** A workspace that does not answer is an `unavailable` line in a + result, not a stop, and `memoryd` without `[honcho]` answers with a sentence. No runbook entry + and no pointer is needed. `bxctl` has no `ask_memory` command, and its usage does not change. + +`serve.rs` is 456 lines after this task (433 after task 11). + +## Every place that changes + +This is the complete list. + +| Place | What happens there | +|---|---| +| `crates/memoryd/src/honcho/ask.rs` | `NOT_CONFIGURED` (edit A1) | +| `crates/memoryd/src/serve.rs` | two `use` lines; `call`'s third arm and its sentence; `ask_memory` (edits E1 to E4) | +| `crates/memoryd/tests/honcho_ask_serve.rs` | new, given | +| everything else | **no change**: not `search.rs`, not the runbook, not `bxctl`, no `Cargo.toml` | + +## Files + +- Copy, from `docs/plans/M5c2/files/` (the same path under it): the new test + `crates/memoryd/tests/honcho_ask_serve.rs`. +- Modify: `crates/memoryd/src/honcho/ask.rs`, `crates/memoryd/src/serve.rs`, + `docs/implementer-log.md`. +- Do not touch: any other file. + +## Facts you need + +- **`serve.rs` today**: `call` matches the tool's name; `recall` is `self.recall(&call)` (task + 09); `memory` is `self.memory(&self.lock(), &call)`; each returns `Result`, + turned into a result or a failed response below the `match`. `self.reading` is + `Option`, `Some` with `[honcho]` (task 09); its `readers` is an + `Arc`, and `&reading.readers` passes as `&Readers`. +- **From task 10**: `ask::ask(&Readers, &AskMemoryArgs, Duration, &Log) -> Asked`, + `ask::answer(Asked) -> Recalled`, `ask::CHAT_WAIT` (150 s). From task 01: + `bxcore::AskMemoryArgs` with `check(&self) -> Result<(), String>`, and + `bxcore::memory::ASK_MEMORY` (`"ask_memory"`). +- **The given test** `honcho_ask_serve.rs` (6 tests; well under a second), through `memoryd`'s + `brokerd` socket, against the fake Honcho: + - `own`'s answer, `[1] memory · own · private`, trusted; a `fleet` answer, `memory-tainted`, + the result untrusted (T58, end to end); + - while every chat hangs, a `memory` call is answered within 2 s and the `ask_memory` is still + waiting; then the fake Honcho is stopped, which ends the chat, and the answer is + `own: unavailable`. (So the test does not wait the 150 s.) + - without `[honcho]`, the sentence above, private and trusted; + - bad arguments: an empty question and three workspaces with `bxcore`'s sentences, an unknown + field with `ask_memory's arguments: unknown field `reasoning``, and nothing sent; + - an unknown name's sentence as the result, nothing sent; + - an unknown tool's new sentence. +- The items the edits use. This table is complete. + + | Item | Where | + |---|---| + | `serde_json::from_str::`, `AskMemoryArgs::check` | E4, as `recall`'s arm | + | `ask::ask`, `ask::answer`, `ask::CHAT_WAIT`, `ask::NOT_CONFIGURED` | E4 | + | `Recalled { text, class, untrusted }`, `DataClass::Private` | E4 | + +## The edits, exactly + +Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file +when its turn comes (make them in order). + +### `crates/memoryd/src/honcho/ask.rs` + +**A1.** The line (its spaces are checked: the sentence is compared letter for letter) + +```rust +/// The text of an answer with no content. +``` + +becomes + +```rust +/// 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. +``` + +### `crates/memoryd/src/serve.rs` + +**E1.** Add one line. The lines + +```rust +use std::time::Duration; + +use bxcore::{ +``` + +become + +```rust +use std::time::Duration; + +use bxcore::AskMemoryArgs; +use bxcore::{ +``` + +**E2.** Add one line. The line + +```rust +use crate::edit::{self, Context}; +``` + +becomes + +```rust +use crate::edit::{self, Context}; +use crate::honcho::ask; +``` + +**E3.** The lines (its spaces are checked: the new sentence is compared letter for letter) + +```rust + /// 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. + 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), + _ => { + return ToolResponse::Failed { + message: "memoryd runs only recall and memory".to_string(), + }; + } + }; +``` + +become + +```rust + /// 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, memory and ask_memory".to_string(), + }; + } + }; +``` + +**E4.** The line + +```rust + /// `memory`: refused, staged for review, or applied and journalled; then what it appended to +``` + +becomes + +```rust + /// `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 +``` + +## Steps + +- [ ] **1. Copy the given test.** From the repository root, one command: + + ```sh + cp docs/plans/M5c2/files/crates/memoryd/tests/honcho_ask_serve.rs crates/memoryd/tests/ + ``` + +- [ ] **2. See it fail.** `cargo test --no-fail-fast -p memoryd --test honcho_ask_serve`, with a + shell timeout of 600000 ms: it compiles, and all 6 tests fail, since `memoryd` answers every + `ask_memory` with `memoryd runs only recall and memory` (the hang test after its 10 s + deadline for the chat, which is never sent). +- [ ] **3. The edits.** Make edits A1 and E1 to E4, in order. `cargo check -p memoryd` prints no + error and no warning. +- [ ] **4. The tests.** With a shell timeout of 600000 ms, every test passes: 6 in + `honcho_ask_serve`, and as before 10 in `honcho_ask`, 4 in `honcho_recall_serve`, 4 in + `serve_admin` and 8 in `serve`: + + ```sh + cargo test -p memoryd --test honcho_ask_serve --test honcho_ask --test honcho_recall_serve \ + --test serve_admin --test serve + ``` +- [ ] **5. Check.** Two commands: + - `git diff --stat` lists exactly `crates/memoryd/src/honcho/ask.rs` and + `crates/memoryd/src/serve.rs`. + - `wc -l crates/memoryd/src/serve.rs` prints 456. +- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms. + Expected last line: `gate: ok`. The test count is the passed total of `cargo test --workspace` + plus one, for the ignored `end_to_end` test that the gate runs on its own: **1454** after this + task, 1448 before it; the 6 new ones are the tests of `honcho_ask_serve.rs`. To count, add up + the `N passed` of every `test result:` line the gate's `cargo test --workspace` printed, and + add 1. Never run the gate again only to count. +- [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is + `M5c2/12-memoryd-serve-ask-memory`. Stage with + `git add crates/memoryd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says. + `Cargo.lock` does not change in this task; the `git add` is harmless. + +## Done when + +- `make gate` prints `gate: ok` with 1454 tests. +- `git diff HEAD~1 --stat` lists only `ask.rs`, `serve.rs`, `honcho_ask_serve.rs` and + `docs/implementer-log.md`. + +## Stop and report if + +- The branch check at the top fails. +- A "before" block is not in its file exactly as shown when its turn comes, or is there more + than once. +- Step 2 shows a different result: a compile error, or a test passing. +- `cargo check` in step 3 prints an error or a warning. Quote it, and stop. +- A test fails in step 4. Do not change a test: stop, and give the failing tests' names and + their messages. +- The fix seems to need a change anywhere else: `ask.rs` beyond A1, `search.rs`, the runbook, + `bxctl`, a new configuration key. It does not; stop and say why you think so. +- A test outside the given file fails in the gate: check once whether it fails without your + change (`git stash -u`, run it, `git stash pop`), then stop and report both results. +- The gate passes with a count other than 1454. +- A comment in an edit does not fit the code under it. Quote it and stop. + +## What the given tests pin + +Each of these changes to the finished code makes at least one given test fail (9 tried, all +caught): `ask_memory` under the state lock; not routed at all; its arguments not checked, or a +decoding error without `ask_memory's arguments: `; without `[honcho]`, an error instead of a +result, a result labelled untrusted, or other words; the unknown tool's old sentence; the +answer's label dropped. diff --git a/docs/plans/M5c2/README.md b/docs/plans/M5c2/README.md index d38ad96..6ee8465 100644 --- a/docs/plans/M5c2/README.md +++ b/docs/plans/M5c2/README.md @@ -84,7 +84,7 @@ Honcho sources and chats outside its state lock, and answers `ask_memory`. | 09 | `memoryd`: `recall` with `[honcho]` runs the local part under the state lock and the Honcho part outside it; the readers taken at start; without `[honcho]`, `recall` as before | memoryd | | 10 | `memoryd`: `ask_memory`: workspace names checked against `[honcho]`, both chats at once, each answer tagged and capped, labels, `: unavailable`; a question holding a secret sends nothing | memoryd | | 11 | `memoryd`, making room: `reindex` and `admin` move unchanged from `serve.rs` to `serve/admin.rs`, with tests for four of their behaviours no test pinned | memoryd | -| 12 | `memoryd` serves `ask_memory` beside `recall` and `memory`, outside the lock; `bxctl`'s usage and the runbook where a new message needs a pointer | memoryd, bxctl | +| 12 | `memoryd` serves `ask_memory` beside `recall` and `memory`, never under the state lock; its arguments checked again; without `[honcho]`, a sentence as the result; no new fail-closed state, so no runbook entry, and `bxctl` unchanged | memoryd | After task 12, the design model runs the M5c-2 device check (spec 13) on straylight, rift and the owner's Mattermost: a second `memoryd` under its own name writes a fleet note that `recall` finds, @@ -111,6 +111,7 @@ The gate count is the passed total of `cargo test --workspace` plus one, for the | 09 | 1434 | 8, 8 | | 10 | 1444 | 18, 18 | | 11 | 1448 | 8, 8 | +| 12 | 1454 | 9, 9 | **A flaky test, fixed before the run:** `bxctl`'s `chat_approvals` test `json_prints_only_json_and_never_asks_brokerd` failed once in 5 loaded runs of the whole workspace diff --git a/docs/plans/M5c2/files/crates/memoryd/tests/honcho_ask_serve.rs b/docs/plans/M5c2/files/crates/memoryd/tests/honcho_ask_serve.rs new file mode 100644 index 0000000..a2995d4 --- /dev/null +++ b/docs/plans/M5c2/files/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" + ); +}