persona: open commitments and asks in front of her every turn, a wake included; the people she may name; origin from the batch; Config.minds / MIND_PERSONA_MINDS (persona.rs, bins, public surface, tests)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
688b221213
commit
c00cc24cc6
5 changed files with 625 additions and 172 deletions
|
|
@ -8,8 +8,8 @@
|
|||
//!
|
||||
//! The unit's `persona.env` and credentials name everything else:
|
||||
//! `MIND_PERSONA_ROOMS` (a,b), `MIND_PERSONA_OPERATOR`
|
||||
//! (id[,id]) and `MIND_PERSONA_SELF_SENDER`, each also a flag that overrides it
|
||||
//! (`--rooms`, `--operator`, `--self-sender`), and `MIND_HEARTBEAT_STATE` (optional: the path of the
|
||||
//! (id[,id]), `MIND_PERSONA_MINDS` (id[,id]: every Mind of the swarm, required) and `MIND_PERSONA_SELF_SENDER`, each also a flag that overrides it
|
||||
//! (`--rooms`, `--operator`, `--minds`, `--self-sender`), and `MIND_HEARTBEAT_STATE` (optional: the path of the
|
||||
//! heartbeat's `heartbeat.cc`, which the unit reads and never writes, for the tempo in her input); `MIND_PERSONA_DOCTRINE`
|
||||
//! (optional, absolute paths separated by `:`) names the doctrine files read at each
|
||||
//! turn start and put ahead of her persona;
|
||||
|
|
@ -40,7 +40,7 @@ const TIMEOUT: Duration = Duration::from_secs(15);
|
|||
/// How long the mouth may take to answer one chat request (a send is a Matrix PUT).
|
||||
const CHAT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
const USAGE: &str = "usage: mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>] [--max-turns n] [--rooms a,b] [--operator id[,id]] [--self-sender id]";
|
||||
const USAGE: &str = "usage: mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>] [--max-turns n] [--rooms a,b] [--operator id[,id]] [--minds id[,id]] [--self-sender id]";
|
||||
|
||||
fn host() -> String {
|
||||
std::fs::read_to_string("/proc/sys/kernel/hostname").map(|name| name.trim().to_string()).ok().filter(|name| !name.is_empty()).unwrap_or_else(|| "unknown".into())
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@
|
|||
//! `PersonaReply` schema (ruling `eureka-body:ruling:persona-reply-typed-by-schema`),
|
||||
//! decodes the answer into that one type and acts on it: each `say` is sent,
|
||||
//! each `remember` becomes a memory in her own mind and each `settle` closes an
|
||||
//! open ask there (ruling `eureka-body:ruling:persona-memory-written-not-transcript`),
|
||||
//! open commitment or ask there (ruling `eureka-body:ruling:persona-memory-written-not-transcript`),
|
||||
//! with her vote in the same admission; `start_self_run` admits its brief as a
|
||||
//! follow-up and opens a Self run that claims it (ruling
|
||||
//! `eureka-body:ruling:self-run-brief-claimed-follow-up`), taking an ask on when
|
||||
//! it names one; and only then are the deliveries acked. Before the call, `recall`
|
||||
//! `eureka-body:ruling:self-run-brief-claimed-follow-up`), taking an open item on when
|
||||
//! it names one; and only then are the deliveries acked. Before the call, the responsibilities
|
||||
//! section shows her open commitments and asks whatever was said, and `recall`
|
||||
//! reads what her mind holds for this turn. A turn that fails for any reason
|
||||
//! sends nothing further, acks nothing and ends the start; the run is closed
|
||||
//! Recorded with the failure, so the breaker and the next wake see it. Nothing
|
||||
|
|
@ -45,7 +46,7 @@ use chrono::{DateTime, Utc};
|
|||
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{
|
||||
Date, Label, Line, MemorySort, PipelineDocument, PipelineFollowUp, PipelineKind, PipelineMemory, PipelinePulse, PipelineRef, PipelineResolution, ResolutionOutcome, RunOperator, RunTurn, SALIENCE_MAX_PERMILLE, Short, Slug,
|
||||
Date, Label, Line, MemoryOrigin, MemorySort, PipelineDocument, PipelineFollowUp, PipelineKind, PipelineMemory, PipelinePulse, PipelineRef, PipelineResolution, ResolutionOutcome, RunOperator, RunTurn, SALIENCE_MAX_PERMILLE, Short, Slug,
|
||||
TEMPO_VOTE_MAX_S, TEMPO_VOTE_MIN_S, URGENCY_MAX_PERMILLE, pipeline_key,
|
||||
};
|
||||
use eureka_state::HuginnClient;
|
||||
|
|
@ -68,6 +69,8 @@ pub const MAX_TURNS_PER_START: u32 = 4;
|
|||
/// The environment variables the unit's `persona.env` sets for the persona flags.
|
||||
pub const ENV_ROOMS: &str = "MIND_PERSONA_ROOMS";
|
||||
pub const ENV_OPERATOR: &str = "MIND_PERSONA_OPERATOR";
|
||||
/// The Matrix ids of the swarm's Minds, comma-separated.
|
||||
pub const ENV_MINDS: &str = "MIND_PERSONA_MINDS";
|
||||
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";
|
||||
|
|
@ -88,8 +91,13 @@ const SETTLE_MAX: usize = 8;
|
|||
const REMEMBER_TEXT_MAX_BYTES: usize = 1000;
|
||||
const ABOUT_MAX: usize = 8;
|
||||
const WHY_MAX_CHARS: usize = 300;
|
||||
/// The longest `learned_in` the organ writes, in bytes (the kind's `Short`).
|
||||
const LEARNED_IN_MAX_BYTES: usize = 200;
|
||||
/// The longest id or alias the kind's `Short` holds, in bytes.
|
||||
const SHORT_MAX_BYTES: usize = 200;
|
||||
/// The most bytes each open-item list of the responsibilities section carries, outside `GOALS_MAX_BYTES`.
|
||||
const OPEN_COMMITTED_MAX_BYTES: usize = 4 * 1024;
|
||||
const OPEN_ASKED_MAX_BYTES: usize = 4 * 1024;
|
||||
/// The most open items one list fetches; the rest are counted.
|
||||
const OPEN_FETCH: u32 = 64;
|
||||
/// The most event ids a Persona memory cites as its sources.
|
||||
const SOURCES_MAX: usize = 8;
|
||||
/// The longest raw reply a trace keeps.
|
||||
|
|
@ -103,6 +111,8 @@ pub struct Config {
|
|||
pub rooms: Vec<String>,
|
||||
/// Sender ids that are the operator.
|
||||
pub operators: Vec<String>,
|
||||
/// The Matrix ids of the swarm's Minds: whom she may name in a memory when none of them has spoken yet. Her own id may be among them and is skipped where used.
|
||||
pub minds: Vec<String>,
|
||||
/// Her own sender id: lines from it are her own.
|
||||
pub own_sender: String,
|
||||
pub max_turns: u32,
|
||||
|
|
@ -129,18 +139,19 @@ impl Sender {
|
|||
|
||||
impl Config {
|
||||
/// Takes the persona flags out of `args` (`--rooms a,b`,
|
||||
/// `--operator id[,id]`, `--self-sender id`, optionally `--max-turns n`) and
|
||||
/// `--operator id[,id]`, `--minds id[,id]`, `--self-sender id`, optionally `--max-turns n`) and
|
||||
/// returns the rest for the shared `--instance`/`--endpoint` parser. A flag
|
||||
/// twice, a missing one, an empty list or a zero `--max-turns` is
|
||||
/// refused with `usage`; nothing parsed is echoed.
|
||||
pub fn from_args(args: &[String], usage: &str) -> Result<(Self, Vec<String>)> {
|
||||
let (mut rooms, mut operators, mut own, mut turns) = (None, None, None, None);
|
||||
let (mut rooms, mut operators, mut minds, mut own, mut turns) = (None, None, None, None, None);
|
||||
let mut rest = Vec::new();
|
||||
let mut args = args.iter();
|
||||
while let Some(flag) = args.next() {
|
||||
let slot = match flag.as_str() {
|
||||
"--rooms" => &mut rooms,
|
||||
"--operator" => &mut operators,
|
||||
"--minds" => &mut minds,
|
||||
"--self-sender" => &mut own,
|
||||
"--max-turns" => &mut turns,
|
||||
_ => {
|
||||
|
|
@ -162,19 +173,19 @@ impl Config {
|
|||
if max_turns == 0 {
|
||||
bail!("{usage}");
|
||||
}
|
||||
let config = Self { rooms, operators: list(operators)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, max_turns, doctrine: Vec::new() };
|
||||
let config = Self { rooms, operators: list(operators)?, minds: list(minds)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, max_turns, doctrine: Vec::new() };
|
||||
Ok((config, rest))
|
||||
}
|
||||
|
||||
/// `from_args`, with each persona flag the command line lacks taken from the
|
||||
/// environment (`MIND_PERSONA_ROOMS`,
|
||||
/// environment (`MIND_PERSONA_ROOMS`, `MIND_PERSONA_MINDS`,
|
||||
/// `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. `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), ("--operator", ENV_OPERATOR), ("--self-sender", ENV_SELF_SENDER)] {
|
||||
for (flag, name) in [("--rooms", ENV_ROOMS), ("--operator", ENV_OPERATOR), ("--minds", ENV_MINDS), ("--self-sender", ENV_SELF_SENDER)] {
|
||||
if !args.iter().any(|arg| arg == flag)
|
||||
&& let Some(value) = lookup(name).filter(|value| !value.is_empty())
|
||||
{
|
||||
|
|
@ -218,7 +229,7 @@ pub struct PersonaReply {
|
|||
pub start_self_run: Option<StartSelfRun>,
|
||||
/// Required, empty when she keeps nothing: what she must still know when this conversation is gone.
|
||||
pub remember: Vec<Remember>,
|
||||
/// Required, empty when she settles nothing: the open asks shown this turn that she closes.
|
||||
/// Required, empty when she settles nothing: the open commitments and asks shown this turn that she closes.
|
||||
pub settle: Vec<Settle>,
|
||||
/// The beat she votes for, in seconds. Omitted, she says nothing about her
|
||||
/// vote and her standing one stands; an explicit null withdraws it.
|
||||
|
|
@ -242,14 +253,14 @@ pub struct StartSelfRun {
|
|||
pub brief: String,
|
||||
pub repo: String,
|
||||
pub campaign: String,
|
||||
/// Required, null when this run answers no ask: an open ask id shown this turn, or `new:<i>`
|
||||
/// naming `remember[i]` (sort Asked), which this run takes on.
|
||||
/// Required, null when this run answers no open item: an open commitment or ask id shown this turn, or `new:<i>`
|
||||
/// naming `remember[i]` (sort Asked or Committed), which this run takes on.
|
||||
#[serde(deserialize_with = "Option::deserialize")]
|
||||
pub for_ask: Option<String>,
|
||||
pub for_item: Option<String>,
|
||||
}
|
||||
|
||||
/// One thing she keeps. The organ, not she, fills where it was learned, whether it was a direct
|
||||
/// message and what it rests on.
|
||||
/// One thing she keeps. The organ, not she, fills where and from whom it was learned and what it
|
||||
/// rests on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Remember {
|
||||
|
|
@ -268,11 +279,11 @@ pub enum SettleOutcome {
|
|||
Wrong,
|
||||
}
|
||||
|
||||
/// An open ask she closes: done is Recorded, declined and wrong are Withdrawn, with her reason.
|
||||
/// An open commitment or ask she closes: done is Recorded, declined and wrong are Withdrawn, with her reason.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settle {
|
||||
pub ask: String,
|
||||
pub item: String,
|
||||
pub outcome: SettleOutcome,
|
||||
pub why: String,
|
||||
}
|
||||
|
|
@ -307,14 +318,14 @@ fn start_in_bounds(ask: &StartSelfRun) -> bool {
|
|||
/// 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.
|
||||
const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversation is gone, in your own words; the chat will not be there to read again. One entry for each of: asks of you (sort Asked); decisions, the operator's and those you agreed with other Minds (Decided); your own commitments, your part of a shared plan, and who holds the other parts (Committed, with about naming those Minds); what you learned (Learned) and did (Did); ideas, your own brainstorming and other Minds' ideas (Idea, about whose); rapport, how you and another Mind or person work and bounce off each other (Rapport, about that one). Agreements between Minds and their parts are kept here until the shared Atlas mind takes them. Do not invent a bond from one exchange, do not keep an echo of what others said as your own, and do not keep every joke. Leave empty when nothing here is worth keeping.";
|
||||
const SETTLE_DESCRIPTION: &str = "Close an open ask shown under 'Asked of you, not yet taken on': done when you have done it, declined when you will not, wrong when it was a mistake. Leave empty when you close none.";
|
||||
const FOR_ASK_DESCRIPTION: &str = "The ask this Self run answers: null, an open ask id shown this turn, or new:<i> for remember[i] (sort Asked) written in this same reply. An ask taken on leaves the open list.";
|
||||
const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversation is gone, in your own words; the chat will not be there to read again. One entry for each of: asks of you (sort Asked); decisions, the operator's and those you agreed with other Minds (Decided); your own commitments (Committed): a shared plan you are in is one Committed entry, your part in your own words and who holds each other part, with about naming those Minds; it stays in front of you every turn until you settle it. Decided is for decisions that are not your own part of the work; what you learned (Learned) and did (Did); ideas, your own brainstorming and other Minds' ideas (Idea, about whose); rapport, how you and another Mind or person work and bounce off each other (Rapport, about that one). For Asked, about names who asked. Agreements between Minds and their parts are kept here until the shared Atlas mind takes them. Do not invent a bond from one exchange, do not keep an echo of what others said as your own, and do not keep every joke. Leave empty when nothing here is worth keeping.";
|
||||
const SETTLE_DESCRIPTION: &str = "Close an open item shown under 'You committed to' or 'Asked of you, not yet taken on': done when you have done it, declined when you will not (a commitment you let go: tell whoever holds the other parts), wrong when it was a mistake. Leave empty when you close none.";
|
||||
const FOR_ITEM_DESCRIPTION: &str = "The open commitment or ask this Self run answers: null, an open item id shown this turn, or new:<i> for remember[i] (sort Asked or Committed) written in this same reply. An item taken on leaves the open lists.";
|
||||
|
||||
pub fn reply_schema(rooms: &[String], campaigns: &[String], people: &[String], asks: &[String]) -> String {
|
||||
let mut for_ask_options = vec![json!({ "type": "null" }), json!({ "type": "string", "pattern": format!("^new:[0-{}]$", REMEMBER_MAX - 1) })];
|
||||
if !asks.is_empty() {
|
||||
for_ask_options.push(json!({ "type": "string", "enum": asks }));
|
||||
pub fn reply_schema(rooms: &[String], campaigns: &[String], people: &[String], open: &[String]) -> String {
|
||||
let mut for_item_options = vec![json!({ "type": "null" }), json!({ "type": "string", "pattern": format!("^new:[0-{}]$", REMEMBER_MAX - 1) })];
|
||||
if !open.is_empty() {
|
||||
for_item_options.push(json!({ "type": "string", "enum": open }));
|
||||
}
|
||||
let remember = json!({
|
||||
"description": REMEMBER_DESCRIPTION,
|
||||
|
|
@ -328,11 +339,11 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], people: &[String], a
|
|||
"sort": { "type": "string", "enum": MemorySort::NAMES },
|
||||
"text": { "type": "string", "minLength": 1, "description": format!("At most {REMEMBER_TEXT_MAX_BYTES} bytes.") },
|
||||
"about": { "type": "array", "maxItems": ABOUT_MAX, "uniqueItems": true, "items": { "type": "string", "enum": people } },
|
||||
"salience_permille": { "type": "integer", "minimum": 0, "maximum": SALIENCE_MAX_PERMILLE, "description": "How much it matters, in thousandths; it only ranks." }
|
||||
"salience_permille": { "type": "integer", "minimum": 0, "maximum": SALIENCE_MAX_PERMILLE, "description": "How much it matters, in thousandths; with how recent it is, it decides which memories you see when not all fit." }
|
||||
}
|
||||
}
|
||||
});
|
||||
let settle = if asks.is_empty() {
|
||||
let settle = if open.is_empty() {
|
||||
json!({ "description": SETTLE_DESCRIPTION, "type": "array", "maxItems": 0 })
|
||||
} else {
|
||||
json!({
|
||||
|
|
@ -342,9 +353,9 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], people: &[String], a
|
|||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ask", "outcome", "why"],
|
||||
"required": ["item", "outcome", "why"],
|
||||
"properties": {
|
||||
"ask": { "type": "string", "enum": asks },
|
||||
"item": { "type": "string", "enum": open },
|
||||
"outcome": { "type": "string", "enum": ["done", "declined", "wrong"] },
|
||||
"why": { "type": "string", "minLength": 1, "maxLength": WHY_MAX_CHARS }
|
||||
}
|
||||
|
|
@ -360,12 +371,12 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], people: &[String], a
|
|||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["brief", "repo", "campaign", "for_ask"],
|
||||
"required": ["brief", "repo", "campaign", "for_item"],
|
||||
"properties": {
|
||||
"brief": { "type": "string", "minLength": 1, "maxLength": BRIEF_MAX_CHARS },
|
||||
"repo": { "type": "string", "pattern": REPO_PATTERN },
|
||||
"campaign": { "type": "string", "enum": campaigns },
|
||||
"for_ask": { "description": FOR_ASK_DESCRIPTION, "anyOf": for_ask_options }
|
||||
"for_item": { "description": FOR_ITEM_DESCRIPTION, "anyOf": for_item_options }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -741,12 +752,14 @@ fn one_turn(
|
|||
let usage = organs.connector.usage_readout().map_or_else(|_| "Plan usage: unavailable.".to_string(), |readout| render_plan(&readout.plan_quota, readout.at_unix_ms));
|
||||
let tempo = render_tempo(organs.tempo, &instance);
|
||||
let history = recent_runs(organs.mind, &instance)?;
|
||||
let goals = render_responsibilities(organs.mind, &instance)?;
|
||||
let names = sender_names(&deliveries);
|
||||
let goals = render_responsibilities(organs.mind, &instance, &names)?;
|
||||
let senders = other_senders(config, &deliveries);
|
||||
let cue = recall::Cue { senders: senders.clone(), texts: deliveries.iter().rev().map(|delivery| delivery.text.as_str()).collect(), names: sender_names(&deliveries), listed: &goals.listed };
|
||||
let cue = recall::Cue { senders, texts: deliveries.iter().rev().map(|delivery| delivery.text.as_str()).collect(), names: names.clone(), listed: &goals.listed, now: organs.clock.now() };
|
||||
let recalled = recall::select(organs.mind, &cue)?;
|
||||
let asks = recalled.ask_ids();
|
||||
let people = memory_people(config, &senders);
|
||||
let shown: Vec<&str> = goals.people.iter().map(String::as_str).chain(recalled.entries().flat_map(|entry| entry.people.iter().map(String::as_str))).collect();
|
||||
let people = people_she_may_name(config, &deliveries, &shown);
|
||||
let open = goals.open.clone();
|
||||
let campaigns = campaigns_in_force(organs.mind)?;
|
||||
let instructions = format!("{doctrine}{persona}\n\n{}", render_campaigns(&campaigns));
|
||||
let rooms = reply_rooms(config, &deliveries);
|
||||
|
|
@ -755,7 +768,7 @@ fn one_turn(
|
|||
request_id: format!("{}-t{turn}", label_of(run)),
|
||||
instructions,
|
||||
input: render_input(config, &deliveries, &history, &goals.text, &recall::render(&recalled), &format!("{usage}\n\n{tempo}")),
|
||||
output_schema_json: reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::<Vec<_>>(), &people, &asks),
|
||||
output_schema_json: reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::<Vec<_>>(), &people, &open),
|
||||
};
|
||||
let inputs = trace_inputs(config, &deliveries);
|
||||
let now = organs.clock.now();
|
||||
|
|
@ -765,7 +778,7 @@ fn one_turn(
|
|||
organs.trace.record(&record(vec![], "failed: connector".into()))?;
|
||||
return Ok(Step::Failed(Failure::Connector));
|
||||
};
|
||||
let reply = match decode_reply(&text, &rooms, &people, &asks) {
|
||||
let reply = match decode_reply(&text, &rooms, &people, &open) {
|
||||
Ok(reply) => reply,
|
||||
Err(why) => {
|
||||
let raw = TraceItem { provenance: "her own".into(), room: String::new(), text: text.chars().take(TRACE_RAW_MAX).collect() };
|
||||
|
|
@ -795,12 +808,12 @@ fn one_turn(
|
|||
}
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: say.room.clone(), text });
|
||||
}
|
||||
let memories = build_memories(&instance, run, turn, &reply, &provenance_of(config, &deliveries));
|
||||
let memories = build_memories(&instance, run, turn, &reply, &origin_of(config, &deliveries));
|
||||
for remembered in &reply.remember {
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("remember {:?}: {}", remembered.sort, remembered.text) });
|
||||
}
|
||||
for settled in &reply.settle {
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("settle {} {:?}: {}", settled.ask, settled.outcome, settled.why) });
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("settle {} {:?}: {}", settled.item, settled.outcome, settled.why) });
|
||||
}
|
||||
if votes(&reply) {
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("pulse tempo_vote_beat_s={:?} urgency_permille={:?}", reply.tempo_vote_beat_s, reply.urgency_permille) });
|
||||
|
|
@ -811,7 +824,7 @@ fn one_turn(
|
|||
}
|
||||
if let Some(ask) = &reply.start_self_run {
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("start_self_run {} {}: {}", ask.campaign, ask.repo, ask.brief) });
|
||||
let taken = ask_taken(ask, &memories)?;
|
||||
let taken = item_taken(ask, &memories)?;
|
||||
match start_self_run(organs, control, swarm, run, &batch, ask, taken)? {
|
||||
Ok(started) => *self_run = Some(started),
|
||||
Err(why) => {
|
||||
|
|
@ -859,9 +872,9 @@ fn distinct<T: PartialEq>(items: &[T]) -> bool {
|
|||
items.iter().enumerate().all(|(index, item)| !items[..index].contains(item))
|
||||
}
|
||||
|
||||
/// What she keeps and settles is in the schema's bounds, names only people of this batch and the
|
||||
/// operators, and settles or takes on only an ask shown this turn (or a `new:<i>` ask she writes now).
|
||||
fn memory_in_bounds(reply: &PersonaReply, people: &[String], asks: &[String]) -> bool {
|
||||
/// What she keeps and settles is in the schema's bounds, names only people she may name, and
|
||||
/// settles or takes on only an open item shown this turn (or a `new:<i>` ask or commitment she writes now).
|
||||
fn memory_in_bounds(reply: &PersonaReply, people: &[String], open: &[String]) -> bool {
|
||||
let remembered = reply.remember.len() <= REMEMBER_MAX
|
||||
&& reply.remember.iter().all(|remember| {
|
||||
(1..=REMEMBER_TEXT_MAX_BYTES).contains(&remember.text.len())
|
||||
|
|
@ -873,24 +886,24 @@ fn memory_in_bounds(reply: &PersonaReply, people: &[String], asks: &[String]) ->
|
|||
});
|
||||
let settled = reply.settle.len() <= SETTLE_MAX
|
||||
&& reply.settle.iter().all(|settle| {
|
||||
asks.contains(&settle.ask) && (1..=WHY_MAX_CHARS).contains(&settle.why.chars().count()) && settle.why.len() <= 1000 && !settle.why.trim().is_empty()
|
||||
open.contains(&settle.item) && (1..=WHY_MAX_CHARS).contains(&settle.why.chars().count()) && settle.why.len() <= 1000 && !settle.why.trim().is_empty()
|
||||
})
|
||||
&& distinct(&reply.settle.iter().map(|settle| settle.ask.as_str()).collect::<Vec<_>>());
|
||||
let taken = reply.start_self_run.as_ref().and_then(|start| start.for_ask.as_deref()).is_none_or(|ask| match ask.strip_prefix("new:") {
|
||||
&& distinct(&reply.settle.iter().map(|settle| settle.item.as_str()).collect::<Vec<_>>());
|
||||
let taken = reply.start_self_run.as_ref().and_then(|start| start.for_item.as_deref()).is_none_or(|item| match item.strip_prefix("new:") {
|
||||
Some(index) => {
|
||||
index.len() == 1
|
||||
&& index.parse::<usize>().ok().and_then(|index| reply.remember.get(index)).is_some_and(|remember| remember.sort == MemorySort::Asked)
|
||||
&& index.parse::<usize>().ok().and_then(|index| reply.remember.get(index)).is_some_and(|remember| matches!(remember.sort, MemorySort::Asked | MemorySort::Committed))
|
||||
}
|
||||
None => asks.iter().any(|shown| shown == ask) && !reply.settle.iter().any(|settle| settle.ask == ask),
|
||||
None => open.iter().any(|shown| shown == item) && !reply.settle.iter().any(|settle| settle.item == item),
|
||||
});
|
||||
remembered && settled && taken
|
||||
}
|
||||
|
||||
/// The reply as the one type it must be, with every room one of `rooms`, every person one of
|
||||
/// `people` and every ask one of the open `asks` shown this turn.
|
||||
fn decode_reply(text: &str, rooms: &[String], people: &[String], asks: &[String]) -> Result<PersonaReply, Failure> {
|
||||
/// `people` and every settled or taken-on item one of the `open` items shown this turn.
|
||||
fn decode_reply(text: &str, rooms: &[String], people: &[String], open: &[String]) -> Result<PersonaReply, Failure> {
|
||||
let reply: PersonaReply = serde_json::from_str(text).map_err(|_| Failure::Undecodable)?;
|
||||
if reply.start_self_run.as_ref().is_some_and(|ask| !start_in_bounds(ask)) || !vote_in_bounds(&reply) || !memory_in_bounds(&reply, people, asks) {
|
||||
if reply.start_self_run.as_ref().is_some_and(|ask| !start_in_bounds(ask)) || !vote_in_bounds(&reply) || !memory_in_bounds(&reply, people, open) {
|
||||
return Err(Failure::Undecodable);
|
||||
}
|
||||
if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) {
|
||||
|
|
@ -990,21 +1003,14 @@ fn votes(reply: &PersonaReply) -> bool {
|
|||
reply.tempo_vote_beat_s.is_some() || reply.urgency_permille.is_some_and(|urgency| urgency > 0)
|
||||
}
|
||||
|
||||
/// What the organ knows of where a batch came from, which no model writes.
|
||||
struct Provenance {
|
||||
learned_in: String,
|
||||
direct: bool,
|
||||
sources: Vec<String>,
|
||||
}
|
||||
|
||||
/// A batch's provenance: for a direct message its room id, else the rooms' aliases; the newest
|
||||
/// event ids. A wake has her first room, indirect, and no sources.
|
||||
fn provenance_of(config: &Config, deliveries: &[ChatDelivery]) -> Provenance {
|
||||
let Some(first) = deliveries.first() else {
|
||||
return Provenance { learned_in: config.rooms.first().cloned().unwrap_or_default(), direct: false, sources: vec![] };
|
||||
};
|
||||
let mut learned_in = if first.is_direct {
|
||||
first.room_id.clone()
|
||||
/// Where a batch came from and whom she heard it from, and the event ids it rests on: what the organ
|
||||
/// knows, which no model writes. No deliveries is a wake; a direct first delivery is that DM and the
|
||||
/// others who spoke in it; else the rooms' aliases and the batch's other senders.
|
||||
fn origin_of(config: &Config, deliveries: &[ChatDelivery]) -> (MemoryOrigin, Vec<String>) {
|
||||
let Some(first) = deliveries.first() else { return (MemoryOrigin::Wake, vec![]) };
|
||||
let heard: Vec<Short> = other_senders(config, deliveries).into_iter().take(SOURCES_MAX).map(|id| Short(id.to_string())).collect();
|
||||
let origin = if first.is_direct {
|
||||
MemoryOrigin::Direct { room: Short(first.room_id.clone()), with: heard }
|
||||
} else {
|
||||
let mut aliases: Vec<&str> = Vec::new();
|
||||
for delivery in deliveries {
|
||||
|
|
@ -1012,18 +1018,21 @@ fn provenance_of(config: &Config, deliveries: &[ChatDelivery]) -> Provenance {
|
|||
aliases.push(&delivery.room_alias);
|
||||
}
|
||||
}
|
||||
aliases.join(", ")
|
||||
let mut rooms = aliases.join(", ");
|
||||
let mut end = rooms.len().min(SHORT_MAX_BYTES);
|
||||
while !rooms.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
rooms.truncate(end);
|
||||
MemoryOrigin::Room { rooms: Short(rooms), heard_from: heard }
|
||||
};
|
||||
while learned_in.len() > LEARNED_IN_MAX_BYTES {
|
||||
learned_in.pop();
|
||||
}
|
||||
let mut sources: Vec<String> = deliveries.iter().rev().map(|delivery| delivery.event_id.clone()).filter(|id| !id.is_empty()).take(SOURCES_MAX).collect();
|
||||
sources.reverse();
|
||||
Provenance { learned_in, direct: first.is_direct, sources }
|
||||
(origin, sources)
|
||||
}
|
||||
|
||||
/// Her `remember` entries as memories, labelled by run, turn and index.
|
||||
fn build_memories(instance: &Slug, run: &PipelineRef, turn: u32, reply: &PersonaReply, provenance: &Provenance) -> Vec<PipelineDocument> {
|
||||
fn build_memories(instance: &Slug, run: &PipelineRef, turn: u32, reply: &PersonaReply, (origin, sources): &(MemoryOrigin, Vec<String>)) -> Vec<PipelineDocument> {
|
||||
reply
|
||||
.remember
|
||||
.iter()
|
||||
|
|
@ -1035,9 +1044,8 @@ fn build_memories(instance: &Slug, run: &PipelineRef, turn: u32, reply: &Persona
|
|||
sort: remember.sort,
|
||||
text: Line(remember.text.clone()),
|
||||
about: remember.about.iter().cloned().map(Short).collect(),
|
||||
learned_in: Short(provenance.learned_in.clone()),
|
||||
direct: provenance.direct,
|
||||
sources: provenance.sources.iter().cloned().map(Short).collect(),
|
||||
origin: origin.clone(),
|
||||
sources: sources.iter().cloned().map(Short).collect(),
|
||||
salience_permille: remember.salience_permille,
|
||||
consolidates: vec![],
|
||||
})
|
||||
|
|
@ -1045,26 +1053,26 @@ fn build_memories(instance: &Slug, run: &PipelineRef, turn: u32, reply: &Persona
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// The ask a Self run takes on: a shown ask's memory, or the memory this reply wrote at `new:<i>`.
|
||||
fn ask_taken(start: &StartSelfRun, memories: &[PipelineDocument]) -> Result<Option<PipelineRef>> {
|
||||
let Some(ask) = start.for_ask.as_deref() else { return Ok(None) };
|
||||
let id = match ask.strip_prefix("new:") {
|
||||
/// The open item a Self run takes on: a shown item's memory, or the memory this reply wrote at `new:<i>`.
|
||||
fn item_taken(start: &StartSelfRun, memories: &[PipelineDocument]) -> Result<Option<PipelineRef>> {
|
||||
let Some(item) = start.for_item.as_deref() else { return Ok(None) };
|
||||
let id = match item.strip_prefix("new:") {
|
||||
Some(index) => {
|
||||
let memory = index.parse::<usize>().ok().and_then(|index| memories.get(index)).ok_or_else(|| anyhow!("for_ask names no memory of this reply"))?;
|
||||
let memory = index.parse::<usize>().ok().and_then(|index| memories.get(index)).ok_or_else(|| anyhow!("for_item names no memory of this reply"))?;
|
||||
pipeline_key(memory).map_err(|_| anyhow!("the memory could not be keyed"))?
|
||||
}
|
||||
None => ask.to_string(),
|
||||
None => item.to_string(),
|
||||
};
|
||||
Ok(Some(PipelineRef { kind: PipelineKind::Memory, id: id.as_str().into() }))
|
||||
}
|
||||
|
||||
/// One admission per turn: her memories, the resolutions that settle asks, and her vote and
|
||||
/// One admission per turn: her memories, the resolutions that settle open items, and her vote and
|
||||
/// declared urgency as one Pulse (its label is her run label and the turn, which sorts in time
|
||||
/// order like a run label). True when there was nothing to admit or her mind took it.
|
||||
fn admit_turn_documents(organs: &Organs, run: &PipelineRef, turn: u32, reply: &PersonaReply, memories: Vec<PipelineDocument>) -> Result<bool> {
|
||||
let mut documents = memories;
|
||||
for settle in &reply.settle {
|
||||
let subject = PipelineRef { kind: PipelineKind::Memory, id: settle.ask.as_str().into() };
|
||||
let subject = PipelineRef { kind: PipelineKind::Memory, id: settle.item.as_str().into() };
|
||||
let reason = Line(settle.why.clone());
|
||||
documents.push(PipelineDocument::Resolution(PipelineResolution {
|
||||
sequence: next_sequence(organs.mind, &subject)?,
|
||||
|
|
@ -1093,25 +1101,35 @@ fn admit_turn_documents(organs: &Organs, run: &PipelineRef, turn: u32, reply: &P
|
|||
Ok(matches!(organs.mind.admit(AGENT, label_of(run), documents)?, PipelineAdmissionOutcome::Committed { .. } | PipelineAdmissionOutcome::AlreadyAdmitted { .. }))
|
||||
}
|
||||
|
||||
/// The Matrix ids of this batch's senders other than her own, in arrival order.
|
||||
/// The Matrix ids of this batch's senders other than her own, in arrival order. An id longer than
|
||||
/// `Short` holds is left out, so no cue, origin or schema enum ever carries one the mind would refuse;
|
||||
/// that member's messages still render as message lines.
|
||||
fn other_senders<'a>(config: &Config, deliveries: &'a [ChatDelivery]) -> Vec<&'a str> {
|
||||
let mut senders: Vec<&str> = Vec::new();
|
||||
for delivery in deliveries {
|
||||
if delivery.sender != config.own_sender && !senders.contains(&delivery.sender.as_str()) {
|
||||
if delivery.sender != config.own_sender && delivery.sender.len() <= SHORT_MAX_BYTES && !senders.contains(&delivery.sender.as_str()) {
|
||||
senders.push(&delivery.sender);
|
||||
}
|
||||
}
|
||||
senders
|
||||
}
|
||||
|
||||
/// Whom a memory may be about: the batch's other senders (Minds included) and the operators.
|
||||
fn memory_people(config: &Config, senders: &[&str]) -> Vec<String> {
|
||||
let mut people: Vec<String> = senders.iter().map(|id| id.to_string()).collect();
|
||||
for operator in &config.operators {
|
||||
if !people.contains(operator) {
|
||||
people.push(operator.clone());
|
||||
/// Whom a memory may be about, each within `Short`'s bound, once, in this order: the batch's other
|
||||
/// senders, the senders of the earlier lines shown beside them, the Minds mentioned, the operators,
|
||||
/// the swarm's Minds, then everyone a memory shown this turn names. Never her own id.
|
||||
fn people_she_may_name(config: &Config, deliveries: &[ChatDelivery], shown: &[&str]) -> Vec<String> {
|
||||
let mut people: Vec<String> = Vec::new();
|
||||
let mut add = |id: &str| {
|
||||
if id != config.own_sender && id.len() <= SHORT_MAX_BYTES && !id.is_empty() && !people.iter().any(|known| known == id) {
|
||||
people.push(id.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
other_senders(config, deliveries).into_iter().for_each(&mut add);
|
||||
deliveries.iter().flat_map(|delivery| delivery.context.iter().map(|line| line.sender.as_str())).for_each(&mut add);
|
||||
deliveries.iter().flat_map(|delivery| delivery.mentions.iter().map(String::as_str)).for_each(&mut add);
|
||||
config.operators.iter().map(String::as_str).for_each(&mut add);
|
||||
config.minds.iter().map(String::as_str).for_each(&mut add);
|
||||
shown.iter().copied().for_each(&mut add);
|
||||
people
|
||||
}
|
||||
|
||||
|
|
@ -1144,11 +1162,12 @@ fn ruling_ids_in(text: &str) -> Vec<String> {
|
|||
}
|
||||
|
||||
/// What she is responsible for, from her own mind: her stewardships in force,
|
||||
/// always whole, with each record's note; her follow-ups in force, newest
|
||||
/// always whole, with each record's note; her open commitments, then her open asks, each newest first with
|
||||
/// its id and its own bound; her follow-ups in force, newest
|
||||
/// first, until the bound; and the rulings those cite, so she knows which to
|
||||
/// `huginn view`. Her open runs are the recent-runs section's. Anything the
|
||||
/// bound drops is counted in the text.
|
||||
fn render_responsibilities(mind: &dyn MindPort, instance: &Slug) -> Result<Responsibilities> {
|
||||
fn render_responsibilities(mind: &dyn MindPort, instance: &Slug, names: &BTreeMap<&str, &str>) -> Result<Responsibilities> {
|
||||
let mut listed: Vec<String> = Vec::new();
|
||||
let claimed: Vec<PipelineRef> = live_claims(mind, instance, &[])?;
|
||||
let mut out = String::from("What you are responsible for (from your own mind; `huginn view <id>` reads a record):\n");
|
||||
|
|
@ -1170,6 +1189,46 @@ fn render_responsibilities(mind: &dyn MindPort, instance: &Slug) -> Result<Respo
|
|||
if stewards.is_empty() {
|
||||
out.push_str("- no stewardship is in force in your mind.\n");
|
||||
}
|
||||
let mut open_ids: Vec<String> = Vec::new();
|
||||
let mut open_people: Vec<String> = Vec::new();
|
||||
let open_start = out.len();
|
||||
for (sort, heading, noun, budget) in [
|
||||
(MemorySort::Committed, "You committed to:", "commitments", OPEN_COMMITTED_MAX_BYTES),
|
||||
(MemorySort::Asked, "Asked of you, not yet taken on:", "asks", OPEN_ASKED_MAX_BYTES),
|
||||
] {
|
||||
let selection = Selection {
|
||||
descending: true,
|
||||
limit: Some(OPEN_FETCH),
|
||||
projection: "document".into(),
|
||||
..any_of(in_force(of_kind(PipelineKind::Memory)), "sort", &[format!("{sort:?}").as_str()])
|
||||
};
|
||||
let page = mind.query(&selection)?;
|
||||
let matched = page.matched as usize;
|
||||
let PipelinePageItems::Documents(views) = page.items else { bail!("the mind answered headers to a document selection") };
|
||||
let (mut section, mut shown) = (String::new(), 0usize);
|
||||
for view in &views {
|
||||
let Some(line) = recall::memory_line(view, names, true) else { continue };
|
||||
if section.len() + line.len() > budget {
|
||||
continue;
|
||||
}
|
||||
section.push_str(&line);
|
||||
shown += 1;
|
||||
open_ids.push(view.id.id.0.clone());
|
||||
if let PipelineDocument::Memory(memory) = &view.document {
|
||||
open_people.extend(recall::people_of(memory));
|
||||
}
|
||||
}
|
||||
if matched > 0 {
|
||||
out.push_str(heading);
|
||||
out.push('\n');
|
||||
out.push_str(§ion);
|
||||
}
|
||||
if matched > shown {
|
||||
out.push_str(&format!("({} older {noun} not shown: settle what is finished and they come back into view.)\n", matched - shown));
|
||||
}
|
||||
}
|
||||
// The open_ids items are outside the follow-ups' and rulings' bound.
|
||||
let open_len = out.len() - open_start;
|
||||
let open = Selection { descending: true, ..in_force(of_kind(PipelineKind::FollowUp)) };
|
||||
let follow_ups = headers(mind, &open)?;
|
||||
let mut dropped = 0;
|
||||
|
|
@ -1181,7 +1240,7 @@ fn render_responsibilities(mind: &dyn MindPort, instance: &Slug) -> Result<Respo
|
|||
let campaign = follow_up.id.id.0.split_once(":follow_up:").map_or("", |(campaign, _)| campaign);
|
||||
let held = if claimed.contains(&follow_up.id) { " [claimed by a run]" } else { "" };
|
||||
let line = format!("- {campaign}/{} ({}){held}: {}\n", label.0, repo.0, item.0.lines().next().unwrap_or(""));
|
||||
if out.len() + line.len() > GOALS_MAX_BYTES - GOALS_RULINGS_RESERVE {
|
||||
if out.len() - open_len + line.len() > GOALS_MAX_BYTES - GOALS_RULINGS_RESERVE {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1213,7 +1272,7 @@ fn render_responsibilities(mind: &dyn MindPort, instance: &Slug) -> Result<Respo
|
|||
_ => None,
|
||||
});
|
||||
let line = format!("- {id}: {}\n", title.as_deref().unwrap_or("not in your mind"));
|
||||
if out.len() + line.len() > GOALS_MAX_BYTES {
|
||||
if out.len() - open_len + line.len() > GOALS_MAX_BYTES {
|
||||
dropped_rulings += 1;
|
||||
} else {
|
||||
listed.push(id.clone());
|
||||
|
|
@ -1223,7 +1282,7 @@ fn render_responsibilities(mind: &dyn MindPort, instance: &Slug) -> Result<Respo
|
|||
if dropped_rulings > 0 {
|
||||
out.push_str(&format!("({dropped_rulings} more cited rulings not shown.)\n"));
|
||||
}
|
||||
Ok(Responsibilities { text: out, listed })
|
||||
Ok(Responsibilities { text: out, listed, open: open_ids, people: open_people })
|
||||
}
|
||||
|
||||
/// The responsibilities section, and the ids of the follow-ups and rulings it lists, which recall
|
||||
|
|
@ -1231,6 +1290,10 @@ fn render_responsibilities(mind: &dyn MindPort, instance: &Slug) -> Result<Respo
|
|||
struct Responsibilities {
|
||||
text: String,
|
||||
listed: Vec<String>,
|
||||
/// The ids of the open commitments and asks it showed: the only ones a reply may settle or take on.
|
||||
open: Vec<String>,
|
||||
/// The Matrix ids those open items name.
|
||||
people: Vec<String>,
|
||||
}
|
||||
|
||||
/// Her latest runs with how each ended, one entry each, newest first. A Self
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ use std::collections::VecDeque;
|
|||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use eureka_pipeline::{PipelineDocument, PipelineKind as K, PipelineMemory, ResolutionOutcome, RunOperator, RunTurn, Short, Slug};
|
||||
use eureka_pipeline::{MemoryOrigin, PipelineDocument, PipelineKind as K, PipelineMemory, ResolutionOutcome, RunOperator, RunTurn, Short, Slug};
|
||||
use huginn_mind::PipelineAdmissionOutcome as AdmissionOutcome;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
|
@ -140,6 +141,7 @@ fn config() -> Config {
|
|||
Config {
|
||||
rooms: ["swarm", "usage", "operator"].map(String::from).to_vec(),
|
||||
operators: vec!["@op:chat".into()],
|
||||
minds: vec!["@eureka:chat".into(), "@libby:chat".into()],
|
||||
own_sender: "@eureka:chat".into(),
|
||||
max_turns: 4,
|
||||
doctrine: vec![],
|
||||
|
|
@ -441,7 +443,7 @@ fn direct(seq: u64, room_id: &str, sender: &str, text: &str) -> ChatDelivery {
|
|||
fn start_self_run_admits_brief_and_launches() {
|
||||
let reply = json!({
|
||||
"say": [{ "room": "operator", "text": "on it" }],
|
||||
"start_self_run": { "brief": "Check the swarm queue", "repo": REPO, "campaign": CAMPAIGN, "for_ask": null },
|
||||
"start_self_run": { "brief": "Check the swarm queue", "repo": REPO, "campaign": CAMPAIGN, "for_item": null },
|
||||
"remember": [],
|
||||
"settle": [],
|
||||
"tempo_vote_beat_s": null,
|
||||
|
|
@ -467,7 +469,7 @@ fn start_self_run_admits_brief_and_launches() {
|
|||
|
||||
#[test]
|
||||
fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ack() {
|
||||
let reply = json!({ "say": [], "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN, "for_ask": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let reply = json!({ "say": [], "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let rig = Rig::new(vec![operator_says(1, "go")], &[reply]);
|
||||
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!(failure, Some(Failure::Undecodable));
|
||||
|
|
@ -489,7 +491,7 @@ fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
|
|||
#[test]
|
||||
fn the_decoder_holds_the_bounds_the_schema_states() {
|
||||
let c = config();
|
||||
let start = |brief: &str, repo: &str, campaign: &str| json!({ "say": [], "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign, "for_ask": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let start = |brief: &str, repo: &str, campaign: &str| json!({ "say": [], "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms, &[], &[]).is_ok();
|
||||
assert!(ok("b", "GameCult/Huginn", "eureka-body"));
|
||||
assert!(ok(&"b".repeat(1000), "a-b/c.d_e-f", &"c".repeat(64)));
|
||||
|
|
@ -678,7 +680,7 @@ fn no_member_text_name_or_context_can_read_as_an_operator_line() {
|
|||
|
||||
#[test]
|
||||
fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is_admitted() {
|
||||
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_ask": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).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, self_run), (Some(Failure::SelfRun), None));
|
||||
|
|
@ -686,7 +688,7 @@ fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is
|
|||
assert!(rig.launcher.started.borrow().is_empty());
|
||||
assert!(rig.chat.acked.borrow().is_empty());
|
||||
// A prefix of a real campaign's name is not that campaign.
|
||||
let prefix = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1], "for_ask": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let prefix = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1], "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let rig = Rig::new(vec![operator_says(1, "go")], &[prefix]);
|
||||
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!(failure, Some(Failure::SelfRun));
|
||||
|
|
@ -786,6 +788,7 @@ fn doctrine_comes_from_the_environment_as_absolute_paths_in_order() {
|
|||
move |name: &str| match name {
|
||||
ENV_ROOMS => Some("swarm".to_string()),
|
||||
ENV_OPERATOR => Some("@op:chat".to_string()),
|
||||
ENV_MINDS => Some("@libby:chat".to_string()),
|
||||
ENV_SELF_SENDER => Some("@eureka:chat".to_string()),
|
||||
ENV_DOCTRINE => Some(doctrine.to_string()),
|
||||
_ => None,
|
||||
|
|
@ -812,7 +815,7 @@ fn the_campaigns_in_force_are_listed_and_are_the_schemas_campaign_enum() {
|
|||
|
||||
#[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, "for_ask": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).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);
|
||||
|
|
@ -881,7 +884,7 @@ fn a_turn_shows_her_stewardships_follow_ups_and_the_rulings_they_cite() {
|
|||
|
||||
#[test]
|
||||
fn a_mind_with_nothing_to_answer_for_says_so() {
|
||||
let text = render_responsibilities(&TestMind::bare(), &Slug(INSTANCE.into())).unwrap().text;
|
||||
let text = render_responsibilities(&TestMind::bare(), &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text;
|
||||
assert!(text.contains("no stewardship is in force") && text.contains("no follow-up is open"), "{text}");
|
||||
assert!(!text.contains("Rulings these cite") && !text.contains("Open follow-ups, newest first:"), "{text}");
|
||||
}
|
||||
|
|
@ -899,7 +902,7 @@ fn rulings_named_in_a_note_or_an_item_are_resolved_by_their_own_title() {
|
|||
let src = reference(K::Ruling, &id("ruling", "src"));
|
||||
rig.mind.committed(vec![steward_of("GameCult/cinny", "law: eureka-body:ruling:from-note, and eureka-body:ruling:ghost.")]);
|
||||
rig.mind.committed(vec![open_follow_up("fu", &src, "See eureka-body:ruling:from-item; not x:ruling or :ruling:y.")]);
|
||||
let text = render_responsibilities(&rig.mind, &Slug(INSTANCE.into())).unwrap().text;
|
||||
let text = render_responsibilities(&rig.mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text;
|
||||
for line in ["- eureka-body:ruling:from-note: Note law", "- eureka-body:ruling:from-item: Item law", "- eureka-body:ruling:src: Source", "- eureka-body:ruling:ghost: not in your mind"] {
|
||||
assert!(text.contains(line), "{text}");
|
||||
}
|
||||
|
|
@ -913,7 +916,7 @@ fn more_than_the_most_cited_rulings_are_counted_not_listed() {
|
|||
let rig = Rig::new(vec![], &[]);
|
||||
let cites: Vec<String> = (0..14).map(|n| format!("eureka-body:ruling:r{n:02}")).collect();
|
||||
rig.mind.committed(vec![steward_of("GameCult/cinny", &cites.join(" "))]);
|
||||
let text = render_responsibilities(&rig.mind, &Slug(INSTANCE.into())).unwrap().text;
|
||||
let text = render_responsibilities(&rig.mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text;
|
||||
assert_eq!(text.matches("- eureka-body:ruling:r").count(), GOALS_RULINGS_MAX, "{text}");
|
||||
assert!(text.contains("(2 more cited rulings not shown.)"), "{text}");
|
||||
}
|
||||
|
|
@ -927,7 +930,7 @@ fn the_responsibilities_hold_their_bound_and_mark_what_they_drop() {
|
|||
for n in 0..30 {
|
||||
rig.mind.committed(vec![open_follow_up(&format!("fu-{n:02}"), &source, &long)]);
|
||||
}
|
||||
let text = render_responsibilities(&rig.mind, &Slug(INSTANCE.into())).unwrap().text;
|
||||
let text = render_responsibilities(&rig.mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text;
|
||||
assert!(text.len() <= GOALS_MAX_BYTES, "{}", text.len());
|
||||
assert!(text.contains("older open follow-ups not shown"), "{text}");
|
||||
assert!(text.contains("fu-29") && !text.contains("fu-00"), "{text}");
|
||||
|
|
@ -1175,23 +1178,27 @@ fn a_turn_is_named_by_its_run_and_its_number() {
|
|||
fn the_persona_flags_are_taken_out_and_the_rest_is_left_for_the_shared_parser() {
|
||||
let args = |text: &str| text.split_whitespace().map(String::from).collect::<Vec<_>>();
|
||||
let (parsed, rest) = Config::from_args(
|
||||
&args("--instance eureka --rooms swarm,usage,operator --operator @op:chat,@op2:chat --self-sender @eureka:chat --endpoint rudp://10.0.0.1:1"),
|
||||
&args("--instance eureka --rooms swarm,usage,operator --operator @op:chat,@op2:chat --minds @eureka:chat,@libby:chat --self-sender @eureka:chat --endpoint rudp://10.0.0.1:1"),
|
||||
"usage",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parsed.rooms, ["swarm", "usage", "operator"]);
|
||||
assert_eq!((parsed.own_sender.as_str(), parsed.max_turns), ("@eureka:chat", MAX_TURNS_PER_START));
|
||||
assert_eq!(parsed.operators, ["@op:chat", "@op2:chat"]);
|
||||
assert_eq!(parsed.minds, ["@eureka:chat", "@libby:chat"]);
|
||||
assert_eq!(rest, args("--instance eureka --endpoint rudp://10.0.0.1:1"));
|
||||
let (two, _) = Config::from_args(&args("--rooms a --operator o --self-sender s --max-turns 2"), "usage").unwrap();
|
||||
let (two, _) = Config::from_args(&args("--rooms a --operator o --minds m --self-sender s --max-turns 2"), "usage").unwrap();
|
||||
assert_eq!(two.max_turns, 2);
|
||||
for bad in [
|
||||
"--rooms a --operator o",
|
||||
"--rooms a,,b --operator o --self-sender s",
|
||||
"--rooms a --rooms a --operator o --self-sender s",
|
||||
"--rooms a --operator o --self-sender s --max-turns 0",
|
||||
"--rooms a --operator o --self-sender s --max-turns x",
|
||||
"--rooms a --operator o --self-sender",
|
||||
"--rooms a --operator o --minds m",
|
||||
"--rooms a --operator o --self-sender s",
|
||||
"--rooms a --operator o --minds m,,n --self-sender s",
|
||||
"--rooms a --operator o --minds m --minds m --self-sender s",
|
||||
"--rooms a,,b --operator o --minds m --self-sender s",
|
||||
"--rooms a --rooms a --operator o --minds m --self-sender s",
|
||||
"--rooms a --operator o --minds m --self-sender s --max-turns 0",
|
||||
"--rooms a --operator o --minds m --self-sender s --max-turns x",
|
||||
"--rooms a --operator o --minds m --self-sender",
|
||||
] {
|
||||
assert!(Config::from_args(&args(bad), "usage").is_err(), "{bad}");
|
||||
}
|
||||
|
|
@ -1204,6 +1211,7 @@ fn the_persona_flags_come_from_the_environment_when_the_command_line_lacks_them(
|
|||
match name {
|
||||
ENV_ROOMS => "swarm,usage",
|
||||
ENV_OPERATOR => "@op:chat,@op2:chat",
|
||||
ENV_MINDS => "@libby:chat,@aqua:chat",
|
||||
ENV_SELF_SENDER => "@eureka:chat",
|
||||
_ => "",
|
||||
}
|
||||
|
|
@ -1214,7 +1222,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(), "usage".into()], operators: vec!["@op:chat".into(), "@op2:chat".into()], own_sender: "@eureka:chat".into(), max_turns: MAX_TURNS_PER_START, doctrine: vec![] }
|
||||
Config { rooms: vec!["swarm".into(), "usage".into()], operators: vec!["@op:chat".into(), "@op2:chat".into()], minds: vec!["@libby:chat".into(), "@aqua: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.
|
||||
|
|
@ -1223,6 +1231,7 @@ fn the_persona_flags_come_from_the_environment_when_the_command_line_lacks_them(
|
|||
// An empty variable is unset, and a missing setting is the usage error.
|
||||
assert!(Config::from_args_and_env(&args("--instance eureka"), |name| if name == ENV_ROOMS { Some(String::new()) } else { env(name) }, "usage").is_err());
|
||||
assert!(Config::from_args_and_env(&args("--instance eureka"), |_| None, "usage").is_err());
|
||||
assert!(Config::from_args_and_env(&args("--instance eureka"), |name| if name == ENV_MINDS { Some(String::new()) } else { env(name) }, "usage").is_err(), "a persona without MIND_PERSONA_MINDS refuses to start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1322,8 +1331,8 @@ fn keeps(sort: &str, text: &str, about: &[&str]) -> serde_json::Value {
|
|||
json!({ "sort": sort, "text": text, "about": about, "salience_permille": 600 })
|
||||
}
|
||||
|
||||
fn starting(for_ask: serde_json::Value) -> serde_json::Value {
|
||||
json!({ "brief": "Fork the Cinny client", "repo": REPO, "campaign": CAMPAIGN, "for_ask": for_ask })
|
||||
fn starting(for_item: serde_json::Value) -> serde_json::Value {
|
||||
json!({ "brief": "Fork the Cinny client", "repo": REPO, "campaign": CAMPAIGN, "for_item": for_item })
|
||||
}
|
||||
|
||||
fn memories(rig: &Rig) -> Vec<(String, PipelineMemory)> {
|
||||
|
|
@ -1399,6 +1408,8 @@ fn parts_ideas_and_rapport_survive_and_come_back_about_their_people() {
|
|||
assert_eq!(second.matches(text).count(), 1, "{text} once: {second}");
|
||||
}
|
||||
assert!(second.contains("Idea about \"libby\"") && second.contains("Rapport about \"libby\""), "{second}");
|
||||
assert!(under(responsibilities_of(&second), "You committed to:").contains("I integrate the Cinny fork"), "the commitment is in front of her, not recalled: {second}");
|
||||
assert_eq!(second.matches("I integrate the Cinny fork").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1439,9 +1450,9 @@ fn settling_an_ask_records_or_withdraws_it_with_the_reason() {
|
|||
assert_eq!(failure_of(rig.go()), None);
|
||||
let ids: Vec<String> = memories(&rig).into_iter().map(|(id, _)| id).collect();
|
||||
let settle = json!([
|
||||
{ "ask": ids[0], "outcome": "done", "why": "finished it" },
|
||||
{ "ask": ids[1], "outcome": "declined", "why": "not mine" },
|
||||
{ "ask": ids[2], "outcome": "wrong", "why": "misread" },
|
||||
{ "item": ids[0], "outcome": "done", "why": "finished it" },
|
||||
{ "item": ids[1], "outcome": "declined", "why": "not mine" },
|
||||
{ "item": ids[2], "outcome": "wrong", "why": "misread" },
|
||||
]);
|
||||
rig.connector.replies.borrow_mut().push_back(reply(&[], json!([]), settle, json!(null)));
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(2, "swarm", "@op:chat", "status"));
|
||||
|
|
@ -1458,9 +1469,9 @@ fn settling_an_ask_records_or_withdraws_it_with_the_reason() {
|
|||
|
||||
#[test]
|
||||
fn an_ask_not_shown_this_turn_is_a_decode_failure_and_nothing_is_sent() {
|
||||
let bad_settle = json!([{ "ask": format!("{INSTANCE}:memory:nope"), "outcome": "done", "why": "x" }]);
|
||||
let bad_settle = json!([{ "item": format!("{INSTANCE}:memory:nope"), "outcome": "done", "why": "x" }]);
|
||||
let bad_take = starting(json!(format!("{INSTANCE}:memory:nope")));
|
||||
let not_asked = |for_ask: &str| reply(&[("swarm", "x")], json!([keeps("Idea", "an idea", &[])]), json!([]), starting(json!(for_ask)));
|
||||
let not_asked = |for_item: &str| reply(&[("swarm", "x")], json!([keeps("Idea", "an idea", &[])]), json!([]), starting(json!(for_item)));
|
||||
for text in [
|
||||
reply(&[("swarm", "x")], json!([]), bad_settle, json!(null)),
|
||||
reply(&[("swarm", "x")], json!([]), json!([]), bad_take),
|
||||
|
|
@ -1480,44 +1491,46 @@ fn an_ask_cannot_be_settled_and_taken_on_in_one_reply() {
|
|||
let rig = Rig::new(vec![delivery(1, "swarm", "@op:chat", "fork it")], &[first]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let id = memories(&rig).remove(0).0;
|
||||
rig.connector.replies.borrow_mut().push_back(reply(&[], json!([]), json!([{ "ask": id, "outcome": "done", "why": "x" }]), starting(json!(id))));
|
||||
rig.connector.replies.borrow_mut().push_back(reply(&[], json!([]), json!([{ "item": id, "outcome": "done", "why": "x" }]), starting(json!(id))));
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(2, "swarm", "@op:chat", "go"));
|
||||
assert_eq!(failure_of(rig.go()), Some(Failure::Undecodable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provenance_comes_from_the_batch_and_no_model_field_can_write_it() {
|
||||
fn where_and_from_whom_she_learned_a_memory_comes_from_the_batch_and_no_model_field_can_write_it() {
|
||||
let rig = Rig::new(
|
||||
vec![delivery(1, "swarm", "@aqua:chat", "hello"), delivery(2, "usage", "@libby:chat", "hi")],
|
||||
&[reply(&[], json!([keeps("Learned", "Aqua and Libby are in", &["@aqua:chat"])]), json!([]), json!(null))],
|
||||
);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let (_, memory) = memories(&rig).remove(0);
|
||||
assert_eq!((memory.learned_in.0.as_str(), memory.direct), ("swarm, usage", false));
|
||||
assert_eq!(memory.origin, MemoryOrigin::Room { rooms: "swarm, usage".into(), heard_from: vec!["@aqua:chat".into(), "@libby:chat".into()] });
|
||||
assert_eq!(memory.sources, vec![Short("$e1".into()), Short("$e2".into())]);
|
||||
// A direct message: the room id, direct, and its own event ids only.
|
||||
// A direct message: the room id and the others who spoke in it, and its own event ids only.
|
||||
let dm = Rig::new(vec![direct(1, "dm", "@aqua:chat", "psst")], &[reply(&[], json!([keeps("Learned", "a secret", &["@aqua:chat"])]), json!([]), json!(null))]);
|
||||
assert_eq!(failure_of(dm.go_with(&config())), None);
|
||||
let (_, memory) = memories(&dm).remove(0);
|
||||
assert_eq!((memory.learned_in.0.as_str(), memory.direct, memory.sources.len()), ("!dm:chat", true, 1));
|
||||
// A wake: her first room, indirect, no sources.
|
||||
assert_eq!((memory.origin, memory.sources.len()), (MemoryOrigin::Direct { room: "!dm:chat".into(), with: vec!["@aqua:chat".into()] }, 1));
|
||||
// A wake: no one spoke, no sources.
|
||||
let wake = Rig::new(vec![], &[reply(&[], json!([keeps("Did", "looked around", &[])]), json!([]), json!(null))]);
|
||||
assert_eq!(failure_of(wake.go()), None);
|
||||
let (_, memory) = memories(&wake).remove(0);
|
||||
assert_eq!((memory.learned_in.0.as_str(), memory.direct, memory.sources.len()), ("swarm", false, 0));
|
||||
assert_eq!((memory.origin, memory.sources.len()), (MemoryOrigin::Wake, 0));
|
||||
// The schema has no such fields and the decoder refuses them.
|
||||
let schema = reply_schema(&["swarm".to_string()], &[CAMPAIGN.to_string()], &["@op:chat".to_string()], &[]);
|
||||
for field in ["learned_in", "\"direct\"", "sources"] {
|
||||
for field in ["origin", "heard_from", "sources"] {
|
||||
assert!(!schema.contains(field), "{field}");
|
||||
}
|
||||
let forged = json!({ "say": [], "start_self_run": null, "urgency_permille": null, "settle": [],
|
||||
"remember": [{ "sort": "Learned", "text": "x", "about": [], "salience_permille": 1, "learned_in": "elsewhere" }] })
|
||||
.to_string();
|
||||
assert_eq!(decode_reply(&forged, &["swarm".to_string()], &[], &[]), Err(Failure::Undecodable));
|
||||
for forged_field in ["place", "origin", "sources"] {
|
||||
let forged = json!({ "say": [], "start_self_run": null, "urgency_permille": null, "settle": [],
|
||||
"remember": [{ "sort": "Learned", "text": "x", "about": [], "salience_permille": 1, forged_field: "elsewhere" }] })
|
||||
.to_string();
|
||||
assert_eq!(decode_reply(&forged, &["swarm".to_string()], &[], &[]), Err(Failure::Undecodable), "{forged_field}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_she_keeps_names_only_this_batchs_people_and_stays_in_bounds() {
|
||||
fn what_she_keeps_names_only_people_she_may_name_and_stays_in_bounds() {
|
||||
let c = config();
|
||||
let rooms = c.rooms.clone();
|
||||
let people = ["@aqua:chat".to_string(), "@op:chat".to_string()];
|
||||
|
|
@ -1526,7 +1539,7 @@ fn what_she_keeps_names_only_this_batchs_people_and_stays_in_bounds() {
|
|||
assert!(ok(json!([keeps("Idea", "x", &["@aqua:chat", "@op:chat"])])));
|
||||
assert!(ok(json!([keeps("Idea", &"x".repeat(1000), &[])])));
|
||||
assert!(ok(json!((0..8).map(|_| keeps("Did", "x", &[])).collect::<Vec<_>>())));
|
||||
assert!(!ok(json!([keeps("Idea", "x", &["@stranger:chat"])])), "a person not in this batch or the operators");
|
||||
assert!(!ok(json!([keeps("Idea", "x", &["@stranger:chat"])])), "a person she may not name");
|
||||
assert!(!ok(json!([keeps("Idea", "x", &["@aqua:chat", "@aqua:chat"])])));
|
||||
assert!(!ok(json!([keeps("Idea", &"x".repeat(1001), &[])])));
|
||||
assert!(!ok(json!([keeps("Idea", " ", &[])])));
|
||||
|
|
@ -1535,26 +1548,66 @@ fn what_she_keeps_names_only_this_batchs_people_and_stays_in_bounds() {
|
|||
assert!(!ok(json!([{ "sort": "Idea", "text": "x", "about": [], "salience_permille": 1001 }])));
|
||||
let without = reply(&[], json!([]), json!([]), json!(null)).replace("\"remember\":[],", "");
|
||||
assert_eq!(decode_reply(&without, &rooms, &people, &[]), Err(Failure::Undecodable), "remember is required");
|
||||
let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[], &people, &["a".into()])).unwrap();
|
||||
let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], &people, &["a".into()])).unwrap();
|
||||
let items = &schema["properties"]["remember"]["items"]["properties"];
|
||||
assert_eq!(items["about"]["items"]["enum"], json!(people));
|
||||
assert_eq!(items["sort"]["enum"], json!(["Asked", "Decided", "Committed", "Learned", "Did", "Idea", "Rapport"]));
|
||||
let described = schema["properties"]["remember"]["description"].as_str().unwrap();
|
||||
for word in ["asks of you", "your own commitments", "who holds the other parts", "brainstorming", "rapport", "Atlas"] {
|
||||
for word in ["asks of you", "your own commitments", "one Committed entry", "who holds each other part", "stays in front of you every turn", "Decided is for decisions that are not your own part", "brainstorming", "rapport", "Atlas"] {
|
||||
assert!(described.contains(word), "{word}: {described}");
|
||||
}
|
||||
assert!(items["salience_permille"]["description"].as_str().unwrap().contains("which memories you see when not all fit"));
|
||||
let settle = schema["properties"]["settle"]["description"].as_str().unwrap();
|
||||
assert!(settle.contains("You committed to") && settle.contains("Asked of you"), "{settle}");
|
||||
let for_item = schema["properties"]["start_self_run"]["anyOf"][1]["properties"]["for_item"]["description"].as_str().unwrap();
|
||||
assert!(for_item.contains("commitment or ask") && for_item.contains("Asked or Committed"), "{for_item}");
|
||||
}
|
||||
|
||||
/// A mind that takes every admission except one holding a memory.
|
||||
struct RefusesMemories<'a>(&'a TestMind);
|
||||
|
||||
impl MindPort for RefusesMemories<'_> {
|
||||
fn instance(&self) -> &Slug {
|
||||
self.0.instance()
|
||||
}
|
||||
|
||||
fn query(&self, selection: &cultnet_rs::Selection) -> Result<huginn_mind::PipelineSelectionPage> {
|
||||
self.0.query(selection)
|
||||
}
|
||||
|
||||
fn query_near(&self, selection: &cultnet_rs::Selection, text: &str, top_k: u32) -> Result<huginn_mind::PipelineSelectionPage> {
|
||||
self.0.query_near(selection, text, top_k)
|
||||
}
|
||||
|
||||
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<AdmissionOutcome> {
|
||||
if documents.iter().any(|document| matches!(document, PipelineDocument::Memory(_))) {
|
||||
return Ok(AdmissionOutcome::Conflict { identities: vec![] });
|
||||
}
|
||||
self.0.admit(agent, session, documents)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mind_that_refuses_the_turns_documents_fails_the_turn_after_the_says_with_no_ack_and_no_self_run() {
|
||||
// A sender id longer than a `Short`: she may write it in `about`, and admission refuses it.
|
||||
let long = format!("@{}:chat", "a".repeat(220));
|
||||
let text = reply(&[("swarm", "said")], json!([keeps("Learned", "x", &[&long])]), json!([]), starting(json!(null)));
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", &long, "hi")], &[text]);
|
||||
let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") };
|
||||
let text = reply(&[("swarm", "said")], json!([keeps("Learned", "x", &["@op:chat"])]), json!([]), starting(json!(null)));
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", "@op:chat", "hi")], &[text]);
|
||||
rig.open_the_run();
|
||||
let refusing = RefusesMemories(&rig.mind);
|
||||
let organs = Organs {
|
||||
mind: &refusing,
|
||||
launcher: &rig.launcher,
|
||||
clock: &rig.clock,
|
||||
host: "yggdrasil-host",
|
||||
chat: &rig.chat,
|
||||
connector: &rig.connector,
|
||||
persona: &rig.persona,
|
||||
trace: &rig.trace,
|
||||
tempo: None,
|
||||
};
|
||||
let Outcome::Served { failure, self_run, .. } = start(&organs, &FileSource::new(rig.store()), &FileSource::new(rig.swarm_store()), &config()).unwrap() else { panic!("served") };
|
||||
assert_eq!(failure, Some(Failure::Memory));
|
||||
assert_eq!(rig.sent_texts(), vec!["said".to_string()]);
|
||||
assert!(rig.chat.acked.borrow().is_empty() && self_run.is_none() && rig.mind.ids(K::FollowUp).is_empty());
|
||||
assert!(rig.chat.acked.borrow().is_empty() && self_run.is_none() && rig.mind.ids(K::FollowUp).is_empty() && rig.mind.ids(K::Memory).is_empty());
|
||||
assert!(rig.launcher.started.borrow().is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -1574,18 +1627,18 @@ fn memories_and_the_pulse_are_one_admission_after_the_says_and_before_the_self_r
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn the_asks_shown_are_what_the_schema_lets_her_settle_or_take_on() {
|
||||
fn the_open_items_shown_are_what_the_schema_lets_her_settle_or_take_on() {
|
||||
let rooms = ["swarm".to_string()];
|
||||
let with = |asks: &[String]| -> serde_json::Value { serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], &["@op:chat".to_string()], asks)).unwrap() };
|
||||
let with = |open: &[String]| -> serde_json::Value { serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], &["@op:chat".to_string()], open)).unwrap() };
|
||||
let shown = with(&["yggdrasil:memory:a".to_string()]);
|
||||
assert_eq!(shown["properties"]["settle"]["items"]["properties"]["ask"]["enum"], json!(["yggdrasil:memory:a"]));
|
||||
let options = shown["properties"]["start_self_run"]["anyOf"][1]["properties"]["for_ask"]["anyOf"].as_array().unwrap().clone();
|
||||
assert_eq!(shown["properties"]["settle"]["items"]["properties"]["item"]["enum"], json!(["yggdrasil:memory:a"]));
|
||||
let options = shown["properties"]["start_self_run"]["anyOf"][1]["properties"]["for_item"]["anyOf"].as_array().unwrap().clone();
|
||||
assert!(options.contains(&json!({ "type": "string", "enum": ["yggdrasil:memory:a"] })), "{options:?}");
|
||||
assert!(options.contains(&json!({ "type": "string", "pattern": "^new:[0-7]$" })));
|
||||
// With none shown she can settle nothing and can only take on an ask she writes now.
|
||||
// With none shown she can settle nothing and can only take on an item she writes now.
|
||||
let none = with(&[]);
|
||||
assert_eq!(none["properties"]["settle"]["maxItems"], 0);
|
||||
let options = none["properties"]["start_self_run"]["anyOf"][1]["properties"]["for_ask"]["anyOf"].as_array().unwrap().clone();
|
||||
let options = none["properties"]["start_self_run"]["anyOf"][1]["properties"]["for_item"]["anyOf"].as_array().unwrap().clone();
|
||||
assert!(options.iter().all(|option| option.get("enum").is_none()), "{options:?}");
|
||||
}
|
||||
|
||||
|
|
@ -1606,20 +1659,353 @@ fn resolution_sequences_count_up_per_subject() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_memorys_place_is_cut_to_what_a_short_holds_and_whole_characters() {
|
||||
let place = |room_id: &str| provenance_of(&config(), &[ChatDelivery { is_direct: true, room_id: room_id.into(), ..delivery(1, "dm", "@a:chat", "x") }]).learned_in;
|
||||
assert_eq!(place(&"r".repeat(200)).len(), 200, "exactly the bound is kept whole");
|
||||
assert_eq!(place(&"r".repeat(201)).len(), 200);
|
||||
fn a_memorys_rooms_are_cut_to_what_a_short_holds_and_whole_characters() {
|
||||
let rooms = |alias: &str| match origin_of(&config(), &[delivery(1, alias, "@a:chat", "x")]).0 {
|
||||
MemoryOrigin::Room { rooms, .. } => rooms.0,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(rooms(&"r".repeat(200)).len(), 200, "exactly the bound is kept whole");
|
||||
assert_eq!(rooms(&"r".repeat(201)).len(), 200);
|
||||
let wide = format!("{}é", "r".repeat(199));
|
||||
assert_eq!(place(&wide), "r".repeat(199), "a character that would straddle the bound goes whole");
|
||||
assert_eq!(rooms(&wide), "r".repeat(199), "a character that would straddle the bound goes whole");
|
||||
let joined = origin_of(&config(), &[delivery(1, &"a".repeat(150), "@a:chat", "x"), delivery(2, &"b".repeat(150), "@a:chat", "y")]).0;
|
||||
let MemoryOrigin::Room { rooms, .. } = joined else { panic!("a room origin") };
|
||||
assert_eq!(rooms.0.len(), 200, "{rooms:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batchs_other_senders_exclude_her_own_and_repeat_nobody_and_the_people_she_may_name_repeat_nobody() {
|
||||
fn a_batchs_other_senders_exclude_her_own_and_repeat_nobody() {
|
||||
let c = config();
|
||||
let batch = [delivery(1, "swarm", "@eureka:chat", "me"), delivery(2, "swarm", "@aqua:chat", "a"), delivery(3, "swarm", "@aqua:chat", "again"), delivery(4, "swarm", "@op:chat", "b")];
|
||||
let senders = other_senders(&c, &batch);
|
||||
assert_eq!(senders, vec!["@aqua:chat", "@op:chat"]);
|
||||
assert_eq!(memory_people(&c, &senders), vec!["@aqua:chat".to_string(), "@op:chat".to_string()], "the operator is a sender and is listed once");
|
||||
assert_eq!(memory_people(&c, &["@aqua:chat"]), vec!["@aqua:chat".to_string(), "@op:chat".to_string()]);
|
||||
assert_eq!(other_senders(&c, &batch), vec!["@aqua:chat", "@op:chat"]);
|
||||
// An id past a Short is no sender here: no cue, origin or enum holds it.
|
||||
let long = format!("@{}:chat", "a".repeat(200));
|
||||
assert_eq!(long.len(), 206);
|
||||
let at_bound = format!("@{}:chat", "a".repeat(194));
|
||||
assert_eq!(at_bound.len(), 200);
|
||||
let batch = [delivery(1, "swarm", &long, "long"), delivery(2, "swarm", &at_bound, "exact")];
|
||||
assert_eq!(other_senders(&c, &batch), vec![at_bound.as_str()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_people_she_may_name_come_in_order_once_each_never_her_own_never_past_a_short() {
|
||||
let c = config();
|
||||
let mut said = delivery(2, "swarm", "@aqua:chat", "hello");
|
||||
said.context = vec![ContextLine { sender: "@earlier:chat".into(), text: "before".into(), at_unix_ms: 1 }, ContextLine { sender: "@eureka:chat".into(), text: "me".into(), at_unix_ms: 2 }];
|
||||
said.mentions = vec!["@mentioned:chat".into(), "@aqua:chat".into(), "@eureka:chat".into()];
|
||||
let too_long = format!("@{}:chat", "z".repeat(220));
|
||||
let batch = [delivery(1, "swarm", "@eureka:chat", "me"), said, delivery(3, "swarm", &too_long, "long")];
|
||||
let shown = ["@named-in-memory:chat", "@op:chat", too_long.as_str()];
|
||||
assert_eq!(
|
||||
people_she_may_name(&c, &batch, &shown),
|
||||
["@aqua:chat", "@earlier:chat", "@mentioned:chat", "@op:chat", "@libby:chat", "@named-in-memory:chat"].map(String::from)
|
||||
);
|
||||
assert_eq!(people_she_may_name(&c, &[], &[]), ["@op:chat", "@libby:chat"].map(String::from), "on a wake: the operators and the swarm's Minds");
|
||||
}
|
||||
|
||||
fn keeps_at(sort: &str, text: &str, about: &[&str], salience: u16) -> serde_json::Value {
|
||||
json!({ "sort": sort, "text": text, "about": about, "salience_permille": salience })
|
||||
}
|
||||
|
||||
/// The lines under `heading` of a turn's input, up to the first line that is not an item or a count.
|
||||
fn under(input: &str, heading: &str) -> String {
|
||||
let Some(at) = input.find(heading) else { return String::new() };
|
||||
input[at + heading.len()..].lines().skip(1).take_while(|line| line.starts_with("- ") || line.starts_with('(')).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The responsibilities section of a turn's input.
|
||||
fn responsibilities_of(input: &str) -> &str {
|
||||
let start = input.find("What you are responsible for").expect("a responsibilities section");
|
||||
let end = input[start..].find("What you remember").map_or(input.len(), |end| start + end);
|
||||
&input[start..end]
|
||||
}
|
||||
|
||||
fn settle_enum(rig: &Rig, turn: usize) -> Vec<String> {
|
||||
let schema: serde_json::Value = serde_json::from_str(&rig.connector.requests.borrow()[turn].output_schema_json).unwrap();
|
||||
schema["properties"]["settle"]["items"]["properties"]["item"]["enum"].as_array().map(|ids| ids.iter().map(|id| id.as_str().unwrap().to_string()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn about_enum(rig: &Rig, turn: usize) -> Vec<String> {
|
||||
let schema: serde_json::Value = serde_json::from_str(&rig.connector.requests.borrow()[turn].output_schema_json).unwrap();
|
||||
schema["properties"]["remember"]["items"]["properties"]["about"]["items"]["enum"].as_array().unwrap().iter().map(|who| who.as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commitments_stay_in_front_of_her_off_cue() {
|
||||
let libby = "@libby:chat";
|
||||
let mut replies = vec![reply(
|
||||
&[("swarm", "Agreed.")],
|
||||
json!([keeps_at("Committed", "I integrate the Cinny fork; Libby owns the shaders", &[libby], 800), keeps_at("Decided", "Aqua owns the sounds", &[libby], 900)]),
|
||||
json!([]),
|
||||
json!(null),
|
||||
)];
|
||||
for index in 0..10 {
|
||||
replies.push(reply(&[("swarm", "ha")], json!([keeps_at("Rapport", &format!("Libby jokes about cats number {index}"), &[libby], 100)]), json!([]), json!(null)));
|
||||
}
|
||||
replies.push(says(&[("swarm", "?")]));
|
||||
replies.push(says(&[("swarm", "?")]));
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", libby, "I take shaders, you integrate the Cinny fork, Aqua does the sounds")], &replies);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let commitment = memories(&rig).into_iter().find(|(_, memory)| memory.sort == MemorySort::Committed).unwrap().0;
|
||||
for index in 0..10 {
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(10 + index, "swarm", libby, "cats lol"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
}
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(100, "swarm", libby, "cats lol again"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
assert!(matches!(rig.go(), Outcome::Served { failure: None, .. }), "a wake");
|
||||
let (message_turn, wake_turn) = (rig.turns() - 2, rig.turns() - 1);
|
||||
for turn in [message_turn, wake_turn] {
|
||||
let input = input_of(&rig, turn);
|
||||
let section = responsibilities_of(&input);
|
||||
let shown = under(section, "You committed to:");
|
||||
assert!(shown.contains(&commitment) && shown.contains("I integrate the Cinny fork; Libby owns the shaders"), "turn {turn}: {section}");
|
||||
assert!(settle_enum(&rig, turn).contains(&commitment), "turn {turn}: the commitment can be settled");
|
||||
assert!(!input.contains("I take shaders, you integrate"), "no message text of turn 1 is in turn {turn}");
|
||||
}
|
||||
assert!(under(&input_of(&rig, message_turn), "About the people here:").contains("Aqua owns the sounds"));
|
||||
assert!(under(&input_of(&rig, wake_turn), "Weighed most:").contains("Aqua owns the sounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settling_and_taking_on_a_commitment() {
|
||||
let libby = "@libby:chat";
|
||||
let plan = || reply(&[("swarm", "ok")], json!([keeps("Committed", "I integrate the fork", &[libby])]), json!([]), json!(null));
|
||||
// Settling: done is Recorded and the commitment leaves the list.
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", libby, "plan")], &[plan()]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let id = memories(&rig).remove(0).0;
|
||||
rig.connector.replies.borrow_mut().push_back(reply(&[], json!([]), json!([{ "item": id, "outcome": "done", "why": "integrated" }]), json!(null)));
|
||||
rig.connector.replies.borrow_mut().push_back(says(&[]));
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(2, "swarm", libby, "status"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
assert_eq!(outcome_of(&rig, &id), Some(ResolutionOutcome::Recorded { reason: "integrated".into() }));
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(3, "swarm", libby, "again"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
assert!(!input_of(&rig, 2).contains("You committed to:"), "a settled commitment is gone");
|
||||
// Taking on a shown commitment defers it to the follow-up, which the next turn lists in its place.
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", libby, "plan")], &[plan()]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let id = memories(&rig).remove(0).0;
|
||||
rig.connector.replies.borrow_mut().push_back(reply(&[], json!([]), json!([]), starting(json!(id))));
|
||||
rig.connector.replies.borrow_mut().push_back(says(&[]));
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(2, "swarm", libby, "go"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let follow_ups = rig.mind.ids(K::FollowUp);
|
||||
assert_eq!(outcome_of(&rig, &id), Some(ResolutionOutcome::Deferred { to: reference(K::FollowUp, &follow_ups[0]) }));
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(3, "swarm", libby, "status"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let after = input_of(&rig, 2);
|
||||
assert!(!after.contains("You committed to:") && after.contains("Open follow-ups") && after.contains("Fork the Cinny client"), "{after}");
|
||||
// new:<i> names a commitment she writes now, as it names an ask.
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", libby, "fork")], &[reply(&[("swarm", "on it")], json!([keeps("Committed", "I fork the client", &[libby])]), json!([]), starting(json!("new:0")))]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let id = memories(&rig).remove(0).0;
|
||||
assert!(matches!(outcome_of(&rig, &id), Some(ResolutionOutcome::Deferred { .. })));
|
||||
// new:<i> on anything else is a decode failure with nothing sent.
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", libby, "fork")], &[reply(&[("swarm", "x")], json!([keeps("Rapport", "we get on", &[libby])]), json!([]), starting(json!("new:0")))]);
|
||||
assert_eq!(failure_of(rig.go()), Some(Failure::Undecodable));
|
||||
assert!(rig.sent_texts().is_empty() && rig.mind.ids(K::Memory).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dm_memory_names_its_confidant_not_its_subject() {
|
||||
let (libby, op) = ("@libby:chat", "@op:chat");
|
||||
let dm = reply(&[("!dm-libby:chat", "ok, secret kept")], json!([keeps("Learned", "Libby plans a surprise for the operator", &[op])]), json!([]), json!(null));
|
||||
let dm_delivery = direct(1, "!dm-libby:chat", libby, "psst, planning a surprise for op");
|
||||
let room = dm_delivery.room_id.clone();
|
||||
let rig = Rig::new(vec![dm_delivery], &[dm, says(&[("swarm", "hi")]), says(&[("swarm", "hi")])]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let (_, memory) = memories(&rig).remove(0);
|
||||
assert_eq!(memory.origin, MemoryOrigin::Direct { room: room.as_str().into(), with: vec![libby.into()] });
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(2, "swarm", op, "hello, anything new?"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let line = under(&input_of(&rig, 1), "About the people here:");
|
||||
let line = line.lines().find(|line| line.contains("surprise")).unwrap_or_else(|| panic!("the memory is recalled about the operator: {line}")).to_string();
|
||||
assert!(line.contains("privately, with \"@libby:chat\"") && line.contains("Learned about \"op\""), "{line}");
|
||||
assert!(!line.contains("privately, with \"op\""), "{line}");
|
||||
// A public memory reads in its room with its speakers; a wake memory on a wake.
|
||||
let public = Rig::new(vec![delivery(1, "swarm", libby, "hello")], &[reply(&[], json!([keeps("Idea", "shaders on scroll", &[libby])]), json!([]), json!(null)), says(&[])]);
|
||||
assert_eq!(failure_of(public.go()), None);
|
||||
public.chat.deliveries.borrow_mut().push(delivery(2, "swarm", libby, "again"));
|
||||
assert_eq!(failure_of(public.go()), None);
|
||||
let line = under(&input_of(&public, 1), "About the people here:");
|
||||
assert!(line.contains("in #swarm, with \"libby\"") && line.contains("Idea about \"libby\""), "{line}");
|
||||
let wake = Rig::new(vec![], &[reply(&[], json!([keeps_at("Did", "checked the build", &[], 900)]), json!([]), json!(null)), says(&[])]);
|
||||
assert!(matches!(wake.go(), Outcome::Served { failure: None, .. }));
|
||||
assert!(matches!(wake.go(), Outcome::Served { failure: None, .. }));
|
||||
assert!(under(&input_of(&wake, 1), "Weighed most:").contains("on a wake"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn about_can_name_an_absent_mind() {
|
||||
let (libby, op) = ("@libby:chat", "@op:chat");
|
||||
let decided = reply(&[("!dm-op:chat", "ok")], json!([keeps("Decided", "Libby does the shaders for the Cinny fork", &[libby])]), json!([]), json!(null));
|
||||
let rig = Rig::new(vec![direct(1, "!dm-op:chat", op, "Libby will do the shaders for the Cinny fork")], &[decided, says(&[("swarm", "go")])]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
assert_eq!(rig.sent_texts().len(), 1);
|
||||
assert_eq!(rig.chat.acked.borrow().len(), 1);
|
||||
assert_eq!(memories(&rig).len(), 1);
|
||||
// Seven days later Libby writes: the decision is about her and comes back.
|
||||
rig.clock.0.set(7 * 24 * 3600 * 1000);
|
||||
let mut ready = delivery(2, "swarm", libby, "ready to wire the shaders in?");
|
||||
ready.context = vec![ContextLine { sender: "@early:chat".into(), text: "before".into(), at_unix_ms: 1 }];
|
||||
ready.mentions = vec!["@mentioned:chat".into()];
|
||||
rig.chat.deliveries.borrow_mut().push(ready);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let input = input_of(&rig, 1);
|
||||
assert!(under(&input, "About the people here:").contains("Libby does the shaders for the Cinny fork"), "{input}");
|
||||
let allowed = about_enum(&rig, 1);
|
||||
assert_eq!(allowed, ["@libby:chat", "@early:chat", "@mentioned:chat", "@op:chat"].map(String::from), "the schema's enum is people_she_may_name");
|
||||
// A stranger is not in it, and naming one is undecodable with nothing sent.
|
||||
let stranger = reply(&[("swarm", "x")], json!([keeps("Idea", "someone", &["@stranger:chat"])]), json!([]), json!(null));
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", libby, "hi")], &[stranger]);
|
||||
assert_eq!(failure_of(rig.go()), Some(Failure::Undecodable));
|
||||
assert!(rig.sent_texts().is_empty() && rig.chat.acked.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_sender_id_stays_out_of_the_schema_and_never_poisons_a_turn() {
|
||||
let long = format!("@{}:chat", "a".repeat(220));
|
||||
let named = reply(&[("swarm", "said")], json!([keeps("Learned", "x", &[&long])]), json!([]), json!(null));
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", &long, "hi")], &[named]);
|
||||
assert_eq!(failure_of(rig.go()), Some(Failure::Undecodable), "naming it is refused before any say");
|
||||
assert!(rig.sent_texts().is_empty() && rig.chat.acked.borrow().is_empty() && rig.mind.ids(K::Memory).is_empty());
|
||||
assert!(!about_enum(&rig, 0).contains(&long));
|
||||
// A reply that does not name it is admitted and acked; the member's message still reads as a message.
|
||||
let fine = reply(&[("swarm", "said")], json!([keeps("Learned", "x", &["@op:chat"])]), json!([]), json!(null));
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", &long, "hello there"), delivery(2, "swarm", "@aqua:chat", "and me")], &[fine]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
assert_eq!(rig.chat.acked.borrow().len(), 1);
|
||||
let (_, memory) = memories(&rig).remove(0);
|
||||
assert_eq!(memory.origin, MemoryOrigin::Room { rooms: "swarm".into(), heard_from: vec!["@aqua:chat".into()] });
|
||||
assert!(input_of(&rig, 0).contains("hello there"));
|
||||
}
|
||||
|
||||
/// Twelve open asks of about 900 bytes, heavy memories about the sender and matching the words, and a few short deeds.
|
||||
#[test]
|
||||
fn caps_count_what_they_leave_out() {
|
||||
let (libby, op) = ("@libby:chat", "@op:chat");
|
||||
let heavy = |tag: &str| format!("{tag} hello {}", "x".repeat(880));
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", op, "hello")], &[says(&[("swarm", "hi")])]);
|
||||
for index in 0..12 {
|
||||
rig.mind.committed(vec![crate::testkit::memory(&format!("ask-{index:02}"), MemorySort::Asked, &heavy(&format!("ASK{index:02}")), &[libby])]);
|
||||
}
|
||||
for index in 0..6 {
|
||||
rig.mind.committed(vec![crate::testkit::memory(&format!("about-{index:02}"), MemorySort::Rapport, &heavy(&format!("ABOUT{index:02}")), &[op])]);
|
||||
}
|
||||
for index in 0..8 {
|
||||
rig.mind.committed(vec![crate::testkit::memory(&format!("near-{index:02}"), MemorySort::Learned, &heavy(&format!("NEAR{index:02}")), &[])]);
|
||||
}
|
||||
for index in 0..4 {
|
||||
rig.mind.committed(vec![crate::testkit::memory(&format!("did-{index:02}"), MemorySort::Did, &format!("DID{index:02} short"), &[])]);
|
||||
}
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let input = input_of(&rig, 0);
|
||||
// The asks: shown with ids, the rest counted exactly, and the settle enum is exactly what was shown.
|
||||
let asked = under(responsibilities_of(&input), "Asked of you, not yet taken on:");
|
||||
let shown_ids: Vec<String> = asked.lines().filter(|line| line.starts_with("- ") && line.contains(":memory:")).map(|line| line[2..].split(' ').next().unwrap().to_string()).collect();
|
||||
assert!(!shown_ids.is_empty() && shown_ids.len() < 12, "{} shown: {asked}", shown_ids.len());
|
||||
assert!(asked.contains(&format!("({} older asks not shown", 12 - shown_ids.len())), "{} shown, ids {shown_ids:?}", shown_ids.len());
|
||||
let mut enum_ids = settle_enum(&rig, 0);
|
||||
enum_ids.sort();
|
||||
let mut expected = shown_ids.clone();
|
||||
expected.sort();
|
||||
assert_eq!(enum_ids, expected, "the settle enum is the ids shown, no more");
|
||||
// Recall: no open item in it; the about lane counts its six memories exactly; the salient lane counts the rest.
|
||||
let recall = &input[input.find("What you remember").unwrap()..];
|
||||
assert!(!recall.contains("ASK"), "no ask in recall");
|
||||
assert!(recall.contains("NEAR"), "at least one relevant item");
|
||||
let about_shown = under(&input, "About the people here:");
|
||||
let k = about_shown.lines().filter(|line| line.starts_with("- ")).count();
|
||||
assert!(k > 0 && k < 6 && about_shown.contains(&format!("({} more not shown.)", 6 - k)), "{about_shown}");
|
||||
let total: usize = recall.lines().filter(|line| line.starts_with("- ")).count();
|
||||
let salient = under(&input, "Weighed most:");
|
||||
assert!(salient.contains(&format!("({} more not shown.)", 18 - total)), "18 memories in all, {total} shown: {salient}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nine_short_asks_are_all_shown_with_ids_and_the_oldest_can_be_settled() {
|
||||
let eight = json!((0..8).map(|index| keeps("Asked", &format!("ask number {index}"), &["@op:chat"])).collect::<Vec<_>>());
|
||||
let rig = Rig::new(vec![delivery(1, "swarm", "@op:chat", "things")], &[reply(&[], eight, json!([]), json!(null)), reply(&[], json!([keeps("Asked", "ask number 8", &["@op:chat"])]), json!([]), json!(null))]);
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(2, "swarm", "@op:chat", "one more"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let ids: Vec<String> = memories(&rig).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(ids.len(), 9);
|
||||
rig.connector.replies.borrow_mut().push_back(reply(&[], json!([]), json!([{ "item": ids[0], "outcome": "done", "why": "did it" }]), json!(null)));
|
||||
rig.chat.deliveries.borrow_mut().push(delivery(3, "swarm", "@op:chat", "status"));
|
||||
assert_eq!(failure_of(rig.go()), None);
|
||||
let input = input_of(&rig, 2);
|
||||
let asked = under(responsibilities_of(&input), "Asked of you, not yet taken on:");
|
||||
for id in &ids {
|
||||
assert!(asked.contains(id.as_str()), "{id} is shown with its id: {asked}");
|
||||
}
|
||||
assert!(!asked.contains("not shown"), "{asked}");
|
||||
let mut settled = settle_enum(&rig, 2);
|
||||
settled.sort();
|
||||
let mut all = ids.clone();
|
||||
all.sort();
|
||||
assert_eq!(settled, all);
|
||||
assert_eq!(outcome_of(&rig, &ids[0]), Some(ResolutionOutcome::Recorded { reason: "did it".into() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_open_list_holds_its_own_bound_exactly_and_counts_what_it_drops() {
|
||||
let names = BTreeMap::new();
|
||||
let slug = Slug(INSTANCE.into());
|
||||
for (sort, heading, noun, bound) in [
|
||||
(MemorySort::Committed, "You committed to:", "commitments", OPEN_COMMITTED_MAX_BYTES),
|
||||
(MemorySort::Asked, "Asked of you, not yet taken on:", "asks", OPEN_ASKED_MAX_BYTES),
|
||||
] {
|
||||
let render = |adjustable: usize| {
|
||||
let mind = TestMind::seeded();
|
||||
mind.committed(vec![crate::testkit::memory("m0", sort, &"w".repeat(adjustable), &[])]);
|
||||
for index in 1..4 {
|
||||
mind.committed(vec![crate::testkit::memory(&format!("m{index}"), sort, &"f".repeat(1000), &[])]);
|
||||
}
|
||||
render_responsibilities(&mind, &slug, &names).unwrap().text
|
||||
};
|
||||
let used = |text: &str| under(text, heading).lines().filter(|line| line.starts_with("- ") && line.contains(":memory:")).map(|line| line.len() + 1).sum::<usize>();
|
||||
let lines = |text: &str| under(text, heading).lines().filter(|line| line.starts_with("- ") && line.contains(":memory:")).count();
|
||||
let fit = 100 + bound - used(&render(100));
|
||||
let fits = render(fit);
|
||||
assert_eq!((used(&fits), lines(&fits)), (bound, 4), "an exact fit is shown: {fits}");
|
||||
assert!(!fits.contains("not shown"), "{fits}");
|
||||
let over = render(fit + 1);
|
||||
assert_eq!(lines(&over), 3, "{over}");
|
||||
assert!(over.contains(&format!("(1 older {noun} not shown: settle what is finished and they come back into view.)")), "{over}");
|
||||
}
|
||||
// Both lists full and a follow-up still has its room: the open lists are outside the goals bound.
|
||||
let mind = TestMind::seeded();
|
||||
mind.committed(vec![spec("x")]);
|
||||
for index in 0..6 {
|
||||
mind.committed(vec![
|
||||
crate::testkit::memory(&format!("c{index}"), MemorySort::Committed, &"c".repeat(900), &[]),
|
||||
crate::testkit::memory(&format!("a{index}"), MemorySort::Asked, &"a".repeat(900), &[]),
|
||||
]);
|
||||
}
|
||||
mind.committed(vec![open_follow_up("later", &spec_ref("x"), "Wire the thing.")]);
|
||||
let goals = render_responsibilities(&mind, &slug, &names).unwrap();
|
||||
assert!(goals.text.contains("eureka-body/later"), "{}", goals.text);
|
||||
assert!(goals.text.len() > GOALS_MAX_BYTES, "the open lists sit outside the goals bound");
|
||||
assert!(goals.text.contains("older commitments not shown") && goals.text.contains("older asks not shown"));
|
||||
assert!(goals.open.len() < 12 && goals.open.iter().all(|id| goals.text.contains(id.as_str())), "open is exactly what was shown");
|
||||
assert!(goals.listed.iter().all(|id| !goals.open.contains(id)), "listed (follow-ups, rulings) and open stay apart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_open_items_come_first_with_their_commitments_before_their_asks_and_after_the_stewardships() {
|
||||
let rig = Rig::new(vec![], &[]);
|
||||
rig.mind.committed(vec![steward_of("GameCult/cinny", "the chat client")]);
|
||||
rig.mind.committed(vec![crate::testkit::memory("a-old", MemorySort::Asked, "older ask", &[])]);
|
||||
rig.mind.committed(vec![crate::testkit::memory("a-new", MemorySort::Asked, "newer ask", &[])]);
|
||||
rig.mind.committed(vec![crate::testkit::memory("c-old", MemorySort::Committed, "older part", &[])]);
|
||||
rig.mind.committed(vec![crate::testkit::memory("c-new", MemorySort::Committed, "newer part", &[])]);
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
rig.mind.committed(vec![open_follow_up("later", &spec_ref("x"), "Wire it.")]);
|
||||
let text = render_responsibilities(&rig.mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text;
|
||||
let at = |needle: &str| text.find(needle).unwrap_or_else(|| panic!("{needle} in {text}"));
|
||||
assert!(at("steward of") < at("You committed to:") && at("You committed to:") < at("Asked of you, not yet taken on:") && at("Asked of you, not yet taken on:") < at("Open follow-ups"), "{text}");
|
||||
assert!(at("newer part") < at("older part") && at("newer ask") < at("older ask"), "{text}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ fn run(dir: &Path, drop: &[&str], extra: &[(&str, &str)], args: &[&str]) -> Outp
|
|||
("STATE_DIRECTORY", dir.to_string_lossy().into_owned()),
|
||||
("MIND_PERSONA_ROOMS", "#swarm:chat,#ops:chat".to_string()),
|
||||
("MIND_PERSONA_OPERATOR", "@op:chat".to_string()),
|
||||
("MIND_PERSONA_MINDS", "@eureka:chat,@libby:chat".to_string()),
|
||||
("MIND_PERSONA_SELF_SENDER", "@eureka:chat".to_string()),
|
||||
("MIND_CONNECTOR_ENDPOINT", "127.0.0.1:4110".to_string()),
|
||||
("MIND_CONNECTOR_KEY_FILE", key("connector-key")),
|
||||
|
|
@ -54,8 +55,9 @@ fn a_flag_overrides_the_environment() {
|
|||
#[test]
|
||||
fn a_setting_that_is_missing_or_wrong_ends_two_naming_the_setting_and_no_value() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cases: [Case; 6] = [
|
||||
let cases: [Case; 7] = [
|
||||
(&["MIND_PERSONA_ROOMS"], &[], "usage"),
|
||||
(&["MIND_PERSONA_MINDS"], &[], "usage"),
|
||||
(&["MIND_CONNECTOR_MODEL"], &[], "MIND_CONNECTOR_MODEL"),
|
||||
(&["MIND_CONNECTOR_KEY_FILE"], &[], "MIND_CONNECTOR_KEY_FILE"),
|
||||
(&[], &[("MIND_CONNECTOR_ENDPOINT", "rudp://10.77.0.1:17871")], "connector endpoint"),
|
||||
|
|
|
|||
|
|
@ -160,10 +160,11 @@ mind_body::persona: const AGENT : & str
|
|||
mind_body::persona: const DOCTRINE_MAX_BYTES : usize
|
||||
mind_body::persona: const ENV_DOCTRINE : & str
|
||||
mind_body::persona: const ENV_OPERATOR : & str
|
||||
mind_body::persona: const ENV_MINDS : & str
|
||||
mind_body::persona: const ENV_ROOMS : & str
|
||||
mind_body::persona: const ENV_SELF_SENDER : & str
|
||||
mind_body::persona: const MAX_TURNS_PER_START : u32
|
||||
mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , people : & [String] , asks : & [String]) -> String
|
||||
mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , people : & [String] , open : & [String]) -> String
|
||||
mind_body::persona: fn start (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome >
|
||||
mind_body::persona: impl Config
|
||||
mind_body::persona: impl Config: fn from_args (args : & [String] , usage : & str) -> Result < (Self , Vec < String >) >
|
||||
|
|
@ -182,15 +183,15 @@ mind_body::persona: pub trait Connector { fn turn (& self , request : & TurnRequ
|
|||
mind_body::persona: pub trait PersonaState { fn instructions (& self) -> Result < String > ; }
|
||||
mind_body::persona: pub trait Trace { fn record (& self , trace : & TurnTrace) -> Result < () > ; }
|
||||
mind_body::persona: pub use model_connector :: { PlanQuota , UsageReadout } ;
|
||||
mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, own_sender: String, max_turns: u32, doctrine: Vec < PathBuf >]
|
||||
mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, minds: Vec < String >, own_sender: String, max_turns: u32, doctrine: Vec < PathBuf >]
|
||||
mind_body::persona: struct FileTrace pub-fields[]
|
||||
mind_body::persona: struct HuginnPersona pub-fields[]
|
||||
mind_body::persona: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, chat: & 'a dyn Chat, connector: & 'a dyn Connector, persona: & 'a dyn PersonaState, trace: & 'a dyn Trace, tempo: Option < & 'a dyn TempoView >]
|
||||
mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[say: Vec < Say >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >]
|
||||
mind_body::persona: struct Remember # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sort: MemorySort, text: String, about: Vec < String >, salience_permille: u16]
|
||||
mind_body::persona: struct Say # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, text: String]
|
||||
mind_body::persona: struct Settle # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[ask: String, outcome: SettleOutcome, why: String]
|
||||
mind_body::persona: struct StartSelfRun # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[brief: String, repo: String, campaign: String, for_ask: Option < String >]
|
||||
mind_body::persona: struct Settle # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[item: String, outcome: SettleOutcome, why: String]
|
||||
mind_body::persona: struct StartSelfRun # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[brief: String, repo: String, campaign: String, for_item: Option < String >]
|
||||
mind_body::persona: struct TraceItem # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[provenance: String, room: String, text: String]
|
||||
mind_body::persona: struct TurnRequest # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[caller: String, request_id: String, instructions: String, input: String, output_schema_json: String]
|
||||
mind_body::persona: struct TurnTrace # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[run: String, turn: u32, at: DateTime < Utc >, inputs: Vec < TraceItem >, outputs: Vec < TraceItem >, outcome: String]
|
||||
|
|
@ -206,6 +207,7 @@ mind_body::queue: impl MindPort for HuginnMind < '_ >
|
|||
mind_body::queue: impl< 'a > HuginnMind < 'a >
|
||||
mind_body::queue: pub trait MindPort { fn instance (& self) -> & Slug ; fn query (& self , selection : & Selection) -> Result < PipelineSelectionPage > ; fn query_near (& self , _selection : & Selection , _text : & str , _top_k : u32) -> Result < PipelineSelectionPage > { bail ! ("this mind port has no semantic index") } fn admit (& self , agent : & str , session : & str , documents : Vec < PipelineDocument >) -> Result < PipelineAdmissionOutcome > ; }
|
||||
mind_body::queue: struct HuginnMind< 'a > pub-fields[]
|
||||
mind_body::recall: impl Lane
|
||||
mind_body::recall: impl Recalled
|
||||
mind_body::self_run: # [derive (Clone , Copy , Debug , PartialEq , Eq , PartialOrd , Ord)] pub enum Stop { Interrupt , Terminate , Kill , }
|
||||
mind_body::self_run: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Cause { Own , FlushRequested , Signalled (Stop) , Unstoppable , }
|
||||
|
|
|
|||
Loading…
Reference in a new issue