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 |
None
|
constraint_severities
|
Optional[Dict[str, ConstraintSeverity]]
|
Severity per constraint name. |
None
|
default_severity
|
ConstraintSeverity
|
For constraints not in |
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'
|
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 |
False
|
nudge_max
|
int
|
How many violations one refusal may report. |
1
|
max_termination_nudges
|
int
|
How many times |
0
|
rank
|
Optional[Callable[[Violation], Any]]
|
Orders the further violations reported with a refusal (a key function on
|
None
|
render
|
Optional[Callable[[Decision, 'Enforcer'], str]]
|
Builds the agent's feedback from a |
None
|
trace
property
¶
The calls that ran, in order (the list itself: append with record_*).
stopped
property
¶
The constraint that stopped the run, while a stop is latched.
to_state
¶
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
¶
Continue a run saved with to_state. Unknown keys are ignored.
begin_generation
¶
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]]]
|
|
required |
chain_key
|
Any
|
What identifies the chain when re-issued (default: the calls). |
None
|
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
¶
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
¶
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
|
|
violations |
List[Violation]
|
The violations that decided it, the deciding one first, then up to
|
notes |
List[Violation]
|
Violations of TOLERATE constraints: the call is allowed, the agent may be told. |
feedback |
str
|
The message for the agent (see |
escalated |
bool
|
A SOFT_BLOCK constraint ran out of retries; |
attempts |
int
|
For |
override |
Optional[str]
|
The constraint a BLOCK_AND_WARN override let the call past. |
latched |
bool
|
Refused because an earlier |
index |
Optional[int]
|
For |
violation
property
¶
The deciding violation, if the call was refused.
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 / TOLERATESoftBlockMode– cumulative / consecutive / hybridConstraintViolation– record of a single runtime violationConstraintViolationError– 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
¶
Abort the run immediately – the offending tool call is not executed.
SOFT_BLOCK
class-attribute
instance-attribute
¶
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 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
¶
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
¶
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
¶
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.
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").
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
Predicatewith noruntime_safeoverride. 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→ emitlogger.warningper mismatch.strict=True→ raiseValueErroron the first mismatch.