Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,17 @@
//! Host-agnostic agent lifecycle hooks shared by multiple agent hosts (e.g. xai-grok-shell).
//! Contributors receive data-only per-hook inputs at dispatch time; anything they act through is a
//! capability injected at install time, and they never own loop control.
pub mod local;
pub mod send;
pub use local::{
LocalCommandContributor, LocalExtensionRegistry, LocalExtensionRegistryBuilder,
LocalSessionLifecycleContributor, LocalTurnInputContributor, LocalTurnLifecycleContributor,
};
pub use send::{
CommandAction, CommandContributor, CommandInvocation, CommandSpec, ExtensionRegistry,
ExtensionRegistryBuilder, SessionIdleInput, SessionLifecycleContributor, TurnAbortInput,
TurnAbortReason, TurnDoneInput, TurnErrorInput, TurnInputContext, TurnInputContributor,
TurnInputFragment, TurnLifecycleContributor, TurnStartInput,
};

View file

@ -0,0 +1,8 @@
pub mod contributors;
pub mod registry;
pub use contributors::{
LocalCommandContributor, LocalSessionLifecycleContributor, LocalTurnInputContributor,
LocalTurnLifecycleContributor,
};
pub use registry::{LocalExtensionRegistry, LocalExtensionRegistryBuilder};

View file

@ -0,0 +1,9 @@
pub mod command;
pub mod session_lifecycle;
pub mod turn_input;
pub mod turn_lifecycle;
pub use command::LocalCommandContributor;
pub use session_lifecycle::LocalSessionLifecycleContributor;
pub use turn_input::LocalTurnInputContributor;
pub use turn_lifecycle::LocalTurnLifecycleContributor;

View file

@ -0,0 +1,27 @@
use async_trait::async_trait;
use crate::send::contributors::command::{
CommandAction, CommandContributor, CommandInvocation, CommandSpec,
};
/// `?Send` twin of [`CommandContributor`] for single-threaded hosts like grok build's TUI agent, whose session state is `Rc`/`RefCell`-based and can
/// never satisfy the `Send` bounds the send flavor bakes into its boxed hook futures.
#[async_trait(?Send)]
pub trait LocalCommandContributor {
fn advertised_commands(&self) -> Vec<CommandSpec>;
async fn handle_command(&self, _input: &CommandInvocation<'_>)
-> Result<CommandAction, String>;
}
/// Send contributors work in single-threaded hosts as-is, so shared logic implements [`CommandContributor`] once and both hosts can register it.
#[async_trait(?Send)]
impl<T: CommandContributor> LocalCommandContributor for T {
fn advertised_commands(&self) -> Vec<CommandSpec> {
CommandContributor::advertised_commands(self)
}
async fn handle_command(&self, input: &CommandInvocation<'_>) -> Result<CommandAction, String> {
CommandContributor::handle_command(self, input).await
}
}

View file

@ -0,0 +1,19 @@
use async_trait::async_trait;
use crate::send::contributors::session_lifecycle::{SessionIdleInput, SessionLifecycleContributor};
/// `?Send` twin of [`SessionLifecycleContributor`].
#[async_trait(?Send)]
pub trait LocalSessionLifecycleContributor {
/// Fired when the session settles idle (no running turn or queued work); the host owns the check.
async fn on_session_idle(&self, _input: &SessionIdleInput) {}
}
/// Send contributors are usable in single-threaded hosts as-is, so shared logic implements
/// [`SessionLifecycleContributor`] once and both hosts can register it.
#[async_trait(?Send)]
impl<T: SessionLifecycleContributor> LocalSessionLifecycleContributor for T {
async fn on_session_idle(&self, input: &SessionIdleInput) {
SessionLifecycleContributor::on_session_idle(self, input).await;
}
}

View file

@ -0,0 +1,22 @@
use async_trait::async_trait;
use crate::send::contributors::turn_input::{
TurnInputContext, TurnInputContributor, TurnInputFragment,
};
/// `?Send` twin of [`TurnInputContributor`] for single-threaded hosts like grok build's TUI agent, whose session state is `Rc`/`RefCell`-based
/// and can never satisfy the `Send` bounds the send flavor bakes into its boxed hook futures.
#[async_trait(?Send)]
pub trait LocalTurnInputContributor {
async fn contribute_turn_input(&self, _input: &TurnInputContext) -> Vec<TurnInputFragment> {
Vec::new()
}
}
/// Send contributors are usable in single-threaded hosts as-is, so shared logic implements [`TurnInputContributor`] once for both hosts.
#[async_trait(?Send)]
impl<T: TurnInputContributor> LocalTurnInputContributor for T {
async fn contribute_turn_input(&self, input: &TurnInputContext) -> Vec<TurnInputFragment> {
TurnInputContributor::contribute_turn_input(self, input).await
}
}

View file

@ -0,0 +1,40 @@
use async_trait::async_trait;
use crate::send::contributors::turn_lifecycle::{
TurnAbortInput, TurnDoneInput, TurnErrorInput, TurnLifecycleContributor, TurnStartInput,
};
/// `?Send` twin of [`TurnLifecycleContributor`] for single-threaded hosts like grok build's TUI
/// agent, whose session state is `Rc`/`RefCell`-based and can never satisfy the `Send` bounds the
/// send flavor bakes into its boxed hook futures.
#[async_trait(?Send)]
pub trait LocalTurnLifecycleContributor {
async fn on_turn_start(&self, _input: &TurnStartInput) {}
async fn on_turn_done(&self, _input: &TurnDoneInput) {}
async fn on_turn_abort(&self, _input: &TurnAbortInput) {}
async fn on_turn_error(&self, _input: &TurnErrorInput<'_>) {}
}
/// Send contributors are usable in single-threaded hosts as-is, so shared logic implements
/// [`TurnLifecycleContributor`] once and both hosts can register it.
#[async_trait(?Send)]
impl<T: TurnLifecycleContributor> LocalTurnLifecycleContributor for T {
async fn on_turn_start(&self, input: &TurnStartInput) {
TurnLifecycleContributor::on_turn_start(self, input).await;
}
async fn on_turn_done(&self, input: &TurnDoneInput) {
TurnLifecycleContributor::on_turn_done(self, input).await;
}
async fn on_turn_abort(&self, input: &TurnAbortInput) {
TurnLifecycleContributor::on_turn_abort(self, input).await;
}
async fn on_turn_error(&self, input: &TurnErrorInput<'_>) {
TurnLifecycleContributor::on_turn_error(self, input).await;
}
}

View file

@ -0,0 +1,101 @@
use std::collections::HashMap;
use std::rc::Rc;
use crate::local::contributors::{
LocalCommandContributor, LocalSessionLifecycleContributor, LocalTurnInputContributor,
LocalTurnLifecycleContributor,
};
/// Mutable registry used while hosts register typed runtime contributions.
#[derive(Default)]
pub struct LocalExtensionRegistryBuilder {
turn_lifecycle_contributors: Vec<Rc<dyn LocalTurnLifecycleContributor>>,
session_lifecycle_contributors: Vec<Rc<dyn LocalSessionLifecycleContributor>>,
turn_input_contributors: Vec<Rc<dyn LocalTurnInputContributor>>,
command_contributors: Vec<Rc<dyn LocalCommandContributor>>,
}
impl LocalExtensionRegistryBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn turn_lifecycle_contributor(
&mut self,
contributor: Rc<dyn LocalTurnLifecycleContributor>,
) {
self.turn_lifecycle_contributors.push(contributor);
}
pub fn session_lifecycle_contributor(
&mut self,
contributor: Rc<dyn LocalSessionLifecycleContributor>,
) {
self.session_lifecycle_contributors.push(contributor);
}
pub fn turn_input_contributor(&mut self, contributor: Rc<dyn LocalTurnInputContributor>) {
self.turn_input_contributors.push(contributor);
}
pub fn command_contributor(&mut self, contributor: Rc<dyn LocalCommandContributor>) {
self.command_contributors.push(contributor);
}
/// Routes each advertised command to its one owner. Duplicate names are a composition bug:
/// first registration wins, panics in debug builds, logs in release.
pub fn build(self) -> LocalExtensionRegistry {
let mut command_handlers: HashMap<String, Rc<dyn LocalCommandContributor>> = HashMap::new();
for contributor in &self.command_contributors {
for spec in contributor.advertised_commands() {
if command_handlers.contains_key(&spec.name) {
debug_assert!(false, "duplicate command contributed: /{}", spec.name);
tracing::error!(command = %spec.name, "Duplicate command contributed; first registration wins");
continue;
}
command_handlers.insert(spec.name, contributor.clone());
}
}
LocalExtensionRegistry {
turn_lifecycle_contributors: self.turn_lifecycle_contributors,
session_lifecycle_contributors: self.session_lifecycle_contributors,
turn_input_contributors: self.turn_input_contributors,
command_contributors: self.command_contributors,
command_handlers,
}
}
}
/// Immutable typed registry produced after extensions are installed.
#[derive(Default)]
pub struct LocalExtensionRegistry {
turn_lifecycle_contributors: Vec<Rc<dyn LocalTurnLifecycleContributor>>,
session_lifecycle_contributors: Vec<Rc<dyn LocalSessionLifecycleContributor>>,
turn_input_contributors: Vec<Rc<dyn LocalTurnInputContributor>>,
command_contributors: Vec<Rc<dyn LocalCommandContributor>>,
command_handlers: HashMap<String, Rc<dyn LocalCommandContributor>>,
}
impl LocalExtensionRegistry {
pub fn turn_lifecycle_contributors(&self) -> &[Rc<dyn LocalTurnLifecycleContributor>] {
&self.turn_lifecycle_contributors
}
pub fn session_lifecycle_contributors(&self) -> &[Rc<dyn LocalSessionLifecycleContributor>] {
&self.session_lifecycle_contributors
}
pub fn turn_input_contributors(&self) -> &[Rc<dyn LocalTurnInputContributor>] {
&self.turn_input_contributors
}
pub fn command_contributors(&self) -> &[Rc<dyn LocalCommandContributor>] {
&self.command_contributors
}
/// The one contributor owning `name`, or `None` when no extension advertised it.
pub fn command_handler(&self, name: &str) -> Option<&Rc<dyn LocalCommandContributor>> {
self.command_handlers.get(name)
}
}

View file

@ -0,0 +1,10 @@
pub mod contributors;
pub mod registry;
pub use contributors::{
CommandAction, CommandContributor, CommandInvocation, CommandSpec, SessionIdleInput,
SessionLifecycleContributor, TurnAbortInput, TurnAbortReason, TurnDoneInput, TurnErrorInput,
TurnInputContext, TurnInputContributor, TurnInputFragment, TurnLifecycleContributor,
TurnStartInput,
};
pub use registry::{ExtensionRegistry, ExtensionRegistryBuilder};

View file

@ -0,0 +1,12 @@
pub mod command;
pub mod session_lifecycle;
pub mod turn_input;
pub mod turn_lifecycle;
pub use command::{CommandAction, CommandContributor, CommandInvocation, CommandSpec};
pub use session_lifecycle::{SessionIdleInput, SessionLifecycleContributor};
pub use turn_input::{TurnInputContext, TurnInputContributor, TurnInputFragment};
pub use turn_lifecycle::{
TurnAbortInput, TurnAbortReason, TurnDoneInput, TurnErrorInput, TurnLifecycleContributor,
TurnStartInput,
};

View file

@ -0,0 +1,32 @@
use async_trait::async_trait;
/// A slash command a contributor advertises; the host maps it onto its own advertising protocol.
pub struct CommandSpec {
pub name: String,
pub description: String,
pub arg_hint: String,
}
/// A parsed `/name args` invocation. The host owns parsing and routes it to the command's one owner.
pub struct CommandInvocation<'a> {
pub name: &'a str,
pub args: &'a str, // Whitespace-trimmed; empty for a bare `/name`.
}
/// What a handled command does to the turn; rejections travel as the `Err` reason.
pub enum CommandAction {
/// Replace the model-visible copy of the message with `model_text`.
Rewrite { model_text: String },
/// Side effect performed, nothing to say; the state change surfaces through the host's own rendering.
Acted,
}
/// Handles the slash commands the extension advertises. Only invoked for commands this contributor
/// owns; the `Err` reason is the only channel for "why not", so hosts must surface it.
#[async_trait]
pub trait CommandContributor: Send + Sync {
fn advertised_commands(&self) -> Vec<CommandSpec>;
async fn handle_command(&self, _input: &CommandInvocation<'_>)
-> Result<CommandAction, String>;
}

View file

@ -0,0 +1,10 @@
use async_trait::async_trait;
/// Input supplied when the host observes the session settling idle.
pub struct SessionIdleInput;
#[async_trait]
pub trait SessionLifecycleContributor: Send + Sync {
/// Fired when the session settles idle (no running turn or queued work); the host owns the check.
async fn on_session_idle(&self, _input: &SessionIdleInput) {}
}

View file

@ -0,0 +1,23 @@
use async_trait::async_trait;
/// Turn facts supplied when the host pulls extension input at its sampling chokepoint.
pub struct TurnInputContext {
/// Stable host-owned turn identifier.
pub turn_id: String,
/// True when the harness produced the turn (auto-wake, drain, cron, continuation), not the user.
pub synthetic: bool,
}
/// A model-visible input fragment contributed into the active turn. The host owns wrapping, origin stamping, and placement.
pub struct TurnInputFragment {
pub text: String,
}
/// Contributes model-visible input fragments into the active turn when the host pulls at its sampling chokepoint.
/// Fragments land in the same turn, never a new one.
#[async_trait]
pub trait TurnInputContributor: Send + Sync {
async fn contribute_turn_input(&self, _input: &TurnInputContext) -> Vec<TurnInputFragment> {
Vec::new()
}
}

View file

@ -0,0 +1,52 @@
use async_trait::async_trait;
/// Input supplied when the host starts a turn.
pub struct TurnStartInput {
/// True when the harness produced the turn (auto-wake, drain, cron, continuation), not the user.
pub synthetic: bool,
}
impl TurnStartInput {
pub fn new(synthetic: bool) -> Self {
TurnStartInput { synthetic }
}
}
/// Input supplied when the host completes a turn.
pub struct TurnDoneInput;
/// Why the host aborted the turn instead of completing it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TurnAbortReason {
/// The client went away mid-turn.
Disconnected,
/// The user interrupted the turn before it completed.
Interrupted,
}
/// Input supplied when the host aborts a turn.
pub struct TurnAbortInput {
pub reason: TurnAbortReason,
}
impl TurnAbortInput {
pub fn new(reason: TurnAbortReason) -> Self {
TurnAbortInput { reason }
}
}
/// Input supplied when the host observes an error for a turn.
pub struct TurnErrorInput<'a> {
pub message: &'a str,
}
#[async_trait]
pub trait TurnLifecycleContributor: Send + Sync {
async fn on_turn_start(&self, _input: &TurnStartInput) {}
async fn on_turn_done(&self, _input: &TurnDoneInput) {}
async fn on_turn_abort(&self, _input: &TurnAbortInput) {}
async fn on_turn_error(&self, _input: &TurnErrorInput<'_>) {}
}

View file

@ -0,0 +1,226 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::send::contributors::{
CommandContributor, SessionLifecycleContributor, TurnInputContributor, TurnLifecycleContributor,
};
/// Mutable registry used while hosts register typed runtime contributions.
#[derive(Default)]
pub struct ExtensionRegistryBuilder {
turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>,
session_lifecycle_contributors: Vec<Arc<dyn SessionLifecycleContributor>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
command_contributors: Vec<Arc<dyn CommandContributor>>,
}
impl ExtensionRegistryBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn turn_lifecycle_contributor(&mut self, contributor: Arc<dyn TurnLifecycleContributor>) {
self.turn_lifecycle_contributors.push(contributor);
}
pub fn session_lifecycle_contributor(
&mut self,
contributor: Arc<dyn SessionLifecycleContributor>,
) {
self.session_lifecycle_contributors.push(contributor);
}
pub fn turn_input_contributor(&mut self, contributor: Arc<dyn TurnInputContributor>) {
self.turn_input_contributors.push(contributor);
}
pub fn command_contributor(&mut self, contributor: Arc<dyn CommandContributor>) {
self.command_contributors.push(contributor);
}
/// Routes each advertised command to its one owner. Duplicate names are a composition bug:
/// first registration wins, panics in debug builds, logs in release.
pub fn build(self) -> ExtensionRegistry {
let mut command_handlers: HashMap<String, Arc<dyn CommandContributor>> = HashMap::new();
for contributor in &self.command_contributors {
for spec in contributor.advertised_commands() {
if command_handlers.contains_key(&spec.name) {
debug_assert!(false, "duplicate command contributed: /{}", spec.name);
tracing::error!(command = %spec.name, "Duplicate command contributed; first registration wins");
continue;
}
command_handlers.insert(spec.name, contributor.clone());
}
}
ExtensionRegistry {
turn_lifecycle_contributors: self.turn_lifecycle_contributors,
session_lifecycle_contributors: self.session_lifecycle_contributors,
turn_input_contributors: self.turn_input_contributors,
command_contributors: self.command_contributors,
command_handlers,
}
}
}
/// Immutable typed registry produced after extensions are installed.
#[derive(Default)]
pub struct ExtensionRegistry {
turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>,
session_lifecycle_contributors: Vec<Arc<dyn SessionLifecycleContributor>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
command_contributors: Vec<Arc<dyn CommandContributor>>,
command_handlers: HashMap<String, Arc<dyn CommandContributor>>,
}
impl ExtensionRegistry {
pub fn turn_lifecycle_contributors(&self) -> &[Arc<dyn TurnLifecycleContributor>] {
&self.turn_lifecycle_contributors
}
pub fn session_lifecycle_contributors(&self) -> &[Arc<dyn SessionLifecycleContributor>] {
&self.session_lifecycle_contributors
}
pub fn turn_input_contributors(&self) -> &[Arc<dyn TurnInputContributor>] {
&self.turn_input_contributors
}
pub fn command_contributors(&self) -> &[Arc<dyn CommandContributor>] {
&self.command_contributors
}
/// The one contributor owning `name`, or `None` when no extension advertised it.
pub fn command_handler(&self, name: &str) -> Option<&Arc<dyn CommandContributor>> {
self.command_handlers.get(name)
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use super::*;
use crate::send::contributors::{
CommandAction, CommandInvocation, CommandSpec, SessionIdleInput, TurnAbortInput,
TurnAbortReason, TurnDoneInput, TurnErrorInput, TurnInputContext, TurnInputFragment,
TurnStartInput,
};
struct Counter(AtomicUsize);
#[async_trait]
impl TurnLifecycleContributor for Counter {
async fn on_turn_done(&self, _input: &TurnDoneInput) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
#[async_trait]
impl SessionLifecycleContributor for Counter {
async fn on_session_idle(&self, _input: &SessionIdleInput) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
#[async_trait]
impl TurnInputContributor for Counter {
async fn contribute_turn_input(&self, _input: &TurnInputContext) -> Vec<TurnInputFragment> {
self.0.fetch_add(1, Ordering::SeqCst);
vec![TurnInputFragment {
text: "nudge".to_string(),
}]
}
}
#[async_trait]
impl CommandContributor for Counter {
fn advertised_commands(&self) -> Vec<CommandSpec> {
vec![CommandSpec {
name: "goal".to_string(),
description: "Set a goal".to_string(),
arg_hint: "<text>".to_string(),
}]
}
async fn handle_command(
&self,
input: &CommandInvocation<'_>,
) -> Result<CommandAction, String> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(CommandAction::Rewrite {
model_text: format!("{} {}", input.name, input.args),
})
}
}
#[test]
#[should_panic(expected = "duplicate command contributed: /goal")]
fn build_rejects_duplicate_command_names() {
let counter = Arc::new(Counter(AtomicUsize::new(0)));
let mut builder = ExtensionRegistryBuilder::new();
builder.command_contributor(counter.clone());
builder.command_contributor(counter);
builder.build();
}
#[tokio::test]
async fn builder_freezes_and_registry_dispatches_in_order() {
let counter = Arc::new(Counter(AtomicUsize::new(0)));
let mut builder = ExtensionRegistryBuilder::new();
builder.turn_lifecycle_contributor(counter.clone());
builder.turn_lifecycle_contributor(counter.clone());
builder.session_lifecycle_contributor(counter.clone());
builder.turn_input_contributor(counter.clone());
builder.command_contributor(counter.clone());
let registry = builder.build();
for contributor in registry.turn_lifecycle_contributors() {
contributor
.on_turn_start(&TurnStartInput { synthetic: false })
.await;
contributor.on_turn_done(&TurnDoneInput).await;
contributor
.on_turn_abort(&TurnAbortInput {
reason: TurnAbortReason::Interrupted,
})
.await;
contributor
.on_turn_error(&TurnErrorInput { message: "boom" })
.await;
}
for contributor in registry.session_lifecycle_contributors() {
contributor.on_session_idle(&SessionIdleInput).await;
}
for contributor in registry.turn_input_contributors() {
let fragments = contributor
.contribute_turn_input(&TurnInputContext {
turn_id: "turn-1".to_string(),
synthetic: false,
})
.await;
assert_eq!(1, fragments.len());
assert_eq!("nudge", fragments[0].text);
}
assert!(registry.command_handler("nope").is_none());
let handler = registry.command_handler("goal").expect("goal has an owner");
let action = handler
.handle_command(&CommandInvocation {
name: "goal",
args: "ship it",
})
.await
.expect("command should be handled");
let CommandAction::Rewrite { model_text } = action else {
panic!("expected a rewrite");
};
assert_eq!("goal ship it", model_text);
assert_eq!(5, counter.0.load(Ordering::SeqCst));
}
}