Decider agent
A refund request with two choice points. A scripted decider stands in for a language model, so the example needs no network and no API key. The chart decides what may be chosen; the decider only picks.
Files: examples/decider-agent/refund.scxml, examples/decider-agent/main.rs, examples/decider-agent/expected-output.txt. All commands run from the repository root.
The chart
vt:decide="policy" hands the choice among a state's events to whatever decider the host binds to policy. The chart still owns the rules. With an amount of 250, the guard ctx.amount <= 100 keeps approve out of the offer in review.
<?xml version="1.0" encoding="UTF-8"?>
<!--
A refund agent with two choice points, on the CEL datamodel.
`review` and `manager` are decide states: `vt:decide="policy"` hands the choice among their
events to whatever decider the host binds to `policy` (a guard table, an LLM, a person, another
actor). The chart still owns the rules: the decider is offered only the events that are
enabled right now, so with an amount over 100 `approve` is never offered in `review`, whatever
the decider would like.
Illustrates the deciders capability (Decide State Declaration, Enabled Event Offer, Decisions
Are Journaled Inputs, Decider Safety).
-->
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:vt="https://w3id.org/vertep/vt#"
version="1.0" datamodel="cel" name="refund-agent" initial="idle">
<datamodel>
<data id="amount" expr="0"/>
</datamodel>
<state id="idle">
<transition event="request" target="review">
<assign location="amount" expr="ev.data.amount"/>
</transition>
</state>
<state id="review" vt:decide="policy" vt:candidates="approve reject escalate">
<transition event="approve" cond="ctx.amount <= 100" target="approved"/>
<transition event="reject" target="rejected"/>
<transition event="escalate" cond="ctx.amount > 100" target="manager"/>
</state>
<state id="manager" vt:decide="policy" vt:candidates="approve reject">
<transition event="approve" target="approved"/>
<transition event="reject" target="rejected"/>
</state>
<state id="approved"/>
<state id="rejected"/>
</scxml>
The program
The first AgentHost binds the scripted decider and runs the request. The second opens the same journal with no decider bound and replays it.
//! Decider agent: choice points decided by a pluggable decider, journaled, and replayed
//! without it.
//!
//! The refund chart has two decide states. A real deployment would bind `policy` to an
//! `LlmDecider` (or a person's inbox); here a scripted decider answers from a fixed list, so
//! the example needs no network and no API key, and prints the same thing on every run.
//!
//! Run 1 sends a refund request and lets the decider choose twice. Every answer becomes a
//! journal entry (`decision`, with its correlation id and rationale) before the chart processes
//! it. Run 2 opens a second host on the same journal with **no decider bound at all**: replaying
//! the journal reproduces the same states and context, and the decider is never asked.
//!
//! Illustrates the `deciders` capability (Decider Contract, Enabled Event Offer, Decisions Are
//! Journaled Inputs).
//!
//! Run with `cargo run -p vertep-examples --bin decider-agent`.
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use vertep::agent::{
AgentHost, AgentInput, Annotations, Decider, DeciderKind, Decision, DecisionOutcome,
DecisionRequest, MemoryJournal,
};
use vertep::prelude::{Event, VirtualClock};
/// The chart, next to this file.
const REFUND: &str = include_str!("refund.scxml");
/// The agent actor's IRI.
const AGENT: &str = "https://example.org/agents/refund/7";
/// 2026-11-11T00:00:00Z, the virtual clock's start.
const T0: i64 = 1_794_355_200_000;
type Error = Box<dyn std::error::Error>;
/// A deterministic stand-in for an LLM: answers from a script, in order, and prints each offer.
struct ScriptedDecider {
answers: Mutex<VecDeque<(&'static str, &'static str)>>,
}
impl Decider for ScriptedDecider {
fn kind(&self) -> DeciderKind {
DeciderKind::Custom
}
/// The request carries the decide state, the candidate events (only those enabled now),
/// a read-only view of the context and the correlation id the answer must carry.
fn decide(
&self,
request: DecisionRequest,
) -> Pin<Box<dyn Future<Output = DecisionOutcome> + Send>> {
let offered: Vec<&str> = request
.candidates
.iter()
.map(|c| c.event.as_str())
.collect();
let answer = self.answers.lock().expect("lock").pop_front();
let outcome = match answer {
Some((event, why)) => {
println!(
" decider asked in `{}`: offered {offered:?} -> {event}",
request.state
);
let mut decision = Decision::new(event, vertep::prelude::Value::Undefined);
decision.annotations = Annotations {
rationale: Some(why.to_owned()),
..Annotations::default()
};
DecisionOutcome::Decision(decision)
}
None => DecisionOutcome::refuse("the script is exhausted"),
};
Box::pin(async move { outcome })
}
}
fn main() -> Result<(), Error> {
let journal = Arc::new(MemoryJournal::new());
// ---- run 1: with the decider ------------------------------------------------------------
println!("run 1, decider bound to `policy`:");
let decider = ScriptedDecider {
answers: Mutex::new(VecDeque::from([
("escalate", "amount is above my approval limit"),
("approve", "manager approved: item returned unopened"),
])),
};
let mut host = AgentHost::builder(Arc::new(VirtualClock::new(T0)))
.journal(journal.clone())
.decider("policy", Arc::new(decider))
.build();
let version = host.register(chart()?)?;
host.spawn(AGENT, &version)?;
host.send(AGENT, request(250));
host.run_until_idle();
let before = host.state(AGENT)?;
println!(
" final: {:?} context={}",
before.configuration,
context(&before)
);
println!("journal:");
for entry in host.journal(AGENT) {
let detail = match &entry.input {
AgentInput::Start { .. } => String::new(),
AgentInput::Event { event, .. } => event.name.clone(),
AgentInput::Decision {
correlation,
event,
annotations,
..
} => format!(
"{event} (offer {correlation}, rationale {:?})",
annotations.rationale.as_deref().unwrap_or("")
),
other => format!("{other:?}"),
};
let line = format!(" {} {:<9} {detail}", entry.position, entry.input.kind());
println!("{}", line.trim_end());
}
// ---- run 2: same journal, no decider ----------------------------------------------------
println!("run 2, same journal, no decider bound:");
let mut replay = AgentHost::builder(Arc::new(VirtualClock::new(T0)))
.journal(journal)
.build();
replay.register(chart()?)?;
let after = replay.state(AGENT)?; // activates the actor by replaying the journal
replay.run_until_idle();
println!(
" final: {:?} context={}",
after.configuration,
context(&after)
);
for position in 2..=after.position {
let at = replay.state_at(AGENT, position)?;
println!(" as of entry {position}: {:?}", at.configuration);
}
println!(
" entries replayed: {}, decider calls: {}",
replay.stats().replayed,
replay.stats().decider_calls.values().sum::<usize>()
);
assert_eq!(after.configuration, before.configuration);
assert_eq!(after.datamodel, before.datamodel);
Ok(())
}
/// Reads the chart. Charts naming a datamodel other than `null` need `AnyDatamodel`.
fn chart() -> Result<vertep::prelude::Chart, Error> {
let opts = vertep::scxml::ReadOptions::new().with_datamodels(&vertep::scxml::AnyDatamodel);
Ok(vertep::scxml::read(REFUND, &opts)?.chart)
}
/// A `request` event with an `amount` payload.
fn request(amount: u32) -> Event {
Event {
name: "request".into(),
data: vertep::agent::json::from_json(&serde_json::json!({ "amount": amount })),
..Event::default()
}
}
/// The context as JSON.
fn context(state: &vertep::agent::AgentState) -> String {
let view = vertep::agent::ContextView::new(state.datamodel.clone());
vertep::agent::json::to_json(view.data()).to_string()
}
Run it
cargo run -p vertep-examples --bin decider-agent
run 1, decider bound to `policy`:
decider asked in `review`: offered ["reject", "escalate"] -> escalate
decider asked in `manager`: offered ["approve", "reject"] -> approve
final: ["approved"] context={"amount":250}
journal:
1 start
2 event request
3 decision escalate (offer https://example.org/agents/refund/7/decision/2.0, rationale "amount is above my approval limit")
4 decision approve (offer https://example.org/agents/refund/7/decision/3.0, rationale "manager approved: item returned unopened")
run 2, same journal, no decider bound:
final: ["approved"] context={"amount":250}
as of entry 2: ["review"]
as of entry 3: ["manager"]
as of entry 4: ["approved"]
entries replayed: 4, decider calls: 0
- The decider was offered
rejectandescalateinreview.approvewas never on the table. - Each decision is a journal entry with its offer's correlation id and the decider's rationale.
- The replay reaches the same states with zero decider calls.
For a language-model agent with tools and a fork from the journal, see crates/vertep-agent/examples/react_support_agent.rs and fork_from_journal.rs, and the article The goat does not choose the play.
The agent host keeps its own journal, and only the in-memory MemoryJournal implements it today. A durable agent journal on tiramemsu is not there yet.