bxcore: ask_memory's arguments and their shape checks

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 22:20:27 -07:00
co-authored by ornith-1.5-35b-a3b
parent 62aaadd17e
commit 3c54096704
4 changed files with 269 additions and 5 deletions
+86 -5
View File
@@ -1,6 +1,7 @@
//! 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.
//! 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.
use serde::{Deserialize, Serialize};
@@ -11,6 +12,7 @@ use crate::wire::ToolRequest;
/// The tool names.
pub const RECALL: &str = "recall";
pub const MEMORY: &str = "memory";
pub const ASK_MEMORY: &str = "ask_memory";
/// The most characters a `recall` query may have.
pub const MAX_QUERY: usize = 500;
@@ -23,6 +25,16 @@ pub const MAX_NOTE: usize = 1_000;
pub const MAX_ENTRY: usize = 2_200;
/// 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";
/// The arguments for `recall`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -37,6 +49,15 @@ 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>,
}
/// Which identity file an edit is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -187,6 +208,66 @@ impl RecallArgs {
}
}
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
/// every `consolidate` line has one action and the list is not empty.
@@ -254,8 +335,8 @@ pub struct SessionStateReply {
pub untrusted: bool,
}
/// `brokerd` → `memoryd`: a decided `recall` or `memory` call, with the session's state when it
/// was decided.
/// `brokerd` → `memoryd`: a decided `recall`, `memory` or `ask_memory` call, with the session's
/// state when it was decided.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryCall {