Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions images/bridge/src/runtime/script.rs
Original file line number Diff line number Diff line change
@@ -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: [<asker>] <text>`;
//! - `tool`: its words before a tool call (`Let me look.`), a reply of
Expand Down Expand Up @@ -679,6 +682,13 @@ async fn ask(events: &mpsc::Sender<Event>, rx: &mut mpsc::Receiver<Heard>, 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<Heard>, events: mpsc::Sender<Event>) {
let id = ts.turn.clone();
let emit = |e: Event| {
Expand All @@ -694,11 +704,11 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver<Heard>, 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;
}
Expand Down Expand Up @@ -760,14 +770,14 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver<Heard>, 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 },
Expand Down Expand Up @@ -844,7 +854,7 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver<Heard>, 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;
Expand All @@ -854,7 +864,7 @@ async fn turn(cfg: ScriptConfig, ts: TurnStart, mut rx: mpsc::Receiver<Heard>, 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() {
Expand Down Expand Up @@ -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 <id>`; anything else says how.
#[test]
Expand Down
23 changes: 23 additions & 0 deletions images/bridge/tests/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> = 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.
Expand Down
Loading