From 934d6aaa3015fa184d65c699cefb50f39ff1e4c1 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 22 Sep 2026 21:28:34 +0530 Subject: [PATCH 1/7] Give the conductor's steps wire forms, and link rows and events A host journals the conductor's commits and events and streams them to whatever draws the desk, so they now have pinned serde forms: internally tagged and snake_case, like Utterance and the driver's own payloads. Two links a desk needs were missing. A commit now names the conversation it belongs to -- a row said inside one, desk work lifted out of one, and the row that concludes it to its asker -- so an agent-to-agent exchange can be shown whole wherever its rows landed. And the events about a row now carry that row's sequence, with a handoff carrying the broadcast it came from, so a host attaches them to the row instead of inferring it from order. Co-Authored-By: Claude Opus 5 --- .../tinyhivemind-driver/src/conduct/README.md | 17 +- crates/tinyhivemind-driver/src/conduct/mod.rs | 7 +- .../tinyhivemind-driver/src/conduct/steps.rs | 72 +++++- .../src/conduct/test/conversations.rs | 6 +- .../src/conduct/test/desk.rs | 8 +- .../src/conduct/test/links.rs | 226 +++++++++++++++++ .../src/conduct/test/mod.rs | 2 + .../src/conduct/test/support.rs | 5 +- .../src/conduct/test/wire.rs | 237 ++++++++++++++++++ .../tinyhivemind-driver/src/conduct/wave.rs | 30 ++- .../tinyhivemind-driver/src/driver/brief.rs | 7 +- examples/openhuman/src/bin/conducted.rs | 15 +- 12 files changed, 595 insertions(+), 37 deletions(-) create mode 100644 crates/tinyhivemind-driver/src/conduct/test/links.rs create mode 100644 crates/tinyhivemind-driver/src/conduct/test/wire.rs diff --git a/crates/tinyhivemind-driver/src/conduct/README.md b/crates/tinyhivemind-driver/src/conduct/README.md index 2c82558c..a60a803d 100644 --- a/crates/tinyhivemind-driver/src/conduct/README.md +++ b/crates/tinyhivemind-driver/src/conduct/README.md @@ -9,8 +9,8 @@ that no single fold can hold. | `mod.rs` | `Conductor`, `ConductPolicy`, `Door`, `starters`; opening the desk, beginning a wave, proposing turns, opening a turn with its brief, recording what it called | | `wave.rs` | After a wave: the phase machine that hands the host one `Step` at a time -- commits in conversations, silent askees, commits on the desk with their consequences, conclusions, the turn wall | | `child.rs` | A conversation: its root, its two seats, its own driver state, its turns, its nudge; and one that concluded | -| `steps.rs` | `Turn`, `Note`, `Commit`, `Event`, `Refusal`, `Step` | -| `test.rs` | Every rule, driven by a host that is only a journal | +| `steps.rs` | `Turn`, `Note`, `Commit`, `Event`, `Refusal`, `Step`: the wire forms a host journals and streams | +| `test/` | Every rule, driven by a host that is only a journal; the exact wire forms; the links from a row to its conversation and from an event to its row | The rules, each with the decision it comes from: @@ -33,3 +33,16 @@ The conductor appends nothing. It hands the host a `Note` to append, a `Commit` to append and report the sequence of, or an `Event` to log, and takes the sequence back through `committed`. The host owns the journal, the rendering of a row, the prompt, and running the turn. + +What a host reads back to draw the desk: + +- **A conversation, whole.** The ask row's sequence is the conversation's + root. Every other row of it carries that root as `Commit::conversation`: + what was said inside it, desk work a seat lifted out of it, and the row + that concluded it to the asker. `Event::Asked` and `Event::Concluded` + mark when it opened and closed. +- **An event's row.** `Broadcast`, `Unplaced`, `CompletedByBroadcast`, + `Refused`, `Discharged` and `Concluded` carry `at`, the sequence the host + gave the row they are about; `Handoff` carries the broadcast row it came + from as `origin`. A refused row is already on the journal, and its event + is what marks it refused. diff --git a/crates/tinyhivemind-driver/src/conduct/mod.rs b/crates/tinyhivemind-driver/src/conduct/mod.rs index deb865fd..39331a7a 100644 --- a/crates/tinyhivemind-driver/src/conduct/mod.rs +++ b/crates/tinyhivemind-driver/src/conduct/mod.rs @@ -441,8 +441,11 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { continue; }; match (turn.thread(), &utterance) { - (None, _) | (Some(_), Utterance::Broadcast { .. } | Utterance::Ask { .. }) => { - self.wave.desk.push((turn.seat.clone(), utterance)); + (None, _) => self.wave.desk.push((turn.seat.clone(), utterance, None)), + (Some(root), Utterance::Broadcast { .. } | Utterance::Ask { .. }) => { + self.wave + .desk + .push((turn.seat.clone(), utterance, Some(root))); } (Some(root), _) => self.wave.thread.push((root, turn.seat.clone(), utterance)), } diff --git a/crates/tinyhivemind-driver/src/conduct/steps.rs b/crates/tinyhivemind-driver/src/conduct/steps.rs index 5878b628..96a3f0ff 100644 --- a/crates/tinyhivemind-driver/src/conduct/steps.rs +++ b/crates/tinyhivemind-driver/src/conduct/steps.rs @@ -1,13 +1,20 @@ //! What passes between the conductor and its host: a turn to run, and the //! steps the host takes on the conductor's behalf after a wave. +//! +//! Every type here is a wire form. A host journals commits and events and +//! streams them to whatever renders the desk, so the serde representation is +//! pinned by a unit test: internally tagged, `snake_case`, and every field a +//! host can act on present by name. +use serde::{Deserialize, Serialize}; use tinyhivemind::Sequence; use tinyhivemind::speech::Utterance; use crate::driver::Channel; /// One turn the host runs this wave. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub struct Turn { /// The seat. pub seat: String, @@ -31,7 +38,8 @@ impl Turn { /// A row the desk says to a seat: the host appends it, attributed to the /// desk, and nothing is committed back. The wording is the episode's. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub struct Note { /// What the desk says. pub body: String, @@ -46,7 +54,8 @@ pub struct Note { /// /// The host renders the utterance as its own desk row and calls /// [`Conductor::committed`](super::Conductor::committed) with the sequence. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub struct Commit { /// The seat the row is attributed to. pub author: String, @@ -56,14 +65,29 @@ pub struct Commit { pub thread: Option, /// On the open desk, the one seat it reaches; `None` reaches every seat. pub only_for: Option, + /// The conversation this row belongs to, by the ask row it is rooted at. + /// + /// Set for a row said inside a conversation, for desk work a seat lifted + /// out of one -- a broadcast or an ask made while talking, which lands on + /// the desk with no `thread` -- and for the row that concludes one to its + /// asker. `None` for everything said on the desk itself, including the + /// ask that opens a conversation: that row's own sequence is the root. + /// A host shows one agent-to-agent exchange whole by taking the ask row + /// and every row whose `conversation` is its sequence, wherever they + /// landed. + pub conversation: Option, + /// What the conductor does with the row once it has its sequence. Opaque + /// to a host: carried so a commit survives the wire whole. + #[serde(rename = "purpose")] pub(super) kind: Kind, } /// What the conductor does with a commit once it has its sequence. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] pub(super) enum Kind { /// A seat spoke in a conversation. - Thread(Sequence), + Thread { root: Sequence }, /// A seat spoke on the desk; a broadcast is routed. Desk, /// A conversation concluded: its outcome, cross-posted to the asker. @@ -73,7 +97,8 @@ pub(super) enum Kind { } /// Why a seat's row was refused, in the terms the desk tells it. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum Refusal { /// It may not complete: the seats it asked have not answered. AwaitingReply { @@ -89,9 +114,14 @@ pub enum Refusal { NotYetShown, } -/// Something the episode did that a host may want to log. Nothing here needs -/// acting on; every consequence is already a [`Note`] or a [`Commit`]. -#[derive(Clone, Debug, Eq, PartialEq)] +/// Something the episode did that a host may want to show. Nothing here +/// needs acting on; every consequence is already a [`Note`] or a [`Commit`]. +/// +/// An event about a row names it by `at`, the sequence the host gave that +/// row, so a host attaches the event to the row it drew rather than +/// inferring it from order. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum Event { /// A seat was told once that nothing will wake it. Nudged { @@ -106,16 +136,22 @@ pub enum Event { seat: String, /// Who took it. to: Vec, + /// The broadcast row. + at: Sequence, }, /// A broadcast fit no seat; the author keeps the work. Unplaced { /// The author. seat: String, + /// The broadcast row. + at: Sequence, }, /// A broadcast closed its author's own assignment. CompletedByBroadcast { /// The author. seat: String, + /// The broadcast row. + at: Sequence, }, /// An ask opened a conversation. Asked { @@ -132,8 +168,11 @@ pub enum Event { to: String, /// The author of the broadcast it came from. from: String, + /// The broadcast row it came from. + origin: Sequence, }, - /// A row was refused, and the seat told why. + /// A row was refused, and the seat told why. The row is already on the + /// host's journal; this is what marks it refused. Refused { /// The seat. seat: String, @@ -141,11 +180,15 @@ pub enum Event { thread: Option, /// Why. why: Refusal, + /// The refused row. + at: Sequence, }, /// A seat spent its broadcast budget and was completed with the work. Discharged { /// The seat. seat: String, + /// The broadcast row that was over budget. + at: Sequence, }, /// A conversation concluded. Concluded { @@ -157,16 +200,21 @@ pub enum Event { askee: String, /// Without an answer: nothing was due, or it ran out of turns. forced: bool, + /// The row that carried the outcome to the asker. + at: Sequence, }, } /// One step the host takes after a wave, in order. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// On the wire, tagged by `step`, with the step's own fields beside the tag. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "step", rename_all = "snake_case")] pub enum Step { /// Append this, attributed to the desk. Note(Note), /// Append this and report its sequence. Commit(Commit), - /// Log this, or don't. + /// Show this, or don't. Event(Event), } diff --git a/crates/tinyhivemind-driver/src/conduct/test/conversations.rs b/crates/tinyhivemind-driver/src/conduct/test/conversations.rs index ca66792d..7b68990c 100644 --- a/crates/tinyhivemind-driver/src/conduct/test/conversations.rs +++ b/crates/tinyhivemind-driver/src/conduct/test/conversations.rs @@ -59,7 +59,7 @@ fn an_ask_opens_a_conversation_that_runs_first_and_concludes_to_the_asker() { )); assert!(matches!( answered.events.as_slice(), - [Event::Concluded { root: at, asker, askee, forced: false }] + [Event::Concluded { root: at, asker, askee, forced: false, .. }] if *at == root && asker == "one" && askee == "two" )); assert_eq!(conductor.conversations(), 1); @@ -122,7 +122,7 @@ fn a_completion_while_a_conversation_is_open_is_refused_and_explained() { .expect("wave"); assert!(seen.events.iter().any(|event| matches!( event, - Event::Refused { seat, thread: None, why: Refusal::AwaitingReply { waiting_on } } + Event::Refused { seat, thread: None, why: Refusal::AwaitingReply { waiting_on }, .. } if seat == "one" && waiting_on == &["two".to_owned()] ))); assert!( @@ -370,7 +370,7 @@ fn a_broadcast_or_ask_inside_a_conversation_is_desk_work_and_a_dm_is_dropped() { assert!( seen.events .iter() - .any(|event| matches!(event, Event::Broadcast { seat, to } if seat == "two" && !to.is_empty())), + .any(|event| matches!(event, Event::Broadcast { seat, to, .. } if seat == "two" && !to.is_empty())), "{:?}", seen.events ); diff --git a/crates/tinyhivemind-driver/src/conduct/test/desk.rs b/crates/tinyhivemind-driver/src/conduct/test/desk.rs index 7672876a..91a945f7 100644 --- a/crates/tinyhivemind-driver/src/conduct/test/desk.rs +++ b/crates/tinyhivemind-driver/src/conduct/test/desk.rs @@ -65,7 +65,7 @@ fn an_unplaced_broadcast_leaves_the_work_with_the_author_and_says_so() { .expect("wave"); assert!(matches!( seen.events.as_slice(), - [Event::Unplaced { seat }] if seat == "one" + [Event::Unplaced { seat, .. }] if seat == "one" )); assert!(journal.private_to("one")[0].contains("nobody on this desk can take that")); assert!(!conductor.finished(), "the author keeps the work"); @@ -104,7 +104,7 @@ fn a_placed_broadcast_completes_its_author_and_a_busy_recipient_gets_it_as_a_han assert!( seen.events.iter().any(|event| matches!( event, - Event::CompletedByBroadcast { seat } if seat == "one" + Event::CompletedByBroadcast { seat, .. } if seat == "one" )), "{:?}", seen.events @@ -119,7 +119,7 @@ fn a_placed_broadcast_completes_its_author_and_a_busy_recipient_gets_it_as_a_han assert!( handed.events.iter().any(|event| matches!( event, - Event::Handoff { to, from } if to == "two" && from == "one" + Event::Handoff { to, from, .. } if to == "two" && from == "one" )), "{:?}", handed.events @@ -174,7 +174,7 @@ fn a_spent_broadcast_budget_completes_the_seat_with_the_work() { assert!( seen.events .iter() - .any(|event| matches!(event, Event::Discharged { seat } if seat == "one")), + .any(|event| matches!(event, Event::Discharged { seat, .. } if seat == "one")), "{:?}", seen.events ); diff --git a/crates/tinyhivemind-driver/src/conduct/test/links.rs b/crates/tinyhivemind-driver/src/conduct/test/links.rs new file mode 100644 index 00000000..61ac8365 --- /dev/null +++ b/crates/tinyhivemind-driver/src/conduct/test/links.rs @@ -0,0 +1,226 @@ +//! What ties a row to its conversation and an event to its row, as a host +//! reads them back to draw the desk. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::support::{ + ClarifyRouter, Journal, ask, broadcast, complete, door, hive, policy, post, two_seat, wave, +}; +use crate::CompletionDriver; +use crate::conduct::{ConductPolicy, Conductor, Event, Refusal}; +use crate::driver::BroadcastRouting; +use tinyhivemind::Sequence; +use tinyhivemind::speech::Utterance; + +#[test] +fn every_row_of_a_conversation_carries_its_root_wherever_it_lands() { + let hive = hive(&["one", "two", "three"]); + let driver = CompletionDriver::new(&hive, 4).expect("driver"); + let route_policy = policy(1); + let routing = BroadcastRouting { + primary: None, + reasoning: None, + policy: &route_policy, + roster_version: 1, + thread_context: &[], + }; + let journal = Journal::default(); + let mut conductor = Conductor::open( + &driver, + routing, + ConductPolicy::default(), + door(&["one", "two", "three"], &["one"], &journal), + ) + .expect("opens"); + + let opened = wave( + &mut conductor, + &journal, + &[( + "one", + vec![post("looking into it"), ask("two", "which port?")], + )], + ) + .expect("wave"); + let (posted_at, posted) = &opened.commits[0]; + assert_eq!( + posted.conversation, None, + "a desk row belongs to no conversation" + ); + assert_eq!(*posted_at, Sequence(2)); + let (root, asking) = &opened.commits[1]; + assert!(matches!(asking.utterance, Utterance::Ask { .. })); + assert_eq!( + asking.conversation, None, + "the ask is a desk row; its own sequence is the conversation" + ); + assert!(opened.events.iter().any(|event| matches!( + event, + Event::Asked { root: at, .. } if at == root + ))); + + let talked = wave( + &mut conductor, + &journal, + &[( + "two", + vec![ + post("checking the config"), + broadcast("three should check the logs"), + complete("port 8080"), + ], + )], + ) + .expect("wave"); + for (_, commit) in &talked.commits { + assert_eq!( + commit.conversation, + Some(*root), + "{:?} belongs to the conversation", + commit.utterance + ); + } + let lifted = talked + .commits + .iter() + .find(|(_, commit)| commit.utterance.broadcasting()) + .map(|(_, commit)| commit) + .expect("the broadcast was committed"); + assert_eq!(lifted.thread, None, "desk work lands on the desk"); + let in_thread: Vec<_> = talked + .commits + .iter() + .filter(|(_, commit)| commit.thread == Some(*root)) + .collect(); + assert_eq!( + in_thread.len(), + 2, + "the post and the answer are in the thread" + ); + let (concluded_at, conclusion) = talked + .commits + .iter() + .find(|(_, commit)| matches!(commit.utterance, Utterance::Dm { .. })) + .expect("the conclusion reached the asker"); + assert_eq!(conclusion.thread, None); + assert_eq!(conclusion.only_for.as_deref(), Some("one")); + assert!(talked.events.iter().any(|event| matches!( + event, + Event::Concluded { root: r, at, forced: false, .. } if r == root && at == concluded_at + ))); +} + +#[test] +fn a_broadcast_event_names_the_row_it_placed_and_the_handoff_its_origin() { + let hive = hive(&["one", "two"]); + let driver = CompletionDriver::new(&hive, 4) + .expect("driver") + .with_queue_depth(2) + .expect("depth"); + let route_policy = policy(1); + let routing = BroadcastRouting { + primary: None, + reasoning: None, + policy: &route_policy, + roster_version: 1, + thread_context: &[], + }; + let journal = Journal::default(); + let mut conductor = Conductor::open( + &driver, + routing, + ConductPolicy::default(), + door(&["one", "two"], &["one", "two"], &journal), + ) + .expect("opens"); + let placed = wave( + &mut conductor, + &journal, + &[("one", vec![broadcast("two: also check the cache")])], + ) + .expect("wave"); + let (broadcast_at, _) = placed.commits[0]; + for event in &placed.events { + match event { + Event::Broadcast { at, .. } | Event::CompletedByBroadcast { at, .. } => { + assert_eq!(*at, broadcast_at); + } + _ => {} + } + } + assert!( + placed + .events + .iter() + .any(|event| matches!(event, Event::CompletedByBroadcast { .. })) + ); + let handed = wave(&mut conductor, &journal, &[("two", vec![complete("mine")])]).expect("wave"); + assert!(handed.events.iter().any(|event| matches!( + event, + Event::Handoff { origin, .. } if *origin == broadcast_at + ))); +} + +#[test] +fn a_refused_unplaced_or_discharged_row_is_named_by_its_sequence() { + let hive = hive(&["one", "two"]); + let driver = CompletionDriver::new(&hive, 4) + .expect("driver") + .with_broadcast_budget(Some(1)); + let route_policy = policy(1); + let routing = BroadcastRouting { + primary: Some(&ClarifyRouter), + reasoning: Some(&ClarifyRouter), + policy: &route_policy, + roster_version: 1, + thread_context: &[], + }; + let journal = Journal::default(); + let mut conductor = two_seat(&driver, routing, ConductPolicy::default(), &journal); + let seen = wave( + &mut conductor, + &journal, + &[("one", vec![broadcast("first"), broadcast("second")])], + ) + .expect("wave"); + let first = seen.commits[0].0; + let second = seen.commits[1].0; + assert!(seen.events.iter().any(|event| matches!( + event, + Event::Unplaced { at, .. } if *at == first + ))); + assert!(seen.events.iter().any(|event| matches!( + event, + Event::Discharged { at, .. } if *at == second + ))); + + // A completion refused while a conversation is open names its own row. + let hive = super::support::hive(&["one", "two"]); + let driver = CompletionDriver::new(&hive, 4).expect("driver"); + let route_policy = policy(1); + let routing = BroadcastRouting { + primary: None, + reasoning: None, + policy: &route_policy, + roster_version: 1, + thread_context: &[], + }; + let journal = Journal::default(); + let mut conductor = two_seat(&driver, routing, ConductPolicy::default(), &journal); + let seen = wave( + &mut conductor, + &journal, + &[("one", vec![ask("two", "?"), complete("too soon")])], + ) + .expect("wave"); + let completion_at = seen + .commits + .iter() + .find(|(_, commit)| matches!(commit.utterance, Utterance::CompleteEpisode { .. })) + .map(|(at, _)| *at) + .expect("the completion was committed"); + assert!(seen.events.iter().any(|event| matches!( + event, + Event::Refused { why: Refusal::AwaitingReply { .. }, at, .. } if *at == completion_at + ))); +} diff --git a/crates/tinyhivemind-driver/src/conduct/test/mod.rs b/crates/tinyhivemind-driver/src/conduct/test/mod.rs index 3ce1eba6..93041eec 100644 --- a/crates/tinyhivemind-driver/src/conduct/test/mod.rs +++ b/crates/tinyhivemind-driver/src/conduct/test/mod.rs @@ -4,4 +4,6 @@ mod conversations; mod desk; mod door; +mod links; mod support; +mod wire; diff --git a/crates/tinyhivemind-driver/src/conduct/test/support.rs b/crates/tinyhivemind-driver/src/conduct/test/support.rs index c8aa92ce..46a56153 100644 --- a/crates/tinyhivemind-driver/src/conduct/test/support.rs +++ b/crates/tinyhivemind-driver/src/conduct/test/support.rs @@ -12,7 +12,7 @@ use tinyhivemind_embed::{ RouterFuture, RoutingEvaluation, RoutingPolicy, RoutingRequest, }; -use crate::conduct::{ConductPolicy, Conductor, Door, Event, Step, Turn}; +use crate::conduct::{Commit, ConductPolicy, Conductor, Door, Event, Step, Turn}; use crate::driver::BroadcastRouting; use crate::test_support::Seat; use crate::{AgentBinding, BoundHive, CompletionDriver, Error, HiveGraph}; @@ -188,6 +188,8 @@ impl Journal { pub(super) struct Wave { pub(super) turns: Vec, pub(super) events: Vec, + /// Every commit the wave handed out, with the sequence it was given. + pub(super) commits: Vec<(Sequence, Commit)>, } /// One wave: nudges, turns, the scripted calls each seat makes, and every @@ -224,6 +226,7 @@ pub(super) fn wave( commit.only_for.clone(), ); run(conductor.committed(sequence))?; + seen.commits.push((sequence, commit.clone())); continue; } take(step, journal, &mut seen); diff --git a/crates/tinyhivemind-driver/src/conduct/test/wire.rs b/crates/tinyhivemind-driver/src/conduct/test/wire.rs new file mode 100644 index 00000000..c40601ea --- /dev/null +++ b/crates/tinyhivemind-driver/src/conduct/test/wire.rs @@ -0,0 +1,237 @@ +//! The wire forms: what a host journals and streams, pinned exactly. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use serde_json::{Value, json}; +use tinyhivemind::Sequence; +use tinyhivemind::speech::Utterance; + +use crate::conduct::steps::Kind; +use crate::conduct::{Commit, Event, Note, Refusal, Step, Turn}; +use crate::driver::Channel; + +/// Every field in `required` must be present: a payload missing one fails to +/// decode rather than decoding to something the conductor never said. +fn rejects_missing(value: &Value, required: &[&str]) { + for field in required { + let mut payload = value.as_object().expect("an object").clone(); + payload.remove(*field); + assert!( + serde_json::from_value::(payload.into()).is_err(), + "missing {field} must be rejected" + ); + } +} + +fn round_trips(value: &T) +where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, +{ + let wire = serde_json::to_value(value).expect("serializes"); + let back: T = serde_json::from_value(wire).expect("deserializes"); + assert_eq!(&back, value); +} + +#[test] +fn a_turn_names_its_seat_channel_and_watermark() { + let thread = Turn { + seat: "two".into(), + channel: Channel::Thread { + root: Sequence(4), + other: "one".into(), + opened_it: false, + }, + since: Sequence(4), + }; + let wire = serde_json::to_value(&thread).expect("serializes"); + assert_eq!( + wire, + json!({ + "seat": "two", + "channel": {"kind": "thread", "root": 4, "other": "one", "opened_it": false}, + "since": 4 + }) + ); + rejects_missing::(&wire, &["seat", "channel", "since"]); + round_trips(&thread); + + let desk = Turn { + seat: "one".into(), + channel: Channel::Desk, + since: Sequence(0), + }; + assert_eq!( + serde_json::to_value(&desk).expect("serializes")["channel"], + json!({"kind": "desk"}) + ); + round_trips(&desk); +} + +#[test] +fn a_note_is_tagged_as_a_step_with_its_fields_beside_the_tag() { + let note = Step::Note(Note { + body: "you hold open work".into(), + thread: None, + only_for: Some("one".into()), + }); + assert_eq!( + serde_json::to_value(¬e).expect("serializes"), + json!({ + "step": "note", + "body": "you hold open work", + "thread": null, + "only_for": "one" + }) + ); + round_trips(¬e); +} + +#[test] +fn a_commit_carries_its_conversation_and_an_opaque_purpose() { + let lifted = Commit { + author: "two".into(), + utterance: Utterance::Broadcast { + message: "three should check the logs".into(), + }, + thread: None, + only_for: None, + conversation: Some(Sequence(4)), + kind: Kind::Desk, + }; + let wire = serde_json::to_value(Step::Commit(lifted.clone())).expect("serializes"); + assert_eq!( + wire, + json!({ + "step": "commit", + "author": "two", + "utterance": {"kind": "broadcast", "message": "three should check the logs"}, + "thread": null, + "only_for": null, + "conversation": 4, + "purpose": {"kind": "desk"} + }) + ); + let bare = serde_json::to_value(&lifted).expect("serializes"); + rejects_missing::(&bare, &["author", "utterance", "purpose"]); + round_trips(&Step::Commit(lifted)); + + for kind in [ + Kind::Thread { root: Sequence(4) }, + Kind::Desk, + Kind::Conclusion { + root: Sequence(4), + forced: true, + }, + Kind::Discharge, + ] { + round_trips(&Commit { + author: "one".into(), + utterance: Utterance::CompleteEpisode { + message: "done".into(), + }, + thread: Some(Sequence(4)), + only_for: None, + conversation: Some(Sequence(4)), + kind, + }); + } +} + +#[test] +fn an_event_is_tagged_by_kind_inside_its_step() { + let asked = Step::Event(Event::Asked { + seat: "one".into(), + askee: "two".into(), + root: Sequence(4), + }); + assert_eq!( + serde_json::to_value(&asked).expect("serializes"), + json!({"step": "event", "kind": "asked", "seat": "one", "askee": "two", "root": 4}) + ); + let refused = Event::Refused { + seat: "one".into(), + thread: None, + why: Refusal::AwaitingReply { + waiting_on: vec!["two".into()], + }, + at: Sequence(5), + }; + let wire = serde_json::to_value(&refused).expect("serializes"); + assert_eq!( + wire, + json!({ + "kind": "refused", + "seat": "one", + "thread": null, + "why": {"kind": "awaiting_reply", "waiting_on": ["two"]}, + "at": 5 + }) + ); + rejects_missing::(&wire, &["kind", "seat", "why", "at"]); +} + +#[test] +fn every_event_and_refusal_survives_the_wire() { + let events = [ + Event::Nudged { + seat: "one".into(), + thread: Some(Sequence(4)), + }, + Event::Broadcast { + seat: "one".into(), + to: vec!["two".into()], + at: Sequence(3), + }, + Event::Unplaced { + seat: "one".into(), + at: Sequence(3), + }, + Event::CompletedByBroadcast { + seat: "one".into(), + at: Sequence(3), + }, + Event::Asked { + seat: "one".into(), + askee: "two".into(), + root: Sequence(4), + }, + Event::Handoff { + to: "two".into(), + from: "one".into(), + origin: Sequence(3), + }, + Event::Refused { + seat: "two".into(), + thread: Some(Sequence(4)), + why: Refusal::NotYetShown, + at: Sequence(6), + }, + Event::Refused { + seat: "two".into(), + thread: None, + why: Refusal::Undelivered { + assigned_at: Sequence(5), + }, + at: Sequence(6), + }, + Event::Discharged { + seat: "one".into(), + at: Sequence(7), + }, + Event::Concluded { + root: Sequence(4), + asker: "one".into(), + askee: "two".into(), + forced: false, + at: Sequence(8), + }, + ]; + for event in events { + round_trips(&Step::Event(event.clone())); + round_trips(&event); + } + assert_eq!( + serde_json::to_value(Refusal::NotYetShown).expect("serializes"), + json!({"kind": "not_yet_shown"}) + ); +} diff --git a/crates/tinyhivemind-driver/src/conduct/wave.rs b/crates/tinyhivemind-driver/src/conduct/wave.rs index 79ec69f2..9d00710f 100644 --- a/crates/tinyhivemind-driver/src/conduct/wave.rs +++ b/crates/tinyhivemind-driver/src/conduct/wave.rs @@ -47,8 +47,9 @@ pub(super) struct Wave { force_conclusions: bool, /// What thread turns said: `(root, seat, utterance)`. pub(super) thread: Vec<(Sequence, String, Utterance)>, - /// What desk turns said, and what thread turns said to the desk. - pub(super) desk: Vec<(String, Utterance)>, + /// What desk turns said, and what thread turns said to the desk: + /// `(seat, utterance, the conversation it was lifted out of)`. + pub(super) desk: Vec<(String, Utterance, Option)>, /// Steps ready for the host, notes and events. steps: VecDeque, /// Commits waiting for the host, in order. @@ -112,13 +113,14 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { self.wave.phase = Phase::Desk; } Phase::Desk => { - for (seat, utterance) in std::mem::take(&mut self.wave.desk) { + for (seat, utterance, conversation) in std::mem::take(&mut self.wave.desk) { let only_for = utterance.asks().map(str::to_owned); self.wave.commits.push_back(Commit { author: seat, utterance, thread: None, only_for, + conversation, kind: Kind::Desk, }); } @@ -156,7 +158,8 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { utterance, thread: Some(root), only_for: None, - kind: Kind::Thread(root), + conversation: Some(root), + kind: Kind::Thread { root }, }); } @@ -215,6 +218,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { }, thread: None, only_for: Some(child.asker.clone()), + conversation: Some(root), kind: Kind::Conclusion { root, forced }, }); } @@ -238,7 +242,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { utterance: commit.utterance.clone(), }; match commit.kind { - Kind::Thread(root) => self.commit_thread(root, committed).await, + Kind::Thread { root } => self.commit_thread(root, committed).await, Kind::Desk => self.commit_desk(committed).await, Kind::Conclusion { root, forced } => { self.commit_conclusion(root, forced, committed).await @@ -259,6 +263,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { return Ok(()); }; let seat = committed.author_id.clone(); + let at = committed.sequence; let said = committed.utterance.message().to_owned(); match self .driver @@ -276,6 +281,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { seat, thread: Some(root), why: Refusal::NotYetShown, + at, }), Err(error) => return Err(error), } @@ -323,6 +329,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { seat, thread: None, why: Refusal::AwaitingReply { waiting_on }, + at: sequence, }); } Err(Error::UndeliveredAssignment { assigned_at, .. }) => { @@ -339,11 +346,15 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { seat, thread: None, why: Refusal::Undelivered { assigned_at }, + at: sequence, }); } Err(Error::BudgetSpent { .. }) => { self.discharged += 1; - self.wave.event(Event::Discharged { seat: seat.clone() }); + self.wave.event(Event::Discharged { + seat: seat.clone(), + at: sequence, + }); // Before whatever else the wave said: the seat keeps the work // now, so a later row from it applies to that. self.wave.commits.push_front(Commit { @@ -353,6 +364,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { }, thread: None, only_for: None, + conversation: None, kind: Kind::Discharge, }); } @@ -372,6 +384,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { self.wave.event(Event::Broadcast { seat: said.seat.clone(), to: agent_ids, + at: said.sequence, }); } // For an ask, this is the signal to open the conversation: a @@ -386,6 +399,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { self.wave.event(Event::Handoff { to: agent_id.clone(), from: handoff.from.clone(), + origin: handoff.origin, }); self.wave.note( format!("handoff from @{}: {}", handoff.from, handoff.body), @@ -398,11 +412,13 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { if said.is_broadcast && said.held && !holds(&transition.state, &said.seat) { self.wave.event(Event::CompletedByBroadcast { seat: said.seat.clone(), + at: said.sequence, }); } if said.is_broadcast && !routed { self.wave.event(Event::Unplaced { seat: said.seat.clone(), + at: said.sequence, }); self.wave.note( "nobody on this desk can take that; the work stays with you. Do what you can \ @@ -443,6 +459,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { let Some(child) = self.children.remove(&root) else { return Ok(()); }; + let at = committed.sequence; let transition = self .driver .apply_committed(&self.state, committed, None) @@ -453,6 +470,7 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { asker: child.asker.clone(), askee: child.askee.clone(), forced, + at, }); self.concluded.push(Concluded { root, diff --git a/crates/tinyhivemind-driver/src/driver/brief.rs b/crates/tinyhivemind-driver/src/driver/brief.rs index d4243d06..64f96c70 100644 --- a/crates/tinyhivemind-driver/src/driver/brief.rs +++ b/crates/tinyhivemind-driver/src/driver/brief.rs @@ -11,6 +11,7 @@ //! Nothing here reads storage. The host passes the rows a seat may see and //! the conversations it was part of; the brief adds only what the state holds. +use serde::{Deserialize, Serialize}; use tinyhivemind::Sequence; use tinyhivemind::speech::ToolSpec; @@ -18,7 +19,11 @@ use super::DriverState; use super::ledger::open_assignment; /// Where a turn runs. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// On the wire, tagged by `kind`: `{"kind":"desk"}` or +/// `{"kind":"thread","root":7,"other":"two","opened_it":false}`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum Channel { /// The open desk. Desk, diff --git a/examples/openhuman/src/bin/conducted.rs b/examples/openhuman/src/bin/conducted.rs index b3482f0d..d3af0dc9 100644 --- a/examples/openhuman/src/bin/conducted.rs +++ b/examples/openhuman/src/bin/conducted.rs @@ -941,17 +941,19 @@ fn log(event: &Event) { seat, thread: Some(root), } => eprintln!("[nudged] @{seat} in thread {}", root.0), - Event::Broadcast { seat, to } => println!("[broadcast] @{seat} -> {}", to.join(", ")), - Event::Unplaced { seat } => { + Event::Broadcast { seat, to, .. } => println!("[broadcast] @{seat} -> {}", to.join(", ")), + Event::Unplaced { seat, .. } => { println!("[unplaced] @{seat}'s broadcast fits no seat; it keeps the work"); } - Event::CompletedByBroadcast { seat } => eprintln!("[completed] @{seat} by its broadcast"), + Event::CompletedByBroadcast { seat, .. } => eprintln!("[completed] @{seat} by its broadcast"), Event::Asked { seat, askee, root } => println!( "[ask] @{seat} opened a conversation with @{askee} (thread {})", root.0 ), - Event::Handoff { to, from } => println!("[handoff] -> @{to} (queued from @{from})"), - Event::Refused { seat, thread, why } => { + Event::Handoff { to, from, .. } => println!("[handoff] -> @{to} (queued from @{from})"), + Event::Refused { + seat, thread, why, .. + } => { let where_ = thread.map_or(String::new(), |root| format!(" in thread {}", root.0)); let reason = match why { Refusal::AwaitingReply { waiting_on } => { @@ -965,7 +967,7 @@ fn log(event: &Event) { }; eprintln!("[refused] @{seat}{where_}: {reason}"); } - Event::Discharged { seat } => { + Event::Discharged { seat, .. } => { eprintln!("[refused] @{seat} has spent its broadcast budget; it keeps the work"); } Event::Concluded { @@ -973,6 +975,7 @@ fn log(event: &Event) { asker, askee, forced, + .. } => println!( "[concluded] thread {} between @{asker} and @{askee}{}", root.0, From ff87c9acf3ad8cf4888f5dde0e9721debfd617ad Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 22 Sep 2026 22:01:54 +0530 Subject: [PATCH 2/7] Keep a conversation open when the fold refuses its conclusion The child was removed before its conclusion was applied, so a conclusion the fold refused left the desk unchanged and the conversation gone, with no way to conclude it later. The fold goes first now; a refused conclusion leaves the conversation open, and the next wave concludes it at a row the host gives properly. Co-Authored-By: Claude Fable 5.1 --- .../src/conduct/test/conversations.rs | 69 +++++++++++++++++++ .../tinyhivemind-driver/src/conduct/wave.rs | 9 ++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/tinyhivemind-driver/src/conduct/test/conversations.rs b/crates/tinyhivemind-driver/src/conduct/test/conversations.rs index 7b68990c..64a086e6 100644 --- a/crates/tinyhivemind-driver/src/conduct/test/conversations.rs +++ b/crates/tinyhivemind-driver/src/conduct/test/conversations.rs @@ -423,3 +423,72 @@ fn nothing_due_concludes_every_open_conversation_without_an_answer() { ); assert_eq!(conductor.conversations(), 1); } + +#[test] +fn a_conclusion_the_fold_refuses_leaves_the_conversation_to_conclude_later() { + let hive = hive(&["one", "two"]); + let driver = CompletionDriver::new(&hive, 4).expect("driver"); + let route_policy = policy(1); + let routing = BroadcastRouting { + primary: None, + reasoning: None, + policy: &route_policy, + roster_version: 1, + thread_context: &[], + }; + let journal = Journal::default(); + let mut conductor = two_seat(&driver, routing, ConductPolicy::default(), &journal); + let asked = wave(&mut conductor, &journal, &[("one", vec![ask("two", "?")])]).expect("wave"); + let root = asked.commits[0].0; + + // The askee answers, and the host reports the conclusion's row at a + // sequence the episode already holds: the fold refuses it. + conductor.begin_wave(); + let turns = conductor.turns().expect("turns"); + for turn in &turns { + conductor.open_turn(turn, journal.latest(), Vec::new(), |root| { + journal.thread(root) + }); + if turn.seat == "two" { + conductor.record(turn, vec![ToolCall::Speak(complete("port 8080"))]); + } + } + let mut refused = false; + loop { + match conductor.step() { + Ok(None) => break, + Ok(Some(Step::Commit(commit))) => { + let sequence = if matches!(commit.utterance, Utterance::Dm { .. }) { + root + } else { + journal.append( + &commit.author, + "row", + commit.thread, + commit.only_for.clone(), + ) + }; + if run(conductor.committed(sequence)).is_err() { + refused = true; + } + } + Ok(Some(_)) => {} + Err(error) => panic!("{error}"), + } + } + assert!(refused, "a reused sequence is refused by the fold"); + assert_eq!(conductor.conversations(), 0, "nothing concluded"); + assert!(!conductor.finished(), "the conversation is still open"); + + // The next wave concludes it, at a row the host gives properly. + let later = wave(&mut conductor, &journal, &[]).expect("wave"); + assert!( + later + .events + .iter() + .any(|event| matches!(event, Event::Concluded { root: at, .. } if *at == root)), + "{:?}", + later.events + ); + assert_eq!(conductor.conversations(), 1); +} diff --git a/crates/tinyhivemind-driver/src/conduct/wave.rs b/crates/tinyhivemind-driver/src/conduct/wave.rs index 9d00710f..6293e0cf 100644 --- a/crates/tinyhivemind-driver/src/conduct/wave.rs +++ b/crates/tinyhivemind-driver/src/conduct/wave.rs @@ -456,15 +456,20 @@ impl<'a, A: BoundAgent> Conductor<'a, A> { forced: bool, committed: CommittedUtterance, ) -> Result<()> { - let Some(child) = self.children.remove(&root) else { + if !self.children.contains_key(&root) { return Ok(()); - }; + } let at = committed.sequence; + // The fold first: a conclusion it refuses leaves the conversation + // open, to be concluded again on a later wave, rather than gone. let transition = self .driver .apply_committed(&self.state, committed, None) .await?; self.state = transition.state; + let Some(child) = self.children.remove(&root) else { + return Ok(()); + }; self.wave.event(Event::Concluded { root, asker: child.asker.clone(), From 5d16143dae3670805a40b804c8c0bf691c95338c Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 22 Sep 2026 22:02:58 +0530 Subject: [PATCH 3/7] Walk the refused-conclusion test's steps without a panic arm Co-Authored-By: Claude Fable 5.1 --- .../src/conduct/test/conversations.rs | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/crates/tinyhivemind-driver/src/conduct/test/conversations.rs b/crates/tinyhivemind-driver/src/conduct/test/conversations.rs index 64a086e6..593d5d3a 100644 --- a/crates/tinyhivemind-driver/src/conduct/test/conversations.rs +++ b/crates/tinyhivemind-driver/src/conduct/test/conversations.rs @@ -454,26 +454,21 @@ fn a_conclusion_the_fold_refuses_leaves_the_conversation_to_conclude_later() { } } let mut refused = false; - loop { - match conductor.step() { - Ok(None) => break, - Ok(Some(Step::Commit(commit))) => { - let sequence = if matches!(commit.utterance, Utterance::Dm { .. }) { - root - } else { - journal.append( - &commit.author, - "row", - commit.thread, - commit.only_for.clone(), - ) - }; - if run(conductor.committed(sequence)).is_err() { - refused = true; - } + while let Some(step) = conductor.step().expect("steps") { + if let Step::Commit(commit) = step { + let sequence = if matches!(commit.utterance, Utterance::Dm { .. }) { + root + } else { + journal.append( + &commit.author, + "row", + commit.thread, + commit.only_for.clone(), + ) + }; + if run(conductor.committed(sequence)).is_err() { + refused = true; } - Ok(Some(_)) => {} - Err(error) => panic!("{error}"), } } assert!(refused, "a reused sequence is refused by the fold"); From 45bc3d6007aae3ff09f848d7358f05903eff0c1b Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 22 Sep 2026 21:44:16 +0530 Subject: [PATCH 4/7] Run the episode on the host's own agents: the hosted runner A host with agents of its own -- OpenCompany -- wants the episode run on those, with nothing about them re-expressed here. HostedRunner asks the host, through EpisodeHost, for three things: its SessionLog, a seat built with the episode's belt, and a wrapper around each turn for the task-locals its tools and gate read. OpenHuman fixes a session's belt at build, so the host builds each seat once per episode from an EpisodeBelt, whose admit() wraps the host's own gate; the runner reuses it every turn. A turn clears the session, seeds it from the host's log as the seat up to its watermark -- the rows above it are the brief's -- runs the brief inside the host's wrapper, and keeps the turn's usage. SeatRunner::turn gains the watermark. The raw runner's library-core setup becomes LibraryHost, shared with any host that has no core of its own, and the example's private journal becomes offline::MemoryLog, a real SessionLog. The example is a hosted host too: its proofs and bench run all three runners. Co-Authored-By: Claude Opus 5 --- crates/tinyhivemind-openhuman/Cargo.toml | 2 +- crates/tinyhivemind-openhuman/README.md | 21 +- crates/tinyhivemind-openhuman/src/README.md | 5 +- .../tinyhivemind-openhuman/src/embed/mod.rs | 3 +- .../tinyhivemind-openhuman/src/error/mod.rs | 4 + .../src/hosted/README.md | 24 ++ .../src/hosted/admission.rs | 43 +++ .../tinyhivemind-openhuman/src/hosted/mod.rs | 265 ++++++++++++++++++ .../tinyhivemind-openhuman/src/hosted/seed.rs | 54 ++++ .../tinyhivemind-openhuman/src/hosted/test.rs | 206 ++++++++++++++ crates/tinyhivemind-openhuman/src/lib.rs | 100 ++++--- .../src/offline/README.md | 6 +- .../tinyhivemind-openhuman/src/offline/log.rs | 197 +++++++++++++ .../tinyhivemind-openhuman/src/offline/mod.rs | 2 + .../tinyhivemind-openhuman/src/raw/README.md | 8 +- .../tinyhivemind-openhuman/src/raw/library.rs | 162 +++++++++++ crates/tinyhivemind-openhuman/src/raw/mod.rs | 56 +--- crates/tinyhivemind-openhuman/src/raw/seat.rs | 115 +++----- .../tinyhivemind-openhuman/src/raw/tools.rs | 2 +- .../tinyhivemind-openhuman/src/runner/mod.rs | 14 +- .../tinyhivemind-openhuman/src/runner/test.rs | 136 +++++++-- examples/openhuman/README.md | 31 +- examples/openhuman/src/bin/conducted.rs | 204 ++++++-------- .../openhuman/src/bin/conducted/hosted.rs | 59 ++++ 24 files changed, 1386 insertions(+), 333 deletions(-) create mode 100644 crates/tinyhivemind-openhuman/src/hosted/README.md create mode 100644 crates/tinyhivemind-openhuman/src/hosted/admission.rs create mode 100644 crates/tinyhivemind-openhuman/src/hosted/mod.rs create mode 100644 crates/tinyhivemind-openhuman/src/hosted/seed.rs create mode 100644 crates/tinyhivemind-openhuman/src/hosted/test.rs create mode 100644 crates/tinyhivemind-openhuman/src/offline/log.rs create mode 100644 crates/tinyhivemind-openhuman/src/raw/library.rs create mode 100644 examples/openhuman/src/bin/conducted/hosted.rs diff --git a/crates/tinyhivemind-openhuman/Cargo.toml b/crates/tinyhivemind-openhuman/Cargo.toml index bd8e5a77..206b3804 100644 --- a/crates/tinyhivemind-openhuman/Cargo.toml +++ b/crates/tinyhivemind-openhuman/Cargo.toml @@ -49,7 +49,7 @@ serde_json.workspace = true thiserror.workspace = true # A turn has a wall; `tokio::time::timeout` is it. Both harnesses run on # tokio already. -tokio = { workspace = true, features = ["time"] } +tokio = { workspace = true, features = ["sync", "time"] } tinyhivemind.workspace = true # The record every call lands in, and the served definitions. tinyhivemind-tools.workspace = true diff --git a/crates/tinyhivemind-openhuman/README.md b/crates/tinyhivemind-openhuman/README.md index 04d8b97f..54ee96ed 100644 --- a/crates/tinyhivemind-openhuman/README.md +++ b/crates/tinyhivemind-openhuman/README.md @@ -2,17 +2,28 @@ The OpenHuman adapter. `tinyhivemind-driver` says who runs next and what a committed row means, over a handle the host binds, and never runs a turn. -This crate is the host's side of that seam for OpenHuman, both ways: +This crate is the host's side of that seam for OpenHuman, three ways: | Runner | Seat | Tools | Context between turns | | --- | --- | --- | --- | +| `HostedRunner` | the host's own agent, built by the host through `EpisodeHost` with the episode's tools added | the four tools in-process, admitted over the host's own gate | seeded every turn from the host's log, as the seat, up to its watermark | | `EmbedRunner` | an `openhuman-embed` `AgentSpec` agent on a runtime the host booted | the three MCP dispatchers, dialling `tinyhivemind-mcp`'s server | OpenHuman's own session, stable for the episode | | `RawRunner` | an `OpenHumanSessionHost` built one level down, per turn | the same tools in-process, each calling `EpisodeTools::call` | a per-seat log this crate seeds the next session with | -Both implement `SeatRunner`, the seam: open a turn, run it, close it and take -what was called. Open and close are the same for both, because every call -lands in the same `EpisodeTools`, so the driver drains identical events and a -seat is refused and acknowledged in the same words either way. `RunnerKind` +All three implement `SeatRunner`, the seam: open a turn, run it, close it and +take what was called. Open and close are the same for every runner, because +every call lands in the same `EpisodeTools`, so the driver drains identical +events and a seat is refused and acknowledged in the same words whichever +runs it. + +The hosted runner is the one for a host that already has agents. It asks the +host, through `EpisodeHost`, for three things: its log, a seat built with the +episode's belt, and a wrapper around each turn. `OpenHuman` fixes a session's +belt when it is built, so the host builds each seat once per episode, and the +runner reuses it: each turn it clears the session, seeds it from the host's +log up to the seat's watermark, runs the brief, and keeps the turn's usage. +Nothing about the host's agent -- model, tools, gate, memory, prompt -- is +re-expressed here. `RunnerKind` names one, from `TINYHIVEMIND_RUNNER` or directly. The raw runner also carries the two things the current OpenHuman asks of a diff --git a/crates/tinyhivemind-openhuman/src/README.md b/crates/tinyhivemind-openhuman/src/README.md index 86c58b24..1745a734 100644 --- a/crates/tinyhivemind-openhuman/src/README.md +++ b/crates/tinyhivemind-openhuman/src/README.md @@ -2,9 +2,10 @@ | Path | Purpose | |---|---| -| `lib.rs` | Crate overview and the public surface: `SeatRunner`, `RunnerKind`, `EmbedRunner`, `EmbedSeat`, `RawRunner`, `RawSeat`, `Route`, `offline`. | +| `lib.rs` | Crate overview and the public surface: `SeatRunner`, `RunnerKind`, `HostedRunner`, `EpisodeHost`, `EpisodeBelt`, `EmbedRunner`, `EmbedSeat`, `RawRunner`, `RawSeat`, `LibraryHost`, `Route`, `offline`. | | `error/` | What seating or running a seat can fail with. | | `runner/` | The seam: open, run, close; `Lane`, `TurnJob`; which runner the environment names. | +| `hosted/` | Seats as the host's own agents, built through `EpisodeHost`, seeded from the host's log. | | `embed/` | Seats as `openhuman-embed` agents, tools over MCP. | | `raw/` | Seats as raw sessions, tools in-process: the belt, the gate, the memory that keeps nothing. | -| `offline/` | The scripted model, the backend stub and the offline config, behind the `offline` feature and in tests. | +| `offline/` | The scripted model, the backend stub, the offline config and an in-memory journal that is a real `SessionLog`, behind the `offline` feature and in tests. | diff --git a/crates/tinyhivemind-openhuman/src/embed/mod.rs b/crates/tinyhivemind-openhuman/src/embed/mod.rs index 5db51d84..dbf6b86f 100644 --- a/crates/tinyhivemind-openhuman/src/embed/mod.rs +++ b/crates/tinyhivemind-openhuman/src/embed/mod.rs @@ -29,6 +29,7 @@ use tinyhivemind_mcp::{EpisodeTools, Server, serve}; use crate::Result; use crate::runner::{Lane, SeatRunner, TURN_TIMEOUT, TurnJob}; +use tinyhivemind::Sequence; /// An `openhuman-embed` agent as the handle the driver binds. /// @@ -119,7 +120,7 @@ impl SeatRunner for EmbedRunner { /// One session per seat for the whole episode, so `OpenHuman` appends to the /// context the agent already holds rather than rebuilding one. - fn turn(&self, seat: String, lane: Lane, prompt: String) -> TurnJob { + fn turn(&self, seat: String, lane: Lane, _since: Sequence, prompt: String) -> TurnJob { let agent = self.agents[&seat].clone(); let session = format!("episode-{}:{seat}", self.run_id); Box::pin(async move { diff --git a/crates/tinyhivemind-openhuman/src/error/mod.rs b/crates/tinyhivemind-openhuman/src/error/mod.rs index 4b039c4d..f9e236ae 100644 --- a/crates/tinyhivemind-openhuman/src/error/mod.rs +++ b/crates/tinyhivemind-openhuman/src/error/mod.rs @@ -33,6 +33,10 @@ pub enum Error { /// An `openhuman-embed` agent could not be instantiated. #[error(transparent)] Agent(#[from] openhuman_embed::AgentError), + /// The host's log failed to read, or broke the port's contract, while a + /// turn was being seeded. + #[error(transparent)] + Session(#[from] tinyhivemind::Error), /// `OpenHuman` refused: booting as a library host, resolving the route, /// building or seeding a session, or running the turn. #[error(transparent)] diff --git a/crates/tinyhivemind-openhuman/src/hosted/README.md b/crates/tinyhivemind-openhuman/src/hosted/README.md new file mode 100644 index 00000000..79bb9a96 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/hosted/README.md @@ -0,0 +1,24 @@ +# `hosted` + +`HostedRunner`: every seat is the host's own agent. The host implements +`EpisodeHost` -- its `SessionLog`, a `build_seat` that adds an `EpisodeBelt` +to the agent it already builds, and a `wrap_turn` that installs what its +tools read while a turn runs -- and the runner does the rest. Each seat is +built once per episode, because `OpenHuman` fixes a belt at build time, and +reused every turn: cleared, seeded from the host's log as the seat up to its +watermark, run on the brief, its usage kept. + +Seeding reads nothing above the watermark. The rows above it are the turn's +new rows, which reach the seat in its brief, so the seat sees every row once, +and a row a peer wrote in the same wave reaches it through neither. + +`EpisodeBelt::admit` wraps the host's own gate: the episode's tools are +admitted, everything else is the host's gate's to decide, and with no host +gate everything else is denied. + +| file | holds | +| --- | --- | +| `mod.rs` | `EpisodeHost`, `HostedTurn`, `EpisodeBelt`, `HostedSeat`, `HostedRunner` | +| `seed.rs` | a seat's history from the host's log, as `(role, content)` pairs | +| `admission.rs` | the gate that admits the episode's tools over the host's | +| `test.rs` | seeding, withholding, the watermark, a thread, the memory log's pages, the belt and its gate | diff --git a/crates/tinyhivemind-openhuman/src/hosted/admission.rs b/crates/tinyhivemind-openhuman/src/hosted/admission.rs new file mode 100644 index 00000000..fc82473c --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/hosted/admission.rs @@ -0,0 +1,43 @@ +//! The episode's tools, admitted through the host's own gate. + +use async_trait::async_trait; +use openhuman_core::agent::tool_policy::{ToolPolicy, ToolPolicyDecision, ToolPolicyRequest}; +use std::sync::Arc; + +/// Admits the episode's tools by name and asks the host's policy about +/// everything else. With no host policy, everything else is denied. +/// +/// The episode's tools act on nothing but the episode's own record, and the +/// record checks every call against the turn it was made in, so there is +/// nothing for a host's approval gate to approve. What the host's gate +/// decides about its own tools is unchanged. +pub(super) struct Admission { + names: Vec, + host: Option>, +} + +impl Admission { + pub(super) fn new(names: Vec, host: Option>) -> Self { + Self { names, host } + } +} + +#[async_trait] +impl ToolPolicy for Admission { + fn name(&self) -> &'static str { + "episode_admission" + } + + async fn check(&self, request: &ToolPolicyRequest) -> ToolPolicyDecision { + if self.names.contains(&request.tool_name) { + return ToolPolicyDecision::Allow; + } + match &self.host { + Some(host) => host.check(request).await, + None => ToolPolicyDecision::deny(format!( + "`{}` is not on this seat's belt", + request.tool_name + )), + } + } +} diff --git a/crates/tinyhivemind-openhuman/src/hosted/mod.rs b/crates/tinyhivemind-openhuman/src/hosted/mod.rs new file mode 100644 index 00000000..4d829361 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/hosted/mod.rs @@ -0,0 +1,265 @@ +//! The hosted runner: the host's own agents, run through the episode. +//! +//! The embed and raw runners seat agents this crate builds. A host with +//! agents of its own -- its model, its tools, its approval gate, its memory, +//! its prompt -- wants the episode run on those, and nothing about them +//! re-expressed as configuration here. This runner asks the host for exactly +//! three things, through [`EpisodeHost`]: +//! +//! - **Its log**, a [`SessionLog`] over the host's own journal, which is the +//! only history there is. Each turn is seeded from it as the seat. +//! - **A seat**, built by the host with the episode's tools on its belt. +//! `OpenHuman` fixes a session's belt when it is built, so the host builds +//! each seat once per episode from an [`EpisodeBelt`], and the runner +//! reuses it every turn. +//! - **A wrapper around each turn**, where the host installs whatever its +//! tools and gate read while a turn runs -- a turn-scoped approval queue, a +//! delegation context, a core context. +//! +//! A turn, then: clear the seat's session, seed it with what the seat was +//! shown up to its watermark, send the brief, and record the usage. The +//! calls it made land in the shared record like any other runner's. + +mod admission; +mod seed; +#[cfg(test)] +mod test; + +use std::collections::BTreeMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; + +use openhuman_core::agent::tinyagents::host::LastTurnUsage; +use openhuman_core::agent::tool_policy::ToolPolicy; +use openhuman_core::agent::{OpenHumanSessionHost, TurnOverrides}; +use tinyhivemind::{Conversation, Sequence, SessionLog}; +use tinyhivemind_driver::{AgentBinding, BoundAgent}; +use tinyhivemind_tools::EpisodeTools; +use tinytools::Tool; + +use crate::raw::tools::belt; +use crate::runner::{Lane, SeatRunner, TURN_TIMEOUT, TurnJob}; +use crate::{Error, Result}; +use admission::Admission; + +/// One hosted turn, as the host wraps it. +pub type HostedTurn<'a> = Pin> + Send + 'a>>; + +/// What a host gives the hosted runner. +pub trait EpisodeHost: Send + Sync + 'static { + /// The host's journal, read as a seat to seed each turn. + fn log(&self) -> &dyn SessionLog; + + /// Build the session `seat` runs on, with `belt` on it. + /// + /// The host builds the agent it would build anyway, adds `belt.tools` to + /// its belt, and gates it with [`EpisodeBelt::admit`] over its own + /// policy. The session is reused for every turn of the episode. + /// + /// # Errors + /// + /// Whatever stops the host building the seat. + fn build_seat(&self, seat: &str, belt: EpisodeBelt) -> Result; + + /// Wrap one turn. The default runs it as it is. + fn wrap_turn<'a>(&'a self, seat: &'a str, turn: HostedTurn<'a>) -> HostedTurn<'a> { + let _ = seat; + turn + } +} + +/// The episode's tools for one seat, and the gate that admits them. +pub struct EpisodeBelt { + /// The tools, each bound to this seat, each calling the shared record. + pub tools: Vec>, + names: Vec, +} + +impl std::fmt::Debug for EpisodeBelt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EpisodeBelt") + .field("names", &self.names) + .finish_non_exhaustive() + } +} + +impl EpisodeBelt { + fn new(seat: &str, tools: &Arc) -> Self { + let tools = belt(seat, tools); + let names = tools.iter().map(|tool| tool.name().to_owned()).collect(); + Self { tools, names } + } + + /// The episode tools' names, for a host that registers a seat's belt by + /// name. + #[must_use] + pub fn names(&self) -> &[String] { + &self.names + } + + /// A policy that admits the episode's tools and asks `host` about every + /// other call. `None` denies every other call. + #[must_use] + pub fn admit(&self, host: Option>) -> Arc { + Arc::new(Admission::new(self.names.clone(), host)) + } +} + +/// A hosted seat, as the driver binds it: its id and the session it runs on. +#[derive(Clone)] +pub struct HostedSeat { + id: String, + session: Arc>, +} + +impl BoundAgent for HostedSeat { + fn runtime_id(&self) -> &str { + &self.id + } +} + +impl std::fmt::Debug for HostedSeat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HostedSeat") + .field("id", &self.id) + .finish_non_exhaustive() + } +} + +/// Seats as the host's own agents, seeded from the host's log every turn. +pub struct HostedRunner { + host: Arc, + tools: Arc, + seats: BTreeMap, + desk: Conversation, + window: usize, + usage: Arc>>, +} + +impl std::fmt::Debug for HostedRunner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HostedRunner") + .field("seats", &self.seats.keys().collect::>()) + .field("desk", &self.desk.desk_id) + .finish_non_exhaustive() + } +} + +impl HostedRunner { + /// Ask `host` to build every seat, once, with its episode belt. + /// + /// `desk` names the desk every turn runs on or in a thread of, as the + /// host's log knows it. `window` bounds how many rows a turn is seeded + /// with; `tinyhivemind::SESSION_WINDOW` is the default the rest of the + /// crate reads with. + /// + /// # Errors + /// + /// The host failing to build a seat. + pub fn seat( + host: Arc, + tools: Arc, + seats: &[String], + desk: &str, + desk_name: &str, + window: usize, + ) -> Result { + let mut built = BTreeMap::new(); + for id in seats { + let session = host.build_seat(id, EpisodeBelt::new(id, &tools))?; + built.insert( + id.clone(), + HostedSeat { + id: id.clone(), + session: Arc::new(tokio::sync::Mutex::new(session)), + }, + ); + } + Ok(Self { + host, + tools, + seats: built, + desk: Conversation { + desk_id: desk.into(), + desk_name: desk_name.into(), + thread_root: None, + }, + window, + usage: Arc::new(Mutex::new(BTreeMap::new())), + }) + } + + /// The usage of `seat`'s last turn, for a host that meters it. + #[must_use] + pub fn usage(&self, seat: &str) -> Option { + self.usage + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(seat) + .cloned() + } +} + +impl SeatRunner for HostedRunner { + fn tools(&self) -> &Arc { + &self.tools + } + + type Bound = HostedSeat; + + fn bindings(&self) -> Vec> { + self.seats + .iter() + .map(|(id, seat)| AgentBinding::new(id.clone(), seat.clone())) + .collect() + } + + /// Clear the seat's session, seed it from the host's log up to `since`, + /// and run the brief, inside the host's wrapper. + fn turn(&self, seat: String, lane: Lane, since: Sequence, prompt: String) -> TurnJob { + let host = Arc::clone(&self.host); + let session = Arc::clone(&self.seats[&seat].session); + let usage = Arc::clone(&self.usage); + let conversation = Conversation { + thread_root: match lane { + Lane::Desk => None, + Lane::Thread(root) => Some(root), + }, + ..self.desk.clone() + }; + let window = self.window; + Box::pin(async move { + let run = { + let seat = seat.clone(); + let host = Arc::clone(&host); + async move { + let history = + seed::history(host.log(), conversation, &seat, since, window).await?; + let mut session = session.lock().await; + // Clearing drops the runtime session, and with it the + // turn state, so the seed and the overrides go after it. + session.clear_history(); + session.seed_resume_from_messages(history, &prompt)?; + session.set_next_turn_overrides(TurnOverrides { + suppress_transcript_autoload: true, + ..TurnOverrides::default() + }); + let reply = tokio::time::timeout(TURN_TIMEOUT, session.turn(&prompt)) + .await + .map_err(|_| Error::TimedOut { seat: seat.clone() })? + .map_err(Error::Harness)?; + if let Some(last) = session.last_turn_usage() { + usage + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(seat.clone(), last); + } + Ok(reply) + } + }; + let result = host.wrap_turn(&seat, Box::pin(run)).await; + (seat, lane, Some(result.map_err(|error| error.to_string()))) + }) + } +} diff --git a/crates/tinyhivemind-openhuman/src/hosted/seed.rs b/crates/tinyhivemind-openhuman/src/hosted/seed.rs new file mode 100644 index 00000000..c75cb34a --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/hosted/seed.rs @@ -0,0 +1,54 @@ +//! A seat's history, read from the host's log as that seat. + +use tinyhivemind::aside::Viewer; +use tinyhivemind::{ + Conversation, Sequence, SessionAuthor, SessionLog, SessionMessage, SessionQuery, + project_session, +}; + +use crate::Result; + +/// What `seat` was shown in `conversation` up to and including `since`, +/// newest `window` rows, as chronological `(role, content)` pairs. +/// +/// Projected as the seat, so a row it was not addressed on is withheld the +/// same way it is everywhere else the host's log is read. The seat's own +/// rows are its turns; everyone else's are attributed messages to it. +/// +/// Nothing above `since` is read. The rows above it are the turn's new rows, +/// which reach the seat in its brief, so between the two it sees every row +/// once, and a row a peer wrote in the same wave -- above `since`, since the +/// watermark was fixed before the wave ran -- reaches it through neither. +/// +/// # Errors +/// +/// The host's log failing to read, or breaking the port's contract. +pub(super) async fn history( + log: &dyn SessionLog, + conversation: Conversation, + seat: &str, + since: Sequence, + window: usize, +) -> Result> { + let query = SessionQuery { + conversation, + viewer: Viewer::Agent { id: seat.into() }, + before: Some(Sequence(since.0.saturating_add(1))), + window, + }; + let rows = project_session(log, &query).await?; + Ok(rows.iter().filter_map(|row| turn(row, seat)).collect()) +} + +/// One row as the seat's model reads it, or `None` for a row withheld from +/// it. +fn turn(row: &SessionMessage, seat: &str) -> Option<(String, String)> { + let content = row.readable()?; + Some(match &row.author { + SessionAuthor::Agent { id, .. } if id == seat => ("assistant".into(), content.into()), + SessionAuthor::Agent { label, .. } + | SessionAuthor::Person { label, .. } + | SessionAuthor::System { label, .. } => ("user".into(), format!("@{label}: {content}")), + SessionAuthor::Operator => ("user".into(), format!("@operator: {content}")), + }) +} diff --git a/crates/tinyhivemind-openhuman/src/hosted/test.rs b/crates/tinyhivemind-openhuman/src/hosted/test.rs new file mode 100644 index 00000000..9ffee76e --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/hosted/test.rs @@ -0,0 +1,206 @@ +//! The hosted runner's pure pieces: seeding from the host's log, the belt, +//! and the gate that admits it. Running a hosted turn is proven with the +//! other runners in `runner/test.rs`. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use async_trait::async_trait; +use openhuman_core::agent::tool_policy::{ + ToolCallContext, ToolPolicy, ToolPolicyDecision, ToolPolicyRequest, +}; +use serde_json::json; +use tinyhivemind::{Conversation, Sequence, SessionLog}; +use tinyhivemind_tools::{EpisodeTools, served_specs}; + +use super::EpisodeBelt; +use super::seed::history; +use crate::offline::MemoryLog; + +fn desk(thread_root: Option) -> Conversation { + Conversation { + desk_id: "engineering".into(), + desk_name: "Engineering".into(), + thread_root, + } +} + +/// The task, a desk post, an ask from one to two and its answer in the +/// thread, a note to one alone, and a desk post from three. +fn journal() -> MemoryLog { + let log = MemoryLog::new("engineering"); + log.append( + "operator", + "the login flow rejects valid credentials", + None, + None, + ); + log.append("one", "looking into it", None, None); + let root = log.append("one", "asks @two: which port?", None, Some("two")); + log.append("two", "port 8080", Some(root), None); + log.append("desk", "you hold open work", None, Some("one")); + log.append("three", "the cache is stale", None, None); + log +} + +#[tokio::test] +async fn a_seat_is_seeded_with_what_it_was_shown_its_own_rows_as_its_turns() { + let log = journal(); + let seen = history(&log, desk(None), "one", Sequence(6), 30) + .await + .expect("reads"); + assert_eq!( + seen[0], + ( + "user".into(), + "@operator: the login flow rejects valid credentials".into() + ) + ); + assert_eq!(seen[1], ("assistant".into(), "looking into it".into())); + assert!(seen.contains(&("assistant".into(), "asks @two: which port?".into()))); + assert!(seen.contains(&("user".into(), "@desk: you hold open work".into()))); + assert_eq!( + seen.last(), + Some(&("user".into(), "@three: the cache is stale".into())) + ); +} + +#[tokio::test] +async fn a_row_the_seat_was_not_addressed_on_is_withheld() { + let log = journal(); + let seen = history(&log, desk(None), "three", Sequence(6), 30) + .await + .expect("reads"); + let text: Vec<&str> = seen.iter().map(|(_, content)| content.as_str()).collect(); + assert!( + !text.iter().any(|row| row.contains("which port")), + "{text:?}" + ); + assert!(!text.iter().any(|row| row.contains("8080")), "{text:?}"); + assert!( + !text.iter().any(|row| row.contains("open work")), + "{text:?}" + ); + assert!(text.contains(&"@operator: the login flow rejects valid credentials")); +} + +#[tokio::test] +async fn nothing_above_the_watermark_is_seeded() { + let log = journal(); + let seen = history(&log, desk(None), "one", Sequence(2), 30) + .await + .expect("reads"); + assert_eq!(seen.len(), 2, "{seen:?}"); + let none = history(&log, desk(None), "one", Sequence(0), 30) + .await + .expect("reads"); + assert!(none.is_empty(), "a first turn has no history"); +} + +#[tokio::test] +async fn a_thread_turn_is_seeded_with_the_conversation_alone() { + let log = journal(); + let seen = history(&log, desk(Some(Sequence(3))), "two", Sequence(4), 30) + .await + .expect("reads"); + assert_eq!( + seen, + vec![ + ("user".into(), "@one: asks @two: which port?".into()), + ("assistant".into(), "port 8080".into()), + ] + ); +} + +#[tokio::test] +async fn the_memory_log_pages_newest_first_and_says_when_it_is_done() { + let log = journal(); + let first = log.read_before(None, 4).await.expect("reads"); + let sequences: Vec = first.messages.iter().map(|row| row.sequence.0).collect(); + assert_eq!(sequences, vec![6, 5, 4, 3]); + assert_eq!(first.next_before, Some(Sequence(3))); + let rest = log.read_before(first.next_before, 4).await.expect("reads"); + let sequences: Vec = rest.messages.iter().map(|row| row.sequence.0).collect(); + assert_eq!(sequences, vec![2, 1]); + assert_eq!(rest.next_before, None, "the log is finished"); + assert_eq!( + first.messages[2].parent, + Some(Sequence(3)), + "a thread row names its root" + ); + assert_eq!(log.latest(), Sequence(6)); + assert_eq!(log.all().len(), 6); + assert_eq!(log.thread(Sequence(3)).len(), 2); + assert_eq!( + log.thread_since(Sequence(3), Sequence(3)), + vec!["@two: port 8080"] + ); + let for_two = log.desk_since("two", Sequence(0)); + assert!(for_two.iter().any(|row| row.contains("which port"))); + assert!(!for_two.iter().any(|row| row.contains("open work"))); + assert!(format!("{log:?}").contains("engineering")); +} + +fn request(tool: &str) -> ToolPolicyRequest { + #[allow(deprecated)] + ToolPolicyRequest { + tool_name: tool.to_owned(), + arguments: json!({}), + context: ToolCallContext::session("session", "internal", "lead", "call-1", 1), + generated_tool: None, + session_id: String::new(), + channel: String::new(), + agent_definition_id: String::new(), + } +} + +/// A host policy that allows everything, so a denial can only be the +/// admission's own. +#[derive(Debug)] +struct AllowAll; + +#[async_trait] +impl ToolPolicy for AllowAll { + fn name(&self) -> &'static str { + "allow_all" + } + + async fn check(&self, _request: &ToolPolicyRequest) -> ToolPolicyDecision { + ToolPolicyDecision::Allow + } +} + +#[tokio::test] +async fn the_belt_is_the_served_vocabulary_admitted_over_the_hosts_own_gate() { + let tools = Arc::new(EpisodeTools::new(["lead"])); + let belt = EpisodeBelt::new("lead", &tools); + let served: Vec<&str> = served_specs().map(|spec| spec.name).collect(); + assert_eq!(belt.names(), served.as_slice()); + assert_eq!(belt.tools.len(), served.len()); + assert!(format!("{belt:?}").contains("complete_episode")); + + let alone = belt.admit(None); + assert_eq!(alone.name(), "episode_admission"); + assert!(matches!( + alone.check(&request("complete_episode")).await, + ToolPolicyDecision::Allow + )); + assert!(matches!( + alone.check(&request("shell")).await, + ToolPolicyDecision::Deny { .. } + )); + + let hosted = belt.admit(Some(Arc::new(AllowAll))); + assert!(matches!( + hosted.check(&request("read")).await, + ToolPolicyDecision::Allow + )); + assert!( + matches!( + hosted.check(&request("chargebee_refund")).await, + ToolPolicyDecision::Allow + ), + "the host's own tools are the host's gate's to decide" + ); +} diff --git a/crates/tinyhivemind-openhuman/src/lib.rs b/crates/tinyhivemind-openhuman/src/lib.rs index 4d38aabc..015ee29a 100644 --- a/crates/tinyhivemind-openhuman/src/lib.rs +++ b/crates/tinyhivemind-openhuman/src/lib.rs @@ -1,4 +1,4 @@ -//! The `OpenHuman` adapter: how a seat's turn runs on `OpenHuman`, both ways. +//! The `OpenHuman` adapter: how a seat's turn runs on `OpenHuman`. //! //! `tinyhivemind-driver` says who runs next and what a committed row means, //! over a handle the host binds; it never runs a turn. This crate is the @@ -7,23 +7,27 @@ //! and the first and last are the same for every embedding, because //! [`EpisodeTools`](tinyhivemind_tools::EpisodeTools) is where a call lands //! whichever road it took. What genuinely varies is [`SeatRunner::turn`], -//! and there are two answers: +//! and there are three answers: //! +//! - [`HostedRunner`]: a seat is the host's own agent -- its model, tools, +//! approval gate, memory and prompt -- built by the host through +//! [`EpisodeHost`] with the episode's tools added to its belt, and seeded +//! every turn from the host's own log up to the seat's watermark. This is +//! the runner for a host that already has agents. //! - [`EmbedRunner`]: a seat is an `openhuman-embed` `AgentSpec` agent on a //! runtime the host booted, holding one session across the episode, and //! reaching the episode's tools through `OpenHuman`'s three MCP dispatchers //! against `tinyhivemind-mcp`'s server -- the only road a spec offers a //! tool the runtime did not ship. -//! - [`RawRunner`]: a seat is an `OpenHumanSessionHost` built one level down -//! on every turn, handed the same tools natively as its belt, with a policy -//! gate and a memory that keeps nothing, and seeded from a per-seat log this -//! crate keeps. +//! - [`RawRunner`]: a seat is an `OpenHumanSessionHost` this crate builds on +//! a [`LibraryHost`] every turn, handed the same tools natively, with a +//! gate and a memory that keeps nothing, and seeded from a per-seat log it +//! keeps itself. //! -//! Both land every call in the same record, so the driver drains identical -//! events and a seat is refused and acknowledged in the same words either -//! way. The bound handle differs -- [`EmbedSeat`] wraps the agent, [`RawSeat`] -//! is the seat itself -- which is what [`BoundAgent`](tinyhivemind_driver::BoundAgent) -//! is for. +//! All three land every call in the same record, so the driver drains +//! identical events and a seat is refused and acknowledged in the same +//! words whichever runs it. The bound handle differs, which is what +//! [`BoundAgent`](tinyhivemind_driver::BoundAgent) is for. //! //! This is the one crate in the workspace that links a harness. A host that //! seats agents some other way does not link it; it implements `BoundAgent` @@ -31,37 +35,59 @@ //! //! # Example //! -//! A raw seat, against any OpenAI-compatible endpoint: the route is the -//! credential. The same steps seat one against the scripted model the -//! `offline` feature ships, which is how the crate's own tests prove it. +//! A hosted seat. The host has a log and knows how to build its agents; here +//! the agent is a library session with nothing but the episode's tools, and +//! the wrapper is the core context such a session runs under. //! //! ```no_run //! use std::sync::Arc; -//! use openhuman_embed::RuntimeConfig; -//! use tinyhivemind_openhuman::{Lane, RawRunner, Route, SeatRunner}; +//! use openhuman_core::agent::OpenHumanSessionHost; +//! use tinyhivemind::{SESSION_WINDOW, SessionLog}; +//! use tinyhivemind_openhuman::{ +//! EpisodeBelt, EpisodeHost, HostedRunner, HostedTurn, Lane, LibraryHost, SeatRunner, +//! }; //! use tinyhivemind_tools::{Dispatch, EpisodeTools}; //! -//! # async fn run() -> tinyhivemind_openhuman::Result<()> { -//! let workspace = std::env::temp_dir().join("episode"); -//! // Every seat is a registered definition before a raw session runs: the -//! // hosted turn resolves the seat, and its belt, by name. -//! RawRunner::prepare(&workspace, &[("lead", "You lead the desk.")])?; -//! let runner = RawRunner::seat( +//! struct Desk { +//! log: L, +//! library: LibraryHost, +//! } +//! +//! impl EpisodeHost for Desk { +//! fn log(&self) -> &dyn SessionLog { +//! &self.log +//! } +//! +//! fn build_seat( +//! &self, +//! seat: &str, +//! belt: EpisodeBelt, +//! ) -> tinyhivemind_openhuman::Result { +//! // A real host builds the agent it always builds, adds `belt.tools`, +//! // and passes its own gate here instead of `None`. +//! let gate = belt.admit(None); +//! self.library.session(seat, "You lead the desk.", belt.tools, gate) +//! } +//! +//! fn wrap_turn<'a>(&'a self, _seat: &'a str, turn: HostedTurn<'a>) -> HostedTurn<'a> { +//! Box::pin(self.library.scope(turn)) +//! } +//! } +//! +//! # async fn run(desk: Desk) -> tinyhivemind_openhuman::Result<()> { +//! let runner = HostedRunner::seat( +//! Arc::new(desk), //! Arc::new(EpisodeTools::new(["lead"])), -//! &[("lead".to_owned(), "You lead the desk.".to_owned())].into_iter().collect(), -//! "Call `complete_episode` when you are done.", -//! &RuntimeConfig::default(), -//! "http://127.0.0.1:1/backend", -//! &Route { -//! endpoint: "http://127.0.0.1:1/v1".into(), -//! api_key: "key".into(), -//! model: "a-model".into(), -//! }, -//! &workspace, -//! ) -//! .await?; +//! &["lead".to_owned()], +//! "engineering", +//! "Engineering", +//! SESSION_WINDOW, +//! )?; //! runner.open("lead", Vec::new(), Dispatch { chat: "engineering".into(), parent: None }); -//! let (_, _, reply) = runner.turn("lead".into(), Lane::Desk, "Go.".into()).await; +//! // The newest row `lead` was shown before this turn: its history is read +//! // from the host's log up to here, and the brief carries what is above. +//! let since = tinyhivemind::Sequence(1); +//! let (_, _, reply) = runner.turn("lead".into(), Lane::Desk, since, "Go.".into()).await; //! let events = runner.close("lead"); //! # let _ = (reply, events); //! # Ok(()) @@ -70,6 +96,7 @@ pub mod embed; pub mod error; +pub mod hosted; #[cfg(any(test, feature = "offline"))] pub mod offline; pub mod raw; @@ -77,5 +104,6 @@ pub mod runner; pub use embed::{EmbedRunner, EmbedSeat}; pub use error::{Error, Result}; -pub use raw::{RawRunner, RawSeat, Route}; +pub use hosted::{EpisodeBelt, EpisodeHost, HostedRunner, HostedSeat, HostedTurn}; +pub use raw::{LibraryHost, RawRunner, RawSeat, Route}; pub use runner::{Lane, RunnerKind, SeatRunner, TURN_TIMEOUT, TurnJob, TurnResult}; diff --git a/crates/tinyhivemind-openhuman/src/offline/README.md b/crates/tinyhivemind-openhuman/src/offline/README.md index ade3cb04..bf1464c6 100644 --- a/crates/tinyhivemind-openhuman/src/offline/README.md +++ b/crates/tinyhivemind-openhuman/src/offline/README.md @@ -6,4 +6,8 @@ native for a raw session, `mcp_call_tool` for an embed agent -- and a closing sentence once it sees the receipt. `Metrics` is what it saw: request bytes, and the time from a call to its receipt. `config()` is the runtime config an offline run boots with and `backend()` the stub for the core's non-inference -calls. Compiled in tests and under the `offline` feature. +calls. `MemoryLog`, in `log.rs`, is an in-memory journal that is a real +`SessionLog`: a desk row with `only_for` reaches its author and that seat, +and a row in a conversation reaches the conversation's two seats, which is +the rule a host's own journal follows too. Compiled in tests and under the +`offline` feature. diff --git a/crates/tinyhivemind-openhuman/src/offline/log.rs b/crates/tinyhivemind-openhuman/src/offline/log.rs new file mode 100644 index 00000000..1b26a926 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/offline/log.rs @@ -0,0 +1,197 @@ +//! An in-memory journal that is a real [`SessionLog`]. +//! +//! The host owns the log; this is the smallest host log that obeys the +//! port's contract, so an offline run and a test read it through exactly the +//! projection a live host's journal is read through. Two rules decide who may +//! read a row, and they are the ones a host follows too: +//! +//! - A desk row with `only_for` reaches its author and that one seat. +//! - A row in a conversation reaches the conversation's two seats: the author +//! of the ask row it hangs off, and the seat that ask was for. + +use std::sync::{Mutex, PoisonError}; + +use tinyhivemind::aside::Audience; +use tinyhivemind::{LogMessage, Sequence, SessionAuthor, SessionFuture, SessionLog, SessionPage}; + +/// One row of the journal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Row { + /// The sequence the journal gave it. + pub sequence: Sequence, + /// Who it is attributed to: a seat id, `operator`, or `desk`. + pub author: String, + /// What it says, as rendered. + pub body: String, + /// The conversation it is in, by its ask row, or `None` on the desk. + pub thread: Option, + /// On the desk, the one seat it reaches. + pub only_for: Option, +} + +/// An append-only journal for one desk, held in memory. +#[derive(Debug)] +pub struct MemoryLog { + desk: String, + rows: Mutex>, +} + +impl MemoryLog { + /// An empty journal for `desk`. + #[must_use] + pub fn new(desk: impl Into) -> Self { + Self { + desk: desk.into(), + rows: Mutex::new(Vec::new()), + } + } + + fn rows(&self) -> std::sync::MutexGuard<'_, Vec> { + self.rows.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Append a row and return the sequence it was given. + pub fn append( + &self, + author: &str, + body: &str, + thread: Option, + only_for: Option<&str>, + ) -> Sequence { + let mut rows = self.rows(); + let sequence = Sequence(rows.last().map_or(0, |row| row.sequence.0) + 1); + rows.push(Row { + sequence, + author: author.to_owned(), + body: body.to_owned(), + thread, + only_for: only_for.map(str::to_owned), + }); + sequence + } + + /// The newest sequence, or zero for an empty journal. + #[must_use] + pub fn latest(&self) -> Sequence { + self.rows().last().map_or(Sequence(0), |row| row.sequence) + } + + /// What `seat` may read on the open desk above `after`, rendered. + #[must_use] + pub fn desk_since(&self, seat: &str, after: Sequence) -> Vec { + self.rows() + .iter() + .filter(|row| row.sequence > after && row.thread.is_none()) + .filter(|row| { + row.only_for + .as_deref() + .is_none_or(|only| only == seat || row.author == seat) + }) + .map(render) + .collect() + } + + /// One conversation whole, rendered: the ask that rooted it and every row + /// in it. + #[must_use] + pub fn thread(&self, root: Sequence) -> Vec { + self.thread_since(root, Sequence(0)) + } + + /// One conversation above `after`, rendered. + #[must_use] + pub fn thread_since(&self, root: Sequence, after: Sequence) -> Vec { + self.rows() + .iter() + .filter(|row| row.sequence > after) + .filter(|row| row.sequence == root || row.thread == Some(root)) + .map(render) + .collect() + } + + /// Every row, in order. + #[must_use] + pub fn all(&self) -> Vec { + self.rows().clone() + } + + /// Who may read `row`, by the two rules in the module docs. + fn audience(rows: &[Row], row: &Row) -> Audience { + let addressed: Vec = match row.thread { + Some(root) => rows + .iter() + .find(|candidate| candidate.sequence == root) + .map(|ask| { + std::iter::once(ask.author.clone()) + .chain(ask.only_for.clone()) + .collect() + }) + .unwrap_or_default(), + None => row.only_for.clone().into_iter().collect(), + }; + let mut members: Vec = Vec::new(); + for id in addressed { + if id != row.author && !members.contains(&id) { + members.push(id); + } + } + if members.is_empty() && row.thread.is_none() && row.only_for.is_none() { + Audience::Desk + } else { + Audience::Aside { members } + } + } + + fn author(id: &str) -> SessionAuthor { + match id { + "operator" => SessionAuthor::Operator, + "desk" => SessionAuthor::System { + kind: "desk".into(), + label: "desk".into(), + }, + seat => SessionAuthor::Agent { + id: seat.into(), + label: seat.into(), + }, + } + } +} + +/// `@author: body`, as every reader of this journal sees a row. +fn render(row: &Row) -> String { + format!("@{}: {}", row.author, row.body) +} + +impl SessionLog for MemoryLog { + fn read_before(&self, before: Option, limit: usize) -> SessionFuture<'_> { + let rows = self.all(); + let older: Vec<&Row> = rows + .iter() + .rev() + .filter(|row| before.is_none_or(|bound| row.sequence < bound)) + .collect(); + let taken = &older[..older.len().min(limit)]; + let next_before = if older.len() > taken.len() { + taken.last().map(|row| row.sequence) + } else { + None + }; + let messages = taken + .iter() + .map(|row| LogMessage { + sequence: row.sequence, + chat_id: Some(self.desk.clone()), + parent: row.thread, + author: Self::author(&row.author), + content: row.body.clone(), + audience: Self::audience(&rows, row), + }) + .collect(); + Box::pin(async move { + Ok(SessionPage { + messages, + next_before, + }) + }) + } +} diff --git a/crates/tinyhivemind-openhuman/src/offline/mod.rs b/crates/tinyhivemind-openhuman/src/offline/mod.rs index 663bc8a3..713367a0 100644 --- a/crates/tinyhivemind-openhuman/src/offline/mod.rs +++ b/crates/tinyhivemind-openhuman/src/offline/mod.rs @@ -19,6 +19,7 @@ //! trip as the model experiences it, whichever road the call took. That is //! what the example's bench compares between the runners. +mod log; #[cfg(test)] mod test; @@ -26,6 +27,7 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; +pub use log::{MemoryLog, Row}; use openhuman_embed::RuntimeConfig; use serde_json::{Value, json}; use wiremock::matchers::{any, method, path}; diff --git a/crates/tinyhivemind-openhuman/src/raw/README.md b/crates/tinyhivemind-openhuman/src/raw/README.md index dd241884..35a6ad17 100644 --- a/crates/tinyhivemind-openhuman/src/raw/README.md +++ b/crates/tinyhivemind-openhuman/src/raw/README.md @@ -3,11 +3,17 @@ `RawRunner`: every brief a `RawSeat`, which builds an `OpenHumanSessionHost` per turn with the belt, the gate, the memory and the prompt as objects, and drops it. `prepare` registers the seats as workspace definitions before any -runtime boots; `seat` boots the library-host context and resolves the route. +runtime boots; `seat` boots a `LibraryHost` and seats every brief on it. + +`LibraryHost` is the core booted as a library host over one route: sessions +built from objects on it, and turns run under its context. It is public for +any host with no core of its own, which is how the example's hosted host +builds its seats. | file | holds | | --- | --- | | `mod.rs` | `RawRunner`, `Route`, `prepare`, `seat`, the per-seat context log | +| `library.rs` | `LibraryHost`: the library-host core, its sessions, and its scope | | `seat.rs` | `RawSeat`: one session built, seeded, run and dropped | | `tools.rs` | the served definitions as `tinytools::Tool`s whose execute is `EpisodeTools::call` | | `policy.rs` | `EpisodeGate`, admitting only the belt, and `NoMemory` | diff --git a/crates/tinyhivemind-openhuman/src/raw/library.rs b/crates/tinyhivemind-openhuman/src/raw/library.rs new file mode 100644 index 00000000..f249bb89 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/raw/library.rs @@ -0,0 +1,162 @@ +//! The core booted as a library host, and the sessions built on it. +//! +//! A raw session runs inside the core the way a library embedder's does. The +//! core reads its ambient context to decide whose product policy applies; +//! with none it is the desktop's, and inference waits on the operator signing +//! in. `HostKind::Library` says the caller owns the provider and its +//! credential -- this config's route -- and is what the embed runtime says of +//! itself when it boots. Nothing else is asked of the core: no domain, no +//! service, no store. +//! +//! [`RawRunner`](super::RawRunner) seats on one of these, and so can any host +//! that has no core of its own to build sessions on: [`LibraryHost::session`] +//! builds one from the objects a spec cannot carry, and +//! [`LibraryHost::scope`] runs a turn under the context. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use openhuman_core::agent::OpenHumanSessionHost; +use openhuman_core::agent::prompts::SystemPromptBuilder; +use openhuman_core::agent::tool_policy::ToolPolicy; +use openhuman_core::config::schema::ephemeral_route::{self, EphemeralRoute}; +use openhuman_core::config::{AgentConfig, Config}; +use openhuman_core::core::runtime::{CoreContext, DomainSet, TokenSource}; +use openhuman_core::core::types::HostKind; +use openhuman_core::tools::toolpacks::ToolGroups; +use tinytools::Tool; +use tinytools_agent::dialect::NativeDialect; + +use super::Route; +use super::policy::NoMemory; +use crate::{Error, Result}; + +/// The tool-loop ceiling for one turn: think, call, read the receipt, reply. +const MAX_TOOL_ITERATIONS: usize = 6; + +/// A core booted as a library host over one resolved route. +#[derive(Clone)] +pub struct LibraryHost { + /// The resolved config every session is built from, carrying the route. + config: Arc, + /// The context every turn runs under. + context: Arc, + /// What the config resolves `chat` to. + model: String, + /// Where a session is rooted. Nothing is written there -- `auto_save` is + /// off -- but the builder wants a directory. + workspace: PathBuf, +} + +impl std::fmt::Debug for LibraryHost { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LibraryHost") + .field("model", &self.model) + .field("workspace", &self.workspace) + .finish_non_exhaustive() + } +} + +impl LibraryHost { + /// Boot the core as a library host over `base`, with the workspace, the + /// backend and the route written in, and resolve the `chat` role once so + /// a route the factory cannot resolve fails here rather than at the first + /// turn. + /// + /// # Errors + /// + /// An incomplete route, the core refusing to boot, or the route failing + /// to resolve. + pub async fn boot( + base: &Config, + backend_url: &str, + route: &Route, + workspace: &Path, + ) -> Result { + let mut config = base.clone(); + config.workspace_dir = workspace.to_path_buf(); + config.action_dir = workspace.to_path_buf(); + config.api_url = Some(backend_url.to_owned()); + config.default_model = Some(route.model.clone()); + let ephemeral = + EphemeralRoute::from_params(Some(route.endpoint.clone()), Some(route.api_key.clone())) + .ok_or(Error::IncompleteRoute)?; + ephemeral_route::apply(&mut config, ephemeral); + let config = Arc::new(config); + let (context, _, _) = Box::pin(CoreContext::init_with_config( + HostKind::Library, + &TokenSource::Fixed(Arc::new(format!("tinyhivemind-raw-{}", std::process::id()))), + DomainSet::none(), + ToolGroups::default(), + Some((*config).clone()), + None, + )) + .await?; + let (_, model) = CoreContext::scope(Arc::clone(&context), async { + openhuman_core::inference::provider::create_chat_model_with_model_id( + "chat", &config, 0.0, + ) + }) + .await?; + Ok(Self { + config, + context, + model, + workspace: workspace.to_path_buf(), + }) + } + + /// The model id the config resolved `chat` to. + #[must_use] + pub fn model(&self) -> &str { + &self.model + } + + /// Build one session for `seat` from objects: the belt, the policy and + /// the prompt, over a memory that keeps nothing. `seat` is also the + /// definition name the hosted turn resolves, so it must be registered. + /// + /// # Errors + /// + /// The builder refusing the session. + pub fn session( + &self, + seat: &str, + system_prompt: &str, + tools: Vec>, + policy: Arc, + ) -> Result { + OpenHumanSessionHost::builder() + // The same crate-native model source the production factory uses, + // resolved from the config's `chat` role. + .crate_native_provider("chat", Arc::clone(&self.config)) + .model_name(self.model.clone()) + .temperature(0.0) + .tools(tools) + .memory(Arc::new(NoMemory)) + .tool_dispatcher(Box::new(NativeDialect)) + .prompt_builder(SystemPromptBuilder::from_final_body( + system_prompt.to_owned(), + )) + .tool_policy(policy) + .config(AgentConfig { + max_tool_iterations: MAX_TOOL_ITERATIONS, + ..AgentConfig::default() + }) + .workspace_dir(self.workspace.clone()) + .action_dir(self.workspace.clone()) + // The host's log is the only log. A session that also wrote + // `OpenHuman`'s transcript would be a second one. + .auto_save(false) + .agent_definition_name(seat.to_owned()) + .build() + .map_err(Error::Harness) + } + + /// Run `turn` under this host's context: a session is built, and its + /// model resolved, inside it. + pub async fn scope(&self, turn: F) -> F::Output { + Box::pin(CoreContext::scope(Arc::clone(&self.context), turn)).await + } +} diff --git a/crates/tinyhivemind-openhuman/src/raw/mod.rs b/crates/tinyhivemind-openhuman/src/raw/mod.rs index 8c027f90..619d0470 100644 --- a/crates/tinyhivemind-openhuman/src/raw/mod.rs +++ b/crates/tinyhivemind-openhuman/src/raw/mod.rs @@ -38,11 +38,12 @@ //! signing in. So the seats are booted under a library-host context once, //! at seating ([`RawRunner::seat`]), and every turn runs inside it. +mod library; mod policy; mod seat; #[cfg(test)] mod test; -mod tools; +pub(crate) mod tools; use std::collections::BTreeMap; use std::path::Path; @@ -50,15 +51,13 @@ use std::sync::{Arc, Mutex, PoisonError}; use openhuman_core::agent::harness::AgentDefinitionRegistry; use openhuman_core::config::Config; -use openhuman_core::config::schema::ephemeral_route::{self, EphemeralRoute}; -use openhuman_core::core::runtime::{CoreContext, DomainSet, TokenSource}; -use openhuman_core::core::types::HostKind; -use openhuman_core::tools::toolpacks::ToolGroups; +use tinyhivemind::Sequence; use tinyhivemind_driver::AgentBinding; use tinyhivemind_tools::EpisodeTools; use crate::runner::{Lane, SeatRunner, TurnJob}; use crate::{Error, Result}; +pub use library::LibraryHost; pub use seat::RawSeat; /// What each seat has been shown and said, keyed by seat. @@ -146,53 +145,14 @@ impl RawRunner { route: &Route, workspace: &Path, ) -> Result { - let mut config = base.clone(); - config.workspace_dir = workspace.to_path_buf(); - config.action_dir = workspace.to_path_buf(); - config.api_url = Some(backend_url.to_owned()); - config.default_model = Some(route.model.clone()); - let ephemeral = - EphemeralRoute::from_params(Some(route.endpoint.clone()), Some(route.api_key.clone())) - .ok_or(Error::IncompleteRoute)?; - ephemeral_route::apply(&mut config, ephemeral); - let config = Arc::new(config); - // A raw session runs inside the core the way a library embedder's - // does. The core reads its ambient context to decide whose product - // policy applies; with none it is the desktop's, and inference waits - // on the operator signing in. `HostKind::Library` says the caller - // owns the provider and its credential -- this config's route -- and - // is what the embed runtime says of itself when it boots. Nothing - // else is asked of the core: no domain, no service, no store. - let (context, _, _) = Box::pin(CoreContext::init_with_config( - HostKind::Library, - &TokenSource::Fixed(Arc::new(format!("tinyhivemind-raw-{}", std::process::id()))), - DomainSet::none(), - ToolGroups::default(), - Some((*config).clone()), - None, - )) - .await?; - // Resolve the `chat` role once, at seating: a route the factory - // cannot resolve fails here rather than at the first turn. - let (_, model) = CoreContext::scope(Arc::clone(&context), async { - openhuman_core::inference::provider::create_chat_model_with_model_id( - "chat", &config, 0.0, - ) - }) - .await?; + let library = LibraryHost::boot(base, backend_url, route, workspace).await?; + let model = library.model().to_owned(); let seats = briefs .iter() .map(|(id, brief)| { ( id.clone(), - RawSeat::new( - id, - format!("{brief}\n\n{contract}"), - Arc::clone(&config), - Arc::clone(&context), - model.clone(), - workspace.to_path_buf(), - ), + RawSeat::new(id, format!("{brief}\n\n{contract}"), library.clone()), ) }) .collect(); @@ -237,7 +197,7 @@ impl SeatRunner for RawRunner { /// A fresh session, seeded with what this seat has been shown and said /// so far, run once and dropped. Its belt is built for this seat and this /// turn, and every call it makes lands in the shared record. - fn turn(&self, seat: String, lane: Lane, prompt: String) -> TurnJob { + fn turn(&self, seat: String, lane: Lane, _since: Sequence, prompt: String) -> TurnJob { let history = self .contexts .lock() diff --git a/crates/tinyhivemind-openhuman/src/raw/seat.rs b/crates/tinyhivemind-openhuman/src/raw/seat.rs index 6d4e037f..9bfa48ff 100644 --- a/crates/tinyhivemind-openhuman/src/raw/seat.rs +++ b/crates/tinyhivemind-openhuman/src/raw/seat.rs @@ -1,24 +1,17 @@ //! One seat, run as a fresh raw session on every turn. use std::fmt; -use std::path::PathBuf; use std::sync::Arc; -use openhuman_core::agent::prompts::SystemPromptBuilder; -use openhuman_core::agent::{OpenHumanSessionHost, TurnOverrides}; -use openhuman_core::config::{AgentConfig, Config}; -use openhuman_core::core::runtime::CoreContext; +use openhuman_core::agent::TurnOverrides; use tinyhivemind_driver::BoundAgent; use tinytools::Tool; -use tinytools_agent::dialect::NativeDialect; -use super::policy::{EpisodeGate, NoMemory}; +use super::library::LibraryHost; +use super::policy::EpisodeGate; use crate::runner::TURN_TIMEOUT; use crate::{Error, Result}; -/// The tool-loop ceiling for one turn: think, call, read the receipt, reply. -const MAX_TOOL_ITERATIONS: usize = 6; - /// Everything needed to build a seat's session, and no session. /// /// Cloned per turn into the job that runs it, and bound into the hive as the @@ -29,16 +22,8 @@ pub struct RawSeat { id: String, /// The standing prompt: the brief and the contract, whole. system_prompt: String, - /// The resolved config every session is built from, carrying the route. - config: Arc, - /// The core context every session runs under: a library host, so the - /// route in `config` is the credential and no sign-in is waited on. - context: Arc, - /// The model id the config resolves `chat` to. - model_name: String, - /// The workspace a session is rooted in. Nothing is written there -- - /// `auto_save` is off -- but the builder wants a directory. - workspace: PathBuf, + /// The core every session is built on. + library: LibraryHost, } impl BoundAgent for RawSeat { @@ -51,70 +36,29 @@ impl fmt::Debug for RawSeat { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RawSeat") .field("id", &self.id) - .field("model_name", &self.model_name) - .field("workspace", &self.workspace) + .field("library", &self.library) .finish_non_exhaustive() } } impl RawSeat { /// A seat from its parts. [`RawRunner::seat`](super::RawRunner::seat) - /// builds these; it is public for a host that resolves its own route. + /// builds these; it is public for a host that boots its own library. #[must_use] pub fn new( id: impl Into, system_prompt: impl Into, - config: Arc, - context: Arc, - model_name: impl Into, - workspace: PathBuf, + library: LibraryHost, ) -> Self { Self { id: id.into(), system_prompt: system_prompt.into(), - config, - context, - model_name: model_name.into(), - workspace, + library, } } - /// Build one session from this host's objects. Every setter here is one - /// the embed facade cannot express: the belt, the memory, the policy and - /// the prompt are objects, not configuration. - fn session(&self, tools: Vec>) -> Result { - let names: Vec = tools.iter().map(|tool| tool.name().to_owned()).collect(); - OpenHumanSessionHost::builder() - // The same crate-native model source the production factory uses, - // resolved from the config's `chat` role. - .crate_native_provider("chat", Arc::clone(&self.config)) - .model_name(self.model_name.clone()) - .temperature(0.0) - .tools(tools) - .memory(Arc::new(NoMemory)) - .tool_dispatcher(Box::new(NativeDialect)) - .prompt_builder(SystemPromptBuilder::from_final_body( - self.system_prompt.clone(), - )) - .tool_policy(Arc::new(EpisodeGate::new(names))) - .config(AgentConfig { - max_tool_iterations: MAX_TOOL_ITERATIONS, - ..AgentConfig::default() - }) - .workspace_dir(self.workspace.clone()) - .action_dir(self.workspace.clone()) - // The host's log is the only log. A session that also wrote - // `OpenHuman`'s transcript would be a second one. - .auto_save(false) - // The definition `prepare` registered for this seat: the hosted - // turn resolves it by this name. - .agent_definition_name(self.id.clone()) - .build() - .map_err(Error::Harness) - } - /// Run one turn on a fresh session seeded with `history`, chronological - /// `(role, content)` pairs, and drop it. + /// `(role, content)` pairs, and drop it. The belt is gated to itself. /// /// # Errors /// @@ -126,22 +70,27 @@ impl RawSeat { message: &str, tools: Vec>, ) -> Result { - // The whole turn under the library context: the session is built, - // and its model resolved, inside it. - Box::pin(CoreContext::scope(Arc::clone(&self.context), async { - let mut host = self.session(tools)?; - host.seed_resume_from_messages(history, message)?; - host.set_next_turn_overrides(TurnOverrides { - suppress_transcript_autoload: true, - ..TurnOverrides::default() - }); - tokio::time::timeout(TURN_TIMEOUT, host.turn(message)) - .await - .map_err(|_| Error::TimedOut { - seat: self.id.clone(), - })? - .map_err(Error::Harness) - })) - .await + let names: Vec = tools.iter().map(|tool| tool.name().to_owned()).collect(); + self.library + .scope(async { + let mut host = self.library.session( + &self.id, + &self.system_prompt, + tools, + Arc::new(EpisodeGate::new(names)), + )?; + host.seed_resume_from_messages(history, message)?; + host.set_next_turn_overrides(TurnOverrides { + suppress_transcript_autoload: true, + ..TurnOverrides::default() + }); + tokio::time::timeout(TURN_TIMEOUT, host.turn(message)) + .await + .map_err(|_| Error::TimedOut { + seat: self.id.clone(), + })? + .map_err(Error::Harness) + }) + .await } } diff --git a/crates/tinyhivemind-openhuman/src/raw/tools.rs b/crates/tinyhivemind-openhuman/src/raw/tools.rs index 746fdf8c..d9dac85e 100644 --- a/crates/tinyhivemind-openhuman/src/raw/tools.rs +++ b/crates/tinyhivemind-openhuman/src/raw/tools.rs @@ -16,7 +16,7 @@ use tinytools::{PermissionLevel, Tool, ToolResult}; /// The served tools, bound to one seat. #[must_use] -pub(super) fn belt(seat: &str, tools: &Arc) -> Vec> { +pub(crate) fn belt(seat: &str, tools: &Arc) -> Vec> { tool_definitions(&tools.seats()) .into_iter() .map(|definition| { diff --git a/crates/tinyhivemind-openhuman/src/runner/mod.rs b/crates/tinyhivemind-openhuman/src/runner/mod.rs index 33919852..dff554c2 100644 --- a/crates/tinyhivemind-openhuman/src/runner/mod.rs +++ b/crates/tinyhivemind-openhuman/src/runner/mod.rs @@ -48,6 +48,8 @@ pub enum RunnerKind { Embed, /// Raw `OpenHumanSessionHost` sessions, tools in-process. Raw, + /// The host's own agents, tools in-process, seeded from the host's log. + Hosted, } impl RunnerKind { @@ -70,6 +72,7 @@ impl RunnerKind { match value { None | Some("" | "embed") => Ok(Self::Embed), Some("raw") => Ok(Self::Raw), + Some("hosted") => Ok(Self::Hosted), Some(other) => Err(other.to_owned()), } } @@ -80,6 +83,7 @@ impl RunnerKind { match self { Self::Embed => "embed", Self::Raw => "raw", + Self::Hosted => "hosted", } } @@ -92,7 +96,7 @@ impl RunnerKind { "Use `mcp_call_tool` with `server: \"episode\"`; its `arguments` is a JSON \ object, never a string." } - Self::Raw => "Each tool below is yours to call directly, by its name.", + Self::Raw | Self::Hosted => "Each tool below is yours to call directly, by its name.", } } } @@ -117,9 +121,11 @@ pub trait SeatRunner: Send + Sync { /// One binding per seat, canonical id to handle. fn bindings(&self) -> Vec>; - /// Run one turn. The prompt is everything the seat is shown this turn; - /// how the seat holds context between turns is the runner's business. - fn turn(&self, seat: String, lane: Lane, prompt: String) -> TurnJob; + /// Run one turn. The prompt is what the seat is shown this turn; `since` + /// is the newest row it was shown before it, which a runner that seeds + /// from the host's log reads up to. How a seat holds context between + /// turns is the runner's business. + fn turn(&self, seat: String, lane: Lane, since: Sequence, prompt: String) -> TurnJob; /// Open a turn: what the seat may `read`, and the chat and parent every /// call it makes must name. diff --git a/crates/tinyhivemind-openhuman/src/runner/test.rs b/crates/tinyhivemind-openhuman/src/runner/test.rs index eb4d5c8e..582a654a 100644 --- a/crates/tinyhivemind-openhuman/src/runner/test.rs +++ b/crates/tinyhivemind-openhuman/src/runner/test.rs @@ -4,14 +4,47 @@ use std::collections::BTreeMap; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use openhuman_core::agent::OpenHumanSessionHost; use openhuman_embed::{Access, Provider, Runtime, Workspace}; use tinyhivemind::speech::{ToolCall, Utterance}; +use tinyhivemind::{SESSION_WINDOW, Sequence, SessionLog}; use tinyhivemind_driver::standing_contract; use tinyhivemind_tools::{Dispatch, EpisodeTools, SeatEvent, served_specs}; use super::{Lane, RunnerKind, SeatRunner}; -use crate::{EmbedRunner, RawRunner, Route, offline}; +use crate::offline::MemoryLog; +use crate::{ + EmbedRunner, EpisodeBelt, EpisodeHost, HostedRunner, HostedTurn, LibraryHost, RawRunner, Route, + offline, +}; + +/// A host with no agents of its own: its seats are library sessions, its +/// log is in memory, and its wrapper is the core context a library session +/// needs -- which is exactly what a real host installs there. +struct TestHost { + log: MemoryLog, + library: LibraryHost, + prompt: String, + wrapped: AtomicUsize, +} + +impl EpisodeHost for TestHost { + fn log(&self) -> &dyn SessionLog { + &self.log + } + + fn build_seat(&self, seat: &str, belt: EpisodeBelt) -> crate::Result { + let policy = belt.admit(None); + self.library.session(seat, &self.prompt, belt.tools, policy) + } + + fn wrap_turn<'a>(&'a self, _seat: &'a str, turn: HostedTurn<'a>) -> HostedTurn<'a> { + self.wrapped.fetch_add(1, Ordering::SeqCst); + Box::pin(self.library.scope(turn)) + } +} #[test] fn the_runner_is_named_by_the_environment_and_defaults_to_embed() { @@ -21,6 +54,7 @@ fn the_runner_is_named_by_the_environment_and_defaults_to_embed() { assert_eq!(RunnerKind::parse(Some("")), Ok(RunnerKind::Embed)); assert_eq!(RunnerKind::parse(Some("embed")), Ok(RunnerKind::Embed)); assert_eq!(RunnerKind::parse(Some("raw")), Ok(RunnerKind::Raw)); + assert_eq!(RunnerKind::parse(Some("hosted")), Ok(RunnerKind::Hosted)); assert!( RunnerKind::parse(Some("rae")).is_err(), "a typo must not run the default" @@ -42,10 +76,15 @@ fn each_runner_states_its_own_mechanics_and_nothing_else() { assert!(!RunnerKind::Raw.how_to_call().contains("mcp")); assert_eq!(RunnerKind::Embed.name(), "embed"); assert_eq!(RunnerKind::Raw.name(), "raw"); + assert_eq!(RunnerKind::Hosted.name(), "hosted"); + assert_eq!( + RunnerKind::Hosted.how_to_call(), + RunnerKind::Raw.how_to_call() + ); } /// One turn through the seam: open, run, close. -async fn one_turn(runner: &R) -> (String, Vec) { +async fn one_turn(runner: &R, since: Sequence) -> (String, Vec) { let bindings = runner.bindings(); assert_eq!(bindings.len(), 1); assert_eq!(bindings[0].hive_agent_id, "lead"); @@ -59,7 +98,7 @@ async fn one_turn(runner: &R) -> (String, Vec) { }, ); let (seat, lane, reply) = runner - .turn("lead".into(), Lane::Desk, "Your turn.".into()) + .turn("lead".into(), Lane::Desk, since, "Your turn.".into()) .await; assert_eq!(seat, "lead"); assert_eq!(lane, Lane::Desk); @@ -69,7 +108,48 @@ async fn one_turn(runner: &R) -> (String, Vec) { (reply, runner.close("lead")) } -/// Both runners, offline, against one scripted model: the same call lands in +/// A hosted runner over a test host whose log already holds the task. +fn hosted(library: LibraryHost, contract: &str) -> (Arc, HostedRunner) { + assert!(format!("{library:?}").contains(offline::MODEL)); + let log = MemoryLog::new("engineering"); + log.append("operator", "state the root cause", None, None); + let host = Arc::new(TestHost { + log, + library, + prompt: format!("You lead the desk.\n\n{contract}"), + wrapped: AtomicUsize::new(0), + }); + let runner = HostedRunner::seat( + Arc::clone(&host), + Arc::new(EpisodeTools::new(["lead"])), + &["lead".to_owned()], + "engineering", + "Engineering", + SESSION_WINDOW, + ) + .expect("hosted seats"); + assert!(format!("{runner:?}").contains("lead")); + assert!(format!("{:?}", runner.bindings()[0].agent).contains("lead")); + (host, runner) +} + +/// The scripted model's one call, recorded once, as any runner records it. +fn one_completion(name: &str, events: &[SeatEvent]) { + assert_eq!(events.len(), 1, "{name}: one call recorded"); + assert_eq!(events[0].seat, "lead"); + assert_eq!(events[0].dispatch.chat, "engineering"); + assert!( + matches!( + &events[0].call, + ToolCall::Speak(Utterance::CompleteEpisode { message, .. }) + if message == offline::COMPLETION + ), + "{name}: {:?}", + events[0].call + ); +} + +/// Every runner, offline, against one scripted model: the same call lands in /// the record the same way, whichever road it took. One test rather than /// two because the runtime and the definition registry are process-wide, and /// the raw seats must be registered before the runtime boots. @@ -162,34 +242,46 @@ async fn both_runners() { assert!(format!("{raw:?}").contains("lead")); assert!(format!("{embed:?}").contains("lead")); - let (embed_reply, embed_events) = one_turn(&embed).await; - let (raw_reply, raw_events) = one_turn(&raw).await; - for (name, events) in [("embed", &embed_events), ("raw", &raw_events)] { - assert_eq!(events.len(), 1, "{name}: one call recorded"); - assert_eq!(events[0].seat, "lead"); - assert_eq!(events[0].dispatch.chat, "engineering"); - assert!( - matches!( - &events[0].call, - ToolCall::Speak(Utterance::CompleteEpisode { message, .. }) - if message == offline::COMPLETION - ), - "{name}: {:?}", - events[0].call - ); + let library = LibraryHost::boot(&config, &backend.uri(), &route, workspace.path()) + .await + .expect("the library boots"); + let (host, hosted) = hosted(library, &contract(RunnerKind::Hosted)); + + let (embed_reply, embed_events) = one_turn(&embed, Sequence(0)).await; + let (raw_reply, raw_events) = one_turn(&raw, Sequence(0)).await; + // Seeded from the host's log: the operator's row is history, not brief. + let (hosted_reply, hosted_events) = one_turn(&hosted, host.log.latest()).await; + assert_eq!( + host.wrapped.load(Ordering::SeqCst), + 1, + "the host wrapped the turn" + ); + assert!(hosted.usage("lead").is_some(), "the turn's usage is kept"); + assert_eq!(hosted_reply, raw_reply); + for (name, events) in [ + ("embed", &embed_events), + ("raw", &raw_events), + ("hosted", &hosted_events), + ] { + one_completion(name, events); } assert_eq!( embed_reply, raw_reply, "the closing sentence is the model's" ); let seen = metrics.snapshot(); - assert_eq!(seen.round_trips.len(), 2, "one receipted call per runner"); - assert!(seen.requests >= 4, "each turn is a call and a receipt"); + assert_eq!(seen.round_trips.len(), 3, "one receipted call per runner"); + assert!(seen.requests >= 6, "each turn is a call and a receipt"); // A second raw turn is seeded with the first: what the seat said is what // it is shown, and the record starts empty again. - let (_, again) = one_turn(&raw).await; + let (_, again) = one_turn(&raw, Sequence(0)).await; assert_eq!(again.len(), 1); + // A second hosted turn reuses the seat's session: cleared, reseeded, run. + host.log.append("lead", "COMPLETE: done", None, None); + let (_, again) = one_turn(&hosted, host.log.latest()).await; + assert_eq!(again.len(), 1, "the reused session ran and called again"); + assert_eq!(host.wrapped.load(Ordering::SeqCst), 2); metrics.reset(); assert_eq!(metrics.snapshot().requests, 0); } diff --git a/examples/openhuman/README.md b/examples/openhuman/README.md index 05df044e..ac89d2c5 100644 --- a/examples/openhuman/README.md +++ b/examples/openhuman/README.md @@ -47,11 +47,8 @@ corpus and paid campaign described in | `src/main.rs` | OpenHuman runtime/agent construction, route binding, two-surface session proof, and assertions. | | `src/bin/pe1006_hive.rs` | OpenRouter GPT-OSS completion-driven hive with stable OpenHuman sessions and live TypeSafe routing. | | `src/bin/deepswe_hive.rs` | Hermetic four-seat software-engineering hive over a caller-prepared disposable Git checkout. | -| `src/bin/conducted.rs` | A live completion-driven episode: the room's tools served by `tinyhivemind-mcp`, the loop stepped through `CompletionDriver`, a hidden-profile desk of five seats over OpenRouter with live Jev routing, or offline with the raw runner. `CONDUCTED_DESK=login` (default) diagnoses a regression; `CONDUCTED_DESK=triage` hands off three tickets on a budget of two, to fire the budget, the broadcast that completes its author, and the in-thread `ask` refusal. | -| `src/bin/conducted/runner.rs` | The seam: `SeatRunner`, the one trait a way of running seats implements, and `RunnerKind` from `TINYHIVEMIND_RUNNER`. | -| `src/bin/conducted/embed.rs` | The embed runner: `openhuman-embed` agents, one session each, tools over MCP. The default. | -| `src/bin/conducted/raw.rs` | The raw runner: `OpenHumanSessionHost` sessions built per turn, the same tools in-process. | -| `src/bin/conducted/raw/` | The raw seat, its native belt over the shared record, its policy gate and null memory, and the scripted offline model. | +| `src/bin/conducted.rs` | A live completion-driven episode: the loop stepped through the `Conductor`, any of the adapter's three runners, a hidden-profile desk of five seats over OpenRouter with live Jev routing, or offline against the adapter's scripted model. `CONDUCTED_DESK=login` (default) diagnoses a regression; `CONDUCTED_DESK=triage` hands off three tickets on a budget of two, to fire the budget, the broadcast that completes its author, and the in-thread `ask` refusal. | +| `src/bin/conducted/hosted.rs` | This example as an `EpisodeHost`: its journal is the log, a seat is a library session with the episode's belt, and the wrapper is the core context. The runners themselves live in `tinyhivemind-openhuman`. | | `src/bin/conducted/jev.rs` | The live `SystemOneTransport` over `tinyjevclient`, bridged through the wire form. | | `deepswe-sandbox/` | Reproducible local Docker image used for agent shell and test execution. | @@ -63,17 +60,18 @@ called, then append the notes and commits it hands back until the wave settles. The journal, the prompt and the log are the host's; the conversations, nudges, sorting, refusals and walls are the conductor's, in `tinyhivemind-driver`. -How a seat's turn *runs* is behind one seam, `SeatRunner`, with two +How a seat's turn *runs* is behind one seam, `SeatRunner`, with three implementations the loop cannot tell apart: | `TINYHIVEMIND_RUNNER` | Seat | Tools | Context between turns | | --- | --- | --- | --- | | `embed` (default) | an `openhuman-embed` `AgentSpec` agent | the three MCP dispatchers, dialling `tinyhivemind-mcp`'s server | OpenHuman's own session, stable for the episode | -| `raw` | an `OpenHumanSessionHost` built one level down, per turn | the same four tools, in-process, each calling `EpisodeTools::call` | a per-seat log the host seeds the next session with | +| `raw` | an `OpenHumanSessionHost` built one level down, per turn | the same four tools, in-process, each calling `EpisodeTools::call` | a per-seat log the runner keeps | +| `hosted` | the host's own seat, built once per episode through `EpisodeHost` | the same four tools, in-process, admitted over the host's gate | seeded every turn from the host's journal, up to the seat's watermark | -Both runners land every call in the same `EpisodeTools`, so the driver drains +All three land every call in the same `EpisodeTools`, so the driver drains identical events and a seat is refused and acknowledged in the same words -either way. The bound handle differs -- an `Agent` for embed, the raw seat +whichever runs it. The bound handle differs -- an `Agent` for embed, the raw seat itself for raw -- which is what `tinyhivemind-driver`'s `BoundAgent` is for: the driver stores a handle and hands it back, and never runs one. @@ -104,18 +102,19 @@ row. ```sh cargo run --manifest-path examples/openhuman/Cargo.toml --bin conducted TINYHIVEMIND_RUNNER=raw cargo run --manifest-path examples/openhuman/Cargo.toml --bin conducted +TINYHIVEMIND_RUNNER=hosted cargo run --manifest-path examples/openhuman/Cargo.toml --bin conducted ``` -### Benchmarking the two runners +### Benchmarking the runners -`CONDUCTED_BENCH=N` runs both runners offline, `N` episodes each on the +`CONDUCTED_BENCH=N` runs every runner offline, `N` episodes each on the selected desk, and prints one table. The model is scripted, so nothing in it is about answers: every seat completes on its first turn, and what differs between the arms is the host. The arms share one process, so each begins with one episode that is run and not counted, for page faults and a cold allocator; `TINYHIVEMIND_RUNNER` names the arm that goes first (`embed` by -default, `raw` for the `AgentBuilder` sessions), and a difference that -survives both orders is the harness's. +default, `raw` or `hosted` for the native ones), and a difference that +survives every order is the harness's. | Column | What it is | | --- | --- | @@ -138,6 +137,12 @@ Before the warm-up episode, whichever arm ran first reported twice the round trip and wall of the other, and the earlier numbers in #65 read that as the harness's. +Five episodes per arm with hosted added: hosted sends the same 24.4 KiB per +turn as raw, since both hand the tools over natively, with wall about 35 ms +against raw's 33 and embed's 43. Each offline episode is one turn, so hosted +seeding has nothing to read yet; what it costs on a longer episode is the +history it seeds, which the scripted model does not exercise. + The driver's own benchmark, `cargo run --release -p tinyhivemind-driver --example bench`, measures the completion driver's policy with no agent at all and binds plain seats; it says nothing about either runner. diff --git a/examples/openhuman/src/bin/conducted.rs b/examples/openhuman/src/bin/conducted.rs index d3af0dc9..90561f79 100644 --- a/examples/openhuman/src/bin/conducted.rs +++ b/examples/openhuman/src/bin/conducted.rs @@ -9,44 +9,51 @@ //! a journal, sessions, and the prompt a turn is shown. //! //! How a seat's turn *runs* is behind one seam, `tinyhivemind_openhuman::SeatRunner`, -//! with two implementations: `openhuman-embed` agents reaching the tools over -//! MCP (`EmbedRunner`, the default), and raw `OpenHumanSessionHost` -//! sessions handed the same tools natively (`RawRunner`). The loop -//! cannot tell them apart; `TINYHIVEMIND_RUNNER=raw` picks the second. +//! with three implementations: `openhuman-embed` agents reaching the tools +//! over MCP (`EmbedRunner`, the default), raw `OpenHumanSessionHost` sessions +//! handed the same tools natively (`RawRunner`), and the host's own seats +//! seeded from its journal (`HostedRunner`, over `conducted::hosted`). The +//! loop cannot tell them apart; `TINYHIVEMIND_RUNNER=raw` or `=hosted` picks +//! one. //! //! ```sh //! set -a; . ~/.config/tinyhivemind/live.env; set +a //! TINYHIVEMIND_LIVE_OPENROUTER=1 cargo run --manifest-path examples/openhuman/Cargo.toml --bin conducted //! # Offline, either runner runs against a scripted model as a proof of its mechanics: //! TINYHIVEMIND_RUNNER=raw cargo run --manifest-path examples/openhuman/Cargo.toml --bin conducted -//! # And both, N episodes each, as one table of what the harness costs: +//! TINYHIVEMIND_RUNNER=hosted cargo run --manifest-path examples/openhuman/Cargo.toml --bin conducted +//! # And all three, N episodes each, as one table of what the harness costs: //! CONDUCTED_BENCH=5 cargo run --release --manifest-path examples/openhuman/Cargo.toml --bin conducted //! ``` mod conducted { + pub mod hosted; pub mod jev; } use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use conducted::hosted::DeskHost; use conducted::jev::LiveJev; use openhuman_embed::{Access, Provider, Runtime, RuntimeConfig, Workspace}; +use tinyhivemind::SESSION_WINDOW; use tinyhivemind::desk::{Desk, ResponderMode}; use tinyhivemind::responder::Probability; use tinyhivemind::speech::Utterance; -use tinyhivemind::Sequence; -use tinyhivemind_embed::{ - ConversationKind, ConversationRef, RouteCandidate, Router, RouterFuture, RoutingPlan, - RoutingPolicy, RoutingRequest, RoutingSource, route_message, -}; use tinyhivemind_driver::{ BoundHive, BroadcastRouting, CompletionDriver, ConductPolicy, Conductor, Door, Event, HiveGraph, Refusal, Step, standing_contract, }; +use tinyhivemind_embed::{ + ConversationKind, ConversationRef, RouteCandidate, Router, RouterFuture, RoutingPlan, + RoutingPolicy, RoutingRequest, RoutingSource, route_message, +}; +use tinyhivemind_openhuman::offline::MemoryLog; use tinyhivemind_openhuman::{ - EmbedRunner, Lane, RawRunner, Route, RunnerKind, SeatRunner, TurnJob, offline, + EmbedRunner, HostedRunner, Lane, LibraryHost, RawRunner, Route, RunnerKind, SeatRunner, + TurnJob, offline, }; use tinyhivemind_tools::{Dispatch, EpisodeTools}; use tinyhivemind_typesafe::JevRouter; @@ -204,90 +211,6 @@ fn main() -> anyhow::Result<()> { .block_on(run()) } -/// One desk row. The host owns the journal; this one is in memory. -#[derive(Clone, Debug)] -struct Row { - sequence: Sequence, - author: String, - body: String, - /// `None` is the open desk; `Some(root)` is the conversation rooted at - /// that ask, which only its two seats read. - thread: Option, - /// On the open desk, a private row reaches one seat. - only_for: Option, -} - -#[derive(Default)] -struct Journal { - rows: Mutex>, -} - -impl Journal { - fn append( - &self, - author: &str, - body: &str, - thread: Option, - only_for: Option<&str>, - ) -> Sequence { - let mut rows = self.rows.lock().expect("journal is not poisoned"); - let sequence = Sequence(rows.last().map_or(0, |row| row.sequence.0) + 1); - rows.push(Row { - sequence, - author: author.to_owned(), - body: body.to_owned(), - thread, - only_for: only_for.map(str::to_owned), - }); - sequence - } - - fn latest(&self) -> Sequence { - self.rows - .lock() - .expect("journal is not poisoned") - .last() - .map_or(Sequence(0), |row| row.sequence) - } - - /// What `seat` may read on the open desk above `after`: desk rows, and - /// private rows addressed to it. - fn desk_since(&self, seat: &str, after: Sequence) -> Vec { - self.rows - .lock() - .expect("journal is not poisoned") - .iter() - .filter(|row| row.sequence > after && row.thread.is_none()) - .filter(|row| row.only_for.as_deref().is_none_or(|only| only == seat)) - .map(render) - .collect() - } - - /// One conversation, whole: the ask that rooted it and every row in it. - fn thread(&self, root: Sequence) -> Vec { - self.thread_since(root, Sequence(0)) - } - - fn thread_since(&self, root: Sequence, after: Sequence) -> Vec { - self.rows - .lock() - .expect("journal is not poisoned") - .iter() - .filter(|row| row.sequence > after) - .filter(|row| row.sequence == root || row.thread == Some(root)) - .map(render) - .collect() - } - - fn all(&self) -> Vec { - self.rows.lock().expect("journal is not poisoned").clone() - } -} - -fn render(row: &Row) -> String { - format!("@{}: {}", row.author, row.body) -} - /// A router that counts its calls: the provider bill, one line. struct Counted { inner: R, @@ -398,16 +321,22 @@ async fn run() -> anyhow::Result<()> { Some(episodes) => bench_runners(&host, kind, episodes, &metrics).await, None => { println!("[runner] {}", kind.name()); + let journal = Arc::new(MemoryLog::new(desk_id)); let report = match kind { RunnerKind::Embed => { let runtime = host.runtime().await?; let runner = host.embed(&runtime, 0).await?; - episode(runner, host.setup(kind, false)).await? + episode(runner, host.setup(kind, false), journal).await? } RunnerKind::Raw => { host.prepare_raw()?; let runner = host.raw(0).await?; - episode(runner, host.setup(kind, false)).await? + episode(runner, host.setup(kind, false), journal).await? + } + RunnerKind::Hosted => { + host.prepare_raw()?; + let runner = host.hosted(&journal).await?; + episode(runner, host.setup(kind, false), journal).await? } }; let _ = report; @@ -521,9 +450,37 @@ impl Host { eprintln!("[route] chat resolves to model={}", runner.model()); Ok(runner) } + + /// Seat the hosted runner over `journal`, which is also the log the + /// episode appends to. Its seats are registered by `prepare_raw`, the + /// same definitions the raw seats resolve. + async fn hosted(&self, journal: &Arc) -> anyhow::Result> { + let library = LibraryHost::boot( + &self.config, + &self.backend_url, + &self.route, + &self.workspace, + ) + .await?; + let contract = self.contract(RunnerKind::Hosted); + let prompts = self + .briefs + .iter() + .map(|(id, brief)| (id.clone(), format!("{brief}\n\n{contract}"))) + .collect(); + let host = Arc::new(DeskHost::new(Arc::clone(journal), library, prompts)); + Ok(HostedRunner::seat( + host, + Arc::new(EpisodeTools::new(self.ids.iter().cloned())), + &self.ids, + self.scenario.id, + self.scenario.name, + SESSION_WINDOW, + )?) + } } -/// Both runners, `episodes` times each, offline, and one table. +/// Every runner, `episodes` times each, offline, and one table. /// /// The model is scripted, so nothing here is about answers: every seat /// completes on its first turn. What differs between the arms is the host -- @@ -531,8 +488,8 @@ impl Host { /// sent to the model -- and that is what the columns are. /// /// `first` runs first. The arms share one process, so each begins with an -/// episode that is run and not counted; `TINYHIVEMIND_RUNNER=raw` puts the -/// raw arm first, and a difference that survives both orders is the +/// episode that is run and not counted; `TINYHIVEMIND_RUNNER` names the arm +/// that goes first, and a difference that survives every order is the /// harness's. async fn bench_runners( host: &Host, @@ -550,19 +507,31 @@ async fn bench_runners( // boots, and a definition written after that is never seen. host.prepare_raw()?; let runtime = host.runtime().await?; - let order = match first { - RunnerKind::Embed => [RunnerKind::Embed, RunnerKind::Raw], - RunnerKind::Raw => [RunnerKind::Raw, RunnerKind::Embed], - }; + let order: Vec = std::iter::once(first) + .chain( + [RunnerKind::Embed, RunnerKind::Raw, RunnerKind::Hosted] + .into_iter() + .filter(|kind| *kind != first), + ) + .collect(); for kind in order { println!("[bench] {} x{episodes}", kind.name()); let runtime = &runtime; let run = move |index: u32| async move { + let journal = Arc::new(MemoryLog::new(host.scenario.id)); match kind { RunnerKind::Embed => { - episode(host.embed(runtime, index).await?, host.setup(kind, true)).await + let runner = host.embed(runtime, index).await?; + episode(runner, host.setup(kind, true), journal).await + } + RunnerKind::Raw => { + let runner = host.raw(index).await?; + episode(runner, host.setup(kind, true), journal).await + } + RunnerKind::Hosted => { + let runner = host.hosted(&journal).await?; + episode(runner, host.setup(kind, true), journal).await } - RunnerKind::Raw => episode(host.raw(index).await?, host.setup(kind, true)).await, } }; // One episode nobody counts: the first turn through either harness @@ -667,7 +636,11 @@ struct Report { /// only what a host owns: the journal, the prompt, running a turn, and the /// log. The rules -- conversations, nudges, what a wave said and where it /// goes, refusals, walls -- are the conductor's. -async fn episode(runner: R, setup: Setup) -> anyhow::Result { +async fn episode( + runner: R, + setup: Setup, + journal: Arc, +) -> anyhow::Result { let Setup { scenario, kind, @@ -713,7 +686,6 @@ async fn episode(runner: R, setup: Setup) -> anyhow::Result = router.as_ref().map(|r| r as &(dyn Router + '_)); - let journal = Journal::default(); let opened_at = journal.append("operator", scenario.task, None, None); // The door route: who starts. @@ -793,9 +765,8 @@ async fn episode(runner: R, setup: Setup) -> anyhow::Result(runner: R, setup: Setup) -> anyhow::Result(runner: R, setup: Setup) -> anyhow::Result "`mcp_call_tool`", RunnerKind::Raw => "native", + RunnerKind::Hosted => "hosted native", } ); } @@ -921,7 +893,7 @@ async fn episode(runner: R, setup: Setup) -> anyhow::Result { journal.append("desk", ¬e.body, note.thread, note.only_for.as_deref()); @@ -945,7 +917,9 @@ fn log(event: &Event) { Event::Unplaced { seat, .. } => { println!("[unplaced] @{seat}'s broadcast fits no seat; it keeps the work"); } - Event::CompletedByBroadcast { seat, .. } => eprintln!("[completed] @{seat} by its broadcast"), + Event::CompletedByBroadcast { seat, .. } => { + eprintln!("[completed] @{seat} by its broadcast") + } Event::Asked { seat, askee, root } => println!( "[ask] @{seat} opened a conversation with @{askee} (thread {})", root.0 diff --git a/examples/openhuman/src/bin/conducted/hosted.rs b/examples/openhuman/src/bin/conducted/hosted.rs new file mode 100644 index 00000000..9ba06a26 --- /dev/null +++ b/examples/openhuman/src/bin/conducted/hosted.rs @@ -0,0 +1,59 @@ +//! The host the hosted runner asks for, as this example is one. +//! +//! A real host -- OpenCompany -- answers these three with its journal, the +//! agents it already builds, and the task-locals its tools read. This one +//! has no agents of its own, so a seat is a library session carrying only +//! the episode's tools, and the wrapper is the core context such a session +//! runs under. The log is the same journal the episode loop appends to. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use openhuman_core::agent::OpenHumanSessionHost; +use tinyhivemind::SessionLog; +use tinyhivemind_openhuman::offline::MemoryLog; +use tinyhivemind_openhuman::{EpisodeBelt, EpisodeHost, HostedTurn, LibraryHost}; + +/// This example's desk, as a host. +pub struct DeskHost { + log: Arc, + library: LibraryHost, + /// Each seat's standing prompt: its brief and the contract. + prompts: BTreeMap, +} + +impl DeskHost { + pub fn new( + log: Arc, + library: LibraryHost, + prompts: BTreeMap, + ) -> Self { + Self { + log, + library, + prompts, + } + } +} + +impl EpisodeHost for DeskHost { + fn log(&self) -> &dyn SessionLog { + &*self.log + } + + fn build_seat( + &self, + seat: &str, + belt: EpisodeBelt, + ) -> tinyhivemind_openhuman::Result { + // No tools of its own, so no gate of its own: the episode's tools + // are admitted and everything else is denied. + let gate = belt.admit(None); + self.library + .session(seat, &self.prompts[seat], belt.tools, gate) + } + + fn wrap_turn<'a>(&'a self, _seat: &'a str, turn: HostedTurn<'a>) -> HostedTurn<'a> { + Box::pin(self.library.scope(turn)) + } +} From 2f491e9ed1867e1e616d83bfad4a87685f3038fd Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 22 Sep 2026 22:24:16 +0530 Subject: [PATCH 5/7] Let a host prefix the episode's tools and act after each turn Admission is by name, so a host tool sharing a bare name -- read is the likely one -- would be admitted past the host's gate. EpisodeHost now gives a prefix: the belt is named with it, the gate admits the prefixed names, and the record is called by the served name. The definition a seat is registered under must name the prefixed tools, since the hosted turn's allowlist comes from there; register_seats takes the names to declare, and prepare names the served belt through it. A hosted turn returns only the reply, so the host had nowhere to park what the turn left waiting or meter its spend. after_turn runs once the turn has run, with its usage, and an error from it is the turn's error. Co-Authored-By: Claude Fable 5.1 --- crates/tinyhivemind-openhuman/README.md | 8 +- crates/tinyhivemind-openhuman/src/README.md | 2 +- .../src/hosted/README.md | 20 ++- .../tinyhivemind-openhuman/src/hosted/mod.rs | 65 +++++++-- .../tinyhivemind-openhuman/src/hosted/test.rs | 63 +++++++- crates/tinyhivemind-openhuman/src/lib.rs | 2 +- .../src/offline/README.md | 3 +- .../tinyhivemind-openhuman/src/offline/mod.rs | 13 +- .../src/offline/test.rs | 7 +- .../tinyhivemind-openhuman/src/raw/README.md | 2 +- crates/tinyhivemind-openhuman/src/raw/mod.rs | 76 ++++++---- .../tinyhivemind-openhuman/src/raw/tools.rs | 27 +++- .../tinyhivemind-openhuman/src/runner/test.rs | 135 ++++++++++++++---- 13 files changed, 338 insertions(+), 85 deletions(-) diff --git a/crates/tinyhivemind-openhuman/README.md b/crates/tinyhivemind-openhuman/README.md index 54ee96ed..7cd6988c 100644 --- a/crates/tinyhivemind-openhuman/README.md +++ b/crates/tinyhivemind-openhuman/README.md @@ -21,9 +21,11 @@ host, through `EpisodeHost`, for three things: its log, a seat built with the episode's belt, and a wrapper around each turn. `OpenHuman` fixes a session's belt when it is built, so the host builds each seat once per episode, and the runner reuses it: each turn it clears the session, seeds it from the host's -log up to the seat's watermark, runs the brief, and keeps the turn's usage. -Nothing about the host's agent -- model, tools, gate, memory, prompt -- is -re-expressed here. `RunnerKind` +log up to the seat's watermark, runs the brief, keeps the turn's usage, and +hands it to the host's after-turn hook, where approvals are parked and spend +is metered. A host with tools of its own prefixes the episode's, so none +shares a name with its own and is admitted past its gate. Nothing about the +host's agent -- model, tools, gate, memory, prompt -- is re-expressed here. `RunnerKind` names one, from `TINYHIVEMIND_RUNNER` or directly. The raw runner also carries the two things the current OpenHuman asks of a diff --git a/crates/tinyhivemind-openhuman/src/README.md b/crates/tinyhivemind-openhuman/src/README.md index 1745a734..ee8850ad 100644 --- a/crates/tinyhivemind-openhuman/src/README.md +++ b/crates/tinyhivemind-openhuman/src/README.md @@ -2,7 +2,7 @@ | Path | Purpose | |---|---| -| `lib.rs` | Crate overview and the public surface: `SeatRunner`, `RunnerKind`, `HostedRunner`, `EpisodeHost`, `EpisodeBelt`, `EmbedRunner`, `EmbedSeat`, `RawRunner`, `RawSeat`, `LibraryHost`, `Route`, `offline`. | +| `lib.rs` | Crate overview and the public surface: `SeatRunner`, `RunnerKind`, `HostedRunner`, `EpisodeHost`, `EpisodeBelt`, `EmbedRunner`, `EmbedSeat`, `RawRunner`, `RawSeat`, `LibraryHost`, `Route`, `register_seats`, `offline`. | | `error/` | What seating or running a seat can fail with. | | `runner/` | The seam: open, run, close; `Lane`, `TurnJob`; which runner the environment names. | | `hosted/` | Seats as the host's own agents, built through `EpisodeHost`, seeded from the host's log. | diff --git a/crates/tinyhivemind-openhuman/src/hosted/README.md b/crates/tinyhivemind-openhuman/src/hosted/README.md index 79bb9a96..49895d45 100644 --- a/crates/tinyhivemind-openhuman/src/hosted/README.md +++ b/crates/tinyhivemind-openhuman/src/hosted/README.md @@ -14,11 +14,25 @@ and a row a peer wrote in the same wave reaches it through neither. `EpisodeBelt::admit` wraps the host's own gate: the episode's tools are admitted, everything else is the host's gate's to decide, and with no host -gate everything else is denied. +gate everything else is denied. Admission is by name, so a host with tools +of its own gives `tool_prefix` -- `desk_` makes `read` into `desk_read` -- +and no tool of its own can share a name with an episode tool and be admitted +past its gate. The record is called by the served name either way. The +definition a host registers for a seat must name the prefixed tools, since +the hosted turn's allowlist comes from there (`register_seats`). + +The host owns the log: `log()` borrows a `SessionLog` the host holds, over +its own journal, and the runner never keeps rows of its own. + +`after_turn` runs once a turn has run, with the usage the session reported. +It is where a host parks what the turn left waiting on approval, meters the +spend, and halts the episode: an error from it is the turn's error, seen by +the host loop as any failed turn is, with the calls the turn made before it +already in the record. | file | holds | | --- | --- | -| `mod.rs` | `EpisodeHost`, `HostedTurn`, `EpisodeBelt`, `HostedSeat`, `HostedRunner` | +| `mod.rs` | `EpisodeHost` with its prefix and after-turn hook, `HostedTurn`, `EpisodeBelt`, `HostedSeat`, `HostedRunner` | | `seed.rs` | a seat's history from the host's log, as `(role, content)` pairs | | `admission.rs` | the gate that admits the episode's tools over the host's | -| `test.rs` | seeding, withholding, the watermark, a thread, the memory log's pages, the belt and its gate | +| `test.rs` | seeding, withholding, the watermark, a thread, the memory log's pages, the belt and its gate, a prefixed belt | diff --git a/crates/tinyhivemind-openhuman/src/hosted/mod.rs b/crates/tinyhivemind-openhuman/src/hosted/mod.rs index 4d829361..e56f03ba 100644 --- a/crates/tinyhivemind-openhuman/src/hosted/mod.rs +++ b/crates/tinyhivemind-openhuman/src/hosted/mod.rs @@ -16,9 +16,16 @@ //! tools and gate read while a turn runs -- a turn-scoped approval queue, a //! delegation context, a core context. //! +//! And two things it may give: a **prefix** for the episode tools' names, +//! so none can share a name with a tool of its own and be admitted past its +//! gate; and an **after-turn hook**, handed the turn's usage, which is where +//! a host parks what the turn left waiting, meters its spend, and halts the +//! episode by returning an error. +//! //! A turn, then: clear the seat's session, seed it with what the seat was -//! shown up to its watermark, send the brief, and record the usage. The -//! calls it made land in the shared record like any other runner's. +//! shown up to its watermark, send the brief inside the wrapper, record the +//! usage, and call the hook. The calls it made land in the shared record +//! like any other runner's. mod admission; mod seed; @@ -38,7 +45,7 @@ use tinyhivemind_driver::{AgentBinding, BoundAgent}; use tinyhivemind_tools::EpisodeTools; use tinytools::Tool; -use crate::raw::tools::belt; +use crate::raw::tools::belt_with_prefix; use crate::runner::{Lane, SeatRunner, TURN_TIMEOUT, TurnJob}; use crate::{Error, Result}; use admission::Admission; @@ -67,6 +74,34 @@ pub trait EpisodeHost: Send + Sync + 'static { let _ = seat; turn } + + /// What the episode's tools are called, in front of their served names: + /// `desk_` makes `read` into `desk_read`. The record is called by the + /// served name either way. The default is no prefix. + /// + /// A host with tools of its own gives one, because [`EpisodeBelt::admit`] + /// admits by name and a host tool sharing a bare name would be admitted + /// past the host's gate. The brief and the desk's notes name the served + /// vocabulary, so a host that prefixes says so in its own prompt. + fn tool_prefix(&self) -> String { + String::new() + } + + /// After a turn ran, with its usage when the session reported any. The + /// default does nothing. + /// + /// This is where a host parks what the turn left waiting on approval, + /// meters the spend, and decides whether the episode goes on: an error + /// here is the turn's error, and the host loop treats it as it treats + /// any failed turn. + /// + /// # Errors + /// + /// Whatever stops the episode. + fn after_turn(&self, seat: &str, usage: Option<&LastTurnUsage>) -> Result<()> { + let _ = (seat, usage); + Ok(()) + } } /// The episode's tools for one seat, and the gate that admits them. @@ -85,14 +120,14 @@ impl std::fmt::Debug for EpisodeBelt { } impl EpisodeBelt { - fn new(seat: &str, tools: &Arc) -> Self { - let tools = belt(seat, tools); + fn new(seat: &str, tools: &Arc, prefix: &str) -> Self { + let tools = belt_with_prefix(seat, tools, prefix); let names = tools.iter().map(|tool| tool.name().to_owned()).collect(); Self { tools, names } } - /// The episode tools' names, for a host that registers a seat's belt by - /// name. + /// The episode tools' names as the model calls them, prefixed, for a + /// host that registers a seat's belt by name. #[must_use] pub fn names(&self) -> &[String] { &self.names @@ -167,7 +202,8 @@ impl HostedRunner { ) -> Result { let mut built = BTreeMap::new(); for id in seats { - let session = host.build_seat(id, EpisodeBelt::new(id, &tools))?; + let belt = EpisodeBelt::new(id, &tools, &host.tool_prefix()); + let session = host.build_seat(id, belt)?; built.insert( id.clone(), HostedSeat { @@ -233,6 +269,7 @@ impl SeatRunner for HostedRunner { let run = { let seat = seat.clone(); let host = Arc::clone(&host); + let usage = Arc::clone(&usage); async move { let history = seed::history(host.log(), conversation, &seat, since, window).await?; @@ -258,7 +295,17 @@ impl SeatRunner for HostedRunner { Ok(reply) } }; - let result = host.wrap_turn(&seat, Box::pin(run)).await; + let result = match host.wrap_turn(&seat, Box::pin(run)).await { + Ok(reply) => { + let last = usage + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(&seat) + .cloned(); + host.after_turn(&seat, last.as_ref()).map(|()| reply) + } + Err(error) => Err(error), + }; (seat, lane, Some(result.map_err(|error| error.to_string()))) }) } diff --git a/crates/tinyhivemind-openhuman/src/hosted/test.rs b/crates/tinyhivemind-openhuman/src/hosted/test.rs index 9ffee76e..b6ef5e02 100644 --- a/crates/tinyhivemind-openhuman/src/hosted/test.rs +++ b/crates/tinyhivemind-openhuman/src/hosted/test.rs @@ -12,7 +12,7 @@ use openhuman_core::agent::tool_policy::{ }; use serde_json::json; use tinyhivemind::{Conversation, Sequence, SessionLog}; -use tinyhivemind_tools::{EpisodeTools, served_specs}; +use tinyhivemind_tools::{Dispatch, EpisodeTools, served_specs}; use super::EpisodeBelt; use super::seed::history; @@ -174,7 +174,7 @@ impl ToolPolicy for AllowAll { #[tokio::test] async fn the_belt_is_the_served_vocabulary_admitted_over_the_hosts_own_gate() { let tools = Arc::new(EpisodeTools::new(["lead"])); - let belt = EpisodeBelt::new("lead", &tools); + let belt = EpisodeBelt::new("lead", &tools, ""); let served: Vec<&str> = served_specs().map(|spec| spec.name).collect(); assert_eq!(belt.names(), served.as_slice()); assert_eq!(belt.tools.len(), served.len()); @@ -204,3 +204,62 @@ async fn the_belt_is_the_served_vocabulary_admitted_over_the_hosts_own_gate() { "the host's own tools are the host's gate's to decide" ); } + +#[tokio::test] +async fn a_prefixed_belt_is_called_by_the_prefixed_name_and_records_the_served_one() { + let tools = Arc::new(EpisodeTools::new(["lead"])); + tools.register( + "lead", + Dispatch { + chat: "engineering".into(), + parent: None, + }, + ); + let belt = EpisodeBelt::new("lead", &tools, "desk_"); + let names: Vec<&str> = belt.tools.iter().map(|tool| tool.name()).collect(); + assert!(names.contains(&"desk_complete_episode"), "{names:?}"); + assert!(names.contains(&"desk_read"), "{names:?}"); + assert!(!names.contains(&"read"), "the bare name is not on the belt"); + assert!(belt.names().iter().all(|name| name.starts_with("desk_"))); + // `read` is still the one read-only tool, by its served name. + let read = belt + .tools + .iter() + .find(|tool| tool.name() == "desk_read") + .expect("served"); + assert_eq!( + read.permission_level(), + tinytools::PermissionLevel::ReadOnly + ); + // The gate admits the prefixed name, and a host tool of the bare name + // is somebody else's: without a host gate, it is denied. + let gate = belt.admit(None); + assert!(matches!( + gate.check(&request("desk_read")).await, + ToolPolicyDecision::Allow + )); + assert!(matches!( + gate.check(&request("read")).await, + ToolPolicyDecision::Deny { .. } + )); + // A call by the prefixed name lands in the record under the served one. + let complete = belt + .tools + .iter() + .find(|tool| tool.name() == "desk_complete_episode") + .expect("served"); + let accepted = complete + .execute(json!({"message": "done", "chat": "engineering", "parent": null})) + .await + .expect("executes"); + assert!(!accepted.is_error); + tools.clear("lead"); + let events = tools.drain("lead"); + assert_eq!(events.len(), 1); + assert!(matches!( + events[0].call, + tinyhivemind::speech::ToolCall::Speak( + tinyhivemind::speech::Utterance::CompleteEpisode { .. } + ) + )); +} diff --git a/crates/tinyhivemind-openhuman/src/lib.rs b/crates/tinyhivemind-openhuman/src/lib.rs index 015ee29a..cae3df04 100644 --- a/crates/tinyhivemind-openhuman/src/lib.rs +++ b/crates/tinyhivemind-openhuman/src/lib.rs @@ -105,5 +105,5 @@ pub mod runner; pub use embed::{EmbedRunner, EmbedSeat}; pub use error::{Error, Result}; pub use hosted::{EpisodeBelt, EpisodeHost, HostedRunner, HostedSeat, HostedTurn}; -pub use raw::{LibraryHost, RawRunner, RawSeat, Route}; +pub use raw::{LibraryHost, RawRunner, RawSeat, Route, register_seats}; pub use runner::{Lane, RunnerKind, SeatRunner, TURN_TIMEOUT, TurnJob, TurnResult}; diff --git a/crates/tinyhivemind-openhuman/src/offline/README.md b/crates/tinyhivemind-openhuman/src/offline/README.md index bf1464c6..9fec4486 100644 --- a/crates/tinyhivemind-openhuman/src/offline/README.md +++ b/crates/tinyhivemind-openhuman/src/offline/README.md @@ -2,7 +2,8 @@ A scripted OpenAI-compatible model on loopback that answers every seat with one `complete_episode` call, in whichever dialect the request advertises -- -native for a raw session, `mcp_call_tool` for an embed agent -- and a closing +native for a raw or hosted session, by whatever name the belt advertises +the tool under, `mcp_call_tool` for an embed agent -- and a closing sentence once it sees the receipt. `Metrics` is what it saw: request bytes, and the time from a call to its receipt. `config()` is the runtime config an offline run boots with and `backend()` the stub for the core's non-inference diff --git a/crates/tinyhivemind-openhuman/src/offline/mod.rs b/crates/tinyhivemind-openhuman/src/offline/mod.rs index 713367a0..853bd48d 100644 --- a/crates/tinyhivemind-openhuman/src/offline/mod.rs +++ b/crates/tinyhivemind-openhuman/src/offline/mod.rs @@ -103,8 +103,9 @@ impl Metrics { /// Which way the request lets the model call the room's tools. #[derive(Debug, PartialEq, Eq)] enum Dialect { - /// The room's tools are the request's own: call `complete_episode`. - Native, + /// The room's tools are the request's own: call `complete_episode`, by + /// whatever name the belt advertises it under -- a host may prefix it. + Native(String), /// `OpenHuman`'s dispatchers are: call `mcp_call_tool` on `episode`. Mcp, /// No tool at all: a session with no belt, such as an answer to an ask. @@ -125,8 +126,8 @@ fn dialect(body: &Value) -> Dialect { .collect() }) .unwrap_or_default(); - if names.contains(&"complete_episode") { - Dialect::Native + if let Some(name) = names.iter().find(|name| name.ends_with("complete_episode")) { + Dialect::Native((*name).to_owned()) } else if names.contains(&"mcp_call_tool") { Dialect::Mcp } else { @@ -159,9 +160,9 @@ impl Respond for ScriptedModel { None } else { match dialect(&body) { - Dialect::Native => Some(("complete_episode", arguments)), + Dialect::Native(name) => Some((name, arguments)), Dialect::Mcp => Some(( - "mcp_call_tool", + "mcp_call_tool".to_owned(), json!({ "server": "episode", "tool": "complete_episode", diff --git a/crates/tinyhivemind-openhuman/src/offline/test.rs b/crates/tinyhivemind-openhuman/src/offline/test.rs index d67445fd..97bc8e7e 100644 --- a/crates/tinyhivemind-openhuman/src/offline/test.rs +++ b/crates/tinyhivemind-openhuman/src/offline/test.rs @@ -23,7 +23,12 @@ fn request(body: &serde_json::Value) -> Request { fn the_dialect_is_read_from_the_tools_the_request_advertises() { assert_eq!( dialect(&json!({"tools": [{"function": {"name": "complete_episode"}}]})), - Dialect::Native + Dialect::Native("complete_episode".into()) + ); + assert_eq!( + dialect(&json!({"tools": [{"function": {"name": "desk_complete_episode"}}]})), + Dialect::Native("desk_complete_episode".into()), + "a host's prefix is called by" ); assert_eq!( dialect(&json!({"tools": [{"name": "mcp_call_tool"}]})), diff --git a/crates/tinyhivemind-openhuman/src/raw/README.md b/crates/tinyhivemind-openhuman/src/raw/README.md index 35a6ad17..11ec4607 100644 --- a/crates/tinyhivemind-openhuman/src/raw/README.md +++ b/crates/tinyhivemind-openhuman/src/raw/README.md @@ -12,7 +12,7 @@ builds its seats. | file | holds | | --- | --- | -| `mod.rs` | `RawRunner`, `Route`, `prepare`, `seat`, the per-seat context log | +| `mod.rs` | `RawRunner`, `Route`, `register_seats` (and `prepare`, which names the served belt), `seat`, the per-seat context log | | `library.rs` | `LibraryHost`: the library-host core, its sessions, and its scope | | `seat.rs` | `RawSeat`: one session built, seeded, run and dropped | | `tools.rs` | the served definitions as `tinytools::Tool`s whose execute is `EpisodeTools::call` | diff --git a/crates/tinyhivemind-openhuman/src/raw/mod.rs b/crates/tinyhivemind-openhuman/src/raw/mod.rs index 619d0470..9ef808ee 100644 --- a/crates/tinyhivemind-openhuman/src/raw/mod.rs +++ b/crates/tinyhivemind-openhuman/src/raw/mod.rs @@ -63,6 +63,49 @@ pub use seat::RawSeat; /// What each seat has been shown and said, keyed by seat. type Contexts = Arc>>>; +/// Register every seat as a workspace definition naming `tools` as its +/// belt, before the process registry is read. +/// +/// A session's turn runs as a hosted root invocation, which resolves the +/// seat against `OpenHuman`'s process registry and takes the model's +/// allowlist from the seat's *definition*, not from the belt the session was +/// built with: a tool the definition does not name is stripped before the +/// model sees it, and a wildcard projects to nothing. So `tools` must name +/// every tool the seat will be handed, as the model calls them -- for a host +/// that prefixes the episode's tools, the prefixed names, alongside its own. +/// +/// The loader wants `id`, `when_to_use` and a non-empty `system_prompt`; the +/// prompt written here is the seat's role for a reader of the workspace, not +/// the one a session runs under. The registry is process-wide, so a host +/// seating more than one desk in one process names its seats apart. +/// +/// # Errors +/// +/// The directory or a file failing to write, or the registry refusing the +/// definitions. +pub fn register_seats(workspace: &Path, seats: &[(&str, &str)], tools: &[String]) -> Result<()> { + let agents = workspace.join("agents"); + std::fs::create_dir_all(&agents)?; + let named: Vec = tools.iter().map(|name| format!("{name:?}")).collect(); + for (id, role) in seats { + let toml = format!( + "id = {id:?}\nwhen_to_use = {role:?}\nsystem_prompt = {{ inline = {role:?} }}\ntools = {{ named = [{}] }}\n", + named.join(", ") + ); + std::fs::write(agents.join(format!("{id}.toml")), toml)?; + } + AgentDefinitionRegistry::init_global(workspace)?; + let registry = AgentDefinitionRegistry::global().ok_or(Error::RegistryMissing)?; + for (id, _) in seats { + if registry.get(id).is_none() { + return Err(Error::SeatNotRegistered { + seat: (*id).to_owned(), + }); + } + } + Ok(()) +} + /// Where a run's inference comes from, as the raw session needs it: the /// embed runtime applies its route per call, a raw session resolves the /// `chat` role from its config, so the route is written into that config. @@ -88,41 +131,20 @@ pub struct RawRunner { } impl RawRunner { - /// Register every seat as a workspace definition, before the runtime - /// boots and the process registry is read. - /// - /// The loader wants `id`, `when_to_use` and a non-empty `system_prompt`; - /// the prompt written here is the seat's role for a reader of the - /// workspace, not the one a session runs under. `tools` is the served - /// belt by name: the hosted turn's allowlist comes from here. + /// Register every seat as a workspace definition naming the served belt, + /// before the runtime boots and the process registry is read. See + /// [`register_seats`] for why, and for a host whose belt is named + /// otherwise. /// /// # Errors /// /// The directory or a file failing to write, or the registry refusing the /// definitions. pub fn prepare(workspace: &Path, seats: &[(&str, &str)]) -> Result<()> { - let agents = workspace.join("agents"); - std::fs::create_dir_all(&agents)?; let belt: Vec = tinyhivemind_tools::served_specs() - .map(|spec| format!("{:?}", spec.name)) + .map(|spec| spec.name.to_owned()) .collect(); - for (id, role) in seats { - let toml = format!( - "id = {id:?}\nwhen_to_use = {role:?}\nsystem_prompt = {{ inline = {role:?} }}\ntools = {{ named = [{}] }}\n", - belt.join(", ") - ); - std::fs::write(agents.join(format!("{id}.toml")), toml)?; - } - AgentDefinitionRegistry::init_global(workspace)?; - let registry = AgentDefinitionRegistry::global().ok_or(Error::RegistryMissing)?; - for (id, _) in seats { - if registry.get(id).is_none() { - return Err(Error::SeatNotRegistered { - seat: (*id).to_owned(), - }); - } - } - Ok(()) + register_seats(workspace, seats, &belt) } /// Seat every brief as a raw seat over one resolved config. diff --git a/crates/tinyhivemind-openhuman/src/raw/tools.rs b/crates/tinyhivemind-openhuman/src/raw/tools.rs index d9dac85e..c839d612 100644 --- a/crates/tinyhivemind-openhuman/src/raw/tools.rs +++ b/crates/tinyhivemind-openhuman/src/raw/tools.rs @@ -17,12 +17,30 @@ use tinytools::{PermissionLevel, Tool, ToolResult}; /// The served tools, bound to one seat. #[must_use] pub(crate) fn belt(seat: &str, tools: &Arc) -> Vec> { + belt_with_prefix(seat, tools, "") +} + +/// The served tools, bound to one seat, each named with `prefix` in front +/// of its served name. +/// +/// The prefix is what the model sees and what a gate admits; the record is +/// called by the served name, so `interpret` and the refusals are unchanged. +/// A host whose own belt could share a bare name -- `read` is the likely +/// one -- keeps the two apart with it. +#[must_use] +pub(crate) fn belt_with_prefix( + seat: &str, + tools: &Arc, + prefix: &str, +) -> Vec> { tool_definitions(&tools.seats()) .into_iter() .map(|definition| { + let served = text(&definition, "name"); Box::new(EpisodeTool { seat: seat.to_owned(), - name: text(&definition, "name"), + name: format!("{prefix}{served}"), + served, description: text(&definition, "description"), schema: definition .get("inputSchema") @@ -44,7 +62,10 @@ fn text(definition: &Value, key: &str) -> String { struct EpisodeTool { seat: String, + /// The name the model calls it by: the served name, prefixed. name: String, + /// The served name, which the record knows it by. + served: String, description: String, schema: Value, tools: Arc, @@ -66,7 +87,7 @@ impl Tool for EpisodeTool { /// `read` looks; everything else moves the episode. fn permission_level(&self) -> PermissionLevel { - if self.name == "read" { + if self.served == "read" { PermissionLevel::ReadOnly } else { PermissionLevel::Write @@ -74,7 +95,7 @@ impl Tool for EpisodeTool { } async fn execute(&self, args: Value) -> anyhow::Result { - Ok(match self.tools.call(&self.seat, &self.name, &args) { + Ok(match self.tools.call(&self.seat, &self.served, &args) { Ok(receipt) => ToolResult::success(receipt), Err(refusal) => ToolResult::error(refusal), }) diff --git a/crates/tinyhivemind-openhuman/src/runner/test.rs b/crates/tinyhivemind-openhuman/src/runner/test.rs index 582a654a..d01e2342 100644 --- a/crates/tinyhivemind-openhuman/src/runner/test.rs +++ b/crates/tinyhivemind-openhuman/src/runner/test.rs @@ -4,9 +4,10 @@ use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use openhuman_core::agent::OpenHumanSessionHost; +use openhuman_core::agent::tinyagents::host::LastTurnUsage; use openhuman_embed::{Access, Provider, Runtime, Workspace}; use tinyhivemind::speech::{ToolCall, Utterance}; use tinyhivemind::{SESSION_WINDOW, Sequence, SessionLog}; @@ -17,7 +18,7 @@ use super::{Lane, RunnerKind, SeatRunner}; use crate::offline::MemoryLog; use crate::{ EmbedRunner, EpisodeBelt, EpisodeHost, HostedRunner, HostedTurn, LibraryHost, RawRunner, Route, - offline, + offline, register_seats, }; /// A host with no agents of its own: its seats are library sessions, its @@ -28,6 +29,11 @@ struct TestHost { library: LibraryHost, prompt: String, wrapped: AtomicUsize, + /// Turns the hook saw, and whether any came with usage. + after: AtomicUsize, + metered: AtomicBool, + /// Whether the hook halts the episode on the next turn. + halt: AtomicBool, } impl EpisodeHost for TestHost { @@ -44,6 +50,24 @@ impl EpisodeHost for TestHost { self.wrapped.fetch_add(1, Ordering::SeqCst); Box::pin(self.library.scope(turn)) } + + fn tool_prefix(&self) -> String { + "desk_".into() + } + + fn after_turn(&self, seat: &str, usage: Option<&LastTurnUsage>) -> crate::Result<()> { + assert_eq!(seat, "lead"); + self.after.fetch_add(1, Ordering::SeqCst); + if usage.is_some() { + self.metered.store(true, Ordering::SeqCst); + } + if self.halt.swap(false, Ordering::SeqCst) { + return Err(crate::Error::Harness(anyhow::anyhow!( + "the desk's budget is spent" + ))); + } + Ok(()) + } } #[test] @@ -118,6 +142,9 @@ fn hosted(library: LibraryHost, contract: &str) -> (Arc, HostedRunner< library, prompt: format!("You lead the desk.\n\n{contract}"), wrapped: AtomicUsize::new(0), + after: AtomicUsize::new(0), + metered: AtomicBool::new(false), + halt: AtomicBool::new(false), }); let runner = HostedRunner::seat( Arc::clone(&host), @@ -149,6 +176,74 @@ fn one_completion(name: &str, events: &[SeatEvent]) { ); } +/// A second turn on each native runner: raw is seeded with what it said, +/// hosted clears and reseeds the session it reuses, and both call again. +async fn again(raw: &RawRunner, host: &TestHost, hosted: &HostedRunner) { + // A second raw turn is seeded with the first: what the seat said is what + // it is shown, and the record starts empty again. + let (_, again) = one_turn(raw, Sequence(0)).await; + assert_eq!(again.len(), 1); + host.log.append("lead", "COMPLETE: done", None, None); + let (_, again) = one_turn(hosted, host.log.latest()).await; + assert_eq!(again.len(), 1, "the reused session ran and called again"); + assert_eq!(host.wrapped.load(Ordering::SeqCst), 2); +} + +/// The hook halting is the turn failing: the host loop sees an error where +/// a reply would be, after the turn ran and its call landed. +async fn halts(host: &TestHost, hosted: &HostedRunner) { + host.halt.store(true, Ordering::SeqCst); + hosted.open( + "lead", + Vec::new(), + Dispatch { + chat: "engineering".into(), + parent: None, + }, + ); + let (_, _, halted) = hosted + .turn( + "lead".into(), + Lane::Desk, + host.log.latest(), + "Once more.".into(), + ) + .await; + assert!( + matches!(&halted, Some(Err(error)) if error.contains("budget is spent")), + "{halted:?}" + ); + assert_eq!( + hosted.close("lead").len(), + 1, + "the call it made before the halt stands" + ); +} + +/// The embed runtime, booted once per process over the scripted route. +async fn runtime( + config: &openhuman_embed::RuntimeConfig, + backend: &wiremock::MockServer, + route: &Route, + workspace: &std::path::Path, +) -> Runtime { + Box::pin( + Runtime::builder() + .config(config.clone()) + .workspace(Workspace::dir(workspace.to_path_buf())) + .services(EmbedRunner::services()) + .backend_url(backend.uri()) + .provider( + Provider::openai_compatible(route.endpoint.clone(), route.api_key.clone()) + .model(route.model.clone()), + ) + .access(Access::full()) + .build(), + ) + .await + .expect("the runtime boots") +} + /// Every runner, offline, against one scripted model: the same call lands in /// the record the same way, whichever road it took. One test rather than /// two because the runtime and the definition registry are process-wide, and @@ -196,23 +291,14 @@ async fn both_runners() { let contract = |kind: RunnerKind| standing_contract(served_specs(), "engineering", kind.how_to_call()); - RawRunner::prepare(workspace.path(), &[("lead", "You lead the desk.")]) + // One definition for `lead` names every tool either native runner hands + // it: the served belt for raw, and the host's prefixed one for hosted. + let named: Vec = served_specs() + .flat_map(|spec| [spec.name.to_owned(), format!("desk_{}", spec.name)]) + .collect(); + register_seats(workspace.path(), &[("lead", "You lead the desk.")], &named) .expect("seats register"); - let runtime = Box::pin( - Runtime::builder() - .config(config.clone()) - .workspace(Workspace::dir(workspace.path().to_path_buf())) - .services(EmbedRunner::services()) - .backend_url(backend.uri()) - .provider( - Provider::openai_compatible(route.endpoint.clone(), route.api_key.clone()) - .model(route.model.clone()), - ) - .access(Access::full()) - .build(), - ) - .await - .expect("the runtime boots"); + let runtime = runtime(&config, &backend, &route, workspace.path()).await; let embed = EmbedRunner::seat( &runtime, Arc::new(EpisodeTools::new(["lead"])), @@ -257,6 +343,8 @@ async fn both_runners() { "the host wrapped the turn" ); assert!(hosted.usage("lead").is_some(), "the turn's usage is kept"); + assert_eq!(host.after.load(Ordering::SeqCst), 1, "the hook ran once"); + assert!(host.metered.load(Ordering::SeqCst), "and saw the usage"); assert_eq!(hosted_reply, raw_reply); for (name, events) in [ ("embed", &embed_events), @@ -273,15 +361,8 @@ async fn both_runners() { assert_eq!(seen.round_trips.len(), 3, "one receipted call per runner"); assert!(seen.requests >= 6, "each turn is a call and a receipt"); - // A second raw turn is seeded with the first: what the seat said is what - // it is shown, and the record starts empty again. - let (_, again) = one_turn(&raw, Sequence(0)).await; - assert_eq!(again.len(), 1); - // A second hosted turn reuses the seat's session: cleared, reseeded, run. - host.log.append("lead", "COMPLETE: done", None, None); - let (_, again) = one_turn(&hosted, host.log.latest()).await; - assert_eq!(again.len(), 1, "the reused session ran and called again"); - assert_eq!(host.wrapped.load(Ordering::SeqCst), 2); + again(&raw, &host, &hosted).await; + halts(&host, &hosted).await; metrics.reset(); assert_eq!(metrics.snapshot().requests, 0); } From 6472c35c1884f354e94d8d0c1d01fa76309a094e Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 22 Sep 2026 23:06:25 +0530 Subject: [PATCH 6/7] Refuse what the runners cannot seat, meter only this turn, and name files safely A turn for a seat no runner seated indexed a map and panicked; it is now a failed turn for that seat. A hosted turn the session reported no usage for kept the previous turn's usage under its name; the entry is cleared. The seed's inclusive watermark read one row short at the last sequence. And a seat id becomes a definition's file name, so register_seats takes one plain path component and refuses the rest before writing anything. The seam test also registers a seat after the registry is set, which is refused by name, and runs a host that keeps every default. Co-Authored-By: Claude Fable 5.1 --- .../tinyhivemind-openhuman/src/embed/mod.rs | 6 +- .../tinyhivemind-openhuman/src/error/mod.rs | 7 ++ .../tinyhivemind-openhuman/src/error/test.rs | 6 ++ .../tinyhivemind-openhuman/src/hosted/mod.rs | 20 ++-- .../tinyhivemind-openhuman/src/hosted/seed.rs | 4 +- crates/tinyhivemind-openhuman/src/raw/mod.rs | 28 +++++- crates/tinyhivemind-openhuman/src/raw/test.rs | 18 ++++ .../tinyhivemind-openhuman/src/runner/mod.rs | 12 +++ .../tinyhivemind-openhuman/src/runner/test.rs | 98 +++++++++++++++++++ 9 files changed, 184 insertions(+), 15 deletions(-) diff --git a/crates/tinyhivemind-openhuman/src/embed/mod.rs b/crates/tinyhivemind-openhuman/src/embed/mod.rs index dbf6b86f..3c6939a7 100644 --- a/crates/tinyhivemind-openhuman/src/embed/mod.rs +++ b/crates/tinyhivemind-openhuman/src/embed/mod.rs @@ -28,7 +28,7 @@ use tinyhivemind_driver::{AgentBinding, BoundAgent}; use tinyhivemind_mcp::{EpisodeTools, Server, serve}; use crate::Result; -use crate::runner::{Lane, SeatRunner, TURN_TIMEOUT, TurnJob}; +use crate::runner::{Lane, SeatRunner, TURN_TIMEOUT, TurnJob, unseated}; use tinyhivemind::Sequence; /// An `openhuman-embed` agent as the handle the driver binds. @@ -121,7 +121,9 @@ impl SeatRunner for EmbedRunner { /// One session per seat for the whole episode, so `OpenHuman` appends to the /// context the agent already holds rather than rebuilding one. fn turn(&self, seat: String, lane: Lane, _since: Sequence, prompt: String) -> TurnJob { - let agent = self.agents[&seat].clone(); + let Some(agent) = self.agents.get(&seat).cloned() else { + return unseated(seat, lane); + }; let session = format!("episode-{}:{seat}", self.run_id); Box::pin(async move { let result = match tokio::time::timeout( diff --git a/crates/tinyhivemind-openhuman/src/error/mod.rs b/crates/tinyhivemind-openhuman/src/error/mod.rs index f9e236ae..3b6f8a5a 100644 --- a/crates/tinyhivemind-openhuman/src/error/mod.rs +++ b/crates/tinyhivemind-openhuman/src/error/mod.rs @@ -12,6 +12,13 @@ pub enum Error { /// The definition registry did not come up after the seats were written. #[error("the definition registry did not initialise")] RegistryMissing, + /// A seat id that cannot name a definition file: empty, `.`, `..`, or + /// carrying anything but ASCII letters, digits, `-`, `_` and `.`. + #[error("seat id `{seat}` is not a plain path component")] + UnsafeSeatId { + /// The id. + seat: String, + }, /// A seat's definition was written and the registry did not load it. #[error("seat `{seat}` did not register")] SeatNotRegistered { diff --git a/crates/tinyhivemind-openhuman/src/error/test.rs b/crates/tinyhivemind-openhuman/src/error/test.rs index 426ac14a..9463935e 100644 --- a/crates/tinyhivemind-openhuman/src/error/test.rs +++ b/crates/tinyhivemind-openhuman/src/error/test.rs @@ -16,6 +16,12 @@ fn each_failure_names_itself() { }, "lead", ), + ( + Error::UnsafeSeatId { + seat: "../x".into(), + }, + "plain path", + ), ( Error::TimedOut { seat: "lead".into(), diff --git a/crates/tinyhivemind-openhuman/src/hosted/mod.rs b/crates/tinyhivemind-openhuman/src/hosted/mod.rs index e56f03ba..298078c6 100644 --- a/crates/tinyhivemind-openhuman/src/hosted/mod.rs +++ b/crates/tinyhivemind-openhuman/src/hosted/mod.rs @@ -46,7 +46,7 @@ use tinyhivemind_tools::EpisodeTools; use tinytools::Tool; use crate::raw::tools::belt_with_prefix; -use crate::runner::{Lane, SeatRunner, TURN_TIMEOUT, TurnJob}; +use crate::runner::{Lane, SeatRunner, TURN_TIMEOUT, TurnJob, unseated}; use crate::{Error, Result}; use admission::Admission; @@ -255,7 +255,9 @@ impl SeatRunner for HostedRunner { /// and run the brief, inside the host's wrapper. fn turn(&self, seat: String, lane: Lane, since: Sequence, prompt: String) -> TurnJob { let host = Arc::clone(&self.host); - let session = Arc::clone(&self.seats[&seat].session); + let Some(session) = self.seats.get(&seat).map(|held| Arc::clone(&held.session)) else { + return unseated(seat, lane); + }; let usage = Arc::clone(&self.usage); let conversation = Conversation { thread_root: match lane { @@ -286,12 +288,14 @@ impl SeatRunner for HostedRunner { .await .map_err(|_| Error::TimedOut { seat: seat.clone() })? .map_err(Error::Harness)?; - if let Some(last) = session.last_turn_usage() { - usage - .lock() - .unwrap_or_else(PoisonError::into_inner) - .insert(seat.clone(), last); - } + // This turn's usage, or none: a turn the session reported + // nothing for must not be metered as the one before it. + let mut metered = usage.lock().unwrap_or_else(PoisonError::into_inner); + match session.last_turn_usage() { + Some(last) => metered.insert(seat.clone(), last), + None => metered.remove(&seat), + }; + drop(metered); Ok(reply) } }; diff --git a/crates/tinyhivemind-openhuman/src/hosted/seed.rs b/crates/tinyhivemind-openhuman/src/hosted/seed.rs index c75cb34a..dc8f19c0 100644 --- a/crates/tinyhivemind-openhuman/src/hosted/seed.rs +++ b/crates/tinyhivemind-openhuman/src/hosted/seed.rs @@ -33,7 +33,9 @@ pub(super) async fn history( let query = SessionQuery { conversation, viewer: Viewer::Agent { id: seat.into() }, - before: Some(Sequence(since.0.saturating_add(1))), + // Exclusive, so one above `since`; nothing is above the last + // sequence, so that reads unbounded rather than one short. + before: (since.0 != u64::MAX).then(|| Sequence(since.0 + 1)), window, }; let rows = project_session(log, &query).await?; diff --git a/crates/tinyhivemind-openhuman/src/raw/mod.rs b/crates/tinyhivemind-openhuman/src/raw/mod.rs index 9ef808ee..0b4cc156 100644 --- a/crates/tinyhivemind-openhuman/src/raw/mod.rs +++ b/crates/tinyhivemind-openhuman/src/raw/mod.rs @@ -55,7 +55,7 @@ use tinyhivemind::Sequence; use tinyhivemind_driver::AgentBinding; use tinyhivemind_tools::EpisodeTools; -use crate::runner::{Lane, SeatRunner, TurnJob}; +use crate::runner::{Lane, SeatRunner, TurnJob, unseated}; use crate::{Error, Result}; pub use library::LibraryHost; pub use seat::RawSeat; @@ -74,6 +74,9 @@ type Contexts = Arc>>>; /// every tool the seat will be handed, as the model calls them -- for a host /// that prefixes the episode's tools, the prefixed names, alongside its own. /// +/// A seat id becomes a file name, so it is one plain path component: +/// ASCII letters, digits, `-`, `_` and `.`, and not `.` or `..` alone. +/// /// The loader wants `id`, `when_to_use` and a non-empty `system_prompt`; the /// prompt written here is the seat's role for a reader of the workspace, not /// the one a session runs under. The registry is process-wide, so a host @@ -81,9 +84,24 @@ type Contexts = Arc>>>; /// /// # Errors /// -/// The directory or a file failing to write, or the registry refusing the -/// definitions. +/// A seat id that is not a plain path component, the directory or a file +/// failing to write, or the registry refusing the definitions. pub fn register_seats(workspace: &Path, seats: &[(&str, &str)], tools: &[String]) -> Result<()> { + // A seat id names a file: one path component, and nothing a path can + // be steered with. + for (id, _) in seats { + let plain = !id.is_empty() + && *id != "." + && *id != ".." + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); + if !plain { + return Err(Error::UnsafeSeatId { + seat: (*id).to_owned(), + }); + } + } let agents = workspace.join("agents"); std::fs::create_dir_all(&agents)?; let named: Vec = tools.iter().map(|name| format!("{name:?}")).collect(); @@ -228,7 +246,9 @@ impl SeatRunner for RawRunner { .cloned() .unwrap_or_default(); let belt = tools::belt(&seat, &self.tools); - let raw_seat = self.seats[&seat].clone(); + let Some(raw_seat) = self.seats.get(&seat).cloned() else { + return unseated(seat, lane); + }; let contexts = Arc::clone(&self.contexts); Box::pin(async move { let result = match Box::pin(raw_seat.turn(history, &prompt, belt)).await { diff --git a/crates/tinyhivemind-openhuman/src/raw/test.rs b/crates/tinyhivemind-openhuman/src/raw/test.rs index d3bf2231..db6d8e1e 100644 --- a/crates/tinyhivemind-openhuman/src/raw/test.rs +++ b/crates/tinyhivemind-openhuman/src/raw/test.rs @@ -161,3 +161,21 @@ async fn a_route_without_a_key_is_refused_before_the_core_is_asked() { .await; assert!(matches!(refused, Err(crate::Error::IncompleteRoute))); } + +#[test] +fn a_seat_id_that_is_not_a_plain_path_component_names_no_file() { + let workspace = tempfile::tempdir().expect("a workspace"); + for id in [ + "", ".", "..", "../lead", "a/b", "lead\\x", "le ad", "l\u{e9}", + ] { + let refused = super::register_seats(workspace.path(), &[(id, "Nobody.")], &[]); + assert!( + matches!(&refused, Err(crate::Error::UnsafeSeatId { seat }) if seat == id), + "{id:?}: {refused:?}" + ); + } + assert!( + !workspace.path().join("agents").exists(), + "nothing was written for a refused id" + ); +} diff --git a/crates/tinyhivemind-openhuman/src/runner/mod.rs b/crates/tinyhivemind-openhuman/src/runner/mod.rs index dff554c2..ae5f20f1 100644 --- a/crates/tinyhivemind-openhuman/src/runner/mod.rs +++ b/crates/tinyhivemind-openhuman/src/runner/mod.rs @@ -35,6 +35,18 @@ pub enum Lane { /// A turn's reply, once it is back: `None` timed out. pub type TurnResult = Option>; +/// The turn a runner returns for a seat it never seated: failed, at once. +/// A runner indexes its seats by what the driver proposed, and the driver +/// proposes only bound seats; a seat outside that is a host bug, and a +/// failed turn is a better report of it than a panic. +#[must_use] +pub fn unseated(seat: String, lane: Lane) -> TurnJob { + Box::pin(async move { + let failed = format!("`{seat}` is not a seat of this runner"); + (seat, lane, Some(Err(failed))) + }) +} + /// One running turn: the seat, its lane, and the reply when it lands. pub type TurnJob = Pin + Send>>; diff --git a/crates/tinyhivemind-openhuman/src/runner/test.rs b/crates/tinyhivemind-openhuman/src/runner/test.rs index d01e2342..5ca91cfe 100644 --- a/crates/tinyhivemind-openhuman/src/runner/test.rs +++ b/crates/tinyhivemind-openhuman/src/runner/test.rs @@ -132,6 +132,75 @@ async fn one_turn(runner: &R, since: Sequence) -> (String, Vec &dyn SessionLog { + &self.log + } + + fn build_seat(&self, seat: &str, belt: EpisodeBelt) -> crate::Result { + let policy = belt.admit(None); + self.library + .session(seat, "You lead the desk.", belt.tools, policy) + } +} + +/// A hosted seat on a host that keeps every default, run once on the desk +/// and once in a thread it is not in: the defaults hold, the thread turn is +/// seeded from the thread, and a call outside its thread is refused. +async fn plain(library: LibraryHost, contract: &str) { + let log = MemoryLog::new("engineering"); + log.append("operator", "state the root cause", None, None); + let host = Arc::new(PlainHost { log, library }); + let runner = HostedRunner::seat( + Arc::clone(&host), + Arc::new(EpisodeTools::new(["lead"])), + &["lead".to_owned()], + "engineering", + "Engineering", + SESSION_WINDOW, + ) + .expect("hosted seats"); + let _ = contract; + let (reply, events) = one_turn(&runner, host.log.latest()).await; + assert!(!reply.is_empty()); + assert_eq!( + events.len(), + 1, + "the bare-named belt is admitted by default" + ); + runner.open( + "lead", + Vec::new(), + Dispatch { + chat: "engineering".into(), + parent: Some("1".into()), + }, + ); + let (_, lane, outcome) = runner + .turn( + "lead".into(), + Lane::Thread(Sequence(1)), + Sequence(1), + "In the thread.".into(), + ) + .await; + assert_eq!(lane, Lane::Thread(Sequence(1))); + assert!(matches!(outcome, Some(Ok(_))), "{outcome:?}"); + assert!( + runner.close("lead").is_empty(), + "the scripted call names no thread, so the record refused it" + ); + assert_eq!(runner.tools().drain_refusals("lead").len(), 1); +} + /// A hosted runner over a test host whose log already holds the task. fn hosted(library: LibraryHost, contract: &str) -> (Arc, HostedRunner) { assert!(format!("{library:?}").contains(offline::MODEL)); @@ -189,6 +258,33 @@ async fn again(raw: &RawRunner, host: &TestHost, hosted: &HostedRunner assert_eq!(host.wrapped.load(Ordering::SeqCst), 2); } +/// A seat none of the runners seated is a failed turn, not a panic; and a +/// seat registered after the process registry is set is refused by name +/// rather than seated as a ghost. +async fn ghosts(embed: &EmbedRunner, raw: &RawRunner, hosted: &HostedRunner) { + for outcome in [ + raw.turn("ghost".into(), Lane::Desk, Sequence(0), "?".into()) + .await, + hosted + .turn("ghost".into(), Lane::Desk, Sequence(0), "?".into()) + .await, + embed + .turn("ghost".into(), Lane::Desk, Sequence(0), "?".into()) + .await, + ] { + assert!( + matches!(&outcome, (seat, Lane::Desk, Some(Err(why))) if seat == "ghost" && why.contains("not a seat")), + "{outcome:?}" + ); + } + let elsewhere = tempfile::tempdir().expect("a workspace"); + let ghost = register_seats(elsewhere.path(), &[("ghost", "Nobody.")], &[]); + assert!( + matches!(&ghost, Err(crate::Error::SeatNotRegistered { seat }) if seat == "ghost"), + "{ghost:?}" + ); +} + /// The hook halting is the turn failing: the host loop sees an error where /// a reply would be, after the turn ran and its call landed. async fn halts(host: &TestHost, hosted: &HostedRunner) { @@ -362,6 +458,8 @@ async fn both_runners() { assert!(seen.requests >= 6, "each turn is a call and a receipt"); again(&raw, &host, &hosted).await; + ghosts(&embed, &raw, &hosted).await; + plain(host.library.clone(), &contract(RunnerKind::Hosted)).await; halts(&host, &hosted).await; metrics.reset(); assert_eq!(metrics.snapshot().requests, 0); From c30669d5b84f429a6744825266145416eeb96bbd Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 23 Sep 2026 00:33:21 +0530 Subject: [PATCH 7/7] Finalize every hosted turn, and name every runner in the diagnostic The after-turn hook now runs for a turn that failed as well as one that came back, with whatever usage the session reported, so a host parks what a failed turn left waiting; the turn's own error outranks the hook's. Usage is cleared before a turn starts, so a turn that fails before the session reports anything is metered as nothing. The refused runner name now lists hosted. Co-Authored-By: Claude Fable 5.1 --- .../tinyhivemind-openhuman/src/error/mod.rs | 2 +- .../tinyhivemind-openhuman/src/hosted/mod.rs | 32 +++++++++++++------ .../tinyhivemind-openhuman/src/runner/mod.rs | 2 +- .../tinyhivemind-openhuman/src/runner/test.rs | 31 +++++++++++++++++- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/tinyhivemind-openhuman/src/error/mod.rs b/crates/tinyhivemind-openhuman/src/error/mod.rs index 3b6f8a5a..3164a3e6 100644 --- a/crates/tinyhivemind-openhuman/src/error/mod.rs +++ b/crates/tinyhivemind-openhuman/src/error/mod.rs @@ -4,7 +4,7 @@ #[derive(Debug, thiserror::Error)] pub enum Error { /// `TINYHIVEMIND_RUNNER` named neither runner. - #[error("TINYHIVEMIND_RUNNER must be `embed` or `raw`, not `{0}`")] + #[error("TINYHIVEMIND_RUNNER must be `embed`, `raw` or `hosted`, not `{0}`")] UnknownRunner(String), /// A route was missing its endpoint or its key. #[error("a route needs both an endpoint and a key")] diff --git a/crates/tinyhivemind-openhuman/src/hosted/mod.rs b/crates/tinyhivemind-openhuman/src/hosted/mod.rs index 298078c6..52ea39ee 100644 --- a/crates/tinyhivemind-openhuman/src/hosted/mod.rs +++ b/crates/tinyhivemind-openhuman/src/hosted/mod.rs @@ -259,6 +259,13 @@ impl SeatRunner for HostedRunner { return unseated(seat, lane); }; let usage = Arc::clone(&self.usage); + // Whatever the turn before left under this seat is not this turn's: + // a turn that fails before the session reports anything is metered + // as nothing, not as its predecessor. + usage + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&seat); let conversation = Conversation { thread_root: match lane { Lane::Desk => None, @@ -299,16 +306,21 @@ impl SeatRunner for HostedRunner { Ok(reply) } }; - let result = match host.wrap_turn(&seat, Box::pin(run)).await { - Ok(reply) => { - let last = usage - .lock() - .unwrap_or_else(PoisonError::into_inner) - .get(&seat) - .cloned(); - host.after_turn(&seat, last.as_ref()).map(|()| reply) - } - Err(error) => Err(error), + // Every started turn is finalized: the hook runs whether the + // turn came back or not, with whatever usage the session + // reported, so a host parks what a failed turn left waiting + // too. A turn that failed keeps its own error over the hook's. + let outcome = host.wrap_turn(&seat, Box::pin(run)).await; + let last = usage + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(&seat) + .cloned(); + let finalized = host.after_turn(&seat, last.as_ref()); + let result = match (outcome, finalized) { + (Ok(reply), Ok(())) => Ok(reply), + (Ok(_), Err(halt)) => Err(halt), + (Err(error), _) => Err(error), }; (seat, lane, Some(result.map_err(|error| error.to_string()))) }) diff --git a/crates/tinyhivemind-openhuman/src/runner/mod.rs b/crates/tinyhivemind-openhuman/src/runner/mod.rs index ae5f20f1..1fd87fdb 100644 --- a/crates/tinyhivemind-openhuman/src/runner/mod.rs +++ b/crates/tinyhivemind-openhuman/src/runner/mod.rs @@ -65,7 +65,7 @@ pub enum RunnerKind { } impl RunnerKind { - /// `TINYHIVEMIND_RUNNER=embed` (default) or `raw`. + /// `TINYHIVEMIND_RUNNER=embed` (default), `raw` or `hosted`. /// /// # Errors /// diff --git a/crates/tinyhivemind-openhuman/src/runner/test.rs b/crates/tinyhivemind-openhuman/src/runner/test.rs index 5ca91cfe..3f36b385 100644 --- a/crates/tinyhivemind-openhuman/src/runner/test.rs +++ b/crates/tinyhivemind-openhuman/src/runner/test.rs @@ -89,7 +89,7 @@ fn the_runner_is_named_by_the_environment_and_defaults_to_embed() { assert_eq!( from_env.map_err(|error| error.to_string()), RunnerKind::parse(value.as_deref()).map_err(|other| format!( - "TINYHIVEMIND_RUNNER must be `embed` or `raw`, not `{other}`" + "TINYHIVEMIND_RUNNER must be `embed`, `raw` or `hosted`, not `{other}`" )) ); } @@ -314,6 +314,35 @@ async fn halts(host: &TestHost, hosted: &HostedRunner) { 1, "the call it made before the halt stands" ); + // A turn in a thread the log does not have is seeded with nothing and + // runs; the hook still runs after it, and halts it. Whichever side + // fails, the hook has run once per started turn. + let before = host.after.load(Ordering::SeqCst); + host.halt.store(true, Ordering::SeqCst); + hosted.open( + "lead", + Vec::new(), + Dispatch { + chat: "engineering".into(), + parent: Some(u64::MAX.to_string()), + }, + ); + let (_, _, failed) = hosted + .turn( + "lead".into(), + Lane::Thread(Sequence(u64::MAX)), + Sequence(u64::MAX), + "Once more.".into(), + ) + .await; + assert_eq!( + host.after.load(Ordering::SeqCst), + before + 1, + "the hook ran" + ); + assert!(matches!(&failed, Some(Err(_))), "{failed:?}"); + hosted.close("lead"); + host.halt.store(false, Ordering::SeqCst); } /// The embed runtime, booted once per process over the scripted route.