The state machine
The LoopMachine in src/engine/core/machine.rs is the decision-making half of your engine.
It holds the conversation, counts turns, watches the context size, and answers one question with total consistency: “what next?”.
It performs no network calls, no tool runs, no clock reads. You feed it results and read its decisions.
Serialize it, restore it, and it resumes exactly where it left off.
If you have not yet, read the big idea first, because this page goes deeper into every rule the brain follows.
What the brain holds
Section titled “What the brain holds”| Field | What it is |
|---|---|
state: MachineState |
Where you are: Start, AwaitingModel, AwaitingTools, AwaitingCompaction, Terminal(outcome) |
history: Vec<Message> |
The notebook: messages of your past successful runs |
pending: Vec<Message> |
The scratchpad: your current run’s messages |
pending_tools: Vec<PendingToolCall> |
Tool calls awaiting dispatch (with any pre-resolved answers) |
turns_taken: usize |
Turns you completed this run |
context_tokens: u64 |
The latest estimate of your conversation’s size |
cancelled: bool |
Whether you requested cancel |
last_compaction_tokens: Option<u64> |
Size measured just before the current compaction pass |
Settings such as max turns, context window and thresholds are not stored, because your driver passes them in as a MachinePolicy on every next_step() call.
That is what keeps your machine pure: serializable state and nothing else.
MachinePolicy: the numbers, and where each comes from
Section titled “MachinePolicy: the numbers, and where each comes from”| Policy field | Source | Default |
|---|---|---|
max_turns |
RunConfig::max_turns |
200 |
context_window |
SessionConfig / ContextManager |
200,000 tokens |
compact_threshold |
ContextManager::threshold |
80 (%) |
auto_compact |
ContextManager::auto_compact |
true |
Your driver rebuilds this struct every loop iteration from the session config, the in-flight run’s config, and the context manager’s sizing policy.
So a mid-run change to any of them takes effect on your very next step, and the machine itself serializes without a single setting inside it.
The feeds’ postconditions, in one table
Section titled “The feeds’ postconditions, in one table”| Feed | Legal from | Effect on buffers | New state |
|---|---|---|---|
accept_input |
any non-terminal | clears pending, pushes the user message, resets counters |
Start |
model_response |
AwaitingModel |
reply appended to pending; tool calls classified into pending_tools (unknown names pre-answered) |
AwaitingTools, or Terminal(Completed) |
tool_results |
AwaitingTools |
results appended; consumes pending_tools (only feed that does) |
Start |
compaction_result |
AwaitingCompaction |
history replaced wholesale, pending cleared, estimate = after |
Start, or Terminal(Failed) on no-progress |
compaction_noop |
AwaitingCompaction |
both untouched | same two exits, buffers preserved |
adopt_pruned |
AwaitingCompaction (prune pass) |
adopts the rewritten conversation, splitting at the old history length | unchanged state |
inject |
non-terminal | one message appended to pending; estimate not refreshed |
unchanged |
cancel / fail |
non-terminal | none (flag / terminal only) | flag set / Terminal(Failed) |
The “illegal from” column is implicit and enforced. A feed arriving in the wrong state is a driver contract violation, not a silent no-op.
That is what makes the transition set auditable for you.
The decision order in next_step(policy)
Section titled “The decision order in next_step(policy)”Every call to next_step walks these checks in this exact order. The first hit wins:
flowchart TD N["next_step(policy)"] --> C{"cancelled flag set?"} C -- yes --> DC["Done(Cancelled)"] C -- no --> T{"already Terminal?"} T -- yes --> DT["Done(same outcome, forever)"] T -- no --> S{"state?"} S -- "AwaitingModel / Start" --> M1{"turns_taken >= max_turns?"} M1 -- yes --> MT["Done(MaxTurnsExceeded)"] M1 -- no --> M2{"tokens >= 95% of window?<br/>(emergency line, always on)"} M2 -- yes --> CP["Compact(Emergency)"] M2 -- no --> M3{"auto_compact AND<br/>tokens > threshold% of window?"} M3 -- yes --> CT["Compact(ThresholdExceeded)"] M3 -- no --> CL["CallLLM(turn)"] S -- "AwaitingTools" --> KO["CallTools(turn, calls)"] S -- "AwaitingCompaction" --> CO["Compact(reason)"]Read the two compaction checks precisely:
-
Emergency line:
tokens >= window × 95%, inclusive. It fires even whenauto_compact = false, and it ignorescompact_threshold. It is your last-ditch net against overflowing the window. -
Threshold line:
tokens > window × compact_threshold%, strict. So a payload exactly at the line serves normally, and you honor the line only whenauto_compactis on. -
The deferral latch (0.4.0): a threshold-triggered pass that declined while your payload still fits the window sets
deferred_compaction. So your next model call serves instead of cycling between compact and defer. The emergency line ignores the latch, so a genuinely over-window payload still forces the pass. -
context_window = 0disables both checks, which turns your window policy off entirely.
next_step is pure and repeatable: call it twice with nothing in between and you get the same answer. Nothing is recorded by asking.
The feeds: how results come back
Section titled “The feeds: how results come back”Each step has exactly one matching feedback method:
model_response(response, context_tokens)
Section titled “model_response(response, context_tokens)”Your driver calls this after a model reply. The brain:
-
Pushes the assistant message onto
pending, adopts the token estimate, counts the turn. -
No tool calls in the reply? Then
Terminal(Completed { final_text }). The run is over, and the final text is the reply’s text. -
Tool calls present? Each one is classified against
available_tools, the names your driver advertised.-
Known name: a real pending call, waiting for dispatch.
-
Unknown name: the brain pre-answers it with a tool-result message “tool ‘x’ is not available” and
is_error: true, stored alongside.
-
-
Your driver feeds that answer back without ever dispatching, so your model sees its mistake and can correct it.
-
State becomes
AwaitingTools { turn }.
tool_results(messages)
Section titled “tool_results(messages)”You call this after tools run. It appends result messages to pending and consumes pending_tools, the only feed that does, which is what makes re-polling safe for you.
State becomes Start. Your next step will be a fresh CallLLM, subject to the checks above.
compaction_result(compacted, tokens_before, tokens_after) when history was rewritten
Section titled “compaction_result(compacted, tokens_before, tokens_after) when history was rewritten”After a successful compaction pass:
-
No-progress guard: if
tokens_after >= tokens_before, the outcome isTerminal(Failed(ContextExceeded)). Compaction achieved nothing, and looping between compact and call would be pointless. Buffers are left untouched. -
Otherwise
history = compacted, a wholesale replacement.pendingclears, because its content was folded into the compacted result. The estimate becomestokens_afterand state moves toStart.
compaction_noop(tokens_before, tokens_after) when nothing changed
Section titled “compaction_noop(tokens_before, tokens_after) when nothing changed”This covers passes that did not rewrite anything: no compactor configured, a hook vetoed it, or the compactor returned your conversation unchanged.
The same no-progress guard applies, but both buffers are left alone. In particular pending survives, so your run continues rather than silently committing early.
The distinction matters to you. Feeding an unchanged conversation through compaction_result would glue your run’s half-finished scratchpad into permanent history mid-run, and compaction_noop exists precisely to avoid that.
The smaller feeds
Section titled “The smaller feeds”-
accept_input(input)starts a run: it clearspending, pushes your user message, resets counters, and state becomesStart. -
set_context_tokens(n)updates the size estimate only. Your driver refreshes it at run start, after tool results, and before model calls carrying fresh extras such as memory and contributor messages. So your compaction trigger always sees the true size of the next request. -
inject(message)lets you push an arbitrary message ontopending. It does not refresh the context estimate, so if you inject something big, callset_context_tokensyourself. -
cancel()sets the flag, and your nextnext_stepreturnsDone(Cancelled). -
fail(error)sets an instantTerminal(Failed), and is how your driver records errors.
The three maintenance operations
Section titled “The three maintenance operations”-
commit_pending()glues your scratchpad into the notebook. Your driver calls this only on a successful run exit. -
salvage_pending()keeps the coherent prefix and drops the tail, which is 0.4.0’s cancel and failure salvage. -
The longest prefix in which every assistant tool-call message is answered by its results glues into
history, and the unanswered remainder is returned for your demotion sink and discarded. An interrupt costs you the in-flight turn, not the run’s whole conversation. -
discard_pending()throws your scratchpad away whole. It is called on theContextExceededfamily, because an unshrinkable over-window history must not wedge every future run. It keeps your notebook clean.
Reading and resuming
Section titled “Reading and resuming”machine.history() // committed messages onlymachine.full_history() // history + pending — exactly what the next request would sendmachine.state() // current MachineStatemachine.turns_taken()machine.context_tokens() // the current size estimate (read accessor, 0.4.0)machine.is_terminal()machine.is_cancelled()
// The checkpoint/resume pair at the driver level:let saved = serde_json::to_string(&agent.into_machine())?; // pause anywherelet m: LoopMachine = serde_json::from_str(&saved)?; // restore anywherelet agent = BareLoop::from_machine(m, config, client, tools); // new body, same brainTests pin that a restored machine makes identical step decisions, so resume is not close enough, it is exact for you.
Once Terminal, a machine accepts no further input and repeats its outcome forever, and a restored finished run still knows it finished.
fail() even ensures an errored machine carries its failure record into serialization.
One driver-level choice: from_machine builds a fresh manager bundle, so every observer you installed is dropped on resume.
Since 0.3.2, BareLoop::from_machine_with_managers(m, config, client, tools, managers) resumes while keeping your LoopManagers, meaning observer host, dispatch pipeline and context manager.
That gives you an observer-preserving resume of a seeded or deserialized machine. A bundle carrying none gets the session-synced default, matching new_with_managers.
Rules recap: the brain’s contract
Section titled “Rules recap: the brain’s contract”-
The brain never does IO, it only decides. All doing belongs to your driver.
-
Policy is passed in fresh each step, never stored, so your brain serializes as pure state.
-
next_stepis pure: asking never changes anything, except consuming the cancelled flag’s transition. -
Feeds are the only way in, and each step has exactly one matching feed.
tool_resultsis the only feed that consumespending_tools. -
Terminal is forever. Every outcome maps to at most one
LoopErrorviato_loop_error(), andCompletedmaps toNone. -
Failed runs keep the coherent prefix. Your
pendingis salvaged on cancellation and ordinary failure, so completed turns commit and the in-flight tail drops. -
It is discarded only on the
ContextExceededfamily. The one exception is a mid-run compaction, which is a commit point, so the compacted history stays even if your run then fails. -
A machine you serialize mid-phase resumes at the run boundary, and checkpoint and resume is documented and pinned for exactly that boundary, not mid-phase.
Related pages
Section titled “Related pages”- State machines: the pattern this machine is an instance of, explained from scratch.
- The driver loop: the hands that execute these steps.
- Compaction: the full story of the Compact step.
- File reference: machine.rs