Skip to content

Enforcement

Judging each proposed call (Enforcer, Decision), what happens when a call would break a constraint (severities and soft-block escalation), and which formulas can be enforced at all (runtime-safety classification). Background: enforcing rules at run time.

Enforcer

The runtime enforcer every harness drives.

An Enforcer holds the constraints and what has happened so far. For each tool call the agent proposes, Enforcer.check returns a Decision; when the call runs, Enforcer.record_completed adds it to the trace:

enforcer = Enforcer(constraints, {"no-force-push": ConstraintSeverity.BLOCK_AND_WARN})
decision = enforcer.check("git_push", {"force": True})
if decision.allowed:
    result = run(...)
    enforcer.record_completed("git_push", {"force": True}, result=result)
else:
    tell_the_agent(decision.feedback)       # decision.action: warn, block, ask, ...

Each call is judged in the five-valued partial-trace semantics of agentltl._partial, and refused only for what it newly breaks.

What a refusal does depends on the constraint's severity, and the strongest one broken decides:

======================== ======== ===================================================== Severity action The call is refused, and ... ======================== ======== ===================================================== HARD_STOP stop the run stops (with latch_stop: every later call is refused until Enforcer.resume) PERSISTENT_BLOCK block the agent must do something else ASK ask a human decides; the agent can't override SOFT_BLOCK retry after max_soft_attempts it escalates to escalate_to (HARD_STOP, or ASK) BLOCK_AND_WARN warn the agent may insist by repeating the exact call TOLERATE allow the call runs; the violation is in notes ======================== ======== =====================================================

The state (trace, counters, override pointer) round-trips through Enforcer.to_state / Enforcer.from_state, so a harness that runs one process per call can carry it between calls.

Enforcer

Enforcer(constraints: Optional[Sequence[Any]] = None, constraint_severities: Optional[Dict[str, ConstraintSeverity]] = None, default_severity: ConstraintSeverity = HARD_STOP, max_soft_attempts: int = 3, soft_block_mode: Any = 'cumulative', max_consecutive_soft_attempts: Optional[int] = None, escalate_to: ConstraintSeverity = HARD_STOP, latch_stop: bool = False, nudge_max: int = 1, max_termination_nudges: int = 0, rank: Optional[Callable[[Violation], Any]] = None, render: Optional[Callable[[Decision, 'Enforcer'], str]] = None)

Judges proposed tool calls against FOLTL constraints, call by call.

Parameters:

Name Type Description Default
constraints Optional[Sequence[Any]]

The Constraint list.

None
constraint_severities Optional[Dict[str, ConstraintSeverity]]

Severity per constraint name.

None
default_severity ConstraintSeverity

For constraints not in constraint_severities.

HARD_STOP
max_soft_attempts int

Refusals of a SOFT_BLOCK constraint before it escalates.

3
soft_block_mode Any

How refusals are counted: cumulative, consecutive (reset by an allowed call) or hybrid (either threshold).

'cumulative'
max_consecutive_soft_attempts Optional[int]

The consecutive threshold (default: the cumulative one).

None
escalate_to ConstraintSeverity

What a SOFT_BLOCK escalates to: HARD_STOP (the run stops) or ASK (a human decides; the constraint's count starts again).

HARD_STOP
latch_stop bool

After a HARD_STOP, refuse every call until resume.

False
nudge_max int

How many violations one refusal may report.

1
max_termination_nudges int

How many times check_termination may send the agent back.

0
rank Optional[Callable[[Violation], Any]]

Orders the further violations reported with a refusal (a key function on Violation; default: those with advice first).

None
render Optional[Callable[[Decision, 'Enforcer'], str]]

Builds the agent's feedback from a Decision (default: render).

None

trace property

trace: List[Dict[str, Any]]

The calls that ran, in order (the list itself: append with record_*).

stopped property

stopped: Optional[str]

The constraint that stopped the run, while a stop is latched.

reset

reset() -> None

Forget the run: trace, counters and logs. Constraints and settings stay.

to_state

to_state(*, max_trace: Optional[int] = None, max_log: Optional[int] = None) -> Dict[str, Any]

Everything about the run so far, as a JSON-serialisable dict.

Parameters:

Name Type Description Default
max_trace Optional[int]

Keep only the last N calls of the trace.

None
max_log Optional[int]

Keep only the last N entries of each log.

None

from_state

from_state(state: Optional[Dict[str, Any]]) -> 'Enforcer'

Continue a run saved with to_state. Unknown keys are ignored.

begin_generation

begin_generation(key: Any = None) -> None

Start a new model generation (one completion).

A BLOCK_AND_WARN override needs the identical call in a later generation, so identical calls issued in parallel by one completion don't count as insisting. A harness that can't call this once per completion passes generation= to check instead (any key identifying the completion).

check

check(tool_name: str, tool_args: Optional[Dict[str, Any]] = None, step_number: Optional[int] = None, *, generation: Any = None) -> Decision

Judge a proposed call. Nothing is recorded as run: see record_completed.

check_chain

check_chain(calls: Sequence[Tuple[str, Dict[str, Any]]], step_number: Optional[int] = None, *, generation: Any = None, chain_key: Any = None) -> Decision

Judge calls that run together (a shell command line), all or nothing.

Each call is checked with the earlier calls of the chain already in the trace. The first refusal decides (Decision.index says which call); nothing is recorded. Insisting on a BLOCK_AND_WARN means re-issuing the same chain.

Parameters:

Name Type Description Default
calls Sequence[Tuple[str, Dict[str, Any]]]

(tool_name, tool_args) pairs in execution order.

required
chain_key Any

What identifies the chain when re-issued (default: the calls).

None

resume

resume() -> None

Lift a latched stop: the next call is judged normally again.

record_completed

record_completed(tool_name: str, tool_args: Optional[Dict[str, Any]] = None, tool_id: str = '', result: Any = None, *, status: Optional[int] = None) -> None

Add a call that ran to the trace.

Parameters:

Name Type Description Default
status Optional[int]

The call's exit status, when it has one (a shell command).

None

record_entry

record_entry(entry: Dict[str, Any]) -> None

Add a prepared trace entry (tool_name, arguments, ...) that ran.

An entry may carry step, the agent step it ran in: ordering atoms then treat calls of the same step as parallel (see Before). The enforcer sets none itself: calls of one command line share a generation but run in sequence.

check_termination

check_termination() -> Decision

The agent is about to finish. Is an obligation still unmet?

Consults the constraints marked applies_to_final_answer on the trace as final (strict semantics), at most max_termination_nudges times per run. Returns block with feedback to send the agent back, or allow.

reset_termination_nudges

reset_termination_nudges() -> None

Allow max_termination_nudges again: for a harness whose run is many turns, each one a new chance to finish (a new user message, say).

Decision dataclass

Decision(action: str = 'allow', violations: List[Violation] = list(), notes: List[Violation] = list(), feedback: str = '', escalated: bool = False, attempts: int = 0, override: Optional[str] = None, latched: bool = False, index: Optional[int] = None)

What to do with a proposed call.

Attributes:

Name Type Description
action str

allow, warn, retry, ask, block or stop.

violations List[Violation]

The violations that decided it, the deciding one first, then up to nudge_max - 1 others of the same severity.

notes List[Violation]

Violations of TOLERATE constraints: the call is allowed, the agent may be told.

feedback str

The message for the agent (see render on Enforcer).

escalated bool

A SOFT_BLOCK constraint ran out of retries; action is its escalation target.

attempts int

For retry: how many times this constraint has refused a call.

override Optional[str]

The constraint a BLOCK_AND_WARN override let the call past.

latched bool

Refused because an earlier stop is still in force.

index Optional[int]

For Enforcer.check_chain: which call of the chain decided.

violation property

violation: Optional[Violation]

The deciding violation, if the call was refused.

render

render(decision: Decision, enforcer: 'Enforcer') -> str

The default feedback for the agent: the rule, what broke it, how to comply.

Severities and violations

Shared runtime enforcement types.

This module defines the public types used by ALL backend integrations (smolagents, the native loop, harnesses) for constraint enforcement at runtime. It has zero framework dependencies and can be imported without installing any optional extras.

Types

  • ConstraintSeverity – HARD_STOP / PERSISTENT_BLOCK / ASK / SOFT_BLOCK / BLOCK_AND_WARN / TOLERATE
  • SoftBlockMode – cumulative / consecutive / hybrid
  • ConstraintViolation – record of a single runtime violation
  • ConstraintViolationError – exception raised when a constraint blocks a tool call before execution

ConstraintSeverity

Bases: Enum

How the agent should react when a constraint is violated at runtime.

HARD_STOP class-attribute instance-attribute

HARD_STOP = 'HARD_STOP'

Abort the run immediately – the offending tool call is not executed.

SOFT_BLOCK class-attribute instance-attribute

SOFT_BLOCK = 'SOFT_BLOCK'

Block the call, return a constraint-violation observation to the model so it can self-correct, and continue the run. Escalates to HARD_STOP after the configured soft-block threshold (see SoftBlockMode).

BLOCK_AND_WARN class-attribute instance-attribute

BLOCK_AND_WARN = 'BLOCK_AND_WARN'

Block the call and return a warning observation to the model, but never escalate to HARD_STOP. If the model's next tool call (in a subsequent generation) is byte-identical to the call that was just blocked — same tool name and same canonical arguments — the override fires and the call is executed. Any non-identical retry is blocked-and-warned again, with the insistence pointer updated to the new blocked call. Use this when the goal is to surface a soft warning that the model can deliberately override by repeating itself verbatim, rather than a hard or escalating block.

PERSISTENT_BLOCK class-attribute instance-attribute

PERSISTENT_BLOCK = 'PERSISTENT_BLOCK'

Block the call and return a warning observation to the model, continue the run, and never escalate to HARD_STOP — like BLOCK_AND_WARN, but the block can NEVER be overridden. Re-issuing the same call is blocked again every time. Use this for a non-fatal but non-negotiable guardrail: the model must choose a compliant action.

ASK class-attribute instance-attribute

ASK = 'ASK'

Block the call and hand the decision to a human: the harness asks the user to approve it. The model cannot override it. Without a human in the loop, a harness treats it like PERSISTENT_BLOCK.

TOLERATE class-attribute instance-attribute

TOLERATE = 'TOLERATE'

Log the violation and continue execution.

SoftBlockMode

Bases: Enum

Escalation counting strategy for SOFT_BLOCK constraints.

CUMULATIVE Count every violation of a constraint across the whole run. Escalates when the total reaches max_soft_attempts. (Default.)

CONSECUTIVE Count consecutive violations. The counter resets to zero whenever a tool call completes successfully without triggering the same constraint. Escalates when max_consecutive_soft_attempts consecutive violations occur. Catches tight retry loops while forgiving occasional re-offences separated by valid work.

HYBRID Escalates on either condition: consecutive violations reach max_consecutive_soft_attempts (tight-loop detection) OR total violations reach max_soft_attempts (persistent-disregard detection).

ConstraintViolation dataclass

ConstraintViolation(constraint_name: str, severity: str, step_number: int, tool_name: str, tool_args: Any, detail: str = '', description: str = '', repair: str = '', witnesses: List[Any] = list(), uncertain: bool = False)

Record of a single constraint violation detected at runtime.

advice

advice() -> str

The most actionable sentence available, or '' if there is none.

ConstraintViolationError

ConstraintViolationError(constraint_name: str, tool_name: str, tool_args: Any, detail: str = '', violation_type: str = 'HARD_STOP', soft_block_mode: Optional[str] = None, threshold_str: str = '')

Bases: Exception

Raised when a constraint blocks a tool call before execution.

This is the framework-agnostic base class. Framework-specific integrations may subclass it to add compatibility with their own exception hierarchies (e.g. the smolagents integration subclasses it to also inherit from smolagents.utils.AgentError so the run-loop catches it correctly).

Parameters

constraint_name Name of the constraint that triggered the block. tool_name Name of the tool call that was blocked. tool_args Arguments of the blocked tool call. detail Human-readable explanation from the constraint evaluator. violation_type "HARD_STOP" – constraint fired immediately, no retries. "SOFT_BLOCK_ESCALATION" – soft-block threshold reached after N attempts. soft_block_mode Active SoftBlockMode value string (set for SOFT_BLOCK_ESCALATION). threshold_str Human-readable threshold description (e.g. "total 3/3").

dict

dict() -> dict

Return a JSON-serialisable dict of all structured fields.

Runtime-safety classification

Static classification of FOLTL formulas by runtime-safety.

A FOLTL formula is runtime-safe iff a violation can be definitively detected at some finite trace prefix. Pairing a runtime-unsafe formula (an unbounded liveness property such as Eventually(Called("done"))) with a blocking severity (HARD_STOP, SOFT_BLOCK, PERSISTENT_BLOCK) produces spurious terminations: the agent is killed for not having satisfied a property whose witness might still arrive on the next step.

This module performs a recursive walk over the formula AST at constraint- registration time and labels each formula one of:

  • SAFE – it only fails finally: a refusal always points at a real violation.
  • UNSAFE – it can fail presumptively (an obligation not met yet), so blocking refuses every call until the obligation is met.
  • INERT – it cannot fail before the run ends; check it at termination.
  • AMBIGUOUS – the classifier cannot decide.

The labels come from reachable_values, the set of values a formula can take in the five-valued partial-trace semantics of agentltl._partial, so they agree with what the enforcer does.

The public API is intentionally small:

  • RuntimeSafety – the labels.
  • ClassificationReport – per-constraint result.
  • classify_constraints – inspect a list of constraints offline.

Internally we also distinguish two kinds of AMBIGUOUS:

  • trusted-predicate – a user-authored Predicate with no runtime_safe override. The user knows what their predicate does; the framework defers to them.
  • unknown-node – an AST node the classifier does not recognise. This means the classifier is out of date. We never silently treat unknown nodes as safe.

This distinction is not exposed in RuntimeSafety but drives the registration-time warn / raise hook in check_runtime_safety_or_warn.

RuntimeSafety

Bases: Enum

Static classification of a FOLTL formula's runtime-safety.

ClassificationReport dataclass

ClassificationReport(constraint_name: str, formula_repr: str, classification: RuntimeSafety, assigned_severity: Optional[ConstraintSeverity], compatible: bool, message: Optional[str])

Per-constraint result of classify_constraints.

reachable_values

reachable_values(formula: Formula) -> Tuple[FrozenSet[int], Optional[_Classification]]

The values formula can take on a partial trace (see agentltl._partial).

An abstract interpretation over the same algebra the evaluator uses, so the classification below agrees with what the enforcer does by construction. The second item is an AMBIGUOUS classification when the formula holds a predicate that declared nothing, or a node this function doesn't know.

classify_runtime_safety

classify_runtime_safety(formula: Formula) -> _Classification

Classify formula by what refusing a call for it would mean.

  • SAFE: it can only fail finally (FALSE): a refusal always points at a real violation, made by the call refused.
  • UNSAFE: it can fail presumptively (PFALSE, an obligation not met yet), so a blocking severity refuses calls until the obligation is met.
  • INERT: it never fails before the run ends (F, in_order): a blocking severity never fires. Check it at termination instead.
  • AMBIGUOUS: a predicate that declared nothing, or an unknown node.

Returns an internal _Classification; callers that only need the public label read .safety.

classify_constraints

classify_constraints(constraints: List[Any], severities: Optional[Dict[str, ConstraintSeverity]] = None, default_severity: ConstraintSeverity = HARD_STOP) -> List[ClassificationReport]

Classify each constraint and report compatibility with its severity.

severities is the same {name: ConstraintSeverity} mapping that AgentWithConstraints accepts. When None is passed the report is purely informational (assigned_severity is None and compatible is always True).

default_severity is consulted whenever severities is provided but a particular constraint name is not listed there.

check_runtime_safety_or_warn

check_runtime_safety_or_warn(constraints: List[Any], severities: Optional[Dict[str, ConstraintSeverity]], default_severity: ConstraintSeverity, *, strict: bool, logger: Logger) -> None

Warn (or raise) on each runtime-unsafe constraint paired with a blocking severity.

Constraints whose applies_to_final_answer attribute is truthy are skipped — they are evaluated once at the end of the run and cannot cause spurious mid-run terminations.

  • strict=False → emit logger.warning per mismatch.
  • strict=True → raise ValueError on the first mismatch.