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,191 @@
|
||||
//! 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 {
|
||||
let count = records
|
||||
.iter()
|
||||
.filter(|r| matches!(r, LogRecord::User { .. }))
|
||||
.count();
|
||||
u64::try_from(count).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let mut seen: u64 = 0;
|
||||
let mut start: Option<usize> = None;
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
if let LogRecord::User { .. } = record {
|
||||
if let Some(start) = start {
|
||||
return Some((start, index));
|
||||
}
|
||||
seen += 1;
|
||||
if seen == turn {
|
||||
start = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
start.map(|start| (start, records.len()))
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
records.get(start..end)
|
||||
}
|
||||
|
||||
/// A Mattermost username as a Honcho peer name: each `.` becomes `_` (M5c spec, section 3).
|
||||
pub fn peer_name(speaker: &str) -> String {
|
||||
speaker.replace('.', "_")
|
||||
}
|
||||
|
||||
/// 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];
|
||||
let masked: String = text
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if refuse(c.encode_utf8(&mut buf)).is_some() {
|
||||
' '
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
refuse(&masked).is_some_and(|r| r.rule.starts_with("secret-"))
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
_ => ("", None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 { .. }));
|
||||
match last {
|
||||
Some(LogRecord::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
}) if tool_calls.is_empty() => content.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
|
||||
|
||||
pub mod config;
|
||||
pub mod decide;
|
||||
pub mod ledger;
|
||||
|
||||
Reference in New Issue
Block a user