Vertep

Agents and deciders

An agent in Vertep is an ordinary chart. A language model, a person or a rule may choose the next move at a vt:decide state, but only among the events the chart enables at that moment. Every answer is journaled before the chart sees it, so a run replays without any model.

Everything on this page lives in the crate vertep-agent (crates/vertep-agent). The facade crate vertep re-exports it as vertep::agent when you turn on its agent feature.

The invariant

A decider only proposes an event. The chart checks the proposal and then decides. Nothing outside the pure step function changes the chart, its datamodel or the store.

The agent layer adds no new execution semantics to SCXML. Each feature lowers onto constructs the core already has: states, <invoke>, <send>, internal events, durable timers and journal entries. Models, people, tools and plans sit outside the core and reach it only through the mailbox. Three properties follow:

PropertyHow it is kept
Bounded choiceThe decider sees only the candidate set, and only an offered candidate is accepted.
Replay without modelsDecisions, model results and tool results are journaled inputs. Replay reads them; it calls nothing.
Bounded reachInvoked LLM, tool and plan actors may send only where their capability grant allows. The send path checks it.
The decision cycle: step computes the offer, the host asks the decider, the answer is journaled, checked against the offer, and only then raised as an event untrusted step enabled_events Agent host the shell Decider llm · human guard · actor Journal append first Check the offer offered? fresh? offer ask vt.decision deliver raise the chosen event else error.decide.invalid
Plate. The decision cycle. The answer is written down before it is checked, and checked before it is taken.

Decide states

A decide state is an ordinary <state> with extension attributes in the vt: namespace (https://w3id.org/vertep/vt#). The IR has no special field for it, so the binding survives the SCXML and YAML readers and writers unchanged. The attributes are read in crates/vertep-agent/src/decide.rs:

AttributeMeaning
vt:decide="llm/claude"The decider binding. Required; an empty value is an error.
vt:candidates="approve reject"Event descriptors the state may offer, separated by spaces. Without it, every enabled event is a candidate.
vt:deadline="30s"How long to wait for an answer. It is a durable timer, so it fires across deactivation and replay.
vt:fallback="human/support"The binding asked when the deadline passes.
vt:maxDecisions="10"How many decisions this state may accept over the actor's life. Entering it once more raises vt.agent.budget-exhausted instead of asking.

Only an atomic or compound <state> can decide. The binding on a <parallel>, <final> or <history> is rejected, as are a malformed deadline or bound. A chart with such an error is not accepted by the agent host.

This is the decide state of crates/vertep-agent/examples/support-agent.scxml:

<state id="decide" vt:decide="llm/claude" vt:fallback="human/support" vt:deadline="30s"
       vt:maxDecisions="10" vt:candidates="call_tool finish escalate">
  <transition event="call_tool" target="tooling">
    <send event="tool.call" targetexpr="ev.data.tool"><content expr="ev.data.args"/></send>
  </transition>
  <transition event="finish" target="done"><assign location="answer" expr="ev.data"/></transition>
  <transition event="escalate" target="human"/>
  <transition event="vt.agent.budget-exhausted" target="budget_out"/>
  <transition event="error.decide.invalid" target="decide"/>
  <transition event="vt.decide.refused" target="human"/>
</state>

The same binding can be written as one element, <vt:decide decider="d" deadline="2s"/>, with attributes decider, deadline, fallback, candidates and maxDecisions. In the YAML surface a decide state lists its extensions. This chart is taken from the test decide_state_parsed_from_scxml in crates/vertep-agent/tests/deciders.rs:

vertep: 1
name: y
states:
  review:
    extensions:
      - { local: decide, attribute: llm/claude }
      - { local: maxDecisions, attribute: "3" }
    on: { approve: done }
  done: { type: final }

One decision, step by step

  1. Offer. After each macrostep the agent machine recomputes the candidate set of every active decide state with the pure function enabled_events. A candidate is an event for which SCXML selection would take a transition out of the decide state, one of its active descendants or one of its ancestors, restricted to vt:candidates when given, in document order. Guards are evaluated on a probe copy of the datamodel, so the actor's own data is never touched.
  2. Ask. The host passes a DecisionRequest to the bound decider. The actor does not block: it stays in an ordinary active state and goes on processing other events.
  3. Return. The answer comes back as the input vt.decision. The host appends it to the journal, deduplicated by correlation id, and only then applies it.
  4. Check. The decision is accepted only if its correlation id names the open offer, its event is a candidate, its payload matches the candidate's schema, its annotations are valid, and the transition it would take now is the one that was enabled at offer time. Then the chosen event is raised and normal SCXML selection takes the transition.

Events whose names start with error., done., vt., vt: or cancel.invoke are never offered. The runtime raises those; a decider cannot choose them.

A guard that reads the event payload cannot be evaluated at offer time, because the payload does not exist yet. Such a guard counts as holding when the offer is made, and is evaluated for real when the decision arrives. If it fails then, the decision is rejected with the reason not-enabled. This makes a payload guard a useful second check on what a model sends.

Here is how the offer bounds the choice. In the refund chart of the decider-agent example (examples/decider-agent/refund.scxml), approve in the state review carries cond="ctx.amount &lt;= 100". For a request of 250 the decider is offered only reject and escalate, whatever it would prefer.

Events of a decide state

EventWhen
vt.decisionThe input that carries a decider's answer. Charts do not handle it; the machine checks it and raises the chosen event.
error.decide.invalidA decision was not accepted. ev.data.reason is not-a-candidate, payload-schema, stale, annotation or not-enabled; the data also carries correlation, event and the violated paths.
vt.decide.refusedThe decider refused. An LLM decider refuses when the model call fails or its answer is not a decision.
error.decide.timeoutThe deadline passed and no fallback is declared.
vt.decide.noneThe candidate set is empty, so nobody is asked.
vt.agent.budget-exhaustedThe state has accepted vt:maxDecisions decisions already.

An open request can change under the actor's feet. Each offer has a fingerprint: the candidate event names plus their schema hashes. When a later macrostep changes the fingerprint, or the state is re-entered, the request is cancelled and offered again under a new correlation id; a late answer to the old id is rejected as stale. When the state is exited, its offer is closed.

Correlation ids have the form <actor>/decision/<position>.<k>, where the position is that of the journaled input whose macrostep made the offer. Replay therefore reproduces the same ids, and a fork renames only the actor prefix.

Deciders

The contract, from crates/vertep-agent/src/decider.rs:

pub trait Decider: Send + Sync {
    /// The decider's kind.
    fn kind(&self) -> DeciderKind;
    /// Decides; the future may take arbitrarily long (the actor keeps running).
    fn decide(&self, request: DecisionRequest) -> BoxFuture<'static, DecisionOutcome>;
    /// The request `correlation` was cancelled or superseded; a later answer is stale.
    fn cancel(&self, _correlation: &str) {}
}

pub enum DecisionOutcome {
    Decision(Decision),   // event, payload, annotations
    Refusal(Refusal),     // reason
}

A DecisionRequest holds the decide state id, the candidates (each with its event name and payload JSON Schema), a read-only ContextView of the datamodel at offer time, the actor IRI, the correlation id, the binding asked, and whether this is a fallback. A decider owns its copy of the request. It has no handle on the chart, the datamodel or the store, so returning a value is all it can do.

Four kinds

KindTypeHow it decides
GuardGuardDeciderThe first candidate, in document order, whose decision guard holds. Guards are CEL over ctx, taken from explicit rules (with_rules) or from vt:decisionGuard on the chart's transitions (from_chart); first_enabled picks among listed events unconditionally. The same candidates and context always give the same answer.
LLMLlmDeciderAsks a model for structured output whose schema is a oneOf over the candidates, each branch {event: const, payload, rationale?, confidence?}.
HumanHumanDeciderPuts the request in an Inbox. A person reads inbox.pending() and answers with inbox.answer(correlation, outcome).
ActorActorDeciderSends vt.decide.request to another actor as a non-blocking request and reads its reply as {event, payload, rationale?, confidence?}.

You can also implement Decider yourself and report DeciderKind::Custom. The decider-agent example on the examples page does this with a scripted decider.

Binding names such as llm/claude are plain strings. The prefix is a convention for readers, not a type: the support-agent kit binds human/support to a guard decider. Bindings are registered on the host builder; this is from crates/vertep-agent/examples/support/kit.rs:

let clock = VirtualClock::new(T0);
let mut b = AgentHost::builder(Arc::new(clock.clone()))
    .provider("triage", Arc::new(triage))
    .decider(
        "llm/claude",
        Arc::new(
            LlmDecider::new("claude", Arc::new(model), "claude-opus-5-5").with_instructions(
                "You are a support agent. Use the tools, then finish with an answer.",
            ),
        ),
    )
    .decider(
        "human/support",
        Arc::new(GuardDecider::with_rules(vec![
            vertep_agent::decider::GuardRule {
                event: "finish".into(),
                guard: None,
                payload: Some("{'text': 'A person will get back to you.'}".into()),
            },
        ])),
    );
for t in tools() {
    b = b.tool(t);
}

Spawning an actor whose chart names a binding that is not registered fails with InstantiateError::UnknownDecider, before any input is processed.

A decision may carry annotations: a rationale, a confidence in [0, 1], the decider kind and binding, the model, the prompt hash, the token usage and whether a fallback answered. They are recorded in the graph. They never influence which transition is taken.

LLM actors

One model call is an invoked child: <invoke type="vt:llm">. It lives as long as the invoking state and is cancelled when that state exits, like any SCXML invoke. From the support agent:

<state id="triaging">
  <invoke type="vt:llm" id="triage" vt:provider="triage" vt:model="claude-opus-5-5"
          vt:template="Triage this support ticket in one line: {{context.ticket.text}}"/>
  <transition event="done.invoke.triage" target="decide"><assign location="triage" expr="ev.data.text"/></transition>
  <transition event="error.llm" target="decide"/>
</state>
AttributeMeaning
vt:providerThe provider binding (required).
vt:modelThe model identifier.
vt:templateThe prompt template; the <content> body may be used instead.
vt:outputA structured output type in the type language, for example { category: billing | tech | other, urgency: int }.
vt:temperature, vt:maxTokensGeneration parameters.
vt:stream="true"Deliver vt.llm.partial events while the model streams. Partials are never journaled.
vt:retry="3", vt:backoff="1s"At most three attempts, with durable exponential backoff from one second.

Templates substitute {{context.<path>}} (the data items) and {{event.<path>}}. Strings go in verbatim and other values as canonical JSON. A missing path is a template error, and then no call is made. The rendered prompt is hashed (sha256:<hex>) and the hash is recorded.

The result reaches the chart as done.invoke.<id>. With vt:output, its data is the parsed and validated value. Without it, the data is an object with text, model, call, promptHash, usage and toolCalls. In the journal the result is an input of kind llm.result, appended before the chart processes it. On replay that entry is read back, and no provider is called.

A failure raises error.llm with a class: rate-limited, schema, transport, auth, invalid-request, provider or unknown-provider. Nothing is retried behind the chart's back. You either handle error.llm with transitions, or declare vt:retry, which retries the classes rate-limited, schema, transport and provider on journaled backoff timers. Each attempt is a separate journaled call.

Every call records prompt and completion tokens, the model, the provider binding, latency and an optional cost. The running totals of an actor are visible to its guards as the read-only data item vt_usage, with the fields prompt_tokens, completion_tokens, total_tokens, calls and cost. A budget guard such as cond="vt_usage.total_tokens &lt; 50000" reads it like any other data. Calls made by an LLM decider count too.

Providers

pub trait Provider: Send + Sync {
    /// Completes `request`.
    fn complete(
        &self,
        request: ProviderRequest,
    ) -> BoxFuture<'static, Result<ProviderResult, ProviderError>>;
    /// Streams `request` as chunks followed by the final result, when the provider can.
    fn stream(
        &self,
        _request: ProviderRequest,
    ) -> Option<BoxStream<'static, Result<StreamItem, ProviderError>>> {
        None
    }
}

A ProviderRequest carries the rendered prompt, the model, the generation parameters, an optional output schema and the tools the model may call (exactly the granted ones). A ProviderResult carries the content, usage, the model id the provider reports, an optional cost and any tool calls.

No vendor SDK is needed to build or run Vertep. Two providers ship with the crate. ScriptedProvider returns answers you push into it; the tests and examples use it, so they run with no network and no key. The anthropic cargo feature adds AnthropicProvider, which maps requests and responses for the Anthropic Messages API over an HttpClient that your application supplies; Vertep pulls in no HTTP stack.

Tools and capability grants

A tool is registered by IRI with an argument type and a result type in the type language. Its JSON Schemas are derived from those types. From the support kit:

let orders = Tool::new(
    ORDERS,                                        // "https://example.org/tools/order-lookup"
    "type Order = { id: string, status: string };", // declarations
    "{ id: string }",                               // argument type
    "Order",                                        // result type
    |args: Value| async move {
        let id = match vertep_agent::json::field(&args, "id") {
            Some(Value::String(id)) => id.clone(),
            _ => return Err("no id".to_owned()),
        };
        Ok(json(serde_json::json!({ "id": id, "status": "returned" })))
    },
)
.expect("the order tool is well-typed")
.with_description("Look up an order by id");

A chart calls a tool in one of two ways. <invoke type="vt:tool" src="<tool IRI>"> passes its <param>s or <content> as arguments, and the result arrives as done.invoke.<id>. A <send> whose target is the tool IRI gets its result as vt:reply; the support agent uses this form. Arguments are checked against the argument type before the tool runs. A failure raises error.tool with the class arguments, execution, result or unknown. Tool results are journaled in the caller's journal.

A capability grant says what an invoked LLM, tool or plan actor may reach: send targets (actor IRIs and #_parent), tools, decider bindings and, for plans, functions. A state or an <invoke> declares it either as attributes or as an element:

<invoke type="vt:llm" id="summary" vt:provider="p" vt:model="m1"
        vt:template="Summarise the case of {{context.customer.name}}."
        vt:grantTools="https://example.org/tools/search https://example.org/tools/fetch"
        vt:grantTargets="#_parent"/>

<vt:grant targets="#_parent vt:actor/Crm/1" tools="vt:tool/search"
          deciders="human/inbox" functions="vt:action/notify"/>

The attribute forms are vt:grantTargets, vt:grantTools, vt:grantDeciders and vt:grantFunctions; several vt:grant elements add up. A child's effective grant is its parent's effective grant intersected with its own declaration, recursively, so authority never widens down the invoke tree. An LLM call is offered exactly the granted tools.

Enforcement sits in the send path, not in the LLM adapter, so it covers plans and nested invokes the same way. An ungranted send is not delivered. The sender gets error.communication with the reason not-granted, and the parent gets vt.grant.denied, which it journals. Runtime actors invoked from ordinary charts are wrapped in GrantedActor, which filters their sends the same way.

Grants restrict invoked children. They do not restrict the agent chart's own <send> elements: those go wherever the chart says. In support-agent.scxml the call_tool transition sends to targetexpr="ev.data.tool", a target the model chose, and the chart declares no payload type, so the decision is checked by event name only. If that matters, give call_tool a payload type or a guard on ev.data.tool.

A ReAct loop is a chart

The model as the main driver needs no feature of its own. It is a decide loop whose candidates are tool calls, a finish event and an escalation, bounded by vt:maxDecisions or by a guard on a counter or on vt_usage. The whole support agent, crates/vertep-agent/examples/support-agent.scxml:

<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:vt="https://w3id.org/vertep/vt#"
       version="1.0" datamodel="cel" initial="idle" name="support-agent">
  <datamodel>
    <data id="ticket" expr="null"/>
    <data id="triage" expr="''"/>
    <data id="steps" expr="0"/>
    <data id="observations" expr="[]"/>
    <data id="answer" expr="null"/>
  </datamodel>

  <state id="idle">
    <transition event="task" target="triaging"><assign location="ticket" expr="ev.data"/></transition>
  </state>

  <state id="triaging">
    <invoke type="vt:llm" id="triage" vt:provider="triage" vt:model="claude-opus-5-5"
            vt:template="Triage this support ticket in one line: {{context.ticket.text}}"/>
    <transition event="done.invoke.triage" target="decide"><assign location="triage" expr="ev.data.text"/></transition>
    <transition event="error.llm" target="decide"/>
  </state>

  <state id="decide" vt:decide="llm/claude" vt:fallback="human/support" vt:deadline="30s"
         vt:maxDecisions="10" vt:candidates="call_tool finish escalate">
    <transition event="call_tool" target="tooling">
      <send event="tool.call" targetexpr="ev.data.tool"><content expr="ev.data.args"/></send>
    </transition>
    <transition event="finish" target="done"><assign location="answer" expr="ev.data"/></transition>
    <transition event="escalate" target="human"/>
    <transition event="vt.agent.budget-exhausted" target="budget_out"/>
    <transition event="error.decide.invalid" target="decide"/>
    <transition event="vt.decide.refused" target="human"/>
  </state>

  <state id="tooling">
    <transition event="vt:reply" target="decide">
      <assign location="observations" expr="ctx.observations + [ev.data.result]"/>
      <assign location="steps" expr="ctx.steps + 1"/>
    </transition>
    <transition event="error.tool" target="decide"><assign location="steps" expr="ctx.steps + 1"/></transition>
  </state>

  <state id="human" vt:decide="human/support" vt:candidates="finish">
    <transition event="finish" target="done"><assign location="answer" expr="ev.data"/></transition>
  </state>

  <final id="done">
    <donedata><param name="answer" expr="ctx.answer"/><param name="steps" expr="ctx.steps"/></donedata>
  </final>
  <final id="budget_out">
    <donedata><param name="steps" expr="ctx.steps"/></donedata>
  </final>
</scxml>

The example react_support_agent runs it on a virtual clock with a scripted model that searches the knowledge base, looks up the order and finishes. It prints the final state, the journal and the usage:

cargo run -p vertep-agent --example react_support_agent
configuration: []
done data: Some(Object([("answer", Object([("confidence", Number(0.95)), ("text", String("Your refund for A-17 is on its way."))])), ("steps", Number(2.0))]))
  1 start
  2 event
  3 llm.result
  4 decision
  5 tool.result
  6 decision
  7 tool.result
  8 decision
model usage: 4 calls, 2520 prompt + 189 completion tokens

The configuration is empty because the chart reached its top-level final state. The journal holds every outside answer in order: the task event, the triage result, three decisions and two tool results. The four model calls are the triage call and the three decisions.

Plan charts

A model may write a chart fragment as a plan. The chart proposes it with the event vt.plan.proposed and runs it with <invoke type="vt:plan">. Before it runs, a plan is parsed from YAML or Turtle into the IR and checked with the same well-formedness rules and SHACL shapes as any chart, plus four restrictions:

An accepted plan is named by the chart version IRI of its canonical RDF, so identical plans share one IRI and one node in the graph. It runs only as an invoked child of the requesting state. It never becomes a virtual actor, never outlives that state, and affects its parent only by sending events. Its grant is the invoking state's effective grant intersected with the plan's own root vt:grant.

This plan is accepted in the test plan_in_yaml_accepted_for_validation (crates/vertep-agent/tests/plan_charts.rs). Its root extension declares the result type:

vertep: 1
name: booking
datamodel: cel
extensions:
  - { local: result, attribute: "{ booked: bool }" }
states:
  book:
    entry:
      - send: { event: hello, target: "#_parent" }
      - send: { event: confirmed }
    on: { confirmed: done }
  done:
    type: final
    donedata: { params: [ { name: booked, expr: "true" } ] }

The invoking state declares the bounds: vt:maxMacrosteps (default 1000), vt:maxSends (default 100) and vt:maxDuration (a durable timer; no default). A plan that finishes delivers done.invoke.<id> if its done data has the declared result type. Otherwise the parent gets error.plan with the class parse, invalid, bounds or result. Accepted and rejected plans are both recorded, with lineage back to the model call that wrote them.

An agent can change its own chart only through a new chart version and a migration (propose_self_modification in crates/vertep-agent/src/plan.rs). The new chart is validated and registered under its content address, and the migration goes through the ordinary chart-versioning procedure on the runtime node. The accepted migration is a journal entry; a rejection is delivered to the actor as vt.self-modification.rejected. A running chart is never patched in place, because that would break replay of the recorded version. See the actor runtime for versions and migrations.

Journaling, replay and forks

Agent actors run in their own deterministic shell, AgentHost, over a pure AgentMachine. Each turn takes the oldest input, drops it if its message id or correlation id was already processed, appends it to the journal, applies it, and then performs the effects. The journal kinds are start, event, decision, refusal, timer, llm.result, llm.error and tool.result.

Deactivating an actor drops only its in-memory machine. The next input or due timer reactivates it by replay, which asks no decider and calls no provider or tool. The decider-agent example shows this: it opens a second host on the same journal with no decider bound at all, and gets the same states and data.

To explore another choice, fork the journal. AgentHost::fork(actor, position, new_actor, inject) copies entries 1 to position under a new actor IRI, replays them, and then delivers the inputs you inject. History is a branch, never an edit. The example fork_from_journal forks the support run right after the first tool result and injects a finish decision:

cargo run -p vertep-agent --example fork_from_journal
original: Some(Object([("answer", Object([("confidence", Number(0.95)), ("text", String("Your refund for A-17 is on its way."))])), ("steps", Number(2.0))]))
forking at 5 in ["decide"]
open offer https://example.org/agents/support/42/fork-1/decision/5.0 with ["call_tool", "finish", "escalate"]
fork: Some(Object([("answer", Object([("text", String("Escalated by a supervisor."))])), ("steps", Number(1.0))]))
triage calls after the fork: 0

The fork reproduces the offer that was open at position 5, under its own IRI. The triage model is not called again.

The agent journal is the trait AgentJournal, and the crate implements it only with MemoryJournal, an in-memory store that keeps entries as JSON text. A durable agent journal on tiramemsu or another store is not in the tree yet; implement AgentJournal (append with a position check, read, list actors) to provide one.

The agent graph

AgentHost::graph(actor) returns the provenance graph of a run in the vt-agent: module (https://w3id.org/vertep/agent#), which ships with vertep-rdf together with its SHACL shapes. It names deciders, decisions, LLM calls, tools, tool calls, grants, plans and plan executions, aligned to PROV-O by subclassing. The alignment axioms are included as plain triples, so PROV-O queries work without a reasoner. A decision's annotations sit on the RDF 1.2 triple term of the statement decision vt-agent:chose event. See RDF and versions for querying.

What is proven

The Lean model (lean/vertep-lean/VertepLean/AgentTheorems.lean) states the agent properties over every decider and every output, adversarial output included. These theorems are in the pinned axiom check:

TheoremIn plain words
decider_safetyIf a decision makes a reachable agent state take a candidate, there was an open offer with that correlation id, the candidate was in it, its transition was enabled at offer time, and selection now picks the same transition.
stale_not_takenA decision whose correlation id names no open offer takes nothing.
unknown_not_takenA decision for an event that is not among the open offer's candidates takes nothing.
decision_dedup, decision_onceA decision whose correlation id was already answered changes nothing, so a redelivered decision has no further effect.
replay_liveReplaying the inputs a live run journaled gives the state the live run reached.
grants_do_not_escalateA child's effective grant is exactly the intersection of its parent's and its own.
grant_enforced, ungranted_not_delivered, granted_deliveredThe send filter delivers granted sends and nothing addressed to an ungranted target.

These are theorems about the Lean model. The Rust code is tied to it by differential testing, not by proof; the Rust side checks the same conditions at run time. See Verification.

Risks and containment

The design contains failures with mechanisms that do not depend on the model behaving well. It is just as important to know what it does not contain.

RiskWhat contains it
Prompt injection makes the model pick a harmful actionOnly offered candidates are accepted, so the model cannot invent a transition. Route high-risk moves to a human decider, and keep the dangerous event out of vt:candidates or behind a guard.
Malformed or hostile payloadsPayloads are checked against the candidate's schema on typed charts, and payload guards are evaluated when the decision arrives (not-enabled).
Injection through a planCEL or null datamodel only, granted functions only, execution bounds, run as a sandboxed child.
Model nondeterminismResults and decisions are journaled before processing; replay and forks call no model.
Token and cost runawayvt:maxDecisions, guards on vt_usage, per-call accounting.
Malformed structured outputValidation after every call, error.llm with class schema, retries written in the chart.
Nobody answersA durable deadline with a fallback decider; the wait never blocks the actor.

What the invariant does not give you:

Running the tests

cargo test -p vertep-agent
cargo test -p vertep-agent --features anthropic   # adds the Anthropic mapping's unit tests

The tests are grouped by capability: tests/deciders.rs, tests/llm_actors.rs, tests/plan_charts.rs, and tests/examples.rs, which runs the support-agent examples. Each test names the spec scenario it checks in an @tg: verifies:: comment, so you can find the test for any requirement in openspec/specs/deciders, llm-actors and plan-charts.