From 2696a368d9808b03f5aa25a9772613ed5d830570 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Fri, 30 Oct 2026 21:22:27 -0500 Subject: [PATCH] stub: a rule is a word said, never text inside one The script runtime's keyword rules matched anywhere in a message, so a storage token's JWT a `fetch` carried, once holding "tOoL", made that turn a tool turn (#778's CI): an extra reply, and every count after it off by one. A rule is now said by a word that starts with it (`says`), past an opening bracket, parenthesis or quote. Co-Authored-By: Claude Opus 5.5 --- images/bridge/src/runtime/script.rs | 36 +++++++++++++++++++++++------ images/bridge/tests/bridge.rs | 23 ++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/images/bridge/src/runtime/script.rs b/images/bridge/src/runtime/script.rs index 14f0d9c5b..cde7835bb 100644 --- a/images/bridge/src/runtime/script.rs +++ b/images/bridge/src/runtime/script.rs @@ -1,7 +1,10 @@ //! The `script` runtime: a deterministic agent that answers from rules, so //! the platform's lanes run with no agent runtime at all (the stub image, //! and the proof that the platform holds nothing Hermes-specific). Each -//! turn is a pure function of its message: +//! turn is a pure function of its message. A rule is said by a word of it +//! that starts with the rule's (past an opening bracket, parenthesis or +//! quote: `slowly`, `[SILENT]`), never by text inside a word, so what a +//! command carries (a token, a URL) says no rule by chance (`says`): //! //! - default: two drafts, then the reply `echo: [] `; //! - `tool`: its words before a tool call (`Let me look.`), a reply of @@ -679,6 +682,13 @@ async fn ask(events: &mpsc::Sender, rx: &mut mpsc::Receiver, turn: } } +/// Whether `text` says the rule `key`: one of its words starts with it, past +/// an opening bracket, parenthesis or quote. A storage token's JWT, carried +/// by a `fetch`, once held "tOoL" and made that turn a tool turn (#778's CI). +fn says(text: &str, key: &str) -> bool { + text.split_whitespace().any(|w| w.trim_start_matches(['[', '(', '"', '\'']).starts_with(key)) +} + async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver, events: mpsc::Sender) { let id = ts.turn.clone(); let emit = |e: Event| { @@ -694,11 +704,11 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver, e emit(Event::End { turn: id, outcome: Outcome::Idle }).await; return; } - if text.contains("fail") { + if says(&text, "fail") { emit(Event::End { turn: id, outcome: Outcome::Error("the script was asked to fail".into()) }).await; return; } - if text.contains("silent") { + if says(&text, "silent") { emit(Event::End { turn: id, outcome: Outcome::Idle }).await; return; } @@ -760,14 +770,14 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver, e }; } let mut part = 1; - if text.contains("tool") { + if says(&text, "tool") { // its words before the call, a reply of their own, then the step emit(Event::Reply { turn: id.clone(), part, text: "Let me look.".into() }).await; part += 1; let step = Step { tool: "search".into(), args: ts.text.chars().take(40).collect(), ok: true, excerpt: "3 results".into(), category: Category::Web, evidence: None }; emit(Event::Step { turn: id.clone(), step }).await; } - if text.contains("approve") || text.contains("risky") { + if says(&text, "approve") || says(&text, "risky") { emit(Event::Step { turn: id.clone(), step: Step { tool: "terminal".into(), args: "rm -rf ./scratch".into(), ok: true, excerpt: String::new(), category: Category::Shell, evidence: None } }).await; let options = vec![ PromptOption { id: "once".into(), label: "Allow once".into(), style: Some("primary".into()), words: false }, @@ -844,7 +854,7 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver, e if text.split_whitespace().any(|w| w == "notice") { emit(Event::Notice { agent: ts.agent.fragment.clone(), fragment: ts.fragment.clone(), category: NoticeCategory::Info, text: "Compressing context".into() }).await; } - let drafts = if text.contains("slow") { 20 } else { 2 }; + let drafts = if says(&text, "slow") { 20 } else { 2 }; for i in 1..=drafts { let cut = reply.chars().count() * i / (drafts + 1); emit(Event::Draft { turn: id.clone(), text: reply.chars().take(cut.max(1)).collect() }).await; @@ -854,7 +864,7 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver, e } } emit(Event::Reply { turn: id.clone(), part, text: reply }).await; - if text.contains("draw") { + if says(&text, "draw") { let path = cfg.scratch.join(format!("{id}-drawing.txt")); let body = format!("a drawing for {}\n", ts.asker_name); if tokio::fs::write(&path, &body).await.is_ok() { @@ -895,6 +905,18 @@ mod tests { } } + /// A rule is a word said, never text a word carries: `slowly` and + /// `[SILENT]` say theirs; a token or a URL with one inside says none. + #[test] + fn a_rule_is_a_word_said_never_text_inside_one() { + for (text, key) in [("tool please", "tool"), ("please use the terminal slowly", "slow"), ("end this turn with exactly [silent]", "silent"), ("do\nthe risky thing", "risky"), ("(approve this)", "approve"), ("the message that fails", "fail")] { + assert!(says(text, key), "{text} says {key}"); + } + for (text, key) in [("fetch http://a.test/x with eyjhbgcioijfuzi1nij9.untoolfail.sig", "tool"), ("fetch http://a.test/x with eyjhbgcioijfuzi1nij9.untoolfail.sig", "fail"), ("fetch http://a.test/tools/x with k", "tool"), ("withdrawn", "draw"), ("its -tool", "tool")] { + assert!(!says(text, key), "{text} does not say {key}"); + } + } + /// `schedule`'s grammar: `add` with five cron fields and words, `list`, /// `rm `; anything else says how. #[test] diff --git a/images/bridge/tests/bridge.rs b/images/bridge/tests/bridge.rs index 35a0a50d2..32b36a2f4 100644 --- a/images/bridge/tests/bridge.rs +++ b/images/bridge/tests/bridge.rs @@ -189,6 +189,29 @@ async fn tool_steps() { bridge.stop().await; } +/// Goal: what a command carries says no rule of the stub's: a `fetch` whose +/// token holds "tOoL" and "fAiL" (as a storage token's JWT once did, #778's +/// CI) is a fetch's turn alone, no tool step, no error, one reply. +#[tokio::test] +async fn a_token_says_no_rule() { + let fake = Fake::start("127.0.0.1:0", &["juniper"]).await; + let chat = fake.chat("talk", &["juniper"]); + let dir = support::dir("token-rule"); + let bridge = support::start(support::config(&fake.url(), &dir, support::settings()), support::script()); + following(&fake, 2).await; + let said = fake.say(&chat, &person("paul"), json!({ "text": "fetch http://127.0.0.1:9/x with eyJhbGciOiJFUzI1NiJ9.unTOoLfAiLx.sig" })); + let turn = turn_of("juniper", &chat, seq(&said)); + fake.until(WAIT, "the turn's end", |w| !w.bodies(&chat, "work", "turn.end").is_empty()).await; + fake.with(|w| { + assert!(w.bodies(&chat, "work", "turn.step").is_empty(), "no tool step"); + let end = w.bodies(&chat, "work", "turn.end"); + assert_eq!(end[0]["outcome"], "idle", "no error: {end:?}"); + let replies: Vec = w.records(&chat, "chat").into_iter().filter(|r| r["body"]["turn"] == turn).map(|r| r["body"].clone()).collect(); + assert!(replies.len() == 1 && replies[0]["text"].as_str().is_some_and(|t| t.starts_with("fetch")), "the fetch's one reply: {replies:?}"); + }); + bridge.stop().await; +} + /// Goal: a runtime's notices are its turn's `turn.notice`s, before its end /// and after it (the stub's `notice`: as Hermes sends its review once the /// turn is done), never a step nor a reply.