M5c-2 plan: task 12, memoryd serves ask_memory
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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
|
(`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
|
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
|
```json
|
||||||
{"question":"Who wrote the launch note?","workspaces":["fleet","boxmaker1"]}
|
{"question":"Who wrote the launch note?","workspaces":["fleet","boxmaker1"]}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ section 9):
|
|||||||
|
|
||||||
`brokerd`'s wait for `memoryd` stays 30 s for every memory tool in this task; task 03 gives
|
`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
|
`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.
|
`memoryd runs only recall and memory`, a failed result.
|
||||||
|
|
||||||
## Every place that changes
|
## Every place that changes
|
||||||
|
|||||||
@@ -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
|
`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`
|
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
|
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.
|
`memoryd`'s serving; until then nothing calls it.
|
||||||
|
|
||||||
- **`ask(readers, args, wait, log) -> Asked`**:
|
- **`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/src/recall.rs` | `one_line`, and `snippet` calls it (edit R1) |
|
||||||
| `crates/memoryd/tests/honcho_ask.rs` | new, given |
|
| `crates/memoryd/tests/honcho_ask.rs` | new, given |
|
||||||
| `crates/memoryd/tests/support/honcho.rs` | new version, 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
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -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<Recalled, String>`,
|
||||||
|
turned into a result or a failed response below the `match`. `self.reading` is
|
||||||
|
`Option<search::Reading>`, `Some` with `[honcho]` (task 09); its `readers` is an
|
||||||
|
`Arc<Readers>`, 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>`, `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<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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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 |
|
| 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, `<workspace>: unavailable`; a question holding a secret sends nothing | memoryd |
|
| 10 | `memoryd`: `ask_memory`: workspace names checked against `[honcho]`, both chats at once, each answer tagged and capped, labels, `<workspace>: 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 |
|
| 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
|
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,
|
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 |
|
| 09 | 1434 | 8, 8 |
|
||||||
| 10 | 1444 | 18, 18 |
|
| 10 | 1444 | 18, 18 |
|
||||||
| 11 | 1448 | 8, 8 |
|
| 11 | 1448 | 8, 8 |
|
||||||
|
| 12 | 1454 | 9, 9 |
|
||||||
|
|
||||||
**A flaky test, fixed before the run:** `bxctl`'s `chat_approvals` test
|
**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
|
`json_prints_only_json_and_never_asks_brokerd` failed once in 5 loaded runs of the whole workspace
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user