Article the second
The goat does not choose the play
The goat is the liveliest figure in the Ukrainian vertep procession. She dances, she falls down, she is mourned and comes back to life, and the crowd loves her for it. But she does not choose the play. The play is old and known, and the goat's wildness happens inside it, at the moments the play leaves open for her.
That is a good picture of how a language model should sit inside a program. It is the liveliest part, and it should have room to be clever. But it should not be the program.
The loop that is the program
The common way to build an "agent" is a loop. Put the goal and the history into a prompt. Ask the model what to do. If it names a tool, call the tool, append the result and ask again. Stop when the model says it is done.
This works in a demo, and it has four problems in production.
- The state is a transcript. Where is the process now? Somewhere in a growing prompt. What may it do next? Whatever the model says. Nothing outside the model knows what is allowed.
- It cannot be replayed. Run it again and the model answers differently. A failure in production cannot be reproduced, only described.
- Its side effects have no bound. A prompt injection in a retrieved document can ask for any tool with any arguments, and the loop will try.
- It has no clock. What if the model hangs, or loops for ever? What if a person must approve a step and goes home for the weekend?
Each of these can be patched. The patches end up building a state machine, one if at a time, without its diagram and without its guarantees.
Put the model at a choice point
Vertep starts from the other end. The program is a statechart. Most of its transitions are ordinary: an event arrives, a guard is checked, the chart moves. At some states, though, the chart cannot decide alone. Should this ticket be answered, escalated, or researched further? Those states are marked vt:decide, and they name a decider.
<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">...</transition>
<transition event="finish" target="done">...</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>
A decider may be a guard, a language model, a person, or another actor. They all share one contract, and it is a narrow one. A decider is shown a set of candidate events, and it may return one of them, with a payload. That is all it can do. The invariant behind it fits in one line:
A decider only proposes events. The chart validates and decides.
Four steps, every time
- Offer. When the chart enters a decide state, the pure core computes which events would actually be enabled now, restricted to the declared candidates. The offer gets a correlation id taken from the actor's journal position, so a replay computes the same offer with the same id.
- Decide. The shell asks the decider, asynchronously. The actor does not block. It stays in an ordinary state and keeps handling other events.
- Return. The answer comes back as an event,
vt.decision, through the mailbox. It is appended to the journal, deduplicated by correlation id, and only then processed. - Validate. The answer is accepted only if its correlation id names the open offer, its event was a candidate, its payload matches the candidate's schema, and the transition it would take is still the one that was enabled when the offer was made. Then the event is raised and normal SCXML selection takes the transition. Otherwise the chart gets
error.decide.invalidwith a reason such asnot-a-candidate,payload-schema,staleornot-enabled.
The model never touches the chart's data, never calls a tool directly and never picks a transition the chart did not offer. If it answers with something else, that is an event the chart handles like any other error. In the support agent above, the chart simply asks again.
What this buys
| Problem with the loop | What the chart does instead |
|---|---|
| The state is a transcript | The state is a configuration of named states. What may happen next is written in the chart. |
| It cannot be replayed | Decisions and model results are journaled before they are processed. Replay feeds them back and never calls a model. |
| Side effects have no bound | Only offered candidates are accepted. Tools are actors, and an invoking state grants its child the tools it may use; the send path enforces the intersection of grants. |
| It has no clock | vt:deadline is a durable timer, so it fires across restarts and replay. When it fires, vt:fallback hands the choice to another decider, perhaps a person. vt:maxDecisions ends runaway loops with an event. |
None of these depends on the model behaving well. A prompt injection can make a model choose badly, but only among the moves the chart offered at that moment. If a move is dangerous, the chart can route it to a human decider, and that too is a transition you can read.
The journal is the run
Everything nondeterministic enters a Vertep actor as a journaled input: the decision with its annotations, the model's result with its prompt hash, usage and model name, the tool's reply, the timer that fired. The offers themselves are not stored: they follow from the chart and the journal, so replay computes them again. Large prompts and results are stored once by content hash.
So an agent run is not a log about the run. It is the run. Replay it with no model, no network and no API key, and the chart passes through exactly the same states. A bug report becomes a journal you can step through.
It also makes a new kind of question easy to ask. What if the model had escalated instead of answering? Fork the journal at the position of the offer, supply a different decision, and run the branch. The original history is not edited. The fork is a new actor with a new past.
Loops, plans and the proof
A ReAct-style agent, one that reasons, calls tools and reasons again, is not a special feature in Vertep. It is a chart: a decide state whose candidates are tool calls, a finish event and an escalation, with an iteration limit and a budget guard. The support agent in crates/vertep-agent/examples/support-agent.scxml is exactly that, in about sixty lines.
Sometimes a model should do more than pick a move. It might lay out a whole plan. Vertep lets it, in the same spirit. A plan is a chart fragment the model writes. It is parsed and validated, restricted to the CEL datamodel and to granted functions, given its own content-addressed version, and run only as a bounded, sandboxed child of the state that asked for it. The plan may be wild, but it runs on a stage of its own and only within the parent's grants.
Finally, the guarantee is not only tested. The Lean model of Vertep includes enabled_events, the offer, the validation guard and the grant intersection, and it proves decider safety: whatever any decider returns, including an adversarial one, the transition taken was enabled when it was offered. It also proves that no actor sends outside its effective grant, and that a run replays without its deciders.
What it costs
Every decision is written to storage before it is used, which adds a write to the path of every choice. You have to design the chart: name the states, decide what is a candidate, think about deadlines and fallbacks. And the model cannot invent an action the chart did not foresee. If it should be able to, the chart must say so, perhaps with a plan state.
That last cost is the point. An agent that can do anything cannot be trusted with anything. An agent that can do any of the things its chart offers, at the moments the chart offers them, with every choice written down, can be given real work. The goat dances as wildly as she likes. The play stays the play.