Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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 03 (loopd declares the
tool), task 07 (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 workspaceown, the agent's own trusted one. Whether a name is one this agent's[honcho]configuration has is not checked here:memorydchecks that in task 07.- 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
reasoningfield; 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 testcrates/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.
brokerdhands a decided memory call tomemorydin aMemoryCall, whoserequest.argumentsis the model's argument text as a string. No message changes, so no fixture undercrates/bxcore/tests/fixtures/is added or changed. The arguments' own tests hold their JSON inline, asmemory_args.rsdoes forrecallandmemory. - The default.
#[serde(default)]onworkspaces: Vec<String>reads a missing field as an empty list. An empty list is written out as"workspaces":[]: every field is written, asRecallArgswrites its defaultk, so that{"question":"q"}and{"question":"q","workspaces":[]}look the same oncebrokerdwrites them out again. So: noskip_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:
fleetandFleetare 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_NAMEandis_nameincrates/memoryd/src/honcho/config.rs), so the limit here is the same: every agent that config allows can be asked.bxcorecannot usememoryd's constant, becausebxcoredepends on no other crate; do not add such a dependency.brokerdcuts 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.rustfmtleaves a string literal as it is, and the gate accepts the long line. Do not split a sentence with\orconcat!. - The calls and attributes the edits use. This table is complete;
serdeandserde_jsonare already dependencies ofbxcore, 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 asVec::new()str::trim(&self) -> &str,str::is_emptythe empty-question check str::chars(&self).count() -> usizethe question's length in characters Vec::len,slice::iter,Iterator::enumerate,Iterator::take(i),Iterator::anythe count and the repeat check: take(i)is the names before theithstr::bytes(&self),u8::is_ascii_alphanumeric(&self) -> bool,Iterator::alla name's form String::as_straskedborrows 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 --shortlists 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, bothE0432:unresolved import `bxcore::AskMemoryArgs`andunresolved 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 bxcoreprints 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 inask_memory_argsand the others as before (6 and 5). -
5. Check. Two commands:
git diff --statlists exactlycrates/bxcore/src/lib.rs(1 line added) andcrates/bxcore/src/memory.rs.grep -c 'skip_serializing_if' crates/bxcore/src/memory.rsprints5, as before this task: the five onConsolidateEntry; none onAskMemoryArgs.
-
6. Run the gate.
cargo fmt --all, thenmake gate, with a shell timeout of 600000 ms. Expected last line:gate: ok. The test count is the passed total ofcargo test --workspaceplus one, for the ignoredend_to_endtest that the gate runs on its own: 1372 after this task, 1364 before it (1363 passed incargo test --workspace, plus 1), and the 8 new ones are the tests ofask_memory_args.rs. To count, add up theN passedof everytest result:line the gate'scargo test --workspaceprinted, 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 isM5c2/01-wire-ask-memory.git status --shortlistscrates/bxcore/src/lib.rs,crates/bxcore/src/memory.rsand your log as modified, and the test as new. Stage withgit add crates/bxcore docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.Cargo.lockdoes not change in this task; thegit addis harmless.
Done when
make gateprintsgate: okwith 1372 tests.git diff HEAD~1 --statlists onlycrates/bxcore/src/lib.rs,crates/bxcore/src/memory.rs,crates/bxcore/tests/ask_memory_args.rsanddocs/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 checkin 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.rsfails 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.