Skip to content

The engine ​

The Engine moves one Task forward. Its single verb, run_one_step, runs the Task until it has to wait or is finished — a whole turn, however many model calls and tool calls that takes — and then returns. It keeps nothing in memory between calls; every call starts from state freshly folded from the log.

One run_one_step = one turnYour codesends a goal viaquery() / ClientWorkertakes the lease,folds the logComposethe View: exactlywhat the model seesPolicy decidesasks the modelSuspend / finishrelease the leaseRun toolsappend resultsto the logGuardallow · deny ·require_approvalAnswer backto your codetool callsloopwait / done
The Policy only decides; the Engine records every effect in the log.

What it guarantees ​

  • Deciding and recording are separate. The policy only returns a decision and never writes to the log; the Engine records every effect. A misbehaving policy cannot corrupt the record.
  • Guards can veto; observers cannot. A guard runs before an action and can block it. An observer sees events only after they are durable and cannot change anything.
  • A failing guard denies. If a guard raises, the call is denied. A failing observer is ignored and the Task carries on.
  • Long steps still leave resume points. A snapshot is written every 20 consecutive tool-call turns.

The loop ​

  1. Compose. The context composer builds the View — the exact prompt, tool schemas and messages the model will see — and the Engine records a ContextPlanComposed event. See Context and caching.
  2. Decide. The policy reads the View and returns a typed Decision. The default is ReActPolicy, which asks the model.
  3. Dispatch. The Engine carries the decision out — run tools, spawn subtasks, suspend, finish — and appends each effect to the log under the worker's lease.

Every continue decision below — not only tool calls — loops straight back to compose. Only a suspend or terminate decision ends the call. A Client turn normally ends in a suspend: the Task waits for your next message. Only query() (or multi_turn=False) finishes the Task with TaskCompleted.

RouteDecisionsEffect
ContinueToolCallsDecision, StatePatchDecision, CompactionRequestedDecision, a background SpawnSubtaskDecisionrecord events, loop again
Suspendforeground SpawnSubtaskDecision, SpawnSubtasksDecision, YieldForHumanDecision, WaitTimerDecision, WaitExternalDecisionsnapshot, TaskSuspended, release
TerminateFinishDecision, FailDecisionsnapshot, terminal event

The vocabulary names no product feature: updating a to-do list is a state patch, asking the user is YieldForHumanDecision. The built-in that contributed the tool does the translating.

One turn from the host's side ​

Your code hands a goal to a worker, which takes the lease and folds the log. Everything between the lease and the release is one run_one_step call. Cancellation is cooperative: the Engine checks for a cancel at the top of each pass and right after the policy decides, so a cancel takes effect at the next turn boundary.

Guards and observers ​

GuardObserver
Runsbefore the action, synchronouslyafter the event is durably appended
Can blockyes: allow, deny or require_approvalno
If it raisesthe action is deniedthe error is swallowed
Use forpermissions, budgets, breaking loopsaudit, metrics, tracing, streaming to a UI

Guards see three kinds of action: ProposedToolCall, ProposedSpawnSubtask, ProposedFinish. They run in ascending priority and the first non-allow verdict wins, so a later guard can only tighten what an earlier one allowed. The governance built-in installs:

PriorityGuardEnforces
10BudgetGuarditeration, tool-call, cost, subtask and depth caps
20PermissionGuardtool and agent allowlists, risk ceiling
30RepetitionGuardstops a run of identical tool calls
100HookGuardyour PreToolUse rules

require_approval suspends the Task exactly like asking a human a question, so approval reuses the normal wake path.

Observers are called after each append commits, outside the writer lock, possibly from several threads at once — guard your own state. The one observer that writes, ChildLifecycleObserver, only appends a SubtaskCompleted to the parent's stream; no stream ever gets a second writer.

What this means for you ​

  • To change what the agent decides, replace the policy. To block an action, write a guard. To watch, write an observer. There is no other kind of hook.
  • Wire your own with Options(guards=(MyGuard(),), observers=(fn,)); neither changes agent identity. A guard or observer contributed by a loaded plugin applies to every agent in the process — governance cannot be opted out of.
  • The Engine's main loop itself is not extensible; everything around it is.

Design records: guard and observer hooks · engine per turn

Next ​

Released under the Apache License 2.0.