Pause guards
Pause guards are a small, opt-in Python SDK library that turns the pause / resume primitive into composable safety checks. You construct the guards you want — a budget ceiling, a latency ceiling, a constitutional-violation predicate, a human-approval gate — wire them around an agent step, and when a wired guard trips the library drives the existing pause round-trip: it registers a pause, blocks until an operator resumes it in the dashboard, then hands you the (possibly edited) state.
Guards ship in v3.35.0 (SDK audittrail>=3.7.0). They live in
audittrail.pause_guards and never touch the network until a wired guard
trips.
The four guards
| Guard | Trips when | Default pause step |
|---|---|---|
BudgetGuard | cumulative cost or tokens reaches a ceiling | generation_in_progress |
LatencyGuard | elapsed wall-clock reaches a ceiling | generation_in_progress |
ConstitutionalGuard | a user predicate (or sample.violation) is true | waiting_tool_result |
HumanApprovalGuard | approval is required (always=True or sample.require_approval) | user_checkpoint |
Every paused_at_step is overridable per guard.
Never auto-pause unless you wire it
This is a hard invariant, enforced structurally — not just documented:
- Importing
audittrail.pause_guardshas no side effects. There is no global registry and no monkeypatch of the client. - Constructing a guard, or even a whole
GuardSet, fires zero HTTP. - A guard only ever pauses because you put it in a
GuardSetand calledrun(...)/guarded_step(...)with aGuardSample. - If pause is disabled (
pause_enabled=Falseon the client, i.e.config.enabled is False), aGuardSetshort-circuits: guards are not even evaluated and no HTTP fires. A wired-but-disabled guard set is inert.
You supply the measurements (honest by design)
The SDK keeps no running cost / token / latency tally — spans are
POSTed to the server and dropped; the client holds no cost accumulator.
So budget and latency guards read caller-supplied values off a
GuardSample. Nothing is invented internally:
from audittrail import GuardSample
sample = GuardSample(
cost_usd=running_cost, # you tally this in your loop
tokens=running_tokens, # you tally this too
elapsed_s=elapsed, # or let LatencyGuard(track=True) self-measure
)If the field a guard needs is None (nothing measured), the guard does
not trip — it never fabricates a measurement.
Likewise there is no local governor in the SDK: constitutional rules
are evaluated server-side at ingest (governor.py), which a remote agent
process can't call. So ConstitutionalGuard takes a predicate you
own, or you set sample.violation=True. (An optional pattern: after a
span ingests, poll client.fetch_trace(...) for the server's
ConstitutionalEvaluation results and feed the outcome into your
predicate.)
Usage
Budget + latency, caller supplies measurements
from audittrail import AuditTrailClient, BudgetGuard, LatencyGuard, GuardSample
client = AuditTrailClient(api_base=..., api_key=..., pause_enabled=True)
await client.register_trace(user_prompt="research task")
gs = client.guard_set(
BudgetGuard(max_cost_usd=2.00),
LatencyGuard(max_elapsed_s=120.0),
pause_ttl_seconds=600, # bound the pause — see TTL below
)
# ...each loop iteration, after you've tallied your own cost/time:
outcome = await gs.run(
GuardSample(cost_usd=running_cost, elapsed_s=elapsed),
state=agent_state,
)
if outcome.tripped:
agent_state = outcome.state # operator may have edited it during the pauseConstitutional guard with a predicate + context-manager wiring
from audittrail import ConstitutionalGuard, guarded_step, GuardSample
def flags_pii(sample) -> bool:
return "ssn" in sample.extra.get("tool_output", "").lower()
gs = client.guard_set(ConstitutionalGuard(condition=flags_pii, rule_id="privacy.pii"))
async with guarded_step(gs, GuardSample(extra={"tool_output": tool_result}),
state=agent_state) as edited:
agent_state = edited or agent_state
# body runs only after any pause resolvesHuman-approval gate before a destructive tool
from audittrail import HumanApprovalGuard, GuardSample
gs = client.guard_set(HumanApprovalGuard(always=True)) # always pause here
outcome = await gs.run(GuardSample(), state={"about_to": "delete_prod_table"})
# blocks until an operator resumes in the dashboard; raises PauseExpiredError on TTLThe GuardSet
GuardSet composes guards and drives the pause on the first trip
(insertion order wins — a single pause per step, so the operator resumes
once). Other tripped guards are recorded on
decision.detail["also_tripped"] for visibility.
trace_idis required to pause.POST /pausesanchors to an existing user-owned trace. If a guard trips but the set has notrace_id, aGuardErroris raised with an actionable message (callclient.register_trace(...)or passtrace_id=) — rather than a confusing 404. Theclient.guard_set(...)factory reads the trace id set byregister_trace.pause_ttl_secondsbounds the pause. Forwarded to the created pause row so the server reaper flips it toexpiredonce the deadline passes even if no operator ever resumes. Recommended so a guard pause can't block forever on a wedged operator. Server accepts 1..86400.on_trip— an optional async callback fired with the trippedGuardDecisionjust before the pause blocks (local logging / metrics).
The trip reason is embedded in the pause snapshot under a __guard__
key so it's visible in the dashboard Edit form; that internal key is
stripped from the state returned to your agent.
Fail-safe on guard errors
A guard's own evaluate() error must never crash the agent:
fail_closed=True(the default forConstitutionalGuardandHumanApprovalGuard) → the guard is treated as tripped so it pauses for human review; the error text goes into the decision reason and is logged at ERROR. It does not raise.fail_closed=False(the default forBudgetGuardandLatencyGuard) → the guard is treated as not tripped, logged at WARNING, and the agent proceeds.
This is a safe-by-default split: a broken cost calculation can't stall your agent, while a broken safety predicate errs toward pausing rather than silently skipping a check.
Errors & terminal states
Guards inherit the pause subsystem's terminal semantics verbatim (they
call the same transport as pause_checkpoint):
| Situation | Result |
|---|---|
| Operator resumes (optionally edits) | GuardOutcome(tripped=True, resumed=True, state=<edited or original>) |
Server TTL / client ttl_s elapses | PauseExpiredError |
| Heartbeat goes stale | PauseAbandonedError |
| 401 / 403 on any pause call | PauseUnauthorizedError |
Guard trips but no trace_id | GuardError |
All pause errors subclass PauseError, so except PauseError catches
every "pause didn't resume cleanly" flavour.
Limitations
- Async only. Like the rest of the pause subsystem, guards require an event loop. Sync-only agents can't use them without one.
- One pause per step. The first tripped guard drives the pause; the
rest are reported on
detail["also_tripped"]. Per-guard pauses are a possible future enhancement.