Expand description
Writing a plugin for the rig agent: the rules every plugin follows, then a short, copy-ready example per extension point.
A plugin is a Bevy plugin: a type implementing Plugin + Default in a crate
that depends on rig-harness. The agent’s own tools, commands, subagents,
compaction, sessions and terminal view are plugins of the same kind, listed in
RIG_HOME/plugins.toml and built only on the same public API. A plugin that
needs another adds it unless app.is_plugin_added::<P>().
Every name below comes from use rig_harness::prelude::*; (Bevy’s preludes,
the kernel’s components and stages, the harness’s services) or from the crate
an example names: rig_basics (the default plugins, and the services some
plugins add, a module each) or rig_tui (with rig_tui::ratatui and
rig_tui::crossterm); rig_harness::{rig_core, rig_memory, rig_tools} are
re-exported. Every one of these types is reflected, so the optional
rig-inspect plugin’s inspect tool lists them in the running agent
(registry.schema gives a type’s fields, world.query with ProvidedBy what
each plugin added): ask it before reading rig’s source.
| To | Read |
|---|---|
| add a tool, or draw its calls | A tool |
| refuse, approve, preview or sandbox tool calls | Gating and reviewing tool calls |
| ask the user, or offer a list to pick from | Asking the user |
| add a slash command | A slash command |
| change or take over what the user types | What the user types |
| change the system prompt, route or adapt model calls, add a provider | Model requests |
| read replies as they stream | What a model streams |
| end, carry on or stop a turn | How a turn ends |
| make a plugin’s own work gated and recorded | Effects of a plugin’s own |
spawn subagents, extend task | Subagents |
| read, redact or replay the effect log | The effect log |
| read settings or options | Settings |
| keep state across restarts | Saved state, Data on a session |
| edit the conversation, review a summary, replace compaction | Changing history |
| turn off or replace a default tool, command or plugin | Turning off or replacing |
| wait, poll, debounce | Time |
| read and watch files, start processes | The workspace |
| keep sessions elsewhere, ask for a secret, hold the exit, fork | Sessions |
add to /model, /resume, /agents, /usage, --print | Adding to another plugin’s lists |
| test a plugin | Testing a plugin |
a front, rig <command>, a run nobody sits at | Fronts, options and commands |
| draw in the terminal | The transcript, status line, panels, keys, a window |
§Words
- Crates. The kernel is rig-ecs (never “runtime” or “core”), the
harness rig-harness, the launcher the std-only
rigbinary; a plugin is a Bevy pluginplugins.tomllists. Sources:crates/<name>for libraries, kernel and harness;plugins/rig-basics/src/<module>for the default plugins,plugins/<name>for rig-tui and the optional ones. - Agents. An agent is an entity with
Agent, a conversation and a model; a subagent is one another agent spawned (SpawnedBy). The main agent is the first unspawned one (main_order), the shown agent the one a front shows and sends typed text to (ShownAgent); “focus” is only keyboard focus. - Conversations. A conversation is ordered entries, asides and a halt
(rig-core’s
Conversation). An entry is one message with aMessageId, an author and a made-time. An aside is a typed side value after an entry, saved and rewritten with the conversation and never sent to a model; a note is an aside of kindnotea front draws (Note). A change is one edit (Change), a change record one with who and when (ChangeRecord). The halt says why the last user message is unanswered. - Turns and forks. A turn runs from a user message to the reply that
ends it, at most one per agent; its model call is the turn call
(
TurnCall), and a side call is built from an agent outside any turn (SideCall). To stop is any early end of a turn or call: the user’s (StopTurn), a plugin’s, a failure’s or a restart’s; an interrupted call is a restored one a crash cut short (Interrupted). A fork is a new agent of the session that goes on from another’s conversation up to a turn boundary (ForkAgent). - Effects. An effect is a unit of work as an entity moving through the
stages (Arrive, Review, Commit, Admit, Prepare, Gate, Start); a phase
is an ordered part of one (
PrepareSystems,ReviewSystems,GateSystems), a hook a plugin’s system in a stage. A call is a model or tool call, never a task: off-thread work on an entity (RunningbecomesDone<T>). A frame is a Bevy update, a pass one run ofAgentPass. Ready to start and ready to settle are Gate’s and Review’s filters (ReadyToStart,ReadyToSettle). A decision is a gate’s first-wins answer, applied at Start (Decision); a hold is any entityHoldOfan effect; to settle is Commit’s last step, recording the effect. An answer is what a handler or decision gave (effect::Answered), the outcome the answer or an error (Outcome). A handler serves an effect, a layer wraps one (Intercept:BeforeServe,AfterServe), an adapter changes the model a call goes to. An open tool’s calls are answered by observers; a footprint what a tool call may read and change; facts are typed values a handler publishes with its answer (Published). - Messages. A message is text sent to an agent (
Incoming) to steer, queue or inform (no answer needed); the inbox holds accepted, unanswered ones. A notice is a transient line for the user (Notice), never saved. - Sessions. A session is everything a run keeps, in a store
(
Store;SessionStoreis the running one,StoreOpeneropens others). A log is one agent’s append-only file, or thesessionlog; writing it is saving (SessionLogPlugin), and “log” alone never means the effect log oragent.log. A record is what is saved about a thing (EffectRecord,TurnRecord,ChangeRecord,LogRecord, …). A saved value is aSavedcomponent or resource under its saved key. The listing is a session’sSessionMeta(meta.json), a listed value a saved value copied into it. A session of another format is refused untouched; a saved value that no longer reads is skipped with a notice and replaced at its next save. A rewrite maps every saved value of a stopped session in place. Scratch is a session’s plain files outside its store, a spill a tool’s whole output kept when it was cut, and plugin files a plugin’s own files per session, project or user (PluginFiles). - Compaction replaces older entries by a summary, split at a cut;
the summary state is what the next one builds on (
SummaryState). A checkpoint or snapshot is the files as they were before a call or turn; rig-cassette’s replay marker is a resume point. - Running. A front is how the user drives the agent: the terminal view,
--print, a window. A question is anAskeffect, a picker a filterable list whose items run commands. A command is/name argsor a launcher subcommand, a setting asettings.tomlvalue, a secret a value passed in the environment. The reasoning setting is what an agent sends for reasoning (ReasoningSetting); effort is a reasoning level (rig_core::completion::Effort).
§Making one
rig plugin new hello # RIG_HOME/plugins/hello, listed in plugins.toml
rig plugin add viz::VizPlugin --path ~/viz # an entry for an existing crate
rig plugin remove viz::VizPlugin # take an entry out; its crate stays
rig plugin list # every entry, on or off, with its comment
rig plugin disable viz::VizPlugin # comment the entry out; `enable` undoes it
rig plugin check --build # build the agent as /reload would, and check its stage orderA commented-out entry is a disabled plugin, and the comment right above an
entry is its description, which rig plugin list and /plugins show.
rig plugin new writes a working, commented slash command and the entry
crate = "hello" (the package), path = "plugins/hello" (or git, with
branch or rev, or version) and plugin = "hello::HelloPlugin".
Each plugin crate is its own workspace in RIG_HOME/plugins/<name>, depending
on rig-harness and the rig plugin crates whose types it uses (rig-basics,
rig-tui for a terminal panel) by version, and on bevy_ecs and
bevy_reflect (or bevy) at the agent’s exact version. A plugin that only
uses rig-basics’ services and contract types (NeedsApproval, Preview,
CheckpointAlso, KeepRunning) depends on it with
default-features = false: its default tui feature builds rig-tui and
ratatui. The agent builds from
a rig checkout (RIG_SOURCE), which a [patch.crates-io] table points at.
/reload (or the reload tool) rebuilds and restarts in the session once
no turn runs; a failed build keeps the running one, a crash rolls back. A
reload the model asked for reports the outcome to that agent in one message
that starts a turn (rig_basics::reload’s ReloadAsked).
§How a plugin steps in: effects and stages
Every step of an agent’s work is an entity, an effect, whose
components are its request and, once answered, its Outcome: a model call
(ModelRequest), a tool call (ToolRequest), a message to an agent
(Incoming), a question for the user (Ask), or a plugin’s own
(EffectRequest). Each works ForAgent an agent (or for the app) and is
recorded once it settles (EffectRecorded). Each frame, AgentLoop in
Update runs the AgentPass schedule, one pass through the stages in
this order, until nothing moves:
| Stage | Who | What happens there |
|---|---|---|
Arrive | kernel | finished work becomes an Outcome; a failed model call gets a RetryVerdict |
Review | kernel, then plugins | outcomes not settled yet (ReadyToSettle): read, edit in place, hold, or take a failure over; a turn’s proposed end (phases: ReviewSystems) |
Commit | kernel | redacts keys in tool answers, then settles what is answered and unheld (Settled), appends results, carries turns on or ends them |
Admit | kernel | Admitted on every new effect; builds turn requests |
Prepare | plugins | what was admitted this pass (Added<Admitted>): edit the request, once (phases: PrepareSystems) |
Gate | plugins | as it opens, the kernel gives each tool call its Footprint and marks Waiting a call behind a conflicting one; then what is about to start (ReadyToStart): hold it or decide it (Decision) |
Start | kernel | applies the decisions as Outcomes, then starts what is admitted, unheld, unanswered and undecided |
The kernel’s stages, Arrive, Commit, Admit and Start, take plugin systems
too, in no order against the kernel’s own there, so such a system writes only
its own data, never an Outcome, a Decision or a request. Arrive runs every
pass, even one where nothing moved, so it suits following running work: the
harness turns a running call’s output into its ToolProgress there. In
Commit, Admit and Start a plugin reacts to what the pass did, in order with
the other stage systems where an observer would not be, such as a successor
agent spawned once a turn settled.
A hook is an ordinary system in a stage that queries stage data:
app.add_systems(AgentPass, my_gate.in_set(Stage::Gate)), ordered by its
stage’s phase and against other plugins’ public sets
(ordering). Every effect is admitted
before it starts, wherever it was spawned, so Prepare sees each once; Gate
each pass it is ready to start; Review an outcome each pass until it settles.
An observer is for reactions that need no order: counting, a status
item, a span, the plugin’s own entities (On<Add<Settled>>). Observers of
one event run in no fixed order, so an observer never edits stage data
another plugin edits; it may decide an effect, since the first one wins.
The conflict rule. A hold is any entity HoldOf(effect), its Name
saying why (waiting for lease on src/a.rs) in the fronts; holds add up. A
gate decides through the effect’s Decision, naming who
(decision.decide(Decider::plugin(NAME), Outcome::refused(..)), or
Decider::Person for the user’s choice in a front). The first decision wins,
so no later gate overturns a refusal; the kernel applies it as the Outcome
at Start and the record keeps decided_by. Allowing is doing nothing. A
reviewer edits the Outcome in place (Query<&mut Outcome, ReadyToSettle>)
once, with a marker, since Review sees it each pass. Plugins never insert
Outcome.
Deciding once. Until Start, a decided call is still ReadyToStart for
later gates. A gate that only reads (a path rule) may decide afresh each time.
A gate that asks someone or has side effects checks decision.is_decided()
first and marks the calls it handled (a SparseSet marker).
Failing closed. A stage system that fails or panics closes its stage for that pass, whatever the app’s error handler: a failed Prepare system answers what that pass admitted with an error, a failed Gate system starts nothing, a failed Review system settles nothing, and a notice names it.
Taking over and trying again. A reviewer that takes a failure over
spawns a successor with the request it needs and the same CallOf, which
the failed effect is SupersededBy (commands.spawn(successor) .add_related::<SupersededBy>(&[failed])), or sends it again
(commands.queue(resend(failed))): the failed effect is recorded as
superseded, never in the conversation. Any number of effects may name one
successor, as a race’s losers do. Slow work holds the outcome first.
The loop’s edge. Work spawned, answered or released after AgentLoop
ran waits for the next frame, so a system that does so every frame runs in
Update .before(AgentLoop). One that reacts to what the loop did that
frame (an agent it started, a call it settled) runs .after(AgentLoop).
use rig_harness::prelude::*;
use rig_harness::rig_core::effect::Answered;
use rig_harness::rig_core::tool::ToolOutput;
#[derive(Default)]
pub struct WriteGuardPlugin;
impl Plugin for WriteGuardPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
AgentPass,
(
src_only.in_set(Stage::Gate),
shorten_errors.in_set(Stage::Review),
),
);
}
}
/// Refuses a `write` outside `src/` when it is about to run.
fn src_only(mut calls: Query<(&ToolRequest, &mut Decision), (ReadyToStart, ToolCalls)>) {
for (request, mut decision) in &mut calls {
let path = request.call.function.arguments["path"].as_str().unwrap_or_default();
if request.call.function.name.as_str() == "write" && !path.starts_with("src/") {
decision.decide(Decider::plugin("src-only"), Outcome::refused("write only under src/"));
}
}
}
/// Keeps the first line of a failed `shell` call's output, before it is
/// committed.
fn shorten_errors(
mut calls: Query<(Entity, &ToolRequest, &mut Outcome), (ReadyToSettle, ToolCalls, Without<Shortened>)>,
mut commands: Commands,
) {
for (call, request, mut outcome) in &mut calls {
let Ok(Answered::ToolResult { result }) = &outcome.0 else { continue };
let text = result.output().as_text().unwrap_or_default();
if request.call.function.name.as_str() == "shell" && !text.contains("[exit code 0]") {
let first = text.lines().next().unwrap_or_default().to_owned();
*outcome = Outcome::from(ToolResult::success(ToolOutput::text(first)));
// Edit once: Review sees the outcome each pass until it settles.
commands.entity(call).insert(Shortened);
}
}
}
/// On a call whose outcome was shortened: once is enough.
#[derive(Component)]
#[component(storage = "SparseSet")]
struct Shortened;§Ordering against other plugins
Bevy reports two stage systems with no order between them that write the same
component, such as two gates that decide (&mut Decision); a plugin’s test
makes it a failure (stage_conflicts). Holds and
spawns go through Commands and need no order. Queries of different kinds of
effect never meet when they say so with a kind filter (ToolCalls,
ModelCalls, IncomingMessages, PluginEffects), and two over plugin
effects when one names its marker (With<Ask>) and the other excludes it. Two
queries of one system that write the same component must rule each other out
so, or Bevy refuses the system (error B0001).
Prepare and Review have phases, chained in this order, so a plugin orders a system by what it does, not by which plugin does what:
| Phase | What goes there | The default plugins’ systems there |
|---|---|---|
PrepareSystems::Input | what came in, rewritten before anything is read from it: a message’s text, a tool call’s arguments | |
PrepareSystems::Compose | the parts that come from what a request names and from its agent | the files a message names (@path, in rig_basics::coding_tools::AttachSystems), the default system prompt, each coding tool call’s handler for its place in the workspace |
PrepareSystems::Edit | the request edited as it will be sent: prompt, messages, tools, options, the model it goes to, its Adapters, a call’s CallHandler | |
PrepareSystems::Fit | the request as sent, measured against the window of the model it goes to and fitted | compaction |
ReviewSystems::Edit | an Outcome, a RetryVerdict or a proposed TurnOutcome, edited in place | compaction reading a summary or notes from its reply |
ReviewSystems::Act | what was reviewed, acted on: a failed call taken over, a result followed up, a summary or notes reviewed | compaction’s recovery from an overflow |
So:
- A stage system that edits or answers data another plugin may also
edit goes in a public
SystemSetof its plugin, configured in its phase:app.configure_sets(AgentPass, MySystems.in_set(PrepareSystems::Edit)). - Another plugin orders against the set only where it must go before or after that plugin, never against a private system. A gate that holds or asks goes after the gates that decide the same kind of effect.
- Two plugins of other authors never depend on each other only to order themselves. Where two meant to be used together edit the same data in one phase, the later one depends on the earlier one’s crate, orders against its set, and tests both in one app for stage conflicts.
The sets of Gate systems that edit shared data:
| Set | Crate | What it does | Order against it |
|---|---|---|---|
GateSystems::FromRecord | rig_ecs | as Gate opens, before every Gate system: decides effects from a record of an earlier run (a session replay), which holds each gate’s decision then | nothing; a cached or dry-run answer is no record and goes in After |
GateSystems::After | rig_ecs | after every Gate system: answers that stand in for running an effect (a cached result, a dry run, an editor’s buffer), and group coordinators; a gate’s decision is applied at Start, so read Decision | nothing; every gate goes before it |
GateSystems::Fallback | rig_ecs | after After: the kernel’s own answers (an Interrupted call’s, an ended turn’s Unsent messages) | .after(GateSystems::After).before it for a fallback of your own |
ApprovalSystems | rig_basics::approval | after After: one question for each effect that NeedsApproval, holding it, or a refusal with nobody to answer | .before it to hold or decide effects first, such as asking about a reply’s calls together |
CommandSystems | rig_harness | takes over a user’s line that starts with / and runs it | .before it to take a line over first |
AskSystems | rig_harness | answers the oldest open question that takes text with the user’s next line, after CommandSystems | .after it to answer questions in Gate, so a line the user typed wins |
PrintSystems | rig_basics::print | answers a --print run’s questions from --answer, after AskSystems | .before it to answer a question first |
SubagentSystems::Gate | rig_basics::subagents | refuses a SpawnSubagent deeper than the policy allows | .after it to decide or hold a request it let through |
use rig_harness::prelude::*;
use rig_harness::rig_core::redact;
/// The redaction plugin's stage systems, for others to order against.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RedactSystems;
fn build(app: &mut App) {
// After attachments and the default prompt, before compaction.
app.configure_sets(AgentPass, RedactSystems.in_set(PrepareSystems::Edit))
.add_systems(AgentPass, redact_messages.in_set(RedactSystems));
}
/// Takes API keys out of each message as it comes in, attached files
/// included.
fn redact_messages(mut messages: Query<&mut Incoming, Added<Admitted>>) {
for mut incoming in &mut messages {
for part in &mut incoming.parts {
if let MessagePart::Text(text) | MessagePart::Attachment(Attachment { content: UserContent::Text(message::Text { text, .. }), .. }) = part {
*text = redact::text(text).into_owned();
}
}
}
}Saving and writing in Last, on the exit frame too: the kernel queues
what the frame changed for the session log (rig_ecs::SaveSystems), then
writes it (rig_ecs::WriteLogs). A Last system that changes saved state, a
conversation or a saved component,
goes .before(SaveSystems); after it, the change is saved a frame late, and
on exit never. A notice sent in Last, such as the session log’s write
failure in WriteLogs, is read the next frame by the systems before its
sender, so one that keeps something on exit goes .after(WriteLogs) or asks
SessionLog::failure().
§A tool
Any rig_core::tool::Tool. Its description says what it does and when to pick
it, such as “Use it rather than wc in shell”: the model reads it with the
tool, and the system prompt stays the same whatever tools are offered. What
each call touches lets calls on different things run side by side: the tool
says it per call (Tool::footprint, as read says it reads its path), else
footprint for every call, else its annotations (Reads(Anything) for one
that only reads, Writes(Anything) otherwise). Blocking work goes in
blocking; a stop drops the call’s future at once, while that work runs on to
its end, unread (a rig-tools Process it started dies with its handle).
args_schema derives the parameters from the arguments type, whose field doc
comments describe them, and a call whose arguments do not fit is refused with
where and why (rig-core’s tool::validate); a schema that declares no
properties takes any arguments.
A tool registered with app.add_tool_per_call(tool, options, make)
is built for each call’s CallPlace: the app’s workspace (an overlay of it
for a preview), the calling agent’s
WorkDir else the ProjectRoot (base, confine for a WorkDir), and
for a tool registered with add_live_tool_per_call the Live its output
goes to, as the coding tools are. A plugin that builds such a call somewhere
else uses PerCallTools (place(agent, workspace), then build(name, place)), as preview builds it on an overlay.
use std::{path::PathBuf, sync::Arc};
use rig_harness::prelude::*;
use rig_harness::rig_tools::workspace::{Local, Workspace};
use schemars::JsonSchema;
use serde::Deserialize;
fn build(app: &mut App) {
let options = ToolOptions {
footprint: Some(Touches::Reads(Scope::Anything)),
};
let here = WordCount { workspace: Arc::new(Local), base: None, confine: false };
app.add_tool_per_call(here, options, |at| {
WordCount { workspace: at.workspace, base: Some(at.base), confine: at.confine }
});
}
/// Reads in `workspace`, a relative path from `base`.
struct WordCount {
workspace: Arc<dyn Workspace>,
base: Option<PathBuf>,
confine: bool,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct Args {
/// The file.
path: String,
}
impl Tool for WordCount {
const NAME: &'static str = "word_count";
type Args = Args;
type Output = String;
type Error = ToolExecutionError;
fn description(&self) -> String {
"Count the words of a file. Use it rather than `wc` in `shell`.".to_owned()
}
fn parameters(&self) -> serde_json::Value {
args_schema::<Args>()
}
async fn call(&self, _: &mut ToolContext, args: Args) -> Result<String, ToolExecutionError> {
let (workspace, base, confine) = (self.workspace.clone(), self.base.clone(), self.confine);
blocking(move || {
let text = workspace.read_text(&workspace.place(base.as_deref(), confine, &args.path)?)?;
Ok(format!("{} words", text.split_whitespace().count()))
})
.await
}
}A tool the plugin answers itself, later, is an open tool:
app.add_open_tool(name, description, options, observer) triggers
ToolCalled<Args> with the call’s entity (call), the calling agent
and the parsed args; the call ends when the plugin decides its
Decision. A tool found while the agent runs, such as one an MCP server
lists, is world.spawn_tool(definition, handler, options) from a queued
command; despawning the entity removes it. Its annotations are hints: a
call that only reads, and so runs again after a restart, is one that
options.footprint declares so.
rig-tui draws a tool’s calls with the first renderer its plugin adds
(add_tool_renderer), given a ToolCallView: the call, its state, result,
output and the calls it started (children). A crate that should also build
without rig-tui puts it behind a default tui = ["dep:rig-tui"] feature.
use rig_harness::prelude::*;
use rig_tui::ratatui::text::Line;
use rig_tui::AppToolRenderersExt;
fn build(app: &mut App) {
app.add_tool_renderer("word_count", |call| {
let mut lines = vec![call.header("Count", call.argument("path").unwrap_or_default())];
lines.extend(call.result_lines());
for child in call.children {
let indent = " ".repeat(child.depth + 1);
lines.push(Line::from(format!("{indent}↳ {} {:?}", child.name, child.state)));
}
lines
});
}§Gating and reviewing tool calls
A tool call is an effect with a ToolRequest (the model’s ToolCall) and the
CallHandler that will run it.
- Prepare edits them as the call is admitted: the arguments, or the
handler, wrapped with
ErasedHandler::layered(to give it a secret the model never sees) or replaced inPrepareSystems::Editto run the call another way, such as in a sandbox (below). - Before every gate the kernel answers a call that cannot run: a missing
tool, one
ToolAccessleaves out, arguments that do not fit. - Gate refuses a call about to run (
Outcome::refused(why), which the model reads), answers it (a cached result), holds it, or wants the user to approve it,commands.entity(call).queue(NeedsApproval::add(plugin, why)), withrig_basics::approval::ApprovalPluginadded. Every plugin’s reasons make one question inApprovalSystems:approval::ALLOWmakes the callApproved(its note kept in the question’s record),DENYor a text alone refuses it with the note, and with nobody to answer it is refused at once. - Review edits each result in place before it is committed. What a tool
published beside its text, such as rig-tools’
FilesRead,FilesChanged(any call that may write) orExitStatus, is the call’sPublished.
Each time Gate opens, a call gets the Footprint of the call as it stands, so
a held call whose ToolRequest was edited is gated again (why a call
waits).
What a call would change, before it runs: with
rig_basics::preview::PreviewPlugin added, a gate puts Preview on it, and
the plugin holds it, runs its tool on an overlay of the workspace that keeps
every write from the disk, and puts Previewed(Ok(changes)) on it,
ReadyToStart again: each FileChange, a deletion’s too, with its hunks, the
text before and after (after_text()), and each side’s FileState
(bytes, lines, hash). A tool not registered per call, or
one that starts a process (shell), gets Previewed(Err(..)).
A restart’s unfinished calls. A restart restores every unanswered call as
Restored work, gated again. One that had started or settled (the kernel
marks such calls in the conversation, rig.call) carries Interrupted
(Started, Settled(result)): no gate that holds or asks sees it, a Gate
system may answer or hold it, removing Interrupted in Prepare (never in
Gate) runs it again, and once nothing holds it the kernel answers it in
GateSystems::Fallback (an error, or its result).
use rig_basics::{approval::NeedsApproval, preview::{Preview, Previewed}};
use rig_harness::prelude::*;
use rig_harness::rig_core::{serve::{ErasedHandler, adapters::ToolAdapter}, tool::FileChange};
use rig_harness::rig_ecs::effects::Handler;
use rig_tools::Shell;
/// On an agent whose `shell` calls run sandboxed.
#[derive(Component)]
pub struct Sandboxed;
fn build(app: &mut App) {
app.add_systems(AgentPass, sandbox.in_set(PrepareSystems::Edit))
.add_systems(AgentPass, (too_big, commits).in_set(Stage::Gate));
}
/// Wants the user to approve each `git commit`.
fn commits(calls: Query<(Entity, &ToolRequest), (ReadyToStart, ToolCalls)>, mut commands: Commands) {
for (call, request) in &calls {
let command = request.call.function.arguments["command"].as_str().unwrap_or_default();
if command.contains("git commit") {
commands.entity(call).queue(NeedsApproval::add("commits", format!("It commits: {command}")));
}
}
}
/// Refuses a call that would change more than 500 lines, or leave a file
/// over 1 MiB, once it knows.
fn too_big(
mut calls: Query<(Entity, &Footprint, Option<&Previewed>, &mut Decision), (ReadyToStart, ToolCalls)>,
mut commands: Commands,
) {
for (call, footprint, previewed, mut decision) in &mut calls {
let lines = |changes: &Vec<FileChange>| changes.iter().map(|c| c.added + c.removed).sum::<usize>();
let huge = |c: &FileChange| c.after.as_ref().is_some_and(|after| after.bytes > 1 << 20);
if previewed.is_none() && !footprint.0.reads_only() {
commands.entity(call).insert(Preview);
} else if let Some(Previewed(Ok(changes))) = previewed
&& (lines(changes) > 500 || changes.iter().any(huge))
{
decision.decide(Decider::plugin("too-big"), Outcome::refused("change less at once"));
}
}
}
/// A sandboxed agent's `shell` calls get a handler of their own, which
/// starts commands in the agent's directory.
fn sandbox(
mut calls: Query<(&ToolRequest, &ForAgent, &mut CallHandler), (Added<Admitted>, ToolCalls)>,
agents: Query<Option<&WorkDir>, With<Sandboxed>>,
(processes, root): (Res<Processes>, Res<ProjectRoot>),
) {
for (request, &ForAgent(agent), mut handler) in &mut calls {
let Ok(work_dir) = agents.get(agent) else { continue };
if request.call.function.name.as_str() == "shell" {
let dir = work_dir.map_or(&root.0, |dir| &dir.0);
let launcher = processes.launcher().in_dir(dir).wrapped(["firejail", "--quiet"]);
let shell = Shell { launcher, confine: true, ..Shell::default() };
handler.0 = Handler(ErasedHandler::new(ToolAdapter::new(shell)));
}
}
}After every gate. An answer that stands in for running a call (a
cached result, a dry run) goes in GateSystems::After and reads
Decision, so it never answers a call a gate refused. So does a group of
calls that runs all or none, such as a patch’s edits: the plugin spawns a
child call per member, unheld (no gate sees a call held at spawn), and in
After, without ReadyToStart, holds the free ones while any member is held
or Waiting; then refuses all if one was refused or failed, else releases
them.
§A held call holds back the later calls of its reply
A call’s Footprint says what it may read and change (Touches): Reads
or Writes some paths (a directory covers what is under it) or
Anything, or Independent, which changes nothing the reply’s other
calls see, such as starting a subagent, and counts as reading anything. A
call waits for an earlier call of its reply when one of them writes what
the other touches, and a held call holds back like a running one. Say the
model’s reply asks for five calls, and a permission gate holds the edit:
| # | Call | Footprint | Runs |
|---|---|---|---|
| 1 | edit src/a.rs | Writes([src/a.rs]) | once the user said yes |
| 2 | read src/b.rs | Reads([src/b.rs]) | at once |
| 3 | search docs/ | Reads([docs]) | at once |
| 4 | read src/a.rs | Reads([src/a.rs]) | once 1 settled: it ran, or was refused |
| 5 | task | Independent | once 1 settled |
shell may change anything (Writes(Anything)), so every call after a
held shell call waits, reads too: a later call may depend on what an
earlier one changes (mkdir x then ls x). So a gate that cannot get an
answer refuses at once rather than holding. A plugin that knows better
says what a call touches in Prepare, with DeclaredFootprint: with
(Footprint(Touches::Writes(Scope::Paths(vec![src]))), DeclaredFootprint) on a
held cargo fmt, a read of docs/ runs at once (a held write still
holds back Reads(Anything), which overlaps every path).
A gate that guards files reads a call’s Footprint, not its tool’s name, so
one rule covers edit, write, delete, move and any plugin’s tool. Its
paths are absolute and normalized as written (src/../x is x), not
through links: a strict guard compares workspace.0.canonical(path).
§Asking the user: questions and pickers
A question for the user is an Ask entity: a title, a body, the choices
offered and whether a line of text answers it too. It is an effect,
recorded with its answer, and any front answers it by deciding it with an
Answer { choices, text } (Answer::choice(id).outcome(),
Answer::text(..), a choice with a note .with_text(..)), which closes
it in the others; answer.is(id) says whether it takes that choice. A
plugin that waits on the answer spawns the Ask as a hold on what it asks
about, and asks only when Answerers::can_answer for the agent (a front
spawns an Answerer). The user’s next line answers an open text question.
A question may propose an answer to edit (Ask::initial), take several
lines (multiline), or wait beside the work (inline), which the user’s
next line never answers: the terminal view puts the first two in the input
(Enter answers, Esc refuses) and an inline one in a strip above it (Alt+1
to Alt+9 pick, Alt+0 dismisses).
A choice from a list is a Picker entity: a title, a body, items that
each may run a command line and show a detail, and whether typed text
answers it (free_text); a new Picker inserted replaces it, and
despawning it closes it. A front answers it, or an open Ask, with
Pick::person(entity, chosen), a Chosen { items, text }: a picker runs
each chosen item’s command, in list order, and gets Picked (None when
closed), a question the chosen choices with the text as their note.
Picker::multiple() (.checked(true)) and Ask::multiple() (with
.with(Choice::new(id, label).checked().more(text))) take several. The
terminal view ticks with Tab, shows a choice’s more on Right, and once
ticks are in play Enter answers just the ticked; a ChoiceKeys component
adds keys of its own (ChoiceAction::TickAll, …). A changed Ask is a
new one, with SupersededBy(new) on the old: the view keeps the user’s
ticks.
use rig_basics::compaction::{Summarizing, Uncompact};
use rig_harness::prelude::*;
/// Asks the user about each compaction's summary before it replaces
/// anything; `/uncompact` puts back what the last one replaced.
fn build(app: &mut App) {
app.add_systems(AgentPass, (answered, ask).chain().in_set(ReviewSystems::Act))
.add_command("uncompact", "Undo the last compaction", |In(args): In<CommandArgs>, mut commands: Commands| {
commands.trigger(Uncompact { entity: args.agent });
});
}
/// On a summary call the user was asked about.
#[derive(Component)]
struct Asked;
/// Holds each summary, once read from the reply, with a question.
fn ask(
calls: Query<(Entity, &Summarizing, &ForAgent), (ReadyToSettle, Without<Asked>)>,
answerers: Answerers,
mut commands: Commands,
) {
for (call, summarizing, &ForAgent(agent)) in &calls {
let Some(summary) = &summarizing.summary else { continue };
if answerers.can_answer(Some(agent)) {
let ask = Ask::new("Use this summary?").body(summary.clone()).free_text();
let ask = ask.choice("use", "Use it").choice("cancel", "Keep the messages");
commands.spawn((ask, ForAgent(agent), HoldOf(call)));
commands.entity(call).insert(Asked);
}
}
}
/// `cancel` replaces nothing, a text typed alone is the summary; the
/// question lets go of the call either way.
fn answered(
asks: Query<(Entity, &Outcome, &HoldOf), (With<Ask>, Added<Outcome>)>,
mut calls: Query<&mut Summarizing>,
mut commands: Commands,
) {
for (ask, outcome, &HoldOf(call)) in &asks {
let answer = Answer::of(outcome).unwrap_or_default();
if let Ok(mut summarizing) = calls.get_mut(call) {
if answer.is("cancel") {
summarizing.summary = None;
} else if answer.choices.is_empty() && answer.text.is_some() {
summarizing.summary = answer.text;
}
}
commands.entity(ask).remove::<HoldOf>();
}
}§A slash command
A command is a one-shot system, app.add_command(name, help, system),
taking In<CommandArgs>: the agent it was typed for, the args after
its name, and the mode its line was sent with; RunCommand::new(agent, line) runs one. An error notice about the agent while it runs refuses it:
the line goes back in the input with the error, and Enter then sends it to
the model as it is. A plugin that takes some / lines over orders its Gate
system .before(CommandSystems).
use rig_harness::prelude::*;
fn build(app: &mut App) {
app.add_command("remind", "Remind the agent of something after its turn", remind);
}
/// `/remind <text>`.
fn remind(In(args): In<CommandArgs>, mut commands: Commands, mut notices: MessageWriter<Notice>) {
if args.args.is_empty() {
notices.write(Notice::error(args.agent, "/remind needs a text"));
return;
}
// `Queue`: once the running turn would end, or now for an idle agent
// (`DeliveryMode` has the others).
let reminder = Deliver::new(args.agent, format!("Reminder: {}", args.args), DeliveryMode::Queue);
commands.trigger(reminder.with_author(Author::plugin("remind")));
}§What the user types
What the user types, in any front, is a message to its agent from
Author::user(), and so is what an agent or a plugin sends: Deliver spawns
an Incoming effect ForAgent the agent. A Prepare system may change it
(Added<Admitted>): its parts, text and attachments in the order the model
reads them (text(), text_mut() the first text); rig_basics::coding_tools
attaches the @path files it names in its public set AttachSystems (in
PrepareSystems::Compose). A Gate system may take it over by deciding it
(Outcome::custom(..)), as the harness runs a / line as a command. At Start
the kernel gives the rest to the agent, which starts a turn with it or keeps
it for its running turn as its mode says: accepted and not sent yet, it is
in the agent’s inbox,
Query<(&Incoming, &ForAgent), (With<Started>, Without<Outcome>)>. Its
effect-log record reads with MessageRecord::read(&record.kind).
The inbox over time. It is saved (a rig.inbox aside per message
until it is answered), and a restart brings it back Started and
Restored, admitted again, so a Prepare system that adds to messages
skips Started ones. A message taken back (decided) leaves it at once.
Each message a turn ended without sending gets Unsent { ended }, how the
turn ended (None when a restart brings it back to an idle agent): a Gate
system keeps it for the agent’s next model call by removing Unsent, or
takes it over by deciding it; else GateSystems::Fallback hands the
user’s back to the views as one Recalled (the terminal view’s input) and
puts the others in the conversation. An exit recalls nothing.
With the user’s next message. What a plugin wants the agent to read
when the user next writes, such as a background check’s failure, it keeps
in its own state and adds to the next Incoming from AuthorKind::User
in Prepare, a MessagePart::Attachment(Attachment { label, content }): no
turn of its own, no hold, and taken back by clearing that state.
A plugin that needs a mark on its own message spawns it,
(Incoming::new(text, mode), ForAgent(agent), MyMark), as Deliver does.
Delivered into a turn, the message gets DeliveredIn(turn) and the turn’s
first model call is recorded under it; it is despawned once it settles, so a
plugin marks the turn then (On<Add<DeliveredIn>>) and finds its calls by
CallOf(turn). A user’s message may carry a title,
Author::user().titled(..) (conversation.author(at)). What the user typed
in message at, without attachments or what a plugin delivered, is
typed(&conversation, at) (first_typed for the first).
use rig_harness::prelude::*;
fn build(app: &mut App) {
// Before the harness's systems that decide messages too.
let gate = keep_to_self.before(CommandSystems).in_set(Stage::Gate);
app.add_systems(AgentPass, gate)
.add_command("review", "Asks for a review, on the reviewing model", review)
.add_observer(mark_review_turn);
}
/// On a message `/review` sent, and on the turn it went into, for a
/// plugin to route its calls.
#[derive(Component)]
struct Review;
/// `/review` asks the model for a review, titled so the transcript says so.
fn review(In(args): In<CommandArgs>, mut commands: Commands) {
let text = "Review the change you just made.";
let author = Author::user().titled("review");
let message = Incoming::new(text, args.mode).with_author(author);
commands.spawn((message, ForAgent(args.agent), Review));
}
/// The review's message goes once it settles; its turn keeps the mark.
fn mark_review_turn(
delivered: On<Add<DeliveredIn>>,
reviews: Query<&DeliveredIn, With<Review>>,
mut commands: Commands,
) {
if let Ok(&DeliveredIn(turn)) = reviews.get(delivered.entity) {
commands.entity(turn).try_insert(Review);
}
}
/// A line that starts with `#` is the user's note to self: it never
/// reaches the model.
fn keep_to_self(
mut typed: Query<(&Incoming, &ForAgent, &mut Decision), (ReadyToStart, IncomingMessages)>,
mut notices: MessageWriter<Notice>,
) {
for (typed, &ForAgent(agent), mut decision) in &mut typed {
if typed.author.kind == AuthorKind::User && typed.text().starts_with('#') {
decision.decide(Decider::plugin("to-self"), Outcome::custom("kept"));
notices.write(Notice::info(agent, "Noted; not sent."));
}
}
}§Model requests
A model call is an effect entity with a ModelRequest: the rig-core
CompletionRequest it sends (tools, options), the connection it goes to,
else its agent’s model (Models::connect makes one), and, for a request the
kernel built, its system prompt in parts, prompt (the agent’s role,
then the PromptSections by order), and its messages, history, a
view of the agent’s conversation whose edits are never saved.
The kernel builds a turn’s own call (TurnCall), or one from an agent
outside its turns (SideCall(agent)), when it is admitted (an
On<Add<Admitted>> observer runs before, a Prepare system after). A plugin
changes it in PrepareSystems::Edit, editing history as a
conversation by its Author (history.unsaved() says
who changed what), so compaction measures what is sent.
Start puts the prompt and history into the request’s messages and fits it
to the model it goes to: an unset option takes that model’s default (its
caching), one it cannot take is left out.
- Routing a call to another model is a Prepare edit of its
connection; it then asks for that model’s caching. - Adapting the model, whichever it is, such as so it reads tools as
text, is an
Adapterpushed on the call’sAdapters, applied at Start to the connection the call finally goes to, in order,outermost()ones last, so a router and an adapter never undo each other. It wrapsconnection.handlerwith rig-core’sErasedHandler::layeredormap_stream(each streamed item rewritten, and recorded so), and says what the wrapped model takes onconnection.spec. - What a call is sent as.
ModelStarts::sentgives a call’s connection and its request as Start will send it (to measure inPrepareSystems::Fit),droppedthe options its model leaves out,promptits system prompt in parts. From Start on, a call hasSentTo(connection), its request as sent, andDroppedOptions, the options left out (by the model’s spec or a dry run, rig-core’sServe::check). A fact amap_streamclosure inserts in the call’sPublishedContextis itsPublishedonce answered. - Caching. A provider that caches by marker marks the prefixes a
request names in
cache_points(Tools,System,Messages(n)). A section that changes every turn breaks every prefix after it, so a plugin moves it inPrepareSystems::Edit(prompt.take(tag)) to the last message. - Tools. The kernel offers an agent the tools its
ToolAccessallows. A Prepare edit takes some out (call.request_mut().tools.retain(..), and aprompt.sectionsline on why), but a tool left out still runs if called: a Gate system refuses a call;ToolAccessis the hard limit. - Below every adapter,
models.0.wrap_http(|inner| inner.with_middleware(..))inbuildhands every model call’s HTTP requests to a rig-coreHttpMiddleware, such as one that rotates API keys: each hook gets the request’sExchange(method, URI, and extensions holding the call it serves,rig_core::serve::Serving), andafter_responsesees every reply’s status and headers, an error reply’s (a 429’s) too. - What the provider said is rig-core’s: a reply’s
unfinished_calls()(cut onLength),spec.compat.prefill,request.foreign_reasoning,Native::reasoning_blob, anOrigin’smodel. - Reaching a vendor.
models.0.provide(vendor, provider)reaches its models through the plugin’s provider (ConnectOptionswith a key);models.0.provider(vendor)hands over the one it replaces. - Answering later, from elsewhere, such as a batch API: the model is
connected with rig-core’s
ErasedHandler::unavailable(descriptor, why), and a system inGateSystems::Afterholds each call, submits whatModelStarts::sentgives, and keeps the batch’s id in a saved component by turn (a restart runs the call again,Restored, to hold again). AWakeAtpolls, and rig-core’swire::decode_replyturns the result’s body into the reply it decides the call with.
A failed call carries the kernel’s RetryVerdict (Retry, GaveUp,
Overflow, Final) in Review: a reviewer edits it in place, takes the
failure over with a successor, or holds the call (HoldOf) while it works, as
rig_basics::compaction does to summarize first; otherwise the kernel retries
after a Backoff or proposes the turn’s end. Only a call its own model
answered is judged. A call refused before it was sent fails the turn and keeps
the user’s message.
A fixed part of the system prompt is a PromptSection entity,
PromptSection::new(order, tag, text), in every agent’s prompt, or with
SectionOf(agent) in that agent’s alone. The provider caches a conversation
from its start, so a section whose text changes costs the whole conversation’s
cache once per change: rig’s own prompt (rig-basics’ DEFAULT_SYSTEM_PROMPT)
never changes, and has no date, paths or instruction files in it. Put what
changes in a message instead,
Deliver::new(agent, text, DeliveryMode::Inform) with
.with_author(Author::plugin(name)) (a note without one is the user’s, and
plugins that react to the user’s messages react to it): it reaches the model
with its next request and stays in the conversation, cached.
use std::sync::Arc;
use rig_harness::prelude::*;
use rig_harness::rig_ecs::effects::Handler;
/// The model a turn falls back to when its own gave up.
const FALLBACK: &str = "deepseek/deepseek-flash";
fn build(app: &mut App) {
let edits = tools_as_text.in_set(PrepareSystems::Edit);
app.add_systems(AgentPass, (edits, fall_back.in_set(ReviewSystems::Act)));
}
/// Tools as text for a model that calls none, whichever a router picks.
fn tools_as_text(mut calls: Query<&mut Adapters, (Added<Admitted>, With<TurnCall>)>) {
for mut adapters in &mut calls {
adapters.0.push(Adapter::new("text-tools", |mut connection: Connection| {
if !connection.spec.tools {
let mut spec = (*connection.spec).clone();
spec.tools = true;
connection.spec = Arc::new(spec);
// Each streamed item, rewritten: here passed on, where the
// plugin's parser reads tool calls from the text.
let pass_on = |_published| |item| vec![item];
connection.handler = Handler(connection.handler.0.map_stream("text-tools", pass_on));
}
connection
}));
}
}
/// A turn's call whose retries gave up is sent again at once, on the
/// fallback model, unless it went there already.
fn fall_back(
failed: Query<
(Entity, &RetryVerdict, &ModelRequest, &Effect, &CallOf, &ForAgent),
(ReadyToSettle, With<TurnCall>, Without<SupersededBy>),
>,
models: Res<Models>,
mut commands: Commands,
) {
for (call, verdict, request, effect, of, agent) in &failed {
let routed = request.connection.as_ref().map(|to| to.spec.reference());
if !matches!(verdict.0, Verdict::GaveUp(_)) || routed.as_deref() == Some(FALLBACK) {
continue;
}
let Ok(fallback) = models.connect(FALLBACK) else { continue };
let retry = (TurnCall, CallOf(of.0), ForAgent(agent.0), Effect::with_parent_id(effect.parent));
commands.spawn((request.clone().on(fallback), retry)).add_related::<SupersededBy>(&[call]);
}
}§What a model streams
Every model call streams, a turn’s and a plugin’s own alike. Each event is a
StreamDelta { effect, agent, event } message carrying rig-core’s
StreamEvent: a part’s Start (a tool call’s name), its Text,
Reasoning or Arguments fragments, and its End (a finished tool call
with its id); part tells parallel tool calls apart. It is written in
Arrive in order, after the call’s adapters rewrote it, and kept two frames,
so a reader in PostUpdate gets a reply’s last events even when it settled
that frame. A turn’s call also adds its text and reasoning up in its
Partial (a successor continuing a reply may start it with the text so
far), and from its first item has a StreamTiming { first, last, items }
beside its Started { at }. A delta moves no stage: a reader spawning
effects runs .before(AgentLoop) or calls Wake::wake.
use rig_harness::prelude::*;
fn build(app: &mut App) {
app.add_systems(PostUpdate, print_text).add_observer(time_to_first);
}
/// Prints every model call's answer text as it comes.
fn print_text(mut deltas: MessageReader<StreamDelta>) {
for delta in deltas.read() {
if let StreamEvent::Text { text, .. } = &delta.event {
print!("{text}");
}
}
}
/// Says how long each model call took to start answering.
fn time_to_first(
streamed: On<Add<StreamTiming>>,
calls: Query<(&StreamTiming, &Started, Option<&ForAgent>)>,
mut notices: MessageWriter<Notice>,
) {
if let Ok((streamed, started, agent)) = calls.get(streamed.entity) {
let waited = streamed.first.duration_since(started.at).unwrap_or_default();
let text = format!("First token after {} ms.", waited.as_millis());
notices.write(Notice::info(agent.map(|agent| agent.0), text));
}
}§How a turn ends
A turn is an entity TurnOf its agent, with a Turn: its index among the
agent’s turns over the session, and start, the MessageId it begins at
(conversation.start_of(id) is its index now, asked(id) the entries it
answers, request(id) the one that asked). It is spawned before its first
request is built, so an On<Add<TurnOf>> observer’s edit is in that request;
once ended it loses TurnOf and keeps ForAgent.
Its end is a proposal. When the model answers without calling tools, or the
turn fails, the kernel proposes a TurnOutcome (Answered(message),
Failed(reason)) in Commit. A Review system sees it in the next pass
(Added<TurnOutcome>) and may:
- edit it in place;
- hold it (
HoldOf(turn)); - carry the turn on: a
QueueorSteermessage the agent accepts takes anAnsweredproposal away, and so does a new(TurnCall, CallOf(turn), ForAgent(agent))once admitted.
A plugin may insert TurnOutcome::Failed(reason) or Stopped(Some(reason))
at any time, such as a budget: calls not started never start, the others are
answered as stopped (or with their IfStopped). StopTurn stops a turn at
once, and the turns of every agent below it in SpawnedBy, even when its own
agent is idle: stopping follows ancestry, so work meant to outlive its parent
is spawned without SpawnedBy. A call that did not finish reads rig-core’s
tool::feedback::not_finished: “Not run: …” when it never started, else
“Stopped: …” and whether it only read or may have partly run (an Independent
footprint may have). Once nothing holds the outcome and every call settled,
the turn ends, Settled until the next frame (an On<Add<Settled>> observer
may Retire the agent). A turn with no TurnCall left stays open until
something inserts a TurnOutcome.
Turn records. Each turn is saved in its agent’s conversation as a
TurnRecord aside by rig (TURN), and Turn is filled from it, a
restarted turn’s too: its index, start, and how it ended,
TurnEnd::Answered { answer }, Failed { why } or Stopped { why }, or
None while it runs or once a crash or an exit cut it short;
conversation.turns() lists them, for a program without the app too. A stop
mid-reply saves what streamed as an aborted reply (StopReason::Aborted) no
model reads again, its request halted() Stopped, and the kernel’s note
after it by Author::rig() (EntryRef::is_stop_marker); a failed or
plugin-stopped turn’s request is halted Kept. A front shows a stop from
the halt, never from text.
Ending a turn with an answer already known, such as the JSON a
submit tool was called with: a Gate system decides the turn’s next
model call itself, with a completion of that text. Nothing is sent, the
turn ends Answered with it, and --print prints it as the answer.
use rig_harness::prelude::*;
use rig_harness::rig_core::completion::{CompletionResponse, Usage};
use rig_harness::rig_core::effect;
use rig_harness::rig_core::message::{AssistantContent, Origin, Text};
fn build(app: &mut App) {
app.add_systems(AgentPass, end_with.in_set(Stage::Gate))
.add_systems(AgentPass, check_the_tests.in_set(Stage::Review));
}
/// On a turn whose answer is known: the text it ends with.
#[derive(Component)]
pub struct EndWith(pub String);
/// Answers the turn's next model call with the known text.
fn end_with(
mut calls: Query<(&CallOf, &mut Decision), (ReadyToStart, ModelCalls, With<TurnCall>)>,
turns: Query<&EndWith>,
) {
for (&CallOf(turn), mut decision) in &mut calls {
if let Ok(EndWith(text)) = turns.get(turn) {
let choice = vec![AssistantContent::Text(Text::new(text.clone()))];
let origin = Origin::new("plugin", "submit", "submit");
let reply = CompletionResponse::new(choice, Usage::default(), origin, serde_json::Value::Null);
decision.decide(Decider::plugin("end-with"), Outcome(Ok(effect::Answered::Completion(reply.into()))));
}
}
}
/// A turn that would end with an answer is asked once more, as its next
/// step, whether the tests pass.
fn check_the_tests(
proposed: Query<(&TurnOf, &TurnOutcome, &Turn), Added<TurnOutcome>>,
mut asked: Local<Vec<(Entity, u64)>>,
mut commands: Commands,
) {
for (&TurnOf(agent), outcome, turn) in &proposed {
let first = !asked.contains(&(agent, turn.index));
if matches!(outcome, TurnOutcome::Answered(_)) && first {
asked.push((agent, turn.index));
let ask = Deliver::new(agent, "Do the tests pass?", DeliveryMode::Queue);
commands.trigger(ask.with_author(Author::plugin("check-the-tests")));
}
}
}§Effects of a plugin’s own
A plugin’s own work gets the same stages, recording and visibility as the
kernel’s when it is an effect: an EffectRequest with its rig-core
EffectKind (Custom for a kind of its own) and a handler the kernel
runs at Start, or none, an open effect the plugin decides when done.
Others can gate it, review its outcome and its Published facts (an open
effect inserts its own as it decides: Published(context.result_context())
of a rig-core ToolContext it insert_results them into) and replay it;
CallOf(turn) keeps a turn open until it settles. Work off the main
thread is Running::answering(pool, wake, future) on the effect.
What a running effect would answer if stopped. Its owner keeps an
IfStopped(outcome) on it current (the output so far), which the kernel
answers with in place of “stopped”, and on a tool call a
ToolProgress line (12 of 40 files); a tool that prints as it works
reports through a rig-tools Live from CallOutput::new(&wake).
An owner follows its effect’s successor. Another plugin may take the
effect over, such as a watchdog that sends a stalled call again
(commands.queue(resend(call))). A plugin that waits on its effect by a
marker registers app.add_observer(follow_successor::<Marker>), which
moves the marker and the effect’s hold to each successor. Make a plugin’s
model call an effect (a ModelRequest) so the effect log and spending see
it; a call made directly, they never see.
use rig_harness::prelude::*;
use rig_harness::rig_core::effect::EffectKind;
/// On a deploy effect: the steps its pipeline (not shown) reported.
#[derive(Component, Default)]
struct Deploy(Vec<String>);
fn build(app: &mut App) {
app.add_command("deploy", "Deploy a target", deploy)
.add_observer(follow_successor::<Deploy>)
.add_systems(Update, track.before(AgentLoop));
}
/// `/deploy`: a deploy effect any plugin can gate.
fn deploy(In(args): In<CommandArgs>, mut commands: Commands) {
let kind = EffectKind::Custom { kind: "deploy".into(), payload: serde_json::json!({ "target": args.args }) };
commands.spawn((EffectRequest { kind, handler: None }, ForAgent(args.agent), Deploy::default()));
}
/// Keeps what each deploy answers if it is stopped first, the steps it
/// did, and answers it once its last step is done.
fn track(
mut deploys: Query<(Entity, &Deploy, &mut Decision), (Changed<Deploy>, PluginEffects)>,
mut commands: Commands,
) {
for (deploy, Deploy(steps), mut decision) in &mut deploys {
if steps.last().is_some_and(|step| step == "live") {
decision.decide(Decider::plugin("deploys"), Outcome::custom("deployed"));
} else {
let text = format!("stopped after: {}", steps.join(", "));
commands.entity(deploy).insert(IfStopped(Outcome::custom(text)));
}
}
}§Subagents
The rig_basics::subagents plugin spawns an agent for a SpawnSubagent
effect ForAgent its parent, made by the model’s task tool or a plugin.
Prepare systems edit it (model, tools, system prompt), Gate systems refuse or
hold it, and at Start the plugin spawns the agent, with what the request
leaves out from the parent, as SpawnedAgent(agent) on it.
SpawnSubagent::task(title, brief)works on a task and reports to its parent, astaskstarts it.SpawnSubagent::helper(title)works for the plugin, which talks to it: it has aHelperand anAgentTitle, reports to no one, andmessagecannot reach it;.brief(text)sends it a first message, and with.once()it retires when its first turn ends.
§Extending task
A plugin adds an argument to task with a property in its ToolDef’s
parameters and a sentence in its description on when to use it (editing
another plugin’s tool):
task puts each argument it does not take into SpawnSubagent.extra.
use rig_harness::prelude::*;
use rig_basics::subagents::{SpawnSubagent, TASK};
#[derive(Default)]
pub struct ReviewersPlugin;
impl Plugin for ReviewersPlugin {
fn build(&self, app: &mut App) {
app.add_systems(AgentPass, as_reviewer.in_set(Stage::Prepare));
}
/// In `finish`, so `task` exists whatever the plugins' order.
fn finish(&self, app: &mut App) {
let world = app.world_mut();
let mut tools = world.query::<&mut ToolDef>();
for mut def in tools.iter_mut(world) {
if def.0.name.as_str() != TASK {
continue;
}
let def = std::sync::Arc::make_mut(&mut def.0);
if let Some(properties) = def.parameters.get_mut("properties")
&& let Some(properties) = properties.as_object_mut()
{
let profile = serde_json::json!({ "type": "string", "enum": ["reviewer"] });
properties.insert("profile".to_owned(), profile);
}
def.description.push_str(" Give `task` the `reviewer` profile for a review.");
}
}
}
/// A task the model gave the reviewer profile may only read.
fn as_reviewer(mut requests: Query<&mut SpawnSubagent, Added<Admitted>>) {
for mut request in &mut requests {
if request.extra.get("profile").and_then(|profile| profile.as_str()) == Some("reviewer") {
request.tools = Some(vec!["read".to_owned(), "search".to_owned()]);
}
}
}§The effect log: reading, redacting, replaying
Every effect that settles is an EffectRecorded(EffectRecord) message, in
settle order: its request (kind), its answer (outcome, a reviewer’s as
reviewed), who decided it before it ran (decided_by), its times, the agent
it worked for (scope, an AgentId), its turn (Turn.index), and the
effect it was made under (parent). rig_basics::telemetry writes them to
the session’s effect log in Last (WriteLogs) and reads them back through
its EffectLog: read(), or read_matching(&filter) by agent, kind name
(completion, tool_call, message, subagent or a plugin’s) and turn
(rig-cassette’s EffectFilter::default().agent(id).turn(n)), as written by
the end of the last frame.
Redacting. A record holds what its effect really carried (tool output,
file changes with the text before, the request as sent). A plugin changes
records before they are written (MessageMutator<EffectRecorded>, below) and
rewrites those written once the
session stopped. rig-core’s redact finds keys and tokens by their shapes
(json, text).
Replaying. A replayer answers effects from a recorded log before
they start: a model call by an adapter placed outermost, the others in
GateSystems::FromRecord (rig-cassette’s RecordedAnswers pairs agents).
use rig_harness::prelude::*;
use rig_harness::rig_core::redact;
use rig_harness::rig_ecs::{StopTurnsOnExit, WriteLogs};
fn build(app: &mut App) {
app.add_systems(Last, hide_secrets.after(StopTurnsOnExit).before(WriteLogs));
}
/// Takes every key-shaped secret out of every record before it is written.
fn hide_secrets(mut records: MessageMutator<EffectRecorded>) {
for EffectRecorded(record) in records.read() {
let Ok(mut json) = serde_json::to_value(&*record) else { continue };
redact::json(&mut json);
if let Ok(clean) = serde_json::from_value(json) {
*record = clean;
}
}
}§Settings
A plugin’s settings are its table of the user’s RIG_HOME/settings.toml:
app.add_setting::<T>("section") in build inserts the resource T read
from [section], else T::default(); a table that does not read as T
keeps the default and is shown at startup. A test inserts
Settings::from_toml(..) before adding the plugin, so the user’s file is
never read. add_project_setting::<T>(section) also merges the table of
the project’s .rig/settings.toml over the user’s, key by key, at
startup (a cloned repository changes only what a plugin lets it). Here a
command-line option overrides the setting.
use rig_harness::prelude::*;
use serde::Deserialize;
/// The `[budget]` table: `max_calls = 50`; no limit by default.
#[derive(Resource, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct Budget {
pub max_calls: Option<usize>,
}
fn build(app: &mut App) {
app.add_setting::<Budget>("budget")
.add_flag(Flag::new("budget", "Stop a turn after N tool calls").value("N"));
let invoked = app.world().get_resource::<Invoked>();
if let Some(n) = invoked.and_then(|it| it.flag_value("budget")?.parse().ok()) {
app.insert_resource(Budget { max_calls: Some(n) });
}
}§Saved state: counting every tool call
An agent component that derives Reflect and says
#[reflect(Component, Saved)] is kept with the session (a generic type only
once registered): each change is logged, and a restart, /reload or
/resume inserts the newest value on the restored agent before
SessionRestored exists, so an On<Insert<T>> observer can re-arm what it
says. A resource that says #[reflect(Resource, Saved)] and is inserted in
build is kept the same way, and one holding rig-core data, such as a
MessageId, is saved through serde. A plugin says what it keeps
(keeps_saved::<T>(), keeps_files("vim")) as Keeps on its entity. A
changed type needs no upgrade step (saved value).
use std::collections::HashMap;
use rig_harness::prelude::*;
/// How often each agent called each tool, kept across restarts.
#[derive(Component, Reflect, Default)]
#[reflect(Component, Saved)]
struct ToolCounts(HashMap<String, u32>);
fn build(app: &mut App) {
app.register_required_components::<Agent, ToolCounts>()
.keeps_saved::<ToolCounts>()
.add_observer(count);
}
/// Counts each call as the model asks for it, not again on a restart.
fn count(
call: On<Add<ToolRequest>>,
calls: Query<(&ToolRequest, &ForAgent), Without<Restored>>,
mut counts: Query<&mut ToolCounts>,
) {
if let Ok((request, ForAgent(agent))) = calls.get(call.entity)
&& let Ok(mut counts) = counts.get_mut(*agent)
{
let name = request.call.function.name.as_str().to_owned();
*counts.0.entry(name).or_default() += 1;
}
}§Changing history
An agent’s Conversation is rig-core’s, changed by calling its methods. Each
message is an entry with a MessageId (id(i), index_of(id)) that stays
across edits and restores, the time it was made (EntryRef::made) and, when
it is not the user’s own or the model’s, an Author (Author::plugin(name),
agent(..), rig(), or the user titled). Every change names its author.
| To | Call |
|---|---|
| add a message | push(Entry::new(message).by(author)) |
replace a range: a cut (from.., nothing), a summary, an edit (entry(i) passed back changed) | splice(range, entries, by); one that reaches the end clears the halt |
| put back entries it held (a summary’s originals, an undone edit), ids, times and asides kept | restore(range, entries, by) |
| make it hold a whole list, such as a transcript edited as text | set_entries(entries, by) |
| merge adjacent replies and repair tool-call pairs | normalize(by) |
| find a message’s copy in another conversation | key(i) (a MessageKey), index_of_key |
where a span that began at id begins now | start_of(id) |
Asides never reach a model: an Aside { kind, after, author, value }
follows the entry after and is saved and restored with the
conversation unless unsaved() (add_aside, replace_aside,
remove_aside, asides()). The harness’s Note is the kind fronts draw
(the transcript).
Saving. A change waits in the conversation’s outbox until the kernel saves
it to the agent’s log: between the stages of every pass, at the end of the
frame, before a tool that may change something starts, and on a stop. Each one
saved is a ConversationChanged { agent, change }, a ChangeRecord with the
Change (Push, Splice, Halt, Aside, Reset for a value inserted over
the component), its author (by) and time. A splice before the end costs the
provider’s prompt cache from there on. Retire { entity } retires an agent
for good, with the agents it spawned.
Compaction (rig_basics::compaction) splices its summary, by
summary_author(), in place of what it summarized (summary_in(&change)
tells that splice apart). Its policy comes only from [compaction] in
settings.toml (CompactionDefault). Other compaction is a plugin listed in
its place, not an extension of it: rig’s
corpus/ct/src/keep_last_compaction.rs drops the oldest turns instead, on
public API alone. A request that does not fit takes one recovery path, carried
through retries (Recovering): old tool outputs cleared, then compacted, then
the turn fails; a model reply starts it again. A notes (Noting) or summary
call is the agent’s request cut where its reading ends, plus one instruction,
so the provider reads it from its cache; a Prepare edit of it costs that from
the edit on. In ReviewSystems::Act Summarizing.summary and Noting.notes
hold the text: a reviewer holds the call, replaces the text or sets None to
cancel. Uncompact { entity } puts the newest compaction’s messages back.
The session format is rig-memory’s (rig-session/4), read and written
without an app: Session::read(&*store)/session.save, load/save for one
conversation, and read_conversation for one agent’s alone. rig’s saved
values have stable keys (SavedAgent::model(), title(),
session.main_agent()). An importer opens home.new_session() with
StoreOpener::open, edits at the source’s time
(conversation.dated(at_ms, |c| ..)), lists it with
SessionMeta { title, .. }.write(&*store) and marks it saved
(dir.mark_saved()), so /resume lists it.
use rig_harness::prelude::*;
/// `/undo`: the user's last message and everything after it go, unless a
/// turn runs.
fn undo(In(args): In<CommandArgs>, mut agents: Query<&mut Conversation, Without<ActiveTurn>>) {
let Ok(mut conversation) = agents.get_mut(args.agent) else { return };
let last = conversation.entries().rev().find(|entry| entry.is_users_own());
if let Some(at) = last.map(|entry| entry.index) {
conversation.splice(at.., [], Author::plugin("undo"));
}
}§Rewriting saved history: a redaction
Records are only appended. A session no rig runs is rewritten in place with
rewrite_stopped(&stores, &paths, &dir, by, &with), under its lock: every
saved record keeps its place and stamp, and the store’s files, the scratch
files and the name are mapped too. The running session is refused; keys are
already redacted as its logs are written. with is a Rewrite: Strings
maps every string (a redaction, a path remap), rig_memory::Messages only
messages, or its own hooks per message, aside, saved value, text and file. A
program without an app calls rig_memory::rewrite(&store, by, &with) on a
session not runs_elsewhere().
use rig_harness::harness_protocol::SessionId;
use rig_harness::prelude::*;
fn build(app: &mut App) {
app.add_command("redact", "Take a text out of a stopped session: /redact <id> <text>", redact);
}
/// `/redact <id> <text>`: every saved copy of it in that session becomes `[REDACTED]`.
fn redact(
In(args): In<CommandArgs>,
(home, stores, paths): (Res<RigHome>, Res<StoreOpener>, Res<SessionPaths>),
mut notices: MessageWriter<Notice>,
) {
let Some((id, secret)) = args.args.split_once(' ') else { return };
let Ok(id) = id.parse::<SessionId>() else { return };
let secret = secret.to_owned();
let with = Strings(move |text: &mut String| {
let hit = text.contains(&secret);
if hit { *text = text.replace(&secret, "[REDACTED]") }
hit
});
let dir = home.0.session(&id);
let done = rewrite_stopped(&stores, &paths, &dir, Author::plugin("redact"), &with);
let text = done.map_or_else(|why| why, |it| format!("{} logs rewritten", it.logs.len()));
notices.write(Notice::info(None, text));
}§Time
Time is Bevy’s Time. .run_if(on_real_timer(Duration)) runs a system
once per interval, an entity with a KeepAwake(Duration) keeps the
sleeping loop running at that pace, and a one-off wait is
commands.delayed().duration(Duration). Bevy’s clock stops while the
machine sleeps; a time of day is a WakeAt(SystemTime) on an entity,
which gets Due once it passed. A wait that may be put off or called off,
such as a debounce, is a WakeAt::after(wait) on an entity of the
plugin’s: a new one inserted puts it off, despawning calls it off.
use rig_harness::prelude::*;
/// How often a running turn says it is still running.
const EVERY: Duration = Duration::from_secs(60);
fn build(app: &mut App) {
app.add_observer(keep_awake)
.add_systems(Update, still_working.run_if(on_real_timer(EVERY)));
}
/// A turn keeps the loop awake while it runs (until its entity goes).
fn keep_awake(turn: On<Add<TurnOf>>, mut commands: Commands) {
commands.entity(turn.entity).insert(KeepAwake(EVERY));
}
fn still_working(turns: Query<&TurnOf>, mut notices: MessageWriter<Notice>) {
for TurnOf(agent) in &turns {
notices.write(Notice::info(*agent, "Still working."));
}
}§The workspace, files and child processes
ProjectRoot is the directory the session works in. An agent with a
WorkDir, such as its own worktree, works there instead, and so does an
agent it spawns: the coding tools resolve its paths from it and refuse one
outside it, its commands start there, and its prompt names it. Both are
paths in the app’s workspace, ProjectWorkspace: this machine’s
(rig-tools’ Local) unless a plugin puts another in (a rig-tools
Workspace, such as one over SSH, workspace.0.host()), which may be
swapped while the app runs, so read it on each use. RigHome is the
RIG_HOME layout, TokioRuntime rig’s tokio runtime; a plugin that
should also run without the harness’s plugins inits what it reads.
The main-thread rule. A workspace call may wait on the network. Tools
call it inside blocking; a system calls it inline only while host() is
None, else as Running::spawn work, holding what waits for it (a turn’s
first TurnCall, admitted in Prepare), as read_todo below does. A file
tool resolves paths with workspace.place(base, confine, path);
edit::apply(path, text, edits) is what edit makes of a text, and
rig_tools::split_status parts a shell result from its status line.
What a call changed. The file tools publish the files they write as
FilesChanged, each change with the FileState before and after, its
hunks (unified(), changed_lines()) and, for a text file read whole,
before_text (after_text() applies the hunks to it). With
rig_basics::checkpoint::CheckpointPlugin added, each call that may write
anywhere (Writes(Anything): shell, a tool that does not say) is wrapped
in rig-tools’ Checkpointed, which publishes the difference
between snapshots just before and after it runs; CallCheckpoint::before()
on the call and TurnCheckpoint::first() on the turn keep the files before,
and CheckpointAlso names ignored paths to include. It says what changed
while the call ran, not who changed it. A plugin’s own snapshots keep in a
directory of their own under the scratch: two with one Taking::keep of one
work tree share a git index and race on its lock.
Child processes. Processes is how every child process starts, a
rig-tools Launcher (a wrapper command, a directory, environment): the
shell tool reads it for each command, so a sandbox plugin that sets it
(processes.set(..)) wraps every later process. A plugin builds its own
with processes.launcher().command(program), or .spawn(program, args, Spawn { stdin, merge_stderr }), a Process. Every launcher it hands out
drops the session’s secrets,
which a bare std::process::Command passes on; one that runs in the
project goes through the workspace (workspace.0.spawn(&launcher, ..)).
Files and streams. An entity with a FileWatch gets FileChanges
messages: first the files under its path when armed (read them then, so
none made in between is missed), then each batch created, changed or
removed (another workspace may look every second), leaving out what the
workspace’s listing does (ignored and hidden files, target) unless
.listing(..) says otherwise: rig-tools’ Listing::Hidden adds the
hidden files the ignore rules keep, All every file, as for
Workspace::files and the list and search tools’ hidden argument. A
long-lived source, such as a socket, is a Running::stream(pool, &wake, items) on an entity: each item is inserted on it as a Done<T>, in
order, waking the loop, and the Running goes once the stream ended. A
Done on a hold or a plugin’s own entity moves no effect, so no stage
after Arrive runs for it: react with On<Insert<Done<T>>> (releasing a
hold or deciding there is a move), never a stage system.
use rig_harness::prelude::*;
/// The templates in `.rig/templates`, watched.
#[derive(Component)]
struct Templates;
fn watch(mut commands: Commands, root: Res<ProjectRoot>) {
commands.spawn((Templates, FileWatch::tree(root.0.join(".rig/templates"))));
}
fn reload(
mut changes: MessageReader<FileChanges>,
templates: Query<(), With<Templates>>,
mut notices: MessageWriter<Notice>,
) {
for change in changes.read().filter(|change| templates.contains(change.watch)) {
notices.write(Notice::info(None, format!("{} templates changed", change.paths.len())));
}
}
/// The project's `TODO.md`, once read.
#[derive(Component)]
struct Todo;
/// Reads `TODO.md` where the project is: at once on this machine, off the
/// main thread in another workspace, so no frame waits on the network.
fn read_todo(
workspace: Res<ProjectWorkspace>,
root: Res<ProjectRoot>,
wake: Res<Wake>,
mut commands: Commands,
) {
let (workspace, path) = (workspace.0.clone(), root.0.join("TODO.md"));
if workspace.host().is_none() {
commands.spawn((Todo, Done(workspace.read_text(&path).unwrap_or_default())));
return;
}
let pool = bevy_tasks::IoTaskPool::get_or_init(bevy_tasks::TaskPool::default);
let read = blocking(move || workspace.read_text(&path));
commands.spawn((Todo, Running::spawn(pool, &wake, async move { read.await.unwrap_or_default() })));
}§Sessions: their store, secrets and exiting
The store. What a session holds goes through its store, a rig-memory
Store (SessionStore): each agent’s log, and files by name, its images’
blobs among them (under blobs/, named by their content). The harness opens
it once every plugin is built and the run’s front is chosen, and reads it
before writing: one not readable whole is refused unchanged. A plugin that
keeps sessions elsewhere or sealed puts its own StoreOpener::new(|dir| ..)
in while the app is built (imports, deletes and /resume open stores through
it); a front whose runs keep their session elsewhere, such as in a chat
thread, says so, Front::session_store(open), for the running session alone.
Reading or writing a store may block the frame: keep it to commands and
startup, as /resume does. What is named by path stays plain, under
Scratch: cut tool output, pasted images and the text log (Scratch(None)
keeps none). A copy lists a store’s files with files(), and a command that
rewrites a session skips one that runs_elsewhere().
A plugin’s own files go in PluginData, there in every run: rig-memory
Files (write_file, read_file, files), names led by the plugin’s
(vim/registers.json). session is in the session’s store and removed with
it, project this user’s for the project ($RIG_HOME/projects/), and user
every session’s ($RIG_HOME/data/).
Secrets. A secret a plugin needs before the session is restored, such as a
store’s passphrase, is asked for in build with
app.add_secret(Secret::new(name, help)) and read from Secrets. A launcher
at a terminal asks for each one its environment (RIG_SECRET_<NAME>) lacks,
once, and passes it to the agent’s process alone, never to a process rig
starts: it is kept from tools and the model’s commands, not from plugin code,
which is trusted.
Exiting. A plugin that ends the run triggers Exit(reason)
(ExitReason::Quit, Reload, Switch(Some(id)) or Switch(None),
Signal, Failed(code)), never AppExit; the first reason is the
ExitReason resource. Each exit asked for is an app-level effect, an
ExitRequest, and the app exits when the first one settles. A plugin
delays it by holding it, with a deadline of its own: nothing cancels an
exit, and a SIGTERM waits as long as the hold. A second exit is a second
effect, which a holder may take as “stop waiting”.
- Start:
AppStartsays why this app started (StartReason):New,Resumed,Reloadedafter/reload, orRestartedon the previous build after a rollback; a switch isNeworResumed. - Fork an agent:
ForkAgent { agent, through }makes a new agent in this session, no restart: its log is a copy ofagent‘s up to a turn boundary at or beforethrough(Nonefor the end), so a compaction made before the cut is undone in it too; it has every saved componentagenthas now (model, reasoning, tools, prompt, working directory), its connection and its prompt-cache key (CacheKey).AgentForked { from, fork, through }names it.rig_basics::sessions’/fork,/agentsand rewind are built on it. - Edit a stopped one:
edit_stopped(&stores, &paths.0, &dir, |session| ..)under its lock;PluginData::of(&stores, &dir)opens its plugin files. - Delete:
DeleteSession(id), finished by the next start if a crash cut it short (SessionDeleted, elseSessionDeleteFailed). - Name: a plain file in its directory (
SessionDir::name), written byrig_basics::sessions’/nameandNameSession { session, name }, looked up byrig --resume <name>,/resume <name>andHome::find.
use std::io;
use std::sync::Arc;
use rig_harness::harness_protocol::SessionDir;
use rig_harness::prelude::*;
use rig_harness::rig_memory::{DirStore, SessionError};
/// Keeps every session's files on the disk mounted at `/vault`, `rig bot`
/// runs' in `/vault/bot`, and spilled output and pasted images nowhere.
fn build(app: &mut App) {
app.insert_resource(Scratch(None))
.insert_resource(StoreOpener::new(|dir: &SessionDir| vault("/vault", dir)));
// The bot's front sets itself up in `finish` (not shown).
let bot = Front::new("bot").on_command("bot", "", "Answer in the chat");
app.add_front(bot.session_store(|dir: &SessionDir| vault("/vault/bot", dir)))
.add_systems(AgentPass, hold_exit.in_set(Stage::Gate))
.add_observer(give_up);
}
fn vault(root: &str, dir: &SessionDir) -> Result<Arc<dyn Store>, SessionError> {
let id = dir.id().ok_or_else(|| io::Error::other("not a session of RIG_HOME"))?;
Ok(Arc::new(DirStore::open(format!("{root}/{id}"))?))
}
#[derive(Component)]
struct NotesHold;
/// Holds the first exit until the notes are written, at most 10 s.
fn hold_exit(
exits: Query<Entity, (With<ExitRequest>, ReadyToStart, PluginEffects)>,
mut held: Local<bool>,
mut commands: Commands,
) {
// Once: a second exit asked for passes, as "do not wait".
if let (false, Some(exit)) = (*held, exits.iter().next()) {
*held = true;
let deadline = WakeAt::after(Duration::from_secs(10));
commands.spawn((Name::new("writing the exit notes"), NotesHold, HoldOf(exit), deadline));
// ... write the notes, then despawn the hold.
}
}
/// `WakeAt` only marks its entity `Due`: the deadline releases the hold.
fn give_up(due: On<Add<Due>>, holds: Query<(), With<NotesHold>>, mut commands: Commands) {
if holds.contains(due.entity) {
commands.entity(due.entity).despawn();
}
}§The native session views
A SessionPicker on a Picker (rig_basics::session_picker, with its
SessionPickerPlugin added) is the native view of the saved sessions
(SessionSource::Saved, Sessions(ids)) or of one agent’s entries
(Entries): filled in place from tasks as they are read, filtered by
SessionFilter (running ones, a project, a text), the highlighted item
previewed (PickPreview, which any picker sets as its Highlighted moves).
Picking runs its command with {session}, {agent} and {entry} filled
in, or, with none, puts SessionPicked on the entity before Picked; show
labels items. /resume and /fork at are this view; a program reads the same
with Home::saved_sessions(), SessionMeta::read(&store) and
read_conversation.
use rig_basics::session_picker::{SessionPicked, SessionPicker, SessionSource};
use rig_harness::prelude::*;
/// `/fork-at`: the entries of the agent's conversation, previewed; the one
/// picked ends a fork of the agent (`rig_basics::sessions`' `fork {entry}` as a
/// `command` does the same).
fn fork_at(In(args): In<CommandArgs>, mut commands: Commands) {
let source = SessionSource::Entries { session: None, agent: None };
let picker = Picker::new("Fork through which message?").for_agent(args.agent);
let agent = args.agent;
let fork = move |picked: On<Picked>, chosen: Query<&SessionPicked>, mut commands: Commands| {
if let Ok(SessionPicked { entry: Some(through), .. }) = chosen.get(picked.entity) {
commands.trigger(ForkAgent { agent, through: Some(*through) });
}
};
commands.spawn((picker, SessionPicker { source, ..Default::default() })).observe(fork);
}§Data on a session
A plugin keeps any serde value with the session under a key of its own:
app.saved_as::<T>("goals.goal") logs a saved resource or agent component
under that key, not its type’s path. A program without Bevy reads it typed,
Session::value::<T>(key) (an agent’s, SavedAgent::value), and writes a
stopped session’s through edit_stopped (which keeps its listing).
.listed() also copies it into the session’s listing (SessionMeta.listed;
keep it small), so /resume, SessionMeta::read and a SessionPicker’s
filter show and find it without reading a log.
use rig_harness::harness_protocol::SessionDir;
use rig_harness::prelude::*;
use rig_harness::rig_memory::{DirStore, Session};
/// What the session is for, shown in `/resume`.
#[derive(Resource, Reflect, Clone, Default, serde::Serialize, serde::Deserialize)]
#[reflect(opaque, Resource, Saved, Default, Clone, Serialize, Deserialize)]
struct Goal(String);
fn build(app: &mut App) {
app.init_resource::<Goal>().saved_as::<Goal>("goals.goal").listed();
}
/// A program's read of a stopped session's goal, without Bevy.
fn goal_of(dir: &SessionDir) -> Option<String> {
let store = DirStore::open(dir.path()).ok()?;
let session = Session::read(&store).ok()?;
session.value::<String>("goals.goal")?.ok()
}§Adding to another plugin’s lists
A plugin that owns a list builds it from entities other plugins spawn, as
the system prompt is built from PromptSections: spawn one with your text
and keep it current; an empty text or Disabled leaves it out.
| List | Spawn | Crate |
|---|---|---|
/model and its picker: a choice that is no catalog model, run by name | ModelItem::new(name, label, command); an agent’s ModelLabel says what its model is in the status line | rig_basics::models |
/resume: a part of this session’s line, kept with it | ResumeLabel::new(tag, text) | rig_basics::sessions |
/agents: a column of one agent’s line | (AgentColumn::new(tag, text), ColumnOf(agent)), despawned with the agent | rig_basics::sessions::agents |
/usage: a line after the usage | UsageRow::new(tag, text) | rig_basics::telemetry::usage |
--print: text printed after the answer | PrintSection::new(tag, text) | rig_basics::print |
A provider plugin’s models are catalog models: it declares its vendor and
adds them to the catalog in Models (rig-core’s ProviderId::declare,
Connector::provide, catalog_mut) at any time.
Spending is data too. rig_basics::telemetry adds each settled model call’s
usage to its agent’s AgentUsage, the session’s SessionUsage (both saved)
and its turn’s TurnUsage, per model and per purpose (turn, compaction, a
plugin’s Effect::with_purpose), and shows the session’s in its /resume
line (the spent label); a plugin that pays for work that is no model request
records it, and one whose model call settles without a reply (superseded,
cancelled) puts its usage on it, CallUsage.
use rig_harness::prelude::*;
use rig_basics::print::PrintSection;
use rig_basics::telemetry::usage::{SessionUsage, UsageRow};
/// What the session may spend, in USD.
const CAP: f64 = 5.0;
/// Refuses the turns' model calls once the session spent `CAP`, and says
/// what is left in `/usage` and after a `--print` answer.
fn build(app: &mut App) {
app.world_mut().spawn((UsageRow::new("budget", ""), PrintSection::new("budget", "")));
let show = show_budget.run_if(resource_exists_and_changed::<SessionUsage>);
app.add_systems(AgentPass, refuse_over_budget.in_set(Stage::Gate)).add_systems(Update, show);
}
fn refuse_over_budget(
mut calls: Query<&mut Decision, (ReadyToStart, ModelCalls, With<TurnCall>)>,
spent: Option<Res<SessionUsage>>,
) {
if spent.is_none_or(|spent| spent.0.total.cost < CAP) {
return;
}
for mut decision in &mut calls {
let why = format!("The session spent its ${CAP:.2} budget.");
decision.decide(Decider::plugin("budget"), Outcome::refused(why));
}
}
fn show_budget(spent: Res<SessionUsage>, mut shown: Query<(&mut UsageRow, &mut PrintSection)>) {
let left = (CAP - spent.0.total.cost).max(0.0);
let text = format!("Budget: ${left:.2} of ${CAP:.2} left.");
for (mut row, mut section) in &mut shown {
row.text.clone_from(&text);
section.text.clone_from(&text);
}
}§Turning off or replacing what another plugin added
A whole plugin is replaced in plugins.toml: disable its entry and list
yours, as for compaction. Tools, slash commands,
renderers, key bindings, options, fronts and prompt sections are entities.
Disabled on one turns it off and frees its name for a replacement (a second
one of a taken name is refused), in Plugin::finish, which runs once every
build did. What a plugin spawns in build, finish and cleanup is marked
ProvidedBy it; what it spawns later it marks itself
(Res<PluginEntity<MyPlugin>>). Changing how another plugin’s tool runs is
rarely a replacement: a Prepare or Gate system on its calls does it, a Review
system changes its results, and a default tool may have a switch, such as
rig_basics::coding_tools::ShellOutput. A plugin’s stage systems in a public
set are turned off with a run condition
(configure_sets(AgentPass, AttachSystems.run_if(|| false)) in finish).
use rig_harness::prelude::*;
#[derive(Default)]
pub struct NoShellPlugin;
impl Plugin for NoShellPlugin {
fn build(&self, _app: &mut App) {}
fn finish(&self, app: &mut App) {
let world = app.world_mut();
let mut tools = world.query::<(Entity, &ToolDef)>();
let shell = tools.iter(world).find(|(_, def)| def.0.name.as_str() == "shell");
if let Some((shell, _)) = shell {
world.entity_mut(shell).insert(Disabled);
}
// `app.add_tool(MyShell)` would replace it. A command is found by its
// `Name` ("/help"), a renderer by its ("renderer:shell").
}
}What a tool tells the model is data on its entity too, and another plugin may
change it, in finish or at any time: its ToolDef (the rig-core
ToolDefinition: name, description, the JSON schema of its parameters; edit
only what you add). A property added to another tool’s parameters (as for
task) is one the model may send; its plugin takes it out
of the call’s ToolRequest in Prepare if the tool would refuse it.
§Testing a plugin
A plugin’s test builds a small app: the kernel (AgentPlugin,
SessionLogPlugin on a rig-memory MemoryStore), the plugin, and the
rig_basics services it needs (ApprovalPlugin, PreviewPlugin,
CheckpointPlugin), run frame by frame with app.update(). An effect spawned
for an agent stands in for what its model would ask, as below. A whole turn
runs on rig-core’s scripted MockCompletionModel (its test-utils feature),
put in Models as a provider plugin’s models are, and Bevy’s
TaskPoolPlugin, as model calls run off the main thread.
A plugin with stage systems also checks they are ordered:
rig_harness::stage_conflicts(app.world_mut()) lists each pair of systems of
one stage that write the same data unordered,
naming their plugins, and is empty for an app with the plugin and those it is
used with. rig plugin check --build runs the same check on the user’s own
plugins.toml, the default plugins included. How long each system takes is
PluginTimings (totals(), last_frame()), filled once a plugin crate
depends on rig-harness with features = ["trace"].
use rig_harness::prelude::*;
use rig_harness::rig_core::message::{ToolFunction, ToolName};
use rig_harness::rig_ecs::session_log::SessionStore;
use rig_harness::rig_memory::MemoryStore;
/// The plugin under test: refuses a `write` outside `src/`.
struct SrcOnlyPlugin;
impl Plugin for SrcOnlyPlugin {
fn build(&self, app: &mut App) {
app.add_systems(AgentPass, src_only.in_set(Stage::Gate));
}
}
fn src_only(mut calls: Query<(&ToolRequest, &mut Decision), (ReadyToStart, ToolCalls)>) {
for (request, mut decision) in &mut calls {
let path = request.call.function.arguments["path"].as_str().unwrap_or_default();
if request.call.function.name.as_str() == "write" && !path.starts_with("src/") {
decision.decide(Decider::plugin("src-only"), Outcome::refused("write only under src/"));
}
}
}
/// A test: `#[test]` in the plugin's `tests.rs`.
fn a_write_outside_src_is_refused() {
let mut app = App::new();
app.insert_resource(SessionStore::new(MemoryStore::default()))
.add_plugins((AgentPlugin, SessionLogPlugin, SrcOnlyPlugin))
.add_open_tool("write", "Write a file.", ToolOptions::default(), |_: On<ToolCalled<serde_json::Value>>| {});
app.finish();
let conflicts = rig_harness::stage_conflicts(app.world_mut());
assert!(conflicts.is_empty(), "{}", conflicts.join("\n"));
// The first frame restores the session and spawns the first agent.
app.update();
let agent = app.world_mut().query_filtered::<Entity, With<Agent>>().single(app.world());
let (Ok(agent), Ok(name)) = (agent, ToolName::new("write")) else {
panic!("the kernel spawns an agent");
};
// The call its model would ask for.
let args = serde_json::json!({ "path": "README.md" });
let call = ToolCall::from_wire("c1", ToolFunction::new(name, args));
let call = app.world_mut().spawn((ToolRequest { call }, ForAgent(agent))).id();
app.update();
let outcome = app.world().get::<Outcome>(call);
let refused = outcome.and_then(|outcome| outcome.0.as_ref().err());
assert!(refused.is_some_and(|why| why.to_string().contains("only under src/")), "{outcome:?}");
}
a_write_outside_src_is_refused();§Fronts, options and commands
A front (the terminal view, --print, a window, a chat bot) offers itself
with app.add_front(..); one takes each run, alone owns stdin and stdout, and
sets itself up in finish only if app.runs_front(name). It sends what the
user types as Deliver from Author::user(), shows Notices (an Error
fails a --print run or refuses a command), each agent’s Conversation (or
ConversationChanged changes) and
notes, its reply as it streams
(StreamDelta), sets ShownAgent to the agent the
user looks at, and may spawn an Answerer and answer questions with
Pick. A front nobody sits at
drives its run with rig_basics::print’s app.run_headless(Some(prompt)),
the loop of the -p and --json fronts too, which ends once no turn runs
and nothing is held, unless an entity has KeepRunning (a file watcher’s,
say; the run may then start with None). HeadlessTurnEnded carries each
answer, and HeadlessEnded comes last.
A plugin’s own options are declared with app.add_flag(Flag::new(name, help)), .value("N") for one that takes a value (rig --name VALUE or
--name=VALUE), and read from Invoked (flag(name), flag_value(name))
from build on, as the settings example does.
§Commands of your own
A command is a front of its own: Front::new("stats").on_command("stats", "[--all]", "Count the saved sessions") takes every run of rig stats …,
and the words after the name are Invoked::command(). A command whose
front is not on_terminal() runs headless: the launcher passes stdin,
stdout, stderr and the exit code through (ExitReason::Failed(n) exits
with n) and never restarts it; headless() is only for runs that name
no command or option. rig -r <id or name> stats runs it in that session;
sessionless() leaves no session behind and refuses a resumed one.
The launcher learns a build’s commands, options and secrets from
rig_harness::run(app), which the generated main.rs ends with: it
builds the app, writes what its plugins declared and runs no frame, so
build only declares, and work goes in finish or systems (rig help).
use rig_harness::prelude::*;
use rig_harness::rig_memory::SessionMeta;
/// `rig stats`: how many sessions there are to resume.
#[derive(Default)]
pub struct StatsPlugin;
impl Plugin for StatsPlugin {
fn build(&self, app: &mut App) {
let stats = Front::new("stats").sessionless();
app.add_front(stats.on_command("stats", "", "Count the saved sessions"));
}
fn finish(&self, app: &mut App) {
if app.runs_front("stats") {
app.add_systems(Startup, stats);
}
}
}
/// Prints the count and their messages, from each one's listing, then exits.
fn stats(home: Res<RigHome>, stores: Res<StoreOpener>, mut commands: Commands) {
let sessions = home.0.saved_sessions();
let listings = sessions.iter().filter_map(|(_, dir)| SessionMeta::read(&*stores.open(dir).ok()?));
println!("{} saved sessions, {} messages", sessions.len(), listings.map(|it| it.messages).sum::<usize>());
commands.trigger(Exit(ExitReason::Quit));
}§The transcript: notes, looks and scrolling
A plugin puts a note in an agent’s transcript as an
aside, Note::new(title, text).aside(author, after),
after a message (after, a MessageId, or the summary that replaced it),
with its title and its text. Every front draws it, it is kept as any aside
is, and Note::of(aside) reads one.
The terminal view draws notes, and what the conversation holds from a plugin
(such as rig_basics::compaction’s summary), with the look
add_entry_renderer(plugin, ..) gives that plugin’s author; other asides with
add_aside_renderer(kind, ..). The transcript puts each item on the band of
what it is, from the Theme resource (mixed from the terminal’s background):
a user message on user, a tool call and what a plugin added on tool, a
reply on none; a look’s line with a background of its own keeps it, as a
diff’s added and removed lines do
(rig_tui::diff::unified_lines(text, &theme), also ToolCallView::theme). A
look draws code in the terminal’s palette with
rig_tui::highlight::code_rows(code, Language::File(path)) (or highlight
for the spans of each line; Language::Named("rust") for a language by name).
The transcript is a TranscriptPane
(another), the default screen’s
marked MainPane. Plugins read and set a pane’s TranscriptView component
(MainTranscript for the main one): the item selected (Alt+Up and Alt+Down
select), the highlights lit, the marks (a TextMark label inside a
reply’s text or a streaming call’s) and where it is scrolled (top(), placed
in TuiSystems::Layout; a panel fitted to it changes in TuiSystems::Fit,
drawn the same frame). ScrollTo { pane, item, row } scrolls to a
TranscriptItem (a Message, a tool Call, an Aside, a Notice), at a
row of it or its nth lit match; item: None follows the newest output. A
DrawnTranscript added to a pane holds the rows it drew by item;
TranscriptLayout::lay_out lays any agent’s out so, undrawn.
A message’s images, audio, videos and documents, the user’s or the
model’s, are drawn by add_content_renderer(ContentKind::Image, ..),
given a ContentView (default_lines() is [image]), each block an item
TranscriptItem::Content(id, index) with its rows; add_call_decorator
draws lines under every tool call, after its look, an item
Decoration(call, name). TuiFrame, written in TuiSystems::Render,
says where the frame drew each panel (area) and row (screen_row) and
what an overlay covered, for a plugin that writes to the terminal
itself, such as an image over rows its renderer left blank, and
RedrawArea(rect) writes cells it drew over again.
use rig_harness::prelude::*;
use rig_tui::ratatui::layout::Rect;
use rig_tui::ratatui::style::{Style, Stylize};
use rig_tui::ratatui::text::Line;
use rig_tui::{
AppToolRenderersExt, ContentKind, DrawnTranscript, Highlight, ItemRow, MainPane, MainTranscript,
ScrollTo, TerminalWrite, TranscriptItem, TuiFrame, TuiSystems,
};
fn build(app: &mut App) {
app.add_command("mark", "Mark the newest message: /mark why", mark)
.add_command("find", "Light a text and show the last message with it", find)
.add_entry_renderer("mark", |entry| {
let mut lines = vec![Line::from(format!("★ {}", entry.title)).yellow()];
lines.extend(entry.text.lines().map(|line| Line::from(line.to_owned())));
lines
})
// An image keeps four rows, which `place_images` draws it over.
.add_content_renderer(ContentKind::Image, |image| [image.default_lines(), vec![Line::default(); 3]].concat())
.add_call_decorator("took", |call| call.took.map(|it| Line::from(format!(" {it:?}"))).into_iter().collect())
.add_systems(PostUpdate, place_images.after(TuiSystems::Render))
.register_required_components::<MainPane, DrawnTranscript>();
}
/// Puts the cursor on each image's first row, unless an overlay covers its
/// rows, where a real plugin writes the picture's escape codes.
fn place_images(frame: Res<TuiFrame>, drawn: Single<&DrawnTranscript, With<MainPane>>, mut out: MessageWriter<TerminalWrite>) {
for (item, rows) in drawn.items() {
let Some(top) = frame.screen_row(&drawn, rows.start) else { continue };
let area = Rect { y: top, height: 4, ..drawn.area() };
if matches!(item, TranscriptItem::Content(..)) && !frame.covered(area) {
out.write(TerminalWrite(format!("\x1b[{};{}H", top + 1, area.x + 3).into_bytes()));
}
}
}
/// `/mark why`: a note after the agent's newest message.
fn mark(In(args): In<CommandArgs>, mut agents: Query<&mut Conversation>) {
let Ok(mut conversation) = agents.get_mut(args.agent) else { return };
let after = conversation.messages().len().checked_sub(1).and_then(|at| conversation.id(at));
conversation.add_aside(Note::new("marked", args.args).aside(Author::plugin("mark"), after));
}
/// `/find text`: lights it, and scrolls to it in the last message the user
/// typed it in.
fn find(
In(args): In<CommandArgs>,
agents: Query<&Conversation>,
(mut view, pane): (MainTranscript, Single<Entity, With<MainPane>>),
mut commands: Commands,
) {
let Ok(conversation) = agents.get(args.agent) else { return };
let typed_it = |at: &usize| typed(conversation, *at).is_some_and(|text| text.contains(&args.args));
let hit = (0..conversation.messages().len()).rev().find(typed_it);
if let Some(id) = hit.and_then(|at| conversation.id(at)) {
let item = Some(TranscriptItem::Message(id));
commands.trigger(ScrollTo { pane: *pane, item, row: ItemRow::Lit(0) });
}
let style = Style::new().on_yellow();
view.highlights = vec![Highlight { text: args.args, style, match_case: true, ..Default::default() }];
}§The status line
The terminal view’s row under the transcript shows the StatusItems of the
shown agent and of its running turn, which every agent and turn has, and the
app’s AppStatus. A plugin shows an item in a system in StatusSystems::Show
that runs when what it shows changed: items.show(item) puts it at its place
(side and order) in place of the item of its name, and an empty item hides it.
Another plugin replaces or hides an item by name (items.hide(name)) in
StatusSystems::Edit. A plugin that writes AppStatus inits it. The default
plugins’ items, and how long each stays when the line is too narrow (keep:
lowest first, u8::MAX never):
| side | order | name | item | keep | plugin |
|---|---|---|---|---|---|
| left | 10 | session | the session’s name (AppStatus) | 2 | rig_basics::sessions |
| left | 20 | title | ⤷ a spawned agent’s name | 14 | rig_basics::subagents |
| left | 30 | model | the model | always | rig_basics::models |
| left | 40 | reasoning | the reasoning setting | 12 | rig_basics::models |
| left | 50 | status | idle, thinking, … | always | rig_basics::activity |
| left | 60 | subagents | the agent’s subagents at work | 15 | rig_basics::subagents |
| left | 70 | others | the other agents at work | 13 | rig_basics::subagents |
| left | 80 | turn | what the turn spent (on the turn) | 11 | rig_basics::telemetry |
| left | 90 | reload | the rebuild of /reload (AppStatus) | 16 | rig_basics::reload |
| right | 10 | tokens | tokens in and out | 4 | rig_basics::telemetry |
| right | 20 | cache | cached input | 1 | rig_basics::telemetry |
| right | 30 | cost | cost | 3 | rig_basics::telemetry |
| right | 40 | context | the context in use; shown again when it changes | 5 | rig_basics::telemetry |
use rig_harness::prelude::*;
use rig_tui::{Side, StatusItem, StatusItems, StatusSystems, Tone};
/// After the status; gone before the reasoning setting.
const MESSAGES: StatusItem = StatusItem::new("messages", Side::Left, 55, 10);
fn build(app: &mut App) {
app.add_systems(PostUpdate, show.in_set(StatusSystems::Show));
}
fn show(mut agents: Query<(&Conversation, &mut StatusItems), Changed<Conversation>>) {
for (conversation, mut items) in &mut agents {
let text = format!("{} messages", conversation.messages().len());
items.show(MESSAGES.says(text, Tone::Dim));
}
}§What agents do and say, in a terminal panel
Each agent is an entity with an Agent, a Name, an AgentId and a
Conversation; a subagent is SpawnedBy its parent (Spawned).
rig_basics::activity::Activity says what an agent does now, and
MessageReader<ConversationChanged> sees every change of every conversation.
A TuiPanel entity takes a side of the transcript (Top, Bottom,
Left, Right, with a ratatui Constraint), rows under the input
(BelowInput), or a box over the screen (Over). The plugin draws it
into its PanelCanvas in TuiSystems::Draw, run only for a frame that is
drawn, and writes a RequestRedraw when its own state changed;
TerminalSize is the terminal’s size, ShownAgent names the shown agent;
Within { panel, part, of } is a column of another panel.
use rig_basics::activity::Activity;
use rig_harness::prelude::*;
use rig_tui::ratatui::layout::Constraint;
use rig_tui::ratatui::widgets::Paragraph;
use rig_tui::{PanelCanvas, Placement, Theme, TuiPanel, TuiSystems};
#[derive(Component)]
struct AgentsPanel;
fn build(app: &mut App) {
app.add_systems(Startup, spawn).add_systems(PostUpdate, draw.in_set(TuiSystems::Draw));
}
fn spawn(mut commands: Commands) {
commands.spawn((AgentsPanel, TuiPanel::new(Placement::Right(Constraint::Length(32)))));
}
/// Each agent, a subagent under its parent, with what it does and how
/// long its conversation is.
fn draw(
mut panels: Query<&mut PanelCanvas, With<AgentsPanel>>,
agents: Query<(&Name, &Activity, &Conversation, Has<SpawnedBy>)>,
theme: Res<Theme>,
) {
let line = |(name, activity, conversation, sub): (&Name, &Activity, &Conversation, bool)| {
let indent = if sub { " " } else { "" };
format!("{indent}{name} {}, {} messages", activity.status, conversation.messages().len())
};
let lines: Vec<_> = agents.iter().map(line).collect();
for mut canvas in &mut panels {
canvas.render(Paragraph::new(lines.join("\n")).block(theme.panel("agents")));
}
}The default screen (DefaultScreenPlugin in TuiPlugin) is made of
widgets a plugin places too, each in a TuiPanel: a TranscriptPane of
the shown agent (TranscriptSource::Shown), one live (Agent(entity)),
or a conversation read from a saved session (Value), which
scrolls and selects with PaneKeys while it has the KeyFocus; an
Editor (the main one MainEditor); a StatusLine { agent }, in a
panel of its own or, as the default screen has it, on the editor, which
draws it in its bottom row, with the keys in its top row, both while
nothing is typed.
use rig_harness::prelude::*;
use rig_tui::ratatui::layout::Constraint;
use rig_tui::{PaneKeys, Placement, TranscriptPane, TranscriptSource, TuiPanel};
/// `/watch <name>`: that agent's conversation, live, beside the main one.
fn watch(In(args): In<CommandArgs>, agents: Query<(Entity, &Name), With<Agent>>, mut commands: Commands) {
let Some((agent, name)) = agents.iter().find(|(_, name)| name.as_str() == args.args) else { return };
commands.spawn((
TranscriptPane::new(TranscriptSource::Agent(agent)).titled(name.as_str()),
TuiPanel::new(Placement::Right(Constraint::Percentage(40))),
PaneKeys,
));
}§Keys and the input in the terminal view
The terminal view (rig_tui) takes part in the input as data, there in
any run and inert without the view:
- The frame’s keys.
PendingInputis what the terminal reported (KeyInput::Event) and the actions plugins put in (KeyInput::Action), in order, done one at a time in theTuiKeysschedule: a system in itsInputSystems::Filterreads, changes, drops or replaces the item before any binding or the input sees it, with the mainEditorandInputContext(the open completion, the question or picker shown, itsselection()) as the keys before it left them;Dispatchdoes the rest. A modal input is one filter, and/starts a slash command unless a filter or binding takes it. - Keys. Every key the view acts on is a
KeyBindingentity, its own editing keys included.app.add_key_binding(binding, observer)binds a key or a sequence (ctrl+x ctrl+e), heard asKeyPressed(run_command("compact")runs a slash command). The first binding of a key stays, a global one shadows the others, andDisabledfrees its keys. While a transcript item is selected (Alt+Up), bindingsin_scope(KeyScope::Transcript)shadow the input’s. A binding with anInputAction(Up,Send, …) does that built-in action.KeyFocus(Some(panel))gives aTuiPanelthe keys until Esc. - Completion. A
CompletionSourceentity offers candidates (.candidate(text, detail)) for a word starting with a marker anywhere in the input,CompletionSource::marker('#')(/and@are the view’s), or for a command’s argument,CompletionSource::arg("model")(.at(n)). One that looks its candidates up reads what is typed,InputContext::typed(), and is inserted again: an open list follows. - The input. An
Editorholds a draft (text(),set,insert,take, …), the main one’s read asMainInput; the keys type into a panel’s own while it has theKeyFocus.PromptHistoryis the prompts Up and Down browse, kept inPluginData.user: the view appends each one sent (Sent, which a plugin writes too) inHistorySystems::Recordand saves inHistorySystems::Save; a system between the two filters what is kept. - The terminal.
TerminalFocussays whether it has the user’s focus, aTerminalWrite(bytes)message goes to the terminal between frames,CopyToClipboard(text)copies (OSC 52 and this machine’s clipboard), and aRunInTerminal(command)entity runs a program with the terminal, without the session’s secrets, and gets itsTerminalExit.
This plugin writes the draft in $EDITOR on Ctrl+E, makes Ctrl+K browse
back as Up does, and opens its search box on / when the input is empty:
use std::path::PathBuf;
use std::process::Command;
use rig_harness::prelude::*;
use rig_tui::crossterm::event::{Event, KeyCode};
use rig_tui::{AppKeyBindingsExt, Editor, InputAction, InputContext, InputSystems, KeyBinding, KeyFocus};
use rig_tui::{KeyInput, KeyPress, KeyPressed, MainEditor, MainInput, PendingInput, RunInTerminal, TerminalExit, TuiKeys};
/// A program writing the draft in this file.
#[derive(Component)]
struct Draft(PathBuf);
/// The plugin's search box, a `TuiPanel` it draws (not shown).
#[derive(Component)]
struct SearchBox;
fn build(app: &mut App) {
app.add_systems(Update, take_draft)
.add_systems(TuiKeys, slash_searches.in_set(InputSystems::Filter))
.add_key_binding(KeyBinding::new(KeyPress::ctrl('e'), "Write the draft in $EDITOR"), edit);
app.world_mut().spawn((KeyBinding::new(KeyPress::ctrl('k'), "Up"), InputAction::Up));
}
/// `/` on an empty input gives the search box the keys; elsewhere it is typed.
fn slash_searches(
mut events: ResMut<PendingInput>,
(input, state): (Single<&Editor, With<MainEditor>>, Res<InputContext>),
boxes: Query<Entity, With<SearchBox>>,
mut focus: ResMut<KeyFocus>,
) {
let slash = |item: &KeyInput| matches!(item, KeyInput::Event(Event::Key(key)) if key.code == KeyCode::Char('/'));
let idle = input.is_empty() && state.completion().is_none() && state.picking().is_none();
if idle && events.0.first().is_some_and(slash) && let Some(search) = boxes.iter().next() {
events.0.remove(0);
focus.0 = Some(search);
}
}
fn edit(_: On<KeyPressed>, input: Single<&Editor, With<MainEditor>>, mut commands: Commands) {
let path = std::env::temp_dir().join("rig-draft.md");
if std::fs::write(&path, input.text()).is_err() {
return;
}
let mut editor = Command::new(std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_owned()));
editor.arg(&path);
commands.spawn((RunInTerminal(editor), Draft(path)));
}
/// The draft as the editor left it, unless the editor failed.
fn take_draft(
done: Query<(Entity, &TerminalExit, &Draft), Added<TerminalExit>>,
mut input: MainInput,
mut commands: Commands,
) {
for (run, exit, Draft(path)) in &done {
if let (true, Ok(text)) = (exit.success(), std::fs::read_to_string(path)) {
input.set(text.trim_end());
}
commands.entity(run).despawn();
}
}§A window
rig_harness::windowed(DefaultPlugins) is Bevy’s DefaultPlugins without
what the agent already has; its winit plugin then runs the app’s loop. The
crate depends on bevy at exactly the agent’s version ("=0.20.0",
without default features, with ui). A window beside the terminal view
(Invoked::interactive) sets ExitCondition::DontExit, so closing it
leaves the agent running, and WinitSettings with
UpdateMode::reactive_low_power(Duration::from_secs(1)); in finish it
points the agent’s Wake at the EventLoopProxyWrapper, sending
WinitUserEvent::WakeUp. A window that is the front offers itself
on_flag.