M5c-1 plan: task 08, the turn decision in memoryd

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 06:57:08 -07:00
co-authored by Claude Opus 5.5
parent aef6525cc7
commit 991545c778
3 changed files with 877 additions and 0 deletions
@@ -0,0 +1,155 @@
//! Where a finished turn goes in Honcho (M5c spec, sections 4 and 5): this agent's trusted or
//! untrusted workspace, or nowhere, and why. Pure: the session's state as `brokerd` answered it
//! and the session's log are given, and nothing is read or written here.
use bxcore::sanitize::refuse;
use bxcore::{DataClass, LogRecord, SessionStateReply};
use crate::honcho::config::is_name;
use crate::honcho::ledger::{Action, Workspace};
/// Why a turn goes nowhere: the `why` of its `Action::Skip`.
pub const SECRET_CLASS: &str = "the session is secret";
pub const NO_SPEAKER: &str = "the turn has no single speaker";
pub const BAD_SPEAKER: &str = "the speaker is not a Honcho peer name";
pub const AGENT_SPEAKER: &str = "the speaker has the agent's peer name";
pub const NO_REPLY: &str = "the turn has no reply";
pub const SECRET_TEXT: &str = "the turn's text holds a secret";
/// The turn asked for is not in the log.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NoSuchTurn {
pub turn: u64,
pub turns: u64,
}
impl std::fmt::Display for NoSuchTurn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"turn {} is not in the session's log, which has {} turns",
self.turn, self.turns
)
}
}
impl std::error::Error for NoSuchTurn {}
/// How many turns the log holds: its `User` records.
pub fn turns(records: &[LogRecord]) -> u64 {
// `records.iter().filter(|r| matches!(r, LogRecord::User { .. })).count()`, as a `u64`:
// `u64::try_from(count).unwrap_or(u64::MAX)`.
todo!()
}
/// Where turn `turn` (counted from 1) lies in the log: the index of its `User` record, and the
/// index just past its last record (the next `User` record's, or the log's length). `None` when
/// the log has no such turn.
fn bounds(records: &[LogRecord], turn: u64) -> Option<(usize, usize)> {
// `seen: u64 = 0` and `start: Option<usize> = None`. Walk `records.iter().enumerate()`; at
// each `LogRecord::User { .. }` (`if let`): if `start` is `Some(start)`, return
// `Some((start, index))`; else `seen += 1`, and when `seen == turn`, `start = Some(index)`.
// After the loop: `start.map(|start| (start, records.len()))`.
todo!()
}
/// Turn `turn`'s records, from its `User` record to the record before the next `User`.
pub fn turn_records(records: &[LogRecord], turn: u64) -> Option<&[LogRecord]> {
// `let (start, end) = bounds(records, turn)?;` then `records.get(start..end)`.
todo!()
}
/// A Mattermost username as a Honcho peer name: each `.` becomes `_` (M5c spec, section 3).
pub fn peer_name(speaker: &str) -> String {
// `speaker.replace('.', "_")`.
todo!()
}
/// Whether `text` holds a known-prefix secret, by `refuse`'s secret rules alone. `refuse` gives
/// only the first rule a text breaks, and its character rules come first, so each character they
/// match is first replaced by a space: it is not a key character and not a letter, so a secret's
/// prefix and run are found as before, and a carriage return cannot hide a key.
pub fn has_secret(text: &str) -> bool {
// `let mut buf = [0u8; 4];`. `masked: String` is `text.chars().map(..).collect()`, each
// char `c` mapped to `' '` when `refuse(c.encode_utf8(&mut buf)).is_some()`, else to `c`.
// Then `refuse(&masked).is_some_and(|r| r.rule.starts_with("secret-"))`.
todo!()
}
/// The turn's user text and its speaker, from its first record, the `User` one.
fn user_text(turn: &[LogRecord]) -> (&str, Option<&str>) {
// `match turn.first()`: `Some(LogRecord::User { content, speaker, .. })` ->
// `(content.as_str(), speaker.as_deref())`; anything else -> `("", None)`.
todo!()
}
/// The turn's final reply: the content of its last `Assistant` record, when that record asks for
/// no tool. A turn that stopped in a tool round, or before any answer, has none.
fn final_reply(turn: &[LogRecord]) -> Option<&str> {
// `let last = turn.iter().rev().find(|r| matches!(r, LogRecord::Assistant { .. }));`
// then `match last`: `Some(LogRecord::Assistant { content, tool_calls, .. }) if
// tool_calls.is_empty()` -> `content.as_deref()`; anything else -> `None`.
todo!()
}
/// Whether any tool result up to the turn's end is labelled untrusted.
fn labelled_untrusted(records: &[LogRecord]) -> bool {
// `records.iter().any(|r| matches!(r, LogRecord::ToolResult { untrusted: true, .. }))`.
todo!()
}
/// Where turn `turn` of the session whose log is `records` goes. `state` is `brokerd`'s answer
/// for the session; `agent` is `[honcho] agent`. The checks run in this order, and the first that
/// holds decides: the class `secret`; no speaker; a speaker that is not a peer name, or is the
/// agent's; no reply; a secret in the user's text or the reply. Otherwise the turn goes to the
/// untrusted workspace if `brokerd` or any tool result up to the turn's end says untrusted, else
/// to the trusted one.
pub fn decide(
state: &SessionStateReply,
records: &[LogRecord],
turn: u64,
agent: &str,
) -> Result<Action, NoSuchTurn> {
let missing = NoSuchTurn {
turn,
turns: turns(records),
};
let (start, end) = bounds(records, turn).ok_or(missing)?;
let upto = records.get(..end).ok_or(missing)?;
let this = records.get(start..end).ok_or(missing)?;
let skip = |why: &str| {
Ok(Action::Skip {
why: why.to_string(),
})
};
if state.class == DataClass::Secret {
return skip(SECRET_CLASS);
}
let (text, speaker) = user_text(this);
let Some(speaker) = speaker else {
return skip(NO_SPEAKER);
};
let peer = peer_name(speaker);
if !is_name(&peer) {
return skip(BAD_SPEAKER);
}
if peer == agent {
return skip(AGENT_SPEAKER);
}
let Some(reply) = final_reply(this).filter(|r| !r.trim().is_empty()) else {
return skip(NO_REPLY);
};
if has_secret(text) || has_secret(reply) {
return skip(SECRET_TEXT);
}
let workspace = if state.untrusted || labelled_untrusted(upto) {
Workspace::Untrusted
} else {
Workspace::Trusted
};
Ok(Action::Turn {
workspace,
speaker: peer,
})
}
@@ -0,0 +1,467 @@
//! Where a finished turn goes in Honcho (M5c spec, sections 4 and 5): the trusted or untrusted
//! workspace, or nowhere and why, from `brokerd`'s answer and the session's log; the order of the
//! checks; and the helpers that find a turn in the log. Do not edit.
use bxcore::sanitize::TAGS;
use bxcore::{
CallId, DataClass, Epoch, Hash32, LogRecord, SessionId, SessionStateReply, Timestamp, ToolCall,
};
use memoryd::honcho::decide::{
AGENT_SPEAKER, BAD_SPEAKER, NO_REPLY, NO_SPEAKER, NoSuchTurn, SECRET_CLASS, SECRET_TEXT,
decide, has_secret, peer_name, turn_records, turns,
};
use memoryd::honcho::ledger::{Action, Workspace};
const AGENT: &str = "boxmaker0";
fn time() -> Timestamp {
Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap()
}
fn start() -> LogRecord {
LogRecord::SessionStart {
time: time(),
session: SessionId::new("chat-1").unwrap(),
epoch: Epoch(0),
slot: 0,
baseline: Hash32::ZERO,
}
}
fn user(content: &str, speaker: Option<&str>) -> LogRecord {
LogRecord::User {
time: time(),
content: content.to_string(),
speaker: speaker.map(str::to_string),
}
}
fn reply(content: Option<&str>) -> LogRecord {
LogRecord::Assistant {
time: time(),
content: content.map(str::to_string),
reasoning_content: Some("thinking".to_string()),
tool_calls: Vec::new(),
}
}
fn calls_tool() -> LogRecord {
calls_tool_saying(None)
}
/// A tool round; a model may say something beside its tool calls.
fn calls_tool_saying(content: Option<&str>) -> LogRecord {
LogRecord::Assistant {
time: time(),
content: content.map(str::to_string),
reasoning_content: None,
tool_calls: vec![ToolCall {
id: "c1".to_string(),
name: "fetch".to_string(),
arguments: "{}".to_string(),
}],
}
}
fn result(untrusted: bool) -> LogRecord {
LogRecord::ToolResult {
time: time(),
call: CallId(1),
tool_call_id: "c1".to_string(),
content: "the page".to_string(),
class: DataClass::Public,
untrusted,
truncated: false,
}
}
fn usage() -> LogRecord {
LogRecord::Usage {
time: time(),
cache_n: 0,
prompt_n: 10,
predicted_n: 5,
reasoning_tokens: 2,
thinking_capped: false,
}
}
/// One finished turn: its `User` record, then the reply and its usage.
fn turn(text: &str, speaker: Option<&str>, answer: &str) -> Vec<LogRecord> {
vec![user(text, speaker), reply(Some(answer)), usage()]
}
/// A finished turn with a tool round: the tool's result is labelled `untrusted`.
fn turn_with_tool(speaker: Option<&str>, untrusted: bool) -> Vec<LogRecord> {
vec![
user("look this up", speaker),
calls_tool(),
usage(),
result(untrusted),
reply(Some("here it is")),
usage(),
]
}
fn log(turns: &[Vec<LogRecord>]) -> Vec<LogRecord> {
let mut records = vec![start()];
for t in turns {
records.extend(t.iter().cloned());
}
records
}
fn state(class: DataClass, untrusted: bool) -> SessionStateReply {
SessionStateReply { class, untrusted }
}
fn clean() -> SessionStateReply {
state(DataClass::Private, false)
}
fn to(workspace: Workspace, speaker: &str) -> Action {
Action::Turn {
workspace,
speaker: speaker.to_string(),
}
}
fn skip(why: &str) -> Action {
Action::Skip {
why: why.to_string(),
}
}
/// `decide` for the last turn of `records`.
fn last(state: &SessionStateReply, records: &[LogRecord]) -> Action {
decide(state, records, turns(records), AGENT).unwrap()
}
/// `n` characters of `A1b2`…, which the secret rules take for a key's run.
fn mixed(n: usize) -> String {
"A1b2".chars().cycle().take(n).collect()
}
/// A GitHub token, built from pieces so that no file holds one whole.
fn github_token() -> String {
format!("{}{}", ["gh", "p_"].concat(), mixed(36))
}
#[test]
fn the_skip_reasons_are_these_texts() {
assert_eq!(SECRET_CLASS, "the session is secret");
assert_eq!(NO_SPEAKER, "the turn has no single speaker");
assert_eq!(BAD_SPEAKER, "the speaker is not a Honcho peer name");
assert_eq!(AGENT_SPEAKER, "the speaker has the agent's peer name");
assert_eq!(NO_REPLY, "the turn has no reply");
assert_eq!(SECRET_TEXT, "the turn's text holds a secret");
}
#[test]
fn turns_counts_the_user_records() {
assert_eq!(turns(&[]), 0);
assert_eq!(turns(&[start()]), 0);
let records = log(&[
turn("one", Some("kyle"), "a"),
turn_with_tool(Some("kyle"), false),
vec![user("failed before any answer", Some("kyle"))],
turn("four", None, "d"),
]);
assert_eq!(turns(&records), 4);
}
#[test]
fn a_turn_runs_from_its_user_record_to_the_next() {
let one = turn("one", Some("kyle"), "a");
let two = turn_with_tool(Some("ana"), true);
let three = vec![user("three", None), calls_tool()];
let records = log(&[one.clone(), two.clone(), three.clone()]);
assert_eq!(turn_records(&records, 1), Some(&one[..]));
assert_eq!(turn_records(&records, 2), Some(&two[..]));
assert_eq!(turn_records(&records, 3), Some(&three[..]));
for n in [0, 4, u64::MAX] {
assert_eq!(turn_records(&records, n), None, "{n}");
}
assert_eq!(turn_records(&[start()], 1), None);
}
#[test]
fn a_turn_not_in_the_log_is_an_error() {
let records = log(&[
turn("one", Some("kyle"), "a"),
turn("two", Some("kyle"), "b"),
]);
for n in [0, 3, u64::MAX] {
let err = decide(&clean(), &records, n, AGENT).unwrap_err();
assert_eq!(err, NoSuchTurn { turn: n, turns: 2 });
}
assert_eq!(
NoSuchTurn { turn: 3, turns: 2 }.to_string(),
"turn 3 is not in the session's log, which has 2 turns"
);
let err = decide(&clean(), &[], 1, AGENT).unwrap_err();
assert_eq!(err, NoSuchTurn { turn: 1, turns: 0 });
}
#[test]
fn a_clean_turn_goes_to_the_trusted_workspace() {
let records = log(&[turn("hello", Some("kyle"), "hi")]);
for class in [DataClass::Private, DataClass::Public] {
assert_eq!(
last(&state(class, false), &records),
to(Workspace::Trusted, "kyle")
);
}
let records = log(&[turn_with_tool(Some("kyle"), false)]);
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
}
#[test]
fn the_speaker_becomes_a_peer_name() {
assert_eq!(peer_name("first.last"), "first_last");
assert_eq!(peer_name("a.b.c-d_e"), "a_b_c-d_e");
assert_eq!(peer_name("kyle"), "kyle");
let records = log(&[turn("hello", Some("ana.b"), "hi")]);
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "ana_b"));
}
#[test]
fn untrusted_by_brokerd_or_by_a_label_goes_to_the_untrusted_workspace() {
let plain = turn("hello", Some("kyle"), "hi");
let records = log(std::slice::from_ref(&plain));
assert_eq!(
last(&state(DataClass::Private, true), &records),
to(Workspace::Untrusted, "kyle")
);
assert_eq!(
last(&state(DataClass::Public, true), &records),
to(Workspace::Untrusted, "kyle")
);
// A label in this turn, or in an earlier one, with `brokerd` saying clean.
let records = log(&[turn_with_tool(Some("kyle"), true)]);
assert_eq!(last(&clean(), &records), to(Workspace::Untrusted, "kyle"));
let records = log(&[turn_with_tool(None, true), plain.clone()]);
assert_eq!(last(&clean(), &records), to(Workspace::Untrusted, "kyle"));
}
#[test]
fn a_label_after_the_turn_does_not_count() {
let records = log(&[
turn("hello", Some("kyle"), "hi"),
turn_with_tool(Some("kyle"), true),
]);
assert_eq!(
decide(&clean(), &records, 1, AGENT).unwrap(),
to(Workspace::Trusted, "kyle")
);
assert_eq!(
decide(&clean(), &records, 2, AGENT).unwrap(),
to(Workspace::Untrusted, "kyle")
);
}
#[test]
fn a_secret_session_goes_nowhere() {
for untrusted in [false, true] {
let records = log(&[turn("hello", Some("kyle"), "hi")]);
assert_eq!(
last(&state(DataClass::Secret, untrusted), &records),
skip(SECRET_CLASS)
);
}
}
#[test]
fn no_speaker_goes_nowhere() {
let records = log(&[turn("hello", None, "hi")]);
assert_eq!(last(&clean(), &records), skip(NO_SPEAKER));
}
#[test]
fn a_speaker_that_is_not_a_peer_name_goes_nowhere() {
let long = "a".repeat(513);
for name in [
"",
"a b",
"caf\u{e9}",
"a/b",
"a@b",
"\u{661}",
long.as_str(),
] {
let records = log(&[turn("hello", Some(name), "hi")]);
assert_eq!(last(&clean(), &records), skip(BAD_SPEAKER), "{name:?}");
}
let longest = "a".repeat(512);
let records = log(&[turn("hello", Some(&longest), "hi")]);
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, &longest));
let records = log(&[turn("hello", Some("."), "hi")]);
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "_"));
}
#[test]
fn a_speaker_with_the_agents_name_goes_nowhere() {
let records = log(&[turn("hello", Some(AGENT), "hi")]);
assert_eq!(last(&clean(), &records), skip(AGENT_SPEAKER));
let records = log(&[turn("hello", Some("box.maker"), "hi")]);
assert_eq!(
decide(&clean(), &records, 1, "box_maker").unwrap(),
skip(AGENT_SPEAKER)
);
let records = log(&[turn("hello", Some("Boxmaker0"), "hi")]);
assert_eq!(
last(&clean(), &records),
to(Workspace::Trusted, "Boxmaker0")
);
}
#[test]
fn a_turn_without_a_reply_goes_nowhere() {
let cases = [
vec![user("hello", Some("kyle"))],
vec![user("hello", Some("kyle")), usage()],
vec![user("hello", Some("kyle")), calls_tool(), usage()],
vec![
user("hello", Some("kyle")),
calls_tool_saying(Some("let me look")),
usage(),
],
vec![
user("hello", Some("kyle")),
calls_tool(),
usage(),
result(false),
],
vec![user("hello", Some("kyle")), reply(None), usage()],
vec![user("hello", Some("kyle")), reply(Some("")), usage()],
vec![user("hello", Some("kyle")), reply(Some(" \n\t")), usage()],
vec![user("hello", Some("kyle")), reply(Some("hi")), calls_tool()],
];
for case in cases {
let records = log(std::slice::from_ref(&case));
assert_eq!(last(&clean(), &records), skip(NO_REPLY), "{case:?}");
}
// A later turn is decided on its own records.
let records = log(&[
vec![user("hello", Some("kyle"))],
turn("again", Some("kyle"), "hi"),
]);
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
}
#[test]
fn a_secret_in_the_text_or_the_reply_goes_nowhere() {
let key = github_token();
let texts = [
format!("my token is {key}, keep it"),
key.clone(),
format!("line one\r\n{key}\r\n"),
format!("\u{7}{key}"),
format!("\u{200b}{key}"),
format!("{}{}", ["AK", "IA"].concat(), "IOSFODNN7EXAMPLE"),
format!("{}{}", ["sk", "-proj-"].concat(), mixed(40)),
];
for text in &texts {
let records = log(&[turn(text, Some("kyle"), "noted")]);
assert_eq!(last(&clean(), &records), skip(SECRET_TEXT), "{text:?}");
let records = log(&[turn("what is my token?", Some("kyle"), text)]);
assert_eq!(last(&clean(), &records), skip(SECRET_TEXT), "{text:?}");
}
}
#[test]
fn a_secret_where_nothing_is_sent_does_not_count() {
let key = github_token();
let mut records = log(&[turn_with_tool(Some("kyle"), false)]);
for record in &mut records {
match record {
LogRecord::ToolResult { content, .. } => *content = key.clone(),
LogRecord::Assistant {
reasoning_content, ..
} => *reasoning_content = Some(key.clone()),
_ => {}
}
}
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
let records = log(&[
turn(&key, Some("kyle"), "noted"),
turn("hello", Some("kyle"), "hi"),
]);
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
}
#[test]
fn only_the_secret_rules_count() {
let tag = TAGS[0].text;
for text in [
"a bell \u{7} and a carriage return \r".to_string(),
"a zero width\u{200b}space".to_string(),
format!("the model's {tag} marker"),
"ghp_ is how GitHub tokens start; sk-learn is a library".to_string(),
format!("{}{}", ["gh", "p_"].concat(), mixed(35)),
] {
assert!(!has_secret(&text), "{text:?}");
let records = log(&[turn(&text, Some("kyle"), &text)]);
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
}
}
#[test]
fn has_secret_sees_past_the_character_rules() {
let key = github_token();
assert!(has_secret(&key));
for mark in ["\r", "\u{7}", "\u{200b}", "\u{e000}", "\u{fdd0}", "\u{7f}"] {
assert!(has_secret(&format!("{mark}{key}")), "{mark:?} before");
assert!(has_secret(&format!("{key}{mark}")), "{mark:?} after");
assert!(has_secret(&format!("x{mark}{mark}{key}")), "{mark:?} twice");
}
// A letter joined to the prefix still hides it, as it does for `refuse`.
assert!(!has_secret(&format!("x{key}")));
let tag = TAGS[1].text;
assert!(has_secret(&format!("{tag}{key}")));
}
#[test]
fn the_checks_run_in_their_order() {
let key = github_token();
let secret = state(DataClass::Secret, true);
let untrusted = state(DataClass::Private, true);
// Each case breaks the rule named and every rule after it; the first one decides.
let cases: Vec<(SessionStateReply, Vec<LogRecord>, &str)> = vec![
(
secret.clone(),
vec![user(&key, None), calls_tool()],
SECRET_CLASS,
),
(secret.clone(), vec![user("hi", Some("a b"))], SECRET_CLASS),
(
untrusted.clone(),
vec![user(&key, None), calls_tool()],
NO_SPEAKER,
),
(
untrusted.clone(),
vec![user(&key, Some("a b"))],
BAD_SPEAKER,
),
(
untrusted.clone(),
vec![user(&key, Some(AGENT))],
AGENT_SPEAKER,
),
(
untrusted.clone(),
vec![user(&key, Some("kyle")), calls_tool()],
NO_REPLY,
),
(
untrusted.clone(),
vec![user("hi", Some("kyle")), reply(Some(&key))],
SECRET_TEXT,
),
];
for (state, turn, why) in cases {
let records = log(std::slice::from_ref(&turn));
assert_eq!(last(&state, &records), skip(why), "{turn:?}");
}
}