Files
boxmaker/docs/plans/M5c2/01-wire-ask-memory.md
T
2026-09-28 21:27:10 -07:00

357 lines
16 KiB
Markdown

# M5c-2 task 01: `ask_memory`'s arguments and their shape checks
**Branch:** `m5c2`. Check it: `git branch --show-current` prints `m5c2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `bxcore: ask_memory's arguments and their shape checks`
**Log row:** the Task cell is `M5c2/01-wire-ask-memory`.
## Goal
M5c-2 adds a core tool, `ask_memory`, that asks Honcho, a memory server shared by a fleet of
agents, a question about one or two of its workspaces (M5c spec, section 9). This task adds only
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 12 (`memoryd` answers it).
```json
{"question":"Who wrote the launch note?","workspaces":["fleet","boxmaker1"]}
{"question":"What does the owner prefer?"}
```
- **`question`**: required; 1 to 2,000 characters, counted as characters (`chars().count()`),
never bytes; a question of only spaces or newlines is empty.
- **`workspaces`**: optional; at most two names, none empty, none repeated, each 1 to 512
characters of ASCII letters, digits, `_` and `-`. Missing, or `[]`, means the one workspace
`own`, the agent's own trusted one. Whether a name is one this agent's `[honcho]` configuration
has is **not** checked here: `memoryd` checks that in task 10.
- There is no other field, and an unknown field is refused, as in every argument type we define.
(An earlier draft of the spec had a `reasoning` field; it was removed, and a given test sends
it and expects a refusal.)
Each refusal is one sentence that the model is shown after `invalid argument shape: `, so each
says what to do instead. They are exact; the given tests compare them.
## Every place that changes
This is the complete list.
| Place | What happens there |
|---|---|
| `crates/bxcore/src/memory.rs` | the module's doc comment; `ASK_MEMORY`; four constants; `AskMemoryArgs`; its `check` and `asked`; the private `workspace_name`; `MemoryCall`'s doc comment (edits M1 to M6) |
| `crates/bxcore/src/lib.rs` | one added line exports `AskMemoryArgs` (edit X1) |
| `crates/bxcore/tests/ask_memory_args.rs` | new, given |
| everything else | **no change**: not `brokerd` (task 02), not `loopd`, not `memoryd`, no fixture, no `Cargo.toml` |
## Files
- Copy, from `docs/plans/M5c2/files/` (the same path under it): the new test
`crates/bxcore/tests/ask_memory_args.rs`.
- Modify: `crates/bxcore/src/memory.rs`, `crates/bxcore/src/lib.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **No wire fixture.** `brokerd` hands a decided memory call to `memoryd` in a `MemoryCall`,
whose `request.arguments` is the model's argument text as a string. No message changes, so no
fixture under `crates/bxcore/tests/fixtures/` is added or changed. The arguments' own tests
hold their JSON inline, as `memory_args.rs` does for `recall` and `memory`.
- **The default.** `#[serde(default)]` on `workspaces: Vec<String>` reads a missing field as an
empty list. An empty list is written out as `"workspaces":[]`: every field is written, as
`RecallArgs` writes its default `k`, so that `{"question":"q"}` and
`{"question":"q","workspaces":[]}` look the same once `brokerd` writes them out again. So:
no `skip_serializing_if`. `asked()` turns the empty list into `["own"]`.
- **The order of the checks** is fixed, and a given test pins it: the question (empty, then too
long), then the number of names, then each name in turn (empty, then its form, then whether an
earlier name is the same). A call with several faults is shown the first one.
- **Names are compared as written**: `fleet` and `Fleet` are two names (a Honcho name is
case-sensitive).
- **Why 512 characters.** The model names a workspace by a short word (`own`, `untrusted`,
`fleet`) or by another agent's name from `[[honcho.read]]`. `memoryd`'s config accepts an
agent's name of 1 to 512 characters of the same set (`honcho::config::MAX_NAME` and `is_name`
in `crates/memoryd/src/honcho/config.rs`), so the limit here is the same: every agent that
config allows can be asked. `bxcore` cannot use `memoryd`'s constant, because `bxcore` depends
on no other crate; do not add such a dependency. `brokerd` cuts a refusal it shows the model to
300 characters, so a long repeated name in a refusal is harmless.
- **Characters and bytes in a name.** A name's form allows ASCII only, so its length in bytes
(`len()`) is its length in characters; a name with any other character fails the form check
whatever its length.
- **The long strings.** Four `return Err(...)` sentences are longer than a line. `rustfmt` leaves a
string literal as it is, and the gate accepts the long line. Do not split a sentence with `\`
or `concat!`.
- The calls and attributes the edits use. This table is complete; `serde` and `serde_json` are
already dependencies of `bxcore`, and no crate's source (the Cargo registry,
`~/.cargo/registry`) is needed or allowed.
| Item | What it does |
|---|---|
| `#[serde(deny_unknown_fields)]` | on `AskMemoryArgs` (M4): an unknown key is refused |
| `#[serde(default)]` | on `workspaces` (M4): missing reads as `Vec::new()` |
| `str::trim(&self) -> &str`, `str::is_empty` | the empty-question check |
| `str::chars(&self).count() -> usize` | the question's length in characters |
| `Vec::len`, `slice::iter`, `Iterator::enumerate`, `Iterator::take(i)`, `Iterator::any` | the count and the repeat check: `take(i)` is the names before the `i`th |
| `str::bytes(&self)`, `u8::is_ascii_alphanumeric(&self) -> bool`, `Iterator::all` | a name's form |
| `String::as_str` | `asked` borrows each name |
## 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). Keep the fields in the order shown.
### `crates/bxcore/src/memory.rs`
**M1.** The module's doc comment, the lines
```rust
//! Memory: the arguments of `recall` and `memory`, their shape checks, and the messages `memoryd`
//! exchanges with `loopd`, `brokerd` and `bxctl` (M5a spec, sections 5 and 7). Content rules (an
//! entry matching the file, a file's limit, the check on characters) are `memoryd`'s.
```
become
```rust
//! Memory: the arguments of `recall`, `memory` and `ask_memory`, their shape checks, and the
//! messages `memoryd` exchanges with `loopd`, `brokerd` and `bxctl` (M5a spec, sections 5 and 7;
//! M5c spec, section 9). Content rules (an entry matching the file, a file's limit, the check on
//! characters, which workspaces `[honcho]` has) are `memoryd`'s.
```
**M2.** The line
```rust
pub const MEMORY: &str = "memory";
```
becomes
```rust
pub const MEMORY: &str = "memory";
pub const ASK_MEMORY: &str = "ask_memory";
```
**M3.** The lines
```rust
/// The line that separates entries in `memory.md`, `user.md` and `notes.md`.
pub const SEPARATOR: &str = "§";
```
become
```rust
/// The line that separates entries in `memory.md`, `user.md` and `notes.md`.
pub const SEPARATOR: &str = "§";
/// The most characters an `ask_memory` question may have.
pub const MAX_QUESTION: usize = 2_000;
/// The most workspaces one `ask_memory` may name: both are asked at once, inside `brokerd`'s wait.
pub const MAX_WORKSPACES: usize = 2;
/// The most characters of a workspace name in `ask_memory`. It matches `memoryd`'s rule for
/// Honcho's names (`honcho::config::MAX_NAME`), so every agent `[[honcho.read]]` allows can be
/// asked.
pub const MAX_WORKSPACE_NAME: usize = 512;
/// The workspace `ask_memory` asks when it names none: the agent's own trusted one.
pub const OWN_WORKSPACE: &str = "own";
```
**M4.** The lines
```rust
fn default_k() -> u64 {
DEFAULT_K
}
```
become
```rust
fn default_k() -> u64 {
DEFAULT_K
}
/// The arguments for `ask_memory` (M5c spec, section 9). An empty `workspaces` asks `own`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AskMemoryArgs {
pub question: String,
#[serde(default)]
pub workspaces: Vec<String>,
}
```
**M5.** The lines (its spaces are checked: every sentence in the new code has single spaces, and
is compared letter for letter by the given tests)
```rust
impl MemoryArgs {
/// The shape rules of `memory` (M5a spec, section 5): every text is a well-formed entry, and
```
become
```rust
impl AskMemoryArgs {
/// The shape rules of `ask_memory` (M5c spec, section 9), in this order: the question, the
/// number of names, then each name in turn. Only a name's form is checked here. Each sentence
/// reaches the model, so it says what to do instead.
pub fn check(&self) -> Result<(), String> {
if self.question.trim().is_empty() {
return Err("question is empty; write the question to ask".to_string());
}
let chars = self.question.chars().count();
if chars > MAX_QUESTION {
return Err(format!(
"question has {chars} characters, over the limit of {MAX_QUESTION}; ask a shorter question"
));
}
let names = self.workspaces.len();
if names > MAX_WORKSPACES {
return Err(format!(
"workspaces names {names}, over the limit of {MAX_WORKSPACES}; ask about the others in another call"
));
}
for (i, name) in self.workspaces.iter().enumerate() {
if name.is_empty() {
return Err(
"a workspace name is empty; name a workspace, or leave workspaces out to ask own"
.to_string(),
);
}
if !workspace_name(name) {
return Err(format!(
"a workspace name has 1 to {MAX_WORKSPACE_NAME} letters, digits, _ or -; use own, untrusted, fleet or another agent's name"
));
}
if self.workspaces.iter().take(i).any(|w| w == name) {
return Err(format!(
"workspaces names {name} twice; name each workspace once"
));
}
}
Ok(())
}
/// The workspaces to ask: those named, in order, or `own` when none is.
pub fn asked(&self) -> Vec<&str> {
if self.workspaces.is_empty() {
vec![OWN_WORKSPACE]
} else {
self.workspaces.iter().map(String::as_str).collect()
}
}
}
/// A workspace name's form: at most `MAX_WORKSPACE_NAME` characters, each an ASCII letter, a
/// digit, `_` or `-`, as Honcho's names are. `check` refuses an empty name first.
fn workspace_name(name: &str) -> bool {
name.len() <= MAX_WORKSPACE_NAME
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
impl MemoryArgs {
/// The shape rules of `memory` (M5a spec, section 5): every text is a well-formed entry, and
```
**M6.** `MemoryCall`'s doc comment, the lines
```rust
/// `brokerd` → `memoryd`: a decided `recall` or `memory` call, with the session's state when it
/// was decided.
```
become
```rust
/// `brokerd` → `memoryd`: a decided `recall`, `memory` or `ask_memory` call, with the session's
/// state when it was decided.
```
### `crates/bxcore/src/lib.rs`
**X1.** Add one line. The line
```rust
pub use log::{LogRecord, ToolCall, Usage};
```
becomes
```rust
pub use log::{LogRecord, ToolCall, Usage};
pub use memory::AskMemoryArgs;
```
Do not change the `pub use memory::{ ... };` block under it.
## Steps
- [ ] **1. Copy the given test.** From the repository root, one command:
```sh
cp docs/plans/M5c2/files/crates/bxcore/tests/ask_memory_args.rs crates/bxcore/tests/
```
Then `git status --short` lists one new file, `crates/bxcore/tests/ask_memory_args.rs`.
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p bxcore`, with a shell timeout of 600000 ms: one
test target, `ask_memory_args`, does not compile, with two errors, both `E0432`:
``unresolved import `bxcore::AskMemoryArgs` `` and ``unresolved imports
`bxcore::memory::ASK_MEMORY`, `bxcore::memory::MAX_QUESTION`,
`bxcore::memory::MAX_WORKSPACE_NAME`, `bxcore::memory::MAX_WORKSPACES`,
`bxcore::memory::OWN_WORKSPACE` ``. Every other target compiles.
- [ ] **3. The edits.** Make edits M1 to M6 and X1, in order. `cargo check -p bxcore` prints no
error and no warning.
- [ ] **4. The tests.** `cargo test -p bxcore --test ask_memory_args --test memory_args
--test memory_wire`: every test passes, 8 in `ask_memory_args` and the others as before (6 and
5).
- [ ] **5. Check.** Two commands:
- `git diff --stat` lists exactly `crates/bxcore/src/lib.rs` (1 line added) and
`crates/bxcore/src/memory.rs`.
- `grep -c 'skip_serializing_if' crates/bxcore/src/memory.rs` prints `5`, as before this task:
the five on `ConsolidateEntry`; none on `AskMemoryArgs`.
- [ ] **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: **1372** after this
task, 1364 before it (1363 passed in `cargo test --workspace`, plus 1), and the 8 new ones are
the tests of `ask_memory_args.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/01-wire-ask-memory`. `git status --short` lists `crates/bxcore/src/lib.rs`,
`crates/bxcore/src/memory.rs` and your log as modified, and the test as new. Stage with
`git add crates/bxcore 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 1372 tests.
- `git diff HEAD~1 --stat` lists only `crates/bxcore/src/lib.rs`, `crates/bxcore/src/memory.rs`,
`crates/bxcore/tests/ask_memory_args.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: another target failing, or other errors.
- `cargo check` in step 3 prints an error or a warning. Quote it, and stop.
- A test fails in step 4. Do not change the test: stop, and give the failing tests' names and
their messages.
- The fix seems to need a change anywhere else: another crate, a fixture, a `skip_serializing_if`,
a check against `[honcho]`. It does not; stop and say why you think so.
- A test outside `ask_memory_args.rs` 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 1372.
- 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 (23 tried, all
caught): the question checked without `trim`; its length in bytes; its limit moved by one
either way; the limit on names moved by one, or dropped; the repeat check dropped, comparing a
name with itself, or ignoring case; the empty-name sentence skipped; `.` allowed in a name, `-`
refused, non-ASCII letters allowed; the 512-character limit moved by one, or dropped; `asked`
empty for no names, or out of order; unknown keys accepted; `workspaces` required; the empty list
left out when written; the names checked before their number; the tool's name; any sentence's
words.