Documentation

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

GuardTrips whenDefault pause step
BudgetGuardcumulative cost or tokens reaches a ceilinggeneration_in_progress
LatencyGuardelapsed wall-clock reaches a ceilinggeneration_in_progress
ConstitutionalGuarda user predicate (or sample.violation) is truewaiting_tool_result
HumanApprovalGuardapproval 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_guards has 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 GuardSet and called run(...) / guarded_step(...) with a GuardSample.
  • If pause is disabled (pause_enabled=False on the client, i.e. config.enabled is False), a GuardSet short-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:

python
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

python
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 pause

Constitutional guard with a predicate + context-manager wiring

python
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 resolves

Human-approval gate before a destructive tool

python
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 TTL

The 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_id is required to pause. POST /pauses anchors to an existing user-owned trace. If a guard trips but the set has no trace_id, a GuardError is raised with an actionable message (call client.register_trace(...) or pass trace_id=) — rather than a confusing 404. The client.guard_set(...) factory reads the trace id set by register_trace.
  • pause_ttl_seconds bounds the pause. Forwarded to the created pause row so the server reaper flips it to expired once 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 tripped GuardDecision just 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 for ConstitutionalGuard and HumanApprovalGuard) → 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 for BudgetGuard and LatencyGuard) → 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):

SituationResult
Operator resumes (optionally edits)GuardOutcome(tripped=True, resumed=True, state=<edited or original>)
Server TTL / client ttl_s elapsesPauseExpiredError
Heartbeat goes stalePauseAbandonedError
401 / 403 on any pause callPauseUnauthorizedError
Guard trips but no trace_idGuardError

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.