Files
boxmaker/docs/plans/M5c2/01-wire-ask-memory.md
T
2026-09-28 19:30:58 -07:00

16 KiB

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 11 (memoryd answers it).

{"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 ith
    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

//! 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

//! 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

pub const MEMORY: &str = "memory";

becomes

pub const MEMORY: &str = "memory";
pub const ASK_MEMORY: &str = "ask_memory";

M3. The lines

/// The line that separates entries in `memory.md`, `user.md` and `notes.md`.
pub const SEPARATOR: &str = "§";

become

/// 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

fn default_k() -> u64 {
    DEFAULT_K
}

become

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)

impl MemoryArgs {
    /// The shape rules of `memory` (M5a spec, section 5): every text is a well-formed entry, and

become

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

/// `brokerd` → `memoryd`: a decided `recall` or `memory` call, with the session's state when it
/// was decided.

become

/// `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

pub use log::{LogRecord, ToolCall, Usage};

becomes

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:

    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.