Runs and the engine¶
The Engine is the runtime. It owns the event store, the bus, and the bookkeeping that makes
a run bounded: spend, deadlines, cancellation, child runs.
from aaa.runtime import Budget, Engine
async with Engine() as engine: # in-memory store + in-process bus
...
async with Engine(store=SQLiteEventStore("agent.db")) as engine: # durable
...
Engine constructor
| Argument | Default | Meaning |
|---|---|---|
store |
InMemoryEventStore() |
Where events live. |
bus |
InProcessBus() |
How events are delivered. Pass a DistributedBus for a cluster, a ClientBus for a submit-only process. |
schemas |
empty SchemaRegistry |
Versioned payload validation and upcasting. |
default_budget |
unlimited | Applied to runs started without an explicit budget=. |
blobs |
none | A BlobStore; enables claim-check. |
claim_threshold |
16384 |
Bytes of JSON above which an event's payload moves to the blob store. |
sweep_interval_s |
none | Run sweep() on this period. Recommended in a cluster. |
store_retry |
RetryPolicy() |
How long to ride out a store outage (backoff + jitter) before the bus redelivers the event instead. Raises StoreUnavailable to your calls (start, signal) after the budget. |
is_transient |
built in | Predicate deciding which exceptions are outages (retry) rather than bugs (fail the run). |
Starting a run¶
outcome = await engine.run(algo, {"task": "..."}) # start and wait
run = await engine.start(algo, {"task": "..."}, budget=Budget(deadline_s=60), run_id="job-42")
outcome = await run.result()
run_id is optional. Starting an id that already exists is idempotent: you get a handle to the existing run
instead of a duplicate. That makes start safe to retry after a timeout and gives you natural job keys.
A RunHandle is available for any run id, from any process: engine.handle("job-42").
RunHandle member |
|
|---|---|
run_id |
The stream name. |
await result() |
The Outcome, whichever process hosted or finished the run. |
events(hydrate=False) |
Async iterator: replay the log from the start, then follow it until the run completes. |
await cancel(reason) |
Ask the engine to end the run as cancelled. |
await signal(type, data) |
Inject an external event (an approval, a webhook, a user message). |
Outcomes¶
A run ends in exactly one Outcome(kind, value, reason). The kinds are distinct on purpose so callers can react
correctly:
kind |
Meaning | Typical reaction |
|---|---|---|
success |
The algorithm produced a result in value. |
Use it. |
no_solution |
It ran to its limit without an answer (e.g. max_turns reached, repair loop exhausted). value may hold the best attempt. |
Show best-effort or escalate. |
timeout |
The deadline passed. | Retry with a bigger budget or give up. |
budget_exceeded |
Steps, tokens or cost exceeded their cap. | Same. |
invalid_input |
The input could not be handled (e.g. a router chose an unknown route). | Fix the caller. |
cancelled |
Someone cancelled, or the parent run ended. | None. |
failed |
An unrecoverable error (LLM auth failure, an algorithm exception). reason has the cause. |
Alert. |
outcome.ok is True only for success. See Outcomes and errors.
Budgets¶
- Steps count completed LLM calls plus completed or failed tool calls.
- Tokens are summed from the
usagethe gateway reports. - Cost is summed from
coston each completion: yourprice_in_per_mtok/price_out_per_mtokin theLLMConfig, or the cost LiteLLM computes. If neither exists, cost is0andmax_costcannot trip. - Deadline is wall-clock seconds from when the run started. It survives restarts: a recovered run keeps the time it has already used.
Limits use strict >: max_steps=20 allows 20 steps and trips on the 21st. When one trips, the engine appends
aaa.budget.exceeded and completes the run with budget_exceeded (or timeout for the deadline). An algorithm cannot
opt out.
Enforcement is eventual, not preemptive
Budgets are checked when events are appended. A single very long LLM call that has already started will finish
(and be counted) before the run stops. Combine budgets with a client timeout (LLMConfig.timeout_s).
Child runs¶
Algorithms such as PlanAndExecute and Supervisor start child runs:
- The child is its own stream with its own outcome.
- Its spend rolls up into the parent's budget (via
aaa.child.completed). - The parent receives
aaa.child.completedwith the child's outcome. - If the parent finishes first (cancelled, timed out, completed), still-open children are cancelled.
trace_idis shared, so one trace covers the whole tree.
Cancellation¶
appends aaa.run.cancel; the engine completes the run as cancelled and tools running for it are cancelled. A cancel
issued from any process works, because it is just an event.
External events¶
run.signal(type, data) appends an arbitrary event to the run's stream. Algorithms that subscribe to it react. This is
how approvals, webhooks and FSM transitions arrive.
Sweeping¶
await engine.sweep() (or Engine(sweep_interval_s=5)) performs stateless, idempotent repairs that are safe to run
in any process at any time:
- complete runs whose deadline passed but whose owning process died (a dead process cannot fire its own timer);
- cancel orphaned children whose parent finished;
- re-announce a finished child's result that never reached its parent.
Recovering after a restart¶
Single-process: await engine.recover([algorithm, ...]) re-adopts every open run and redelivers whatever nothing
reacted to. Clusters do not need recover(); the bus redelivers on its own. See
Persistence and recovery.
Shutting down¶
async with Engine() drains the bus on exit. await engine.abort() is the opposite: a hard stop that models a crash
(nothing drained, no leases released). Use it in tests of your own recovery paths.