From d00b42125e8b2e109d149568b884959544cb8719 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 01:25:03 +0100 Subject: [PATCH] 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 --- crates/mind-body/src/bin/mind-persona.rs | 4 +- crates/mind-body/src/persona.rs | 124 ++++++++++++++++----- crates/mind-body/src/persona/tests.rs | 134 ++++++++++++++++++++++- crates/mind-body/src/testkit.rs | 8 +- 4 files changed, 239 insertions(+), 31 deletions(-) diff --git a/crates/mind-body/src/bin/mind-persona.rs b/crates/mind-body/src/bin/mind-persona.rs index 6b94f51..55ebed9 100644 --- a/crates/mind-body/src/bin/mind-persona.rs +++ b/crates/mind-body/src/bin/mind-persona.rs @@ -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` (`:`, the connector's loopback listener), //! `MIND_CONNECTOR_KEY_FILE` and `MIND_CONNECTOR_MODEL`; `BIFROST_CHAT_ENDPOINT` //! (`rudp://:`, the mouth) and `BIFROST_CHAT_KEY_FILE`. Without diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index c75f25a..c7b09e4 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -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, } #[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, usage: &str) -> Result<(Self, Vec)> { 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 { + 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> { + 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::>()), }; let inputs = trace_inputs(config, &deliveries); let now = organs.clock.now(); @@ -692,8 +767,7 @@ fn start_self_run( batch: &str, ask: &StartSelfRun, ) -> Result> { - 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 { diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 524deb3..be26451 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -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" })); +} diff --git a/crates/mind-body/src/testkit.rs b/crates/mind-body/src/testkit.rs index ffd9610..9504cdf 100644 --- a/crates/mind-body/src/testkit.rs +++ b/crates/mind-body/src/testkit.rs @@ -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(),