memoryd: where a finished turn goes in Honcho, decided from brokerd and the log
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user