mind-persona: doctrine files lead each turn's instructions; the campaigns in force are listed and constrain start_self_run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 01:25:03 +01:00
commit d00b42125e
4 changed files with 239 additions and 31 deletions

View file

@ -9,7 +9,9 @@
//! The unit's `persona.env` and credentials name everything else:
//! `MIND_PERSONA_ROOMS` (a,b), `MIND_PERSONA_BURN_ROOM`, `MIND_PERSONA_OPERATOR`
//! (id[,id]) and `MIND_PERSONA_SELF_SENDER`, each also a flag that overrides it
//! (`--rooms`, `--burn-room`, `--operator`, `--self-sender`);
//! (`--rooms`, `--burn-room`, `--operator`, `--self-sender`); `MIND_PERSONA_DOCTRINE`
//! (optional, absolute paths separated by `:`) names the doctrine files read at each
//! turn start and put ahead of her persona;
//! `MIND_CONNECTOR_ENDPOINT` (`<ip>:<port>`, the connector's loopback listener),
//! `MIND_CONNECTOR_KEY_FILE` and `MIND_CONNECTOR_MODEL`; `BIFROST_CHAT_ENDPOINT`
//! (`rudp://<ip>:<port>`, the mouth) and `BIFROST_CHAT_KEY_FILE`. Without

View file

@ -63,6 +63,10 @@ pub const ENV_ROOMS: &str = "MIND_PERSONA_ROOMS";
pub const ENV_BURN_ROOM: &str = "MIND_PERSONA_BURN_ROOM";
pub const ENV_OPERATOR: &str = "MIND_PERSONA_OPERATOR";
pub const ENV_SELF_SENDER: &str = "MIND_PERSONA_SELF_SENDER";
/// Colon-separated absolute paths of the doctrine files whose text leads every turn's instructions, in order.
pub const ENV_DOCTRINE: &str = "MIND_PERSONA_DOCTRINE";
/// The most doctrine text, in bytes, one turn's instructions may carry.
pub const DOCTRINE_MAX_BYTES: usize = 96 * 1024;
/// How many of her latest runs a turn's input lists.
const RECENT_RUNS: u32 = 5;
/// The longest raw reply a trace keeps.
@ -81,6 +85,8 @@ pub struct Config {
/// Her own sender id: lines from it are her own.
pub own_sender: String,
pub max_turns: u32,
/// Doctrine files, read at every turn start and put ahead of her persona, in this order. Empty is legal.
pub doctrine: Vec<PathBuf>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -137,7 +143,7 @@ impl Config {
if !rooms.contains(&burn_room) || max_turns == 0 {
bail!("{usage}");
}
let config = Self { rooms, burn_room, operators: list(operators)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, max_turns };
let config = Self { rooms, burn_room, operators: list(operators)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, max_turns, doctrine: Vec::new() };
Ok((config, rest))
}
@ -145,7 +151,8 @@ impl Config {
/// environment (`MIND_PERSONA_ROOMS`, `MIND_PERSONA_BURN_ROOM`,
/// `MIND_PERSONA_OPERATOR`, `MIND_PERSONA_SELF_SENDER`) through `lookup`, which
/// the binary supplies because this library reads no environment. A flag on
/// the command line wins; an empty variable is unset.
/// the command line wins; an empty variable is unset. `MIND_PERSONA_DOCTRINE`
/// (absolute paths separated by `:`) fills `doctrine`; a relative path is refused.
pub fn from_args_and_env(args: &[String], lookup: impl Fn(&str) -> Option<String>, usage: &str) -> Result<(Self, Vec<String>)> {
let mut args = args.to_vec();
for (flag, name) in [("--rooms", ENV_ROOMS), ("--burn-room", ENV_BURN_ROOM), ("--operator", ENV_OPERATOR), ("--self-sender", ENV_SELF_SENDER)] {
@ -155,7 +162,14 @@ impl Config {
args.extend([flag.to_string(), value]);
}
}
Self::from_args(&args, usage)
let (mut config, rest) = Self::from_args(&args, usage)?;
if let Some(paths) = lookup(ENV_DOCTRINE) {
config.doctrine = paths.split(':').filter(|path| !path.is_empty()).map(PathBuf::from).collect();
if config.doctrine.iter().any(|path| !path.is_absolute()) {
bail!("{ENV_DOCTRINE} names a path that is not absolute");
}
}
Ok((config, rest))
}
fn sender(&self, id: &str) -> Sender {
@ -199,7 +213,6 @@ pub struct StartSelfRun {
/// constrains the answer to and for `decode_reply`, which checks the same.
const BRIEF_MAX_CHARS: usize = 1000;
const REPO_PATTERN: &str = "^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$";
const CAMPAIGN_PATTERN: &str = "^[A-Za-z0-9_.-]{1,64}$";
fn repo_in_bounds(repo: &str) -> bool {
let mut parts = repo.split('/');
@ -219,8 +232,29 @@ fn start_in_bounds(ask: &StartSelfRun) -> bool {
}
/// The JSON Schema the connector constrains the answer to, with the room enum
/// from the config. Text, because the contract carries it as text.
pub fn reply_schema(rooms: &[String]) -> String {
/// from the config and the campaign enum from the mind's campaigns in force
/// (none in force: `start_self_run` can only be null). Text, because the
/// contract carries it as text.
pub fn reply_schema(rooms: &[String], campaigns: &[String]) -> String {
let start = if campaigns.is_empty() {
json!({ "type": "null" })
} else {
json!({
"anyOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"required": ["brief", "repo", "campaign"],
"properties": {
"brief": { "type": "string", "minLength": 1, "maxLength": BRIEF_MAX_CHARS },
"repo": { "type": "string", "pattern": REPO_PATTERN },
"campaign": { "type": "string", "enum": campaigns }
}
}
]
})
};
json!({
"type": "object",
"additionalProperties": false,
@ -238,21 +272,7 @@ pub fn reply_schema(rooms: &[String]) -> String {
}
}
},
"start_self_run": {
"anyOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"required": ["brief", "repo", "campaign"],
"properties": {
"brief": { "type": "string", "minLength": 1, "maxLength": BRIEF_MAX_CHARS },
"repo": { "type": "string", "pattern": REPO_PATTERN },
"campaign": { "type": "string", "pattern": CAMPAIGN_PATTERN }
}
}
]
}
"start_self_run": start
}
})
.to_string()
@ -364,6 +384,49 @@ impl PersonaState for HuginnPersona {
}
}
/// The doctrine files' text in the configured order, each under a heading that
/// names the file; empty when none is configured. Read afresh at every call, so
/// a changed file needs no restart. Text is passed verbatim.
fn read_doctrine(paths: &[PathBuf]) -> Result<String, Failure> {
let mut out = String::new();
let mut total = 0usize;
for path in paths {
let text = std::fs::read_to_string(path).map_err(|_| Failure::Doctrine)?;
total += text.len();
if total > DOCTRINE_MAX_BYTES {
return Err(Failure::DoctrineTooLarge);
}
out.push_str(&format!("# Doctrine: {}\n\n{text}\n\n", path.display()));
}
Ok(out)
}
/// The campaigns in force in her own mind as (slug, title), by slug: the one
/// selection both the turn's instructions and `start_self_run`'s check use.
fn campaigns_in_force(mind: &dyn MindPort) -> Result<Vec<(String, String)>> {
let mut found: Vec<(String, String)> = headers(mind, &in_force(of_kind(PipelineKind::Campaign)))?
.iter()
.filter_map(|campaign| match &campaign.facts {
PipelineFacts::Campaign { title, .. } => Some((campaign.id.id.0.split_once(":campaign:")?.0.to_string(), title.0.clone())),
_ => None,
})
.collect();
found.sort();
found.dedup_by(|a, b| a.0 == b.0);
Ok(found)
}
fn render_campaigns(campaigns: &[(String, String)]) -> String {
if campaigns.is_empty() {
return "No campaign is in force in your mind, so you cannot start a Self run: start_self_run must be null.".into();
}
let mut out = String::from("Campaigns in force in your mind. start_self_run's campaign must be one of these slugs:\n");
for (slug, title) in campaigns {
out.push_str(&format!("- {slug}: {title}\n"));
}
out
}
fn render_instructions(state: &Value) -> String {
let rendered = serde_json::to_string_pretty(state).unwrap_or_default();
format!("You are the persona whose state follows. Speak and act as it, in your own voice.\n\n{rendered}")
@ -404,6 +467,10 @@ pub enum Failure {
Held(HeldReason),
Chat,
Persona,
/// A configured doctrine file is missing, unreadable or not text.
Doctrine,
/// The doctrine files together exceed `DOCTRINE_MAX_BYTES`.
DoctrineTooLarge,
Readout,
Connector,
Undecodable,
@ -419,6 +486,8 @@ impl fmt::Display for Failure {
Self::Held(reason) => write!(f, "held: {reason:?}"),
Self::Chat => f.write_str("the chat could not be read"),
Self::Persona => f.write_str("no persona state"),
Self::Doctrine => f.write_str("a configured doctrine file could not be read"),
Self::DoctrineTooLarge => f.write_str("the doctrine files exceed the size bound"),
Self::Readout => f.write_str("no spend readout"),
Self::Connector => f.write_str("the connector call failed"),
Self::Undecodable => f.write_str("the reply is not a persona reply"),
@ -556,7 +625,11 @@ fn one_turn(
if deliveries.is_empty() && turn > 0 {
return Ok(Step::Idle);
}
let Ok(instructions) = organs.persona.instructions() else { return Ok(Step::Failed(Failure::Persona)) };
let Ok(persona) = organs.persona.instructions() else { return Ok(Step::Failed(Failure::Persona)) };
let doctrine = match read_doctrine(&config.doctrine) {
Ok(doctrine) => doctrine,
Err(why) => return Ok(Step::Failed(why)),
};
let Ok(readout) = organs.connector.spend_readout() else { return Ok(Step::Failed(Failure::Readout)) };
// The ledger resets at a window boundary: a run that sees the window turn is charged its whole grant, as the heartbeat charges it.
// A readout with no row for her caller cannot say what is left of the run's cap: the run holds, no turn is taken.
@ -577,6 +650,8 @@ fn one_turn(
let live = live_runs(organs.mind, &instance)?;
let Some(burn) = burn_line(&readout, &instance.0, heat, live) else { return Ok(Step::Failed(Failure::Readout)) };
let history = recent_runs(organs.mind, &instance)?;
let campaigns = campaigns_in_force(organs.mind)?;
let instructions = format!("{doctrine}{persona}\n\n{}", render_campaigns(&campaigns));
let rooms = reply_rooms(config, &deliveries);
let request = TurnRequest {
caller: instance.0.clone(),
@ -584,7 +659,7 @@ fn one_turn(
max_cost_micro_usd: remaining,
instructions,
input: render_input(config, &deliveries, &history, &burn),
output_schema_json: reply_schema(&rooms),
output_schema_json: reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::<Vec<_>>()),
};
let inputs = trace_inputs(config, &deliveries);
let now = organs.clock.now();
@ -692,8 +767,7 @@ fn start_self_run(
batch: &str,
ask: &StartSelfRun,
) -> Result<Result<PipelineRef, Failure>> {
let known = format!("{}:campaign:", ask.campaign);
if !headers(organs.mind, &in_force(of_kind(PipelineKind::Campaign)))?.iter().any(|campaign| campaign.id.id.0.starts_with(&known)) {
if !campaigns_in_force(organs.mind)?.iter().any(|(slug, _)| *slug == ask.campaign) {
return Ok(Err(Failure::SelfRun));
}
let follow_up = PipelineDocument::FollowUp(PipelineFollowUp {

View file

@ -153,6 +153,7 @@ fn config() -> Config {
operators: vec!["@op:chat".into()],
own_sender: "@eureka:chat".into(),
max_turns: 4,
doctrine: vec![],
}
}
@ -569,10 +570,10 @@ fn the_decoder_holds_the_bounds_the_schema_states() {
assert!(!ok(brief, repo, campaign), "{repo:?} {campaign:?} brief of {}", brief.chars().count());
}
// The decoder's bounds are the schema's, read from one place.
let schema: serde_json::Value = serde_json::from_str(&reply_schema(&c.rooms)).unwrap();
let schema: serde_json::Value = serde_json::from_str(&reply_schema(&c.rooms, &[CAMPAIGN.to_string()])).unwrap();
let ask = &schema["properties"]["start_self_run"]["anyOf"][1]["properties"];
assert_eq!(ask["repo"]["pattern"], REPO_PATTERN);
assert_eq!(ask["campaign"]["pattern"], CAMPAIGN_PATTERN);
assert_eq!(ask["campaign"]["enum"], json!([CAMPAIGN]));
assert_eq!(ask["brief"]["maxLength"], BRIEF_MAX_CHARS);
}
@ -810,7 +811,7 @@ fn the_persona_flags_come_from_the_environment_when_the_command_line_lacks_them(
let (config, rest) = Config::from_args_and_env(&args("--instance eureka"), env, "usage").unwrap();
assert_eq!(
config,
Config { rooms: vec!["swarm".into(), "burn".into()], burn_room: "burn".into(), operators: vec!["@op:chat".into(), "@op2:chat".into()], own_sender: "@eureka:chat".into(), max_turns: MAX_TURNS_PER_START }
Config { rooms: vec!["swarm".into(), "burn".into()], burn_room: "burn".into(), operators: vec!["@op:chat".into(), "@op2:chat".into()], own_sender: "@eureka:chat".into(), max_turns: MAX_TURNS_PER_START, doctrine: vec![] }
);
assert_eq!(rest, ["--instance", "eureka"]);
// A flag wins over its variable, one at a time.
@ -985,3 +986,130 @@ fn deliveries_from_different_dm_rooms_are_never_in_one_turn() {
assert!(!inputs[0].contains("again-a"), "a later delivery waits for its turn");
assert_eq!(*rig.chat.acked.borrow(), vec![1, 2, 3, 5]);
}
fn write_doctrine(rig: &Rig, name: &str, text: &str) -> PathBuf {
let path = rig.dir.path().join(name);
std::fs::write(&path, text).unwrap();
path
}
fn instructions_of(rig: &Rig) -> String {
rig.connector.requests.borrow()[0].instructions.clone()
}
#[test]
fn doctrine_files_lead_the_instructions_in_the_configured_order_ahead_of_the_persona() {
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[])]);
let (first, second) = (write_doctrine(&rig, "b.md", "SECOND-ALPHABETICALLY"), write_doctrine(&rig, "a.md", "FIRST-ALPHABETICALLY"));
rig.go_with(&Config { doctrine: vec![first.clone(), second.clone()], ..config() });
let text = instructions_of(&rig);
let at = |needle: &str| text.find(needle).unwrap_or_else(|| panic!("{needle} in {text}"));
assert!(at(&first.display().to_string()) < at("SECOND-ALPHABETICALLY"), "each file under a heading naming it");
assert!(at("SECOND-ALPHABETICALLY") < at(&second.display().to_string()));
assert!(at(&second.display().to_string()) < at("FIRST-ALPHABETICALLY"));
assert!(at("FIRST-ALPHABETICALLY") < at("You are Eureka."), "doctrine precedes the persona");
}
#[test]
fn doctrine_is_read_at_every_turn_start() {
let rig = Rig::new(vec![operator_says(1, "a"), operator_says(2, "b")], &[says(&[("swarm", "x")]), says(&[])]);
let path = write_doctrine(&rig, "d.md", "BEFORE-BUMP");
rig.chat.on_send.replace(Some(Box::new({
let path = path.clone();
move || std::fs::write(&path, "AFTER-BUMP").unwrap()
})));
rig.go_with(&Config { doctrine: vec![path], max_turns: 3, ..config() });
let requests = rig.connector.requests.borrow();
assert_eq!(requests.len(), 2);
assert!(requests[0].instructions.contains("BEFORE-BUMP") && requests[1].instructions.contains("AFTER-BUMP") && !requests[1].instructions.contains("BEFORE-BUMP"));
}
#[test]
fn a_missing_doctrine_file_fails_the_turn_before_the_connector_is_called() {
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[("swarm", "never")])]);
let present = write_doctrine(&rig, "ok.md", "x");
let Outcome::Served { answered, failure, .. } = rig.go_with(&Config { doctrine: vec![present, rig.dir.path().join("absent.md")], ..config() }) else { panic!("served") };
assert_eq!((answered, failure), (0, Some(Failure::Doctrine)));
assert_eq!(rig.turns(), 0);
assert!(rig.chat.sent.borrow().is_empty() && rig.chat.acked.borrow().is_empty());
assert!(rig.closing_reason().contains("doctrine"), "{}", rig.closing_reason());
}
#[test]
fn doctrine_over_the_bound_is_refused_and_exactly_the_bound_is_not() {
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[])]);
let big = write_doctrine(&rig, "big.md", &"x".repeat(DOCTRINE_MAX_BYTES + 1));
let Outcome::Served { failure, .. } = rig.go_with(&Config { doctrine: vec![big], ..config() }) else { panic!("served") };
assert_eq!(failure, Some(Failure::DoctrineTooLarge));
assert_eq!(rig.turns(), 0);
// The bound is on the total, not on each file.
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[])]);
let half = "x".repeat(DOCTRINE_MAX_BYTES / 2);
let (one, two) = (write_doctrine(&rig, "1.md", &half), write_doctrine(&rig, "2.md", &(half.clone() + "y")));
let Outcome::Served { failure, .. } = rig.go_with(&Config { doctrine: vec![one, two], ..config() }) else { panic!("served") };
assert_eq!(failure, Some(Failure::DoctrineTooLarge));
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[])]);
let exact = write_doctrine(&rig, "exact.md", &"x".repeat(DOCTRINE_MAX_BYTES));
let Outcome::Served { failure, .. } = rig.go_with(&Config { doctrine: vec![exact], ..config() }) else { panic!("served") };
assert_eq!(failure, None);
}
#[test]
fn with_no_doctrine_configured_the_instructions_begin_with_the_persona() {
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[])]);
rig.go();
let text = instructions_of(&rig);
assert!(text.starts_with("You are Eureka."), "{text}");
assert!(!text.contains("Doctrine"));
}
#[test]
fn doctrine_comes_from_the_environment_as_absolute_paths_in_order() {
let env = |doctrine: &'static str| {
move |name: &str| match name {
ENV_ROOMS | ENV_BURN_ROOM => Some("swarm".to_string()),
ENV_OPERATOR => Some("@op:chat".to_string()),
ENV_SELF_SENDER => Some("@eureka:chat".to_string()),
ENV_DOCTRINE => Some(doctrine.to_string()),
_ => None,
}
};
let (config, _) = Config::from_args_and_env(&[], env("/a/CLAUDE.md:/b/gamecult.md"), "usage").unwrap();
assert_eq!(config.doctrine, [PathBuf::from("/a/CLAUDE.md"), PathBuf::from("/b/gamecult.md")]);
assert!(Config::from_args_and_env(&[], env(""), "usage").unwrap().0.doctrine.is_empty());
assert!(Config::from_args_and_env(&[], env("/a:relative/b"), "usage").is_err());
}
#[test]
fn the_campaigns_in_force_are_listed_and_are_the_schemas_campaign_enum() {
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[])]);
rig.mind.committed(vec![campaign_named("zeta", "Zeta work")]);
rig.go();
let requests = rig.connector.requests.borrow();
let text = &requests[0].instructions;
assert!(text.contains(&format!("- {CAMPAIGN}: Eureka body\n")) && text.contains("- zeta: Zeta work\n"), "{text}");
assert!(text.find(CAMPAIGN).unwrap() < text.find("zeta").unwrap(), "listed by slug");
let schema: serde_json::Value = serde_json::from_str(&requests[0].output_schema_json).unwrap();
assert_eq!(schema["properties"]["start_self_run"]["anyOf"][1]["properties"]["campaign"]["enum"], json!([CAMPAIGN, "zeta"]));
}
#[test]
fn a_self_run_naming_a_listed_campaign_passes_the_campaign_check() {
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN } }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[reply]);
let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, None);
assert!(self_run.is_some());
}
#[test]
fn with_no_campaign_in_force_the_instructions_say_so_and_the_schema_allows_no_start() {
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[])]);
rig.mind.committed(vec![close(PipelineRef { kind: K::Campaign, id: format!("{CAMPAIGN}:campaign:self").as_str().into() }, recorded())]);
rig.go();
let requests = rig.connector.requests.borrow();
assert!(requests[0].instructions.contains("No campaign is in force"), "{}", requests[0].instructions);
assert!(!requests[0].instructions.contains("- eureka-body:"));
let schema: serde_json::Value = serde_json::from_str(&requests[0].output_schema_json).unwrap();
assert_eq!(schema["properties"]["start_self_run"], json!({ "type": "null" }));
}

View file

@ -208,9 +208,13 @@ fn stewardship(name: &str) -> PipelineDocument {
}
fn campaign() -> PipelineDocument {
campaign_named(CAMPAIGN, "Eureka body")
}
pub(crate) fn campaign_named(slug: &str, title: &str) -> PipelineDocument {
PipelineDocument::Campaign(PipelineCampaign {
slug: Slug(CAMPAIGN.into()),
title: "Eureka body".into(),
slug: Slug(slug.into()),
title: title.into(),
repos: vec![REPO.into()],
working_branch: "main".into(),
target_doc: doc_ref(),