feat(automation): add autonomy guardrails and audit trail
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 18s
CI / lint (pull_request) Successful in 23s
CI / security (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 35s
CI / integration_tests (pull_request) Successful in 3m46s
CI / unit_tests (pull_request) Successful in 11m44s
CI / docker (pull_request) Successful in 38s
CI / benchmark-regression (pull_request) Successful in 27m39s
CI / coverage (pull_request) Successful in 55m41s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 16s
CI / lint (push) Successful in 19s
CI / typecheck (push) Successful in 32s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 41s
CI / integration_tests (push) Successful in 2m55s
CI / unit_tests (push) Successful in 11m43s
CI / docker (push) Successful in 40s
CI / benchmark-publish (push) Successful in 16m11s
CI / coverage (push) Successful in 44m49s

Add runtime autonomy constraints (max steps, tool budget, required
confirmations) and a structured audit trail for plan execution.

New domain models:
- AutonomyGuardrails: enforces step limits, tool budgets, and
  confirmation gates with validators and check methods
- GuardrailAuditEntry: records each enforcement event with timestamp,
  event type, guard name, result, reason, and context
- GuardrailAuditTrail: ordered collection of audit entries persisted
  to plan metadata

New service:
- AutonomyGuardrailService: high-level service for configuring
  guardrails per plan, checking constraints, recording audit entries,
  and serializing/restoring state via plan metadata

Tests:
- Behave: 69 scenarios covering model validation, step/budget/
  confirmation checks, audit trail recording, and service operations
- Robot: 8 test cases for autonomy guardrail CLI flag smoke testing
- ASV: 6 benchmark suites measuring enforcement overhead

Documentation:
- Updated docs/reference/automation_profiles.md with guardrail fields,
  enforcement behavior, audit trail schema, and event type reference

ISSUES CLOSED: #204
This commit was merged in pull request #443.
This commit is contained in:
CoreRasurae
2026-02-25 22:21:44 +00:00
committed by Luis Mendes
parent 64af753aab
commit 53455275ba
13 changed files with 3269 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
"""ASV benchmarks for autonomy guardrails enforcement overhead.
Measures the performance of:
- AutonomyGuardrails model construction
- Step limit checks
- Tool budget checks
- Confirmation checks
- Wall-clock time limit checks
- Actor tool-call limit checks
- Audit trail entry creation, appending, and eviction
- AutonomyGuardrailService operations
- Metadata serialization and deserialization
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.domain.models.core.autonomy_guardrails import (
ActorLimits,
AutonomyGuardrails,
GuardrailAuditEntry,
GuardrailAuditTrail,
GuardrailEventType,
GuardrailResult,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.domain.models.core.autonomy_guardrails import (
ActorLimits,
AutonomyGuardrails,
GuardrailAuditEntry,
GuardrailAuditTrail,
GuardrailEventType,
GuardrailResult,
)
class GuardrailsConstructionSuite:
"""Benchmark AutonomyGuardrails model construction."""
def time_guardrails_construction_full(self) -> None:
"""Benchmark fully-populated guardrails creation."""
AutonomyGuardrails(
max_steps=100,
tool_budget=500.0,
required_confirmations=["deploy", "delete", "write"],
)
def time_guardrails_construction_minimal(self) -> None:
"""Benchmark minimal guardrails creation."""
AutonomyGuardrails()
def time_guardrails_construction_steps_only(self) -> None:
"""Benchmark guardrails with only step limit."""
AutonomyGuardrails(max_steps=50)
def time_guardrails_construction_budget_only(self) -> None:
"""Benchmark guardrails with only budget."""
AutonomyGuardrails(tool_budget=100.0)
class StepLimitCheckSuite:
"""Benchmark step limit enforcement checks."""
def setup(self) -> None:
"""Create guardrails for benchmarking."""
self.limited = AutonomyGuardrails(max_steps=100)
self.unlimited = AutonomyGuardrails()
def time_step_limit_allowed(self) -> None:
"""Benchmark step check when allowed."""
self.limited.step_count = 50
self.limited.check_step_limit()
def time_step_limit_denied(self) -> None:
"""Benchmark step check when denied."""
self.limited.step_count = 100
self.limited.check_step_limit()
def time_step_limit_unlimited(self) -> None:
"""Benchmark step check with no limit."""
self.unlimited.step_count = 10000
self.unlimited.check_step_limit()
class BudgetCheckSuite:
"""Benchmark tool budget enforcement checks."""
def setup(self) -> None:
"""Create guardrails for benchmarking."""
self.budgeted = AutonomyGuardrails(tool_budget=100.0)
self.unlimited = AutonomyGuardrails()
def time_budget_allowed(self) -> None:
"""Benchmark budget check when allowed."""
self.budgeted.budget_spent = 50.0
self.budgeted.check_tool_budget(10.0)
def time_budget_denied(self) -> None:
"""Benchmark budget check when denied."""
self.budgeted.budget_spent = 95.0
self.budgeted.check_tool_budget(10.0)
def time_budget_unlimited(self) -> None:
"""Benchmark budget check with no limit."""
self.unlimited.check_tool_budget(999.0)
class ConfirmationCheckSuite:
"""Benchmark confirmation requirement checks."""
def setup(self) -> None:
"""Create guardrails for benchmarking."""
self.with_confirmations = AutonomyGuardrails(
required_confirmations=[
"deploy",
"delete",
"write",
"apply",
"revert",
],
)
self.no_confirmations = AutonomyGuardrails()
def time_confirmation_required(self) -> None:
"""Benchmark check when confirmation is required."""
self.with_confirmations.check_confirmation_required("deploy")
def time_confirmation_not_required(self) -> None:
"""Benchmark check when confirmation is not required."""
self.with_confirmations.check_confirmation_required("read")
def time_confirmation_empty_list(self) -> None:
"""Benchmark check with empty confirmation list."""
self.no_confirmations.check_confirmation_required("deploy")
class AuditTrailSuite:
"""Benchmark audit trail operations."""
def setup(self) -> None:
"""Create audit trail for benchmarking."""
self.trail = GuardrailAuditTrail()
self.entry = GuardrailAuditEntry(
event_type=GuardrailEventType.STEP_ALLOWED,
guard_name="step_limit",
result=GuardrailResult.ALLOWED,
)
def time_audit_entry_creation(self) -> None:
"""Benchmark audit entry creation."""
GuardrailAuditEntry(
event_type=GuardrailEventType.STEP_ALLOWED,
guard_name="step_limit",
result=GuardrailResult.ALLOWED,
)
def time_audit_trail_append(self) -> None:
"""Benchmark appending to audit trail."""
self.trail.add_entry(self.entry)
def time_audit_trail_counts(self) -> None:
"""Benchmark counting allowed/denied entries."""
_ = self.trail.allowed_count
_ = self.trail.denied_count
class ServiceSuite:
"""Benchmark AutonomyGuardrailService operations."""
def setup(self) -> None:
"""Create service for benchmarking."""
self.service = AutonomyGuardrailService()
self.guardrails = AutonomyGuardrails(
max_steps=100,
tool_budget=500.0,
required_confirmations=["deploy"],
)
self.service.configure_guardrails("bench-plan", self.guardrails)
def time_service_check_step(self) -> None:
"""Benchmark service step limit check."""
self.service.check_step_limit("bench-plan", 50)
def time_service_check_budget(self) -> None:
"""Benchmark service budget check."""
self.service.check_tool_budget("bench-plan", 1.0)
def time_service_check_confirmation(self) -> None:
"""Benchmark service confirmation check."""
self.service.check_confirmation_required("bench-plan", "deploy")
def time_service_get_metadata(self) -> None:
"""Benchmark service metadata serialization."""
self.service.get_audit_trail_as_metadata("bench-plan")
def time_service_configure(self) -> None:
"""Benchmark service guardrail configuration."""
self.service.configure_guardrails(
"new-plan",
AutonomyGuardrails(max_steps=50),
)
def time_service_check_wall_clock(self) -> None:
"""Benchmark service wall-clock check."""
self.service.check_wall_clock("bench-plan")
def time_service_check_actor_calls(self) -> None:
"""Benchmark service actor tool-call check."""
self.service.check_actor_tool_calls("bench-plan", 5)
class WallClockCheckSuite:
"""Benchmark wall-clock time limit checks."""
def setup(self) -> None:
"""Create guardrails for benchmarking."""
self.with_limit = AutonomyGuardrails(max_wall_clock_seconds=3600.0)
self.with_limit.mark_started()
self.no_limit = AutonomyGuardrails()
def time_wall_clock_allowed(self) -> None:
"""Benchmark wall-clock check when allowed."""
self.with_limit.check_wall_clock()
def time_wall_clock_no_limit(self) -> None:
"""Benchmark wall-clock check with no limit."""
self.no_limit.check_wall_clock()
class ActorLimitCheckSuite:
"""Benchmark per-actor tool-call limit checks."""
def setup(self) -> None:
"""Create guardrails for benchmarking."""
self.with_limit = AutonomyGuardrails(
actor_limits=ActorLimits(max_tool_calls_per_invocation=100),
)
self.no_limit = AutonomyGuardrails()
def time_actor_limit_allowed(self) -> None:
"""Benchmark actor limit check when allowed."""
self.with_limit.check_actor_tool_calls(50)
def time_actor_limit_denied(self) -> None:
"""Benchmark actor limit check when denied."""
self.with_limit.check_actor_tool_calls(100)
def time_actor_limit_unlimited(self) -> None:
"""Benchmark actor limit check with no limit."""
self.no_limit.check_actor_tool_calls(10000)
class AuditTrailEvictionSuite:
"""Benchmark audit trail eviction under load."""
def setup(self) -> None:
"""Create a full audit trail for benchmarking eviction."""
self.trail = GuardrailAuditTrail(max_entries=100)
for _ in range(100):
self.trail.add_entry(
GuardrailAuditEntry(
event_type=GuardrailEventType.STEP_ALLOWED,
guard_name="step_limit",
result=GuardrailResult.ALLOWED,
)
)
self.entry = GuardrailAuditEntry(
event_type=GuardrailEventType.STEP_ALLOWED,
guard_name="step_limit",
result=GuardrailResult.ALLOWED,
)
def time_eviction_append(self) -> None:
"""Benchmark appending to a full trail (triggers eviction)."""
self.trail.add_entry(self.entry)
+120
View File
@@ -271,3 +271,123 @@ Legacy automation levels are mapped to built-in profiles:
| `full_auto` | `full-auto` |
Use `--automation-profile <name>` instead of `--automation-level`.
## Autonomy Guardrails
Autonomy guardrails extend the automation guard framework with runtime
constraints that enforce step limits, tool budgets, and required human
confirmations during plan execution. Unlike the phase-transition thresholds
which gate entire phases, autonomy guardrails operate at the individual
step and tool invocation level.
### Guardrail Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `max_steps` | `int \| None` | `None` | Maximum execution steps allowed. `None` means unlimited. |
| `tool_budget` | `float \| None` | `None` | Maximum cumulative cost for tool invocations. `None` means unlimited. A value of `0.0` blocks any positive cost. |
| `max_wall_clock_seconds` | `float \| None` | `None` | Maximum wall-clock time in seconds for plan execution. `None` means unlimited. |
| `actor_limits` | `ActorLimits` | `ActorLimits()` | Per-actor runtime limits (see below). |
| `required_confirmations` | `list[str]` | `[]` | Operation names requiring human confirmation before execution (case-insensitive). |
| `step_count` | `int` | `0` | Current execution step counter (managed by the service). |
| `budget_spent` | `float` | `0.0` | Current cumulative budget spent (managed by the service). |
| `start_time` | `str \| None` | `None` | ISO-8601 timestamp of when plan execution started (for wall-clock tracking). |
### Actor Limits
Per-actor constraints that limit tool calls within a single actor invocation:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `max_tool_calls_per_invocation` | `int \| None` | `None` | Maximum tool calls allowed per actor invocation. `None` means unlimited. |
| `max_retries_per_failure` | `int \| None` | `None` | Maximum retries per tool failure within an actor step. `None` means unlimited. |
### Enforcement Behavior
Guardrails are enforced at multiple points during plan execution:
- **Pre-flight** — Before execution begins, `PlanExecutor._guard_execute()` calls `_enforce_guardrails()` which checks wall-clock time and the current step limit. If either check fails, execution is blocked with a `PlanError` before any decisions are processed.
- **Per-decision** — Before each decision in the execution loop, `_enforce_guardrails_per_step()` increments the step counter, checks the step limit, and rechecks wall-clock time. If any check fails, execution halts with a `PlanError`.
- **Start-time tracking** — On the first execution, `_enforce_guardrails()` automatically calls `mark_started()` on the guardrails model to record the wall-clock start time (if not already set).
The individual guardrail checks are:
1. **Step limit** — The `step_count` is compared against `max_steps`. If equal or exceeded, the step is **blocked** and an audit entry is recorded.
2. **Tool budget** — The projected cost (`budget_spent + tool_cost`) is compared against `tool_budget`. If exceeded, the invocation is **blocked** and an audit entry is recorded. When allowed, the cost is recorded **before** the audit entry so the trail reflects the post-deduction budget state.
3. **Wall-clock time** — If `max_wall_clock_seconds` is set and `start_time` is recorded, the elapsed wall-clock time is checked. If the limit is exceeded, the step is **blocked**. Malformed `start_time` values are treated as a block (defense-in-depth) rather than raising an unhandled exception.
4. **Actor tool-call limit** — The actor's current tool call count is compared against `actor_limits.max_tool_calls_per_invocation`. If exceeded, the call is **blocked**.
5. **Retry-per-failure limit** — The current retry count is compared against `actor_limits.max_retries_per_failure`. If equal or exceeded, the retry is **blocked** and an audit entry is recorded. When `max_retries_per_failure` is `None`, retries are unlimited.
6. **Required confirmations** — The operation name is checked (case-insensitively) against `required_confirmations`. If matched, the operation is **blocked** pending human confirmation.
If no guardrail blocks the invocation, it proceeds and the step counter and budget are updated.
### Input Validation
- `start_time` is validated on assignment to ensure it is a parseable ISO-8601 timestamp. Malformed strings are rejected with a `ValidationError`.
- `max_retries_per_failure` must be non-negative (`>= 0`) if set; `None` means unlimited.
- `max_steps` must be positive (`>= 1`) if set.
- `tool_budget` must be non-negative (`>= 0.0`) if set.
### Audit Trail
Every guardrail enforcement event is recorded in a `GuardrailAuditTrail` which is persisted to plan metadata. The audit trail provides a bounded, ordered record of autonomy-related decisions. When the number of entries exceeds `max_entries` (default: 10,000), the oldest entries are evicted to prevent unbounded memory growth.
#### Audit Entry Fields
| Field | Type | Description |
|-------|------|-------------|
| `timestamp` | `str` | ISO-8601 timestamp of the enforcement event. |
| `event_type` | `GuardrailEventType` | Type of guardrail event (see below). |
| `guard_name` | `str` | Name of the guard that was evaluated. |
| `result` | `GuardrailResult` | Outcome: `allowed` or `denied`. |
| `reason` | `str \| None` | Human-readable reason for the decision. |
| `context` | `dict` | Additional metadata about the enforcement event. |
#### Event Types
| Event Type | Description |
|------------|-------------|
| `step_blocked` | A step was blocked due to the step limit. |
| `budget_blocked` | A tool invocation was blocked due to budget. |
| `confirmation_required` | An operation was blocked pending confirmation. |
| `step_allowed` | A step was allowed within the limit. |
| `budget_allowed` | A tool invocation was allowed within budget. |
| `confirmation_granted` | An operation was allowed (no confirmation needed). |
| `time_blocked` | A step was blocked due to the wall-clock time limit. |
| `time_allowed` | A step was allowed within the wall-clock time limit. |
| `actor_limit_blocked` | An actor tool call was blocked due to per-invocation limits. |
| `actor_limit_allowed` | An actor tool call was allowed within per-invocation limits. |
| `retry_blocked` | A retry was blocked due to the per-failure retry limit. |
| `retry_allowed` | A retry was allowed within the per-failure retry limit. |
### Plan Metadata Persistence
The guardrail state and audit trail are serialized to plan metadata using two keys:
- `autonomy_guardrails` — The current guardrail configuration and counters.
- `guardrail_audit_trail` — The ordered list of audit entries.
This allows guardrail state to survive across plan restarts and provides
a complete audit record for post-mortem analysis.
### Example Configuration
```yaml
name: acme/guarded-auto
description: Auto profile with autonomy guardrails
schema_version: "1.0"
auto_strategize: 0.0
auto_execute: 0.0
auto_apply: 1.0
guards:
max_tool_calls_per_step: 10
max_total_cost: 100.0
# Autonomy guardrails (configured via service or CLI)
# max_steps: 50
# tool_budget: 200.0
# required_confirmations:
# - deploy
# - delete
```
+401
View File
@@ -0,0 +1,401 @@
Feature: Autonomy Guardrails and Audit Trail
As a plan executor
I want autonomy guardrails to enforce step limits, tool budgets, and confirmations
So that plan execution stays within defined safety boundaries with a full audit trail
# ---- AutonomyGuardrails model validation ----
Scenario: Create guardrails with all fields
When I create autonomy guardrails with max_steps 10 and tool_budget 50.0
Then the guardrails should have max_steps 10
And the guardrails should have tool_budget 50.0
And the guardrails step_count should be 0
And the guardrails budget_spent should be 0.0
Scenario: Create guardrails with defaults
When I create autonomy guardrails with defaults
Then the guardrails should have max_steps None
And the guardrails should have tool_budget None
And the guardrails should have empty required_confirmations
Scenario: Guardrails reject negative max_steps
When I try to create guardrails with max_steps 0
Then a guardrails validation error should be raised
And the guardrails error should mention "max_steps"
Scenario: Guardrails reject negative tool_budget
When I try to create guardrails with tool_budget -1.0
Then a guardrails validation error should be raised
And the guardrails error should mention "tool_budget"
# ---- Step limit checks ----
Scenario: Step limit allows within bounds
Given guardrails with max_steps 5
When I check step limit at step 3
Then the step check should be allowed
Scenario: Step limit blocks at max
Given guardrails with max_steps 5
When I check step limit at step 5
Then the step check should be denied
Scenario: Step limit blocks above max
Given guardrails with max_steps 3
When I check step limit at step 10
Then the step check should be denied
Scenario: Unlimited steps always allowed
Given guardrails with no step limit
When I check step limit at step 1000
Then the step check should be allowed
# ---- Tool budget checks ----
Scenario: Budget allows within limit
Given guardrails with tool_budget 100.0
When I check tool budget for cost 50.0
Then the budget check should be allowed
Scenario: Budget blocks when exceeded
Given guardrails with tool_budget 10.0 and budget_spent 8.0
When I check tool budget for cost 5.0
Then the budget check should be denied
Scenario: Budget allows exact remaining
Given guardrails with tool_budget 10.0 and budget_spent 5.0
When I check tool budget for cost 5.0
Then the budget check should be allowed
Scenario: Unlimited budget always allowed
Given guardrails with no budget limit
When I check tool budget for cost 999.0
Then the budget check should be allowed
Scenario: Budget rejects negative tool cost
Given guardrails with tool_budget 100.0
When I try to check tool budget for cost -1.0
Then a guardrails validation error should be raised
# ---- Confirmation checks ----
Scenario: Confirmation required for listed operation
Given guardrails with required confirmations "deploy,delete"
When I check confirmation for operation "deploy"
Then confirmation should be required
Scenario: Confirmation not required for unlisted operation
Given guardrails with required confirmations "deploy,delete"
When I check confirmation for operation "read"
Then confirmation should not be required
Scenario: No confirmations required when list empty
Given guardrails with no required confirmations
When I check confirmation for operation "deploy"
Then confirmation should not be required
# ---- Step counter and budget tracking ----
Scenario: Increment step counter
Given guardrails with max_steps 10
When I increment the step counter 3 times
Then the step_count should be 3
Scenario: Record tool cost
Given guardrails with tool_budget 100.0
When I record a tool cost of 25.0
And I record a tool cost of 15.0
Then the budget_spent should be 40.0
Scenario: Record cost rejects negative cost
Given guardrails with tool_budget 100.0
When I try to record a negative cost
Then a guardrails validation error should be raised
# ---- Audit trail ----
Scenario: Audit trail records entries
Given an empty audit trail
When I add a step_allowed entry to the audit trail
And I add a budget_blocked entry to the audit trail
Then the audit trail should have 2 entries
And the audit trail allowed_count should be 1
And the audit trail denied_count should be 1
Scenario: Audit entry has correct fields
When I create an audit entry with event_type step_blocked and guard_name step_limit
Then the audit entry event_type should be "step_blocked"
And the audit entry guard_name should be "step_limit"
And the audit entry result should be "denied"
And the audit entry should have a timestamp
# ---- AutonomyGuardrailService ----
Scenario: Service configures and retrieves guardrails
Given an autonomy guardrail service
When I configure guardrails for plan "plan-001" with max_steps 5
Then the service should return guardrails for plan "plan-001"
And the service guardrails should have max_steps 5
Scenario: Service returns None for unconfigured plan
Given an autonomy guardrail service
Then the service should return None for plan "nonexistent"
Scenario: Service step limit check records audit trail
Given an autonomy guardrail service with guardrails for plan "plan-002"
When I check step limit via service for plan "plan-002" at step 3
Then the audit trail for plan "plan-002" should have 1 entry
Scenario: Service budget check records cost and audit trail
Given an autonomy guardrail service with budget guardrails for plan "plan-003"
When I check tool budget via service for plan "plan-003" with cost 5.0
Then the audit trail for plan "plan-003" should have 1 entry
And the service guardrails budget_spent should be 5.0
Scenario: Service confirmation check records audit trail
Given an autonomy guardrail service with confirmation guardrails for plan "plan-004"
When I check confirmation via service for plan "plan-004" operation "deploy"
Then the audit trail for plan "plan-004" should have 1 entry
Scenario: Service serializes audit trail to metadata
Given an autonomy guardrail service with guardrails for plan "plan-005"
When I check step limit via service for plan "plan-005" at step 1
Then the metadata for plan "plan-005" should contain the audit trail
Scenario: Service loads guardrails from metadata
Given an autonomy guardrail service
When I load guardrails from metadata for plan "plan-006"
Then the service should return guardrails for plan "plan-006"
Scenario: Service removes plan guardrails
Given an autonomy guardrail service with guardrails for plan "plan-007"
When I remove plan "plan-007" from the service
Then the service should return None for plan "plan-007"
Scenario: Service check_step_limit returns True for unconfigured plan
Given an autonomy guardrail service
When I check step limit via service for plan "unknown" at step 100
Then the step limit check should return True
Scenario: Service check_tool_budget returns True for unconfigured plan
Given an autonomy guardrail service
When I check tool budget via service for plan "unknown" with cost 999.0
Then the tool budget check should return True
Scenario: Service check_confirmation returns False for unconfigured plan
Given an autonomy guardrail service
When I check confirmation via service for plan "unknown" operation "deploy"
Then the confirmation check should return False
Scenario: Service configure_guardrails rejects empty plan_id
Given an autonomy guardrail service
When I try to configure guardrails with empty plan_id
Then a guardrails validation error should be raised
Scenario: GuardrailEventType enumerates all event types
Then GuardrailEventType should have at least 10 members
Scenario: GuardrailResult enumerates allowed and denied
Then GuardrailResult should have exactly 2 members
# ---- Wall-clock time checks ----
Scenario: Wall-clock allows when no limit set
Given guardrails with no wall-clock limit
When I check wall-clock time
Then the wall-clock check should be allowed
Scenario: Wall-clock allows when within limit
Given guardrails with wall-clock limit 60.0 seconds started recently
When I check wall-clock time
Then the wall-clock check should be allowed
Scenario: Wall-clock blocks when expired
Given guardrails with wall-clock limit that has already expired
When I check wall-clock time
Then the wall-clock check should be denied
Scenario: Service wall-clock check records audit trail
Given an autonomy guardrail service with wall-clock guardrails for plan "plan-wc"
When I check wall-clock via service for plan "plan-wc"
Then the audit trail for plan "plan-wc" should have 1 entry
# ---- Actor tool-call limits ----
Scenario: Actor tool-call limit allows within bounds
Given guardrails with actor tool-call limit 5
When I check actor tool calls with 3 current calls
Then the actor tool-call check should be allowed
Scenario: Actor tool-call limit blocks at max
Given guardrails with actor tool-call limit 5
When I check actor tool calls with 5 current calls
Then the actor tool-call check should be denied
Scenario: Actor tool-call limit unlimited
Given guardrails with no actor tool-call limit
When I check actor tool calls with 1000 current calls
Then the actor tool-call check should be allowed
Scenario: Service actor tool-call check records audit trail
Given an autonomy guardrail service with actor-limit guardrails for plan "plan-al"
When I check actor tool calls via service for plan "plan-al" with 3 calls
Then the audit trail for plan "plan-al" should have 1 entry
# ---- Bounded audit trail eviction ----
Scenario: Audit trail evicts oldest entry when full
Given an audit trail with max_entries 3
When I add 4 step_allowed entries to the audit trail
Then the audit trail should have 3 entries
And the audit trail allowed_count should be 3
# ---- Edge cases ----
Scenario: Budget blocks when tool_budget is zero
Given guardrails with tool_budget 0.0
When I check tool budget for cost 0.01
Then the budget check should be denied
Scenario: Budget allows zero cost when tool_budget is zero
Given guardrails with tool_budget 0.0
When I check tool budget for cost 0
Then the budget check should be allowed
Scenario: Case-insensitive confirmation matching
Given guardrails with required confirmations "Deploy,DELETE"
When I check confirmation for operation "deploy"
Then confirmation should be required
Scenario: Case-insensitive confirmation matching uppercase query
Given guardrails with required confirmations "deploy"
When I check confirmation for operation "DEPLOY"
Then confirmation should be required
Scenario: Service rejects oversized metadata entries
Given an autonomy guardrail service
When I try to load metadata with oversized audit trail for plan "plan-big"
Then a guardrails validation error should be raised
Scenario: Service rejects oversized confirmations in metadata
Given an autonomy guardrail service
When I try to load metadata with oversized confirmations for plan "plan-big2"
Then a guardrails validation error should be raised
Scenario: Metadata round-trip preserves guardrail state
Given an autonomy guardrail service with guardrails for plan "plan-rt"
When I check step limit via service for plan "plan-rt" at step 3
And I serialize and restore metadata for plan "plan-rt"
Then the restored guardrails for plan "plan-rt" should match the original
# ---- start_time validation ----
Scenario: start_time accepts valid ISO-8601 timestamp
When I create guardrails with a valid start_time
Then the guardrails start_time should be set
Scenario: start_time rejects malformed string
When I try to create guardrails with start_time "not-a-date"
Then a guardrails validation error should be raised
And the guardrails error should mention "start_time"
Scenario: start_time accepts None
When I create autonomy guardrails with defaults
Then the guardrails start_time should be None
Scenario: check_wall_clock handles malformed start_time gracefully
Given guardrails with a malformed start_time bypassing validation
When I check wall-clock time
Then the wall-clock check should be denied
# ---- Retry-per-failure checks ----
Scenario: Retry limit allows within bounds
Given guardrails with retry limit 3
When I check retries per failure with 2 current retries
Then the retry check should be allowed
Scenario: Retry limit blocks at max
Given guardrails with retry limit 3
When I check retries per failure with 3 current retries
Then the retry check should be denied
Scenario: Retry limit blocks above max
Given guardrails with retry limit 2
When I check retries per failure with 5 current retries
Then the retry check should be denied
Scenario: Unlimited retries always allowed
Given guardrails with no retry limit
When I check retries per failure with 1000 current retries
Then the retry check should be allowed
Scenario: Service retry check records audit trail
Given an autonomy guardrail service with retry-limit guardrails for plan "plan-rl"
When I check retries via service for plan "plan-rl" with 1 retries
Then the audit trail for plan "plan-rl" should have 1 entry
Scenario: Service retry check blocks and records audit trail
Given an autonomy guardrail service with retry-limit guardrails for plan "plan-rl2"
When I check retries via service for plan "plan-rl2" with 5 retries
Then the retry service check should return False
And the audit trail for plan "plan-rl2" should have 1 entry
Scenario: Successive retries stop after reaching max_retries_per_failure
Given a guardrail service with max_retries_per_failure 3 for plan "retry-stop"
When I check retries for plan "retry-stop" incrementing from 0 to 4
Then retry checks 0, 1, 2 should be allowed
And retry checks 3, 4 should be denied
And the audit trail for plan "retry-stop" should have 5 entry
# ---- PlanExecutor guardrail integration ----
Scenario: PlanExecutor enforces step limit during stub execution
Given a plan executor with guardrail service and step limit 2
And a plan in execute phase with 3 decisions
When I run execute on the plan
Then execution should fail with a guardrail step limit error
Scenario: PlanExecutor enforces wall-clock limit during execution
Given a plan executor with guardrail service and expired wall-clock
And a plan in execute phase with 1 decisions
When I run execute on the plan
Then execution should fail with a guardrail wall-clock error
Scenario: PlanExecutor runs without guardrails when service is None
Given a plan executor without guardrail service
And a plan in execute phase with 2 decisions
When I run execute on the plan
Then execution should succeed
Scenario: PlanExecutor marks start_time on first execution
Given a plan executor with guardrail service and no wall-clock limit
And a plan in execute phase with 1 decisions
When I run execute on the plan
Then the guardrails start_time should have been set
# ---- Additional validation edge cases for coverage ----
Scenario: Guardrails reject invalid max_tool_calls_per_invocation
When I try to create actor limits with max_tool_calls_per_invocation 0
Then a guardrails validation error should be raised
And the guardrails error should mention "max_tool_calls_per_invocation"
Scenario: Guardrails reject negative max_retries_per_failure
When I try to create actor limits with max_retries_per_failure -1
Then a guardrails validation error should be raised
And the guardrails error should mention "max_retries_per_failure"
Scenario: Guardrails reject non-positive max_wall_clock_seconds
When I try to create guardrails with max_wall_clock_seconds 0
Then a guardrails validation error should be raised
And the guardrails error should mention "max_wall_clock_seconds"
Scenario: Audit trail evicts denied entry when full
Given an audit trail with max_entries 2
When I add a budget_blocked entry to the audit trail
And I add a step_allowed entry to the audit trail
And I add a step_allowed entry to the audit trail
Then the audit trail should have 2 entries
And the audit trail denied_count should be 0
And the audit trail allowed_count should be 2
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
*** Settings ***
Documentation Smoke tests for autonomy guardrails and audit trail
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_autonomy_guardrails.py
*** Test Cases ***
Guardrail Step Limit Allows Within Bounds
[Documentation] Step limit allows when under limit
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} step-allow cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} step-allow-ok
Guardrail Step Limit Blocks At Max
[Documentation] Step limit blocks when at max steps
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} step-block cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} step-block-ok
Guardrail Budget Allows Within Limit
[Documentation] Budget allows when within limit
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} budget-allow cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} budget-allow-ok
Guardrail Budget Blocks Exceeded
[Documentation] Budget blocks when exceeded
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} budget-block cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} budget-block-ok
Guardrail Confirmation Required
[Documentation] Confirmation required for listed operation
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} confirm-required cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} confirm-required-ok
Guardrail Audit Trail Records Events
[Documentation] Audit trail records enforcement events
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} audit-trail cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} audit-trail-ok
Guardrail Service Metadata Persistence
[Documentation] Service can serialize and restore from metadata
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} metadata cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} metadata-ok
Guardrail Wall Clock Allows Within Limit
[Documentation] Wall-clock allows when within time limit
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} wall-clock-allow cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wall-clock-allow-ok
Guardrail Wall Clock Blocks When Expired
[Documentation] Wall-clock blocks when time limit exceeded
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} wall-clock-block cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wall-clock-block-ok
Guardrail Actor Tool Call Limit
[Documentation] Actor tool-call limit enforcement
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} actor-limit cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-limit-ok
Guardrail Audit Trail Eviction
[Documentation] Audit trail evicts oldest entries when full
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} eviction cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} eviction-ok
Guardrail Zero Budget Edge Case
[Documentation] Zero budget blocks any positive cost
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} zero-budget cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} zero-budget-ok
Guardrail CLI Flags Summary
[Documentation] Summary of autonomy guardrail features
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} summary cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} summary-ok
+220
View File
@@ -0,0 +1,220 @@
"""Helper script for Robot Framework autonomy guardrails tests."""
from __future__ import annotations
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.domain.models.core.autonomy_guardrails import (
ActorLimits,
AutonomyGuardrails,
GuardrailAuditEntry,
GuardrailAuditTrail,
GuardrailEventType,
GuardrailResult,
)
def _test_step_allow() -> None:
"""Step limit allows within bounds."""
g = AutonomyGuardrails(max_steps=10)
g.step_count = 5
allowed, _ = g.check_step_limit()
assert allowed is True
print("step-allow-ok")
def _test_step_block() -> None:
"""Step limit blocks at max."""
g = AutonomyGuardrails(max_steps=5)
g.step_count = 5
allowed, reason = g.check_step_limit()
assert allowed is False
assert reason is not None
assert "Step limit" in reason
print("step-block-ok")
def _test_budget_allow() -> None:
"""Budget allows within limit."""
g = AutonomyGuardrails(tool_budget=100.0)
allowed, _ = g.check_tool_budget(50.0)
assert allowed is True
print("budget-allow-ok")
def _test_budget_block() -> None:
"""Budget blocks when exceeded."""
g = AutonomyGuardrails(tool_budget=10.0, budget_spent=8.0)
allowed, reason = g.check_tool_budget(5.0)
assert allowed is False
assert reason is not None
assert "Budget exceeded" in reason
print("budget-block-ok")
def _test_confirm_required() -> None:
"""Confirmation required for listed operation."""
g = AutonomyGuardrails(required_confirmations=["deploy", "delete"])
required, reason = g.check_confirmation_required("deploy")
assert required is True
assert reason is not None
not_required, _ = g.check_confirmation_required("read")
assert not_required is False
print("confirm-required-ok")
def _test_audit_trail() -> None:
"""Audit trail records enforcement events."""
svc = AutonomyGuardrailService()
svc.configure_guardrails(
"test-plan",
AutonomyGuardrails(max_steps=5, tool_budget=50.0),
)
svc.check_step_limit("test-plan", 3)
svc.check_tool_budget("test-plan", 10.0)
trail = svc.get_audit_trail("test-plan")
assert len(trail.entries) == 2
assert trail.entries[0].guard_name == "step_limit"
assert trail.entries[1].guard_name == "tool_budget"
print("audit-trail-ok")
def _test_metadata() -> None:
"""Service can serialize and restore from metadata."""
svc = AutonomyGuardrailService()
svc.configure_guardrails(
"meta-plan",
AutonomyGuardrails(max_steps=10, tool_budget=25.0),
)
svc.check_step_limit("meta-plan", 2)
metadata = svc.get_audit_trail_as_metadata("meta-plan")
assert "guardrail_audit_trail" in metadata
assert "autonomy_guardrails" in metadata
svc2 = AutonomyGuardrailService()
svc2.load_from_metadata("meta-plan", metadata)
g = svc2.get_guardrails("meta-plan")
assert g is not None
assert g.max_steps == 10
print("metadata-ok")
def _test_summary() -> None:
"""Summary of autonomy guardrail features."""
g = AutonomyGuardrails(
max_steps=100,
tool_budget=500.0,
required_confirmations=["deploy", "delete"],
)
assert g.max_steps == 100
assert g.tool_budget == 500.0
assert len(g.required_confirmations) == 2
g.increment_step()
assert g.step_count == 1
g.record_cost(10.0)
assert g.budget_spent == 10.0
trail = GuardrailAuditTrail()
entry = GuardrailAuditEntry(
event_type=GuardrailEventType.STEP_ALLOWED,
guard_name="step_limit",
result=GuardrailResult.ALLOWED,
)
trail.add_entry(entry)
assert len(trail.entries) == 1
print("summary-ok")
def _test_wall_clock_allow() -> None:
"""Wall-clock allows when within limit."""
g = AutonomyGuardrails(max_wall_clock_seconds=600.0)
g.mark_started()
allowed, _ = g.check_wall_clock()
assert allowed is True
print("wall-clock-allow-ok")
def _test_wall_clock_block() -> None:
"""Wall-clock blocks when expired."""
g = AutonomyGuardrails(max_wall_clock_seconds=0.001)
g.start_time = (datetime.now(tz=UTC) - timedelta(seconds=10)).isoformat()
allowed, reason = g.check_wall_clock()
assert allowed is False
assert reason is not None
assert "Wall-clock" in reason
print("wall-clock-block-ok")
def _test_actor_limit() -> None:
"""Actor tool-call limit enforcement."""
g = AutonomyGuardrails(
actor_limits=ActorLimits(max_tool_calls_per_invocation=5),
)
allowed, _ = g.check_actor_tool_calls(3)
assert allowed is True
blocked, reason = g.check_actor_tool_calls(5)
assert blocked is False
assert reason is not None
assert "Actor" in reason
print("actor-limit-ok")
def _test_eviction() -> None:
"""Audit trail evicts oldest entries when full."""
trail = GuardrailAuditTrail(max_entries=3)
for i in range(5):
entry = GuardrailAuditEntry(
event_type=GuardrailEventType.STEP_ALLOWED,
guard_name=f"step_{i}",
result=GuardrailResult.ALLOWED,
)
trail.add_entry(entry)
assert len(trail.entries) == 3
# Oldest two (step_0, step_1) should be evicted
assert trail.entries[0].guard_name == "step_2"
assert trail.allowed_count == 3
print("eviction-ok")
def _test_zero_budget() -> None:
"""Zero budget blocks any positive cost."""
g = AutonomyGuardrails(tool_budget=0.0)
allowed, _ = g.check_tool_budget(0.0)
assert allowed is True
blocked, reason = g.check_tool_budget(0.01)
assert blocked is False
assert reason is not None
print("zero-budget-ok")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "summary"
dispatch: dict[str, Any] = {
"step-allow": _test_step_allow,
"step-block": _test_step_block,
"budget-allow": _test_budget_allow,
"budget-block": _test_budget_block,
"confirm-required": _test_confirm_required,
"audit-trail": _test_audit_trail,
"metadata": _test_metadata,
"wall-clock-allow": _test_wall_clock_allow,
"wall-clock-block": _test_wall_clock_block,
"actor-limit": _test_actor_limit,
"eviction": _test_eviction,
"zero-budget": _test_zero_budget,
"summary": _test_summary,
}
fn = dispatch.get(cmd)
if fn:
fn()
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
@@ -11,6 +11,9 @@ from dependency_injector import containers, providers
from cleveragents.actor.registry import ActorRegistry
from cleveragents.application.reactive_registry_adapter import register_registry_agents
from cleveragents.application.services.actor_service import ActorService
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.application.services.context_service import ContextService
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.application.services.plan_lifecycle_service import (
@@ -250,6 +253,11 @@ class Container(containers.DeclarativeContainer):
database_url=database_url,
)
# Autonomy Guardrail Service - Singleton so all callers share state
autonomy_guardrail_service = providers.Singleton(
AutonomyGuardrailService,
)
# Reactive routing
stream_router = providers.Singleton(ReactiveStreamRouter)
langgraph_bridge = providers.Singleton(
@@ -3,6 +3,9 @@
Contains service classes that orchestrate business operations.
"""
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.application.services.config_service import (
ConfigEntry,
ConfigLevel,
@@ -82,6 +85,7 @@ __all__ = [
"ApplyValidationResult",
"ApplyValidationSummary",
"AttachmentScope",
"AutonomyGuardrailService",
"BrokenReferenceRule",
"ConfigEntry",
"ConfigLevel",
@@ -0,0 +1,524 @@
"""Autonomy guardrail enforcement service.
Provides a high-level service interface for checking autonomy
guardrails during plan execution and recording enforcement
events to the plan's audit trail.
The service integrates with the plan metadata to persist audit
trail entries, providing a complete record of guardrail decisions.
Thread-safe: all state mutations are protected by a reentrant lock
so the service can be shared across threads (e.g. concurrent plan
step evaluations).
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from cleveragents.domain.models.core.autonomy_guardrails import (
AutonomyGuardrails,
GuardrailAuditEntry,
GuardrailAuditTrail,
GuardrailEventType,
GuardrailResult,
)
logger = logging.getLogger(__name__)
# Key used in plan metadata to store the audit trail
_AUDIT_TRAIL_KEY = "guardrail_audit_trail"
# Key used in plan metadata to store guardrail state
_GUARDRAILS_KEY = "autonomy_guardrails"
# Hard ceiling for entries accepted through load_from_metadata to
# prevent memory exhaustion from untrusted or corrupted payloads.
_MAX_METADATA_ENTRIES = 50_000
# Hard ceiling for required_confirmations list length.
_MAX_CONFIRMATIONS = 500
class AutonomyGuardrailService:
"""Service for enforcing autonomy guardrails during plan execution.
Manages guardrail state and audit trail persistence via plan
metadata dictionaries. Each plan can have its own guardrail
configuration and audit trail stored in its metadata.
All public methods are thread-safe.
"""
def __init__(self) -> None:
"""Initialize the autonomy guardrail service."""
self._guardrails: dict[str, AutonomyGuardrails] = {}
self._audit_trails: dict[str, GuardrailAuditTrail] = {}
self._lock = threading.RLock()
def configure_guardrails(
self,
plan_id: str,
guardrails: AutonomyGuardrails,
) -> None:
"""Configure guardrails for a plan.
Args:
plan_id: The plan identifier.
guardrails: The guardrail configuration to apply.
"""
if not plan_id:
raise ValueError("plan_id must not be empty")
with self._lock:
self._guardrails[plan_id] = guardrails
if plan_id not in self._audit_trails:
self._audit_trails[plan_id] = GuardrailAuditTrail()
logger.debug("Configured guardrails for plan %s", plan_id)
def get_guardrails(self, plan_id: str) -> AutonomyGuardrails | None:
"""Retrieve the guardrail configuration for a plan.
Args:
plan_id: The plan identifier.
Returns:
The guardrail configuration, or None if not configured.
"""
with self._lock:
return self._guardrails.get(plan_id)
def check_step_limit(
self,
plan_id: str,
current_step: int,
) -> bool:
"""Check if the plan has exceeded its maximum step limit.
Args:
plan_id: The plan identifier.
current_step: The current step number.
Returns:
True if the step is allowed, False if blocked.
"""
with self._lock:
guardrails = self._guardrails.get(plan_id)
if guardrails is None:
return True
guardrails.step_count = current_step
allowed, reason = guardrails.check_step_limit()
event_type = (
GuardrailEventType.STEP_ALLOWED
if allowed
else GuardrailEventType.STEP_BLOCKED
)
result = GuardrailResult.ALLOWED if allowed else GuardrailResult.DENIED
entry = GuardrailAuditEntry(
event_type=event_type,
guard_name="step_limit",
result=result,
reason=reason,
context={
"current_step": current_step,
"max_steps": guardrails.max_steps,
},
)
self._record_enforcement_locked(plan_id, entry)
if not allowed:
logger.warning(
"Step limit exceeded for plan %s: %s",
plan_id,
reason,
)
return allowed
def check_tool_budget(
self,
plan_id: str,
tool_cost: float,
) -> bool:
"""Check if the plan's budget allows a tool invocation.
When allowed, the cost is recorded **before** the audit entry
so that the trail reflects the post-deduction budget state.
Args:
plan_id: The plan identifier.
tool_cost: The cost of the proposed tool invocation.
Returns:
True if the invocation is within budget, False if blocked.
"""
with self._lock:
guardrails = self._guardrails.get(plan_id)
if guardrails is None:
return True
allowed, reason = guardrails.check_tool_budget(tool_cost)
# Record cost *before* audit entry so context is accurate
if allowed:
guardrails.record_cost(tool_cost)
event_type = (
GuardrailEventType.BUDGET_ALLOWED
if allowed
else GuardrailEventType.BUDGET_BLOCKED
)
result = GuardrailResult.ALLOWED if allowed else GuardrailResult.DENIED
entry = GuardrailAuditEntry(
event_type=event_type,
guard_name="tool_budget",
result=result,
reason=reason,
context={
"tool_cost": tool_cost,
"budget_spent": guardrails.budget_spent,
"tool_budget": guardrails.tool_budget,
},
)
self._record_enforcement_locked(plan_id, entry)
if not allowed:
logger.warning(
"Tool budget exceeded for plan %s: %s",
plan_id,
reason,
)
return allowed
def check_wall_clock(self, plan_id: str) -> bool:
"""Check if the plan has exceeded its wall-clock time limit.
Args:
plan_id: The plan identifier.
Returns:
True if the plan is within the time limit, False if blocked.
"""
with self._lock:
guardrails = self._guardrails.get(plan_id)
if guardrails is None:
return True
allowed, reason = guardrails.check_wall_clock()
event_type = (
GuardrailEventType.TIME_ALLOWED
if allowed
else GuardrailEventType.TIME_BLOCKED
)
result = GuardrailResult.ALLOWED if allowed else GuardrailResult.DENIED
entry = GuardrailAuditEntry(
event_type=event_type,
guard_name="wall_clock",
result=result,
reason=reason,
context={
"max_wall_clock_seconds": guardrails.max_wall_clock_seconds,
},
)
self._record_enforcement_locked(plan_id, entry)
if not allowed:
logger.warning(
"Wall-clock limit exceeded for plan %s: %s",
plan_id,
reason,
)
return allowed
def check_actor_tool_calls(
self,
plan_id: str,
current_calls: int,
) -> bool:
"""Check if the actor has exceeded its per-invocation tool call limit.
Args:
plan_id: The plan identifier.
current_calls: Number of tool calls made so far.
Returns:
True if allowed, False if blocked.
"""
with self._lock:
guardrails = self._guardrails.get(plan_id)
if guardrails is None:
return True
allowed, reason = guardrails.check_actor_tool_calls(current_calls)
event_type = (
GuardrailEventType.ACTOR_LIMIT_ALLOWED
if allowed
else GuardrailEventType.ACTOR_LIMIT_BLOCKED
)
result = GuardrailResult.ALLOWED if allowed else GuardrailResult.DENIED
entry = GuardrailAuditEntry(
event_type=event_type,
guard_name="actor_tool_calls",
result=result,
reason=reason,
context={
"current_calls": current_calls,
"max_tool_calls_per_invocation": (
guardrails.actor_limits.max_tool_calls_per_invocation
),
},
)
self._record_enforcement_locked(plan_id, entry)
if not allowed:
logger.warning(
"Actor tool-call limit exceeded for plan %s: %s",
plan_id,
reason,
)
return allowed
def check_retries_per_failure(
self,
plan_id: str,
current_retries: int,
) -> bool:
"""Check if the retry count exceeds the per-failure retry limit.
Args:
plan_id: The plan identifier.
current_retries: Number of retries attempted so far.
Returns:
True if the retry is allowed, False if blocked.
"""
with self._lock:
guardrails = self._guardrails.get(plan_id)
if guardrails is None:
return True
allowed, reason = guardrails.check_retries_per_failure(current_retries)
event_type = (
GuardrailEventType.RETRY_ALLOWED
if allowed
else GuardrailEventType.RETRY_BLOCKED
)
result = GuardrailResult.ALLOWED if allowed else GuardrailResult.DENIED
entry = GuardrailAuditEntry(
event_type=event_type,
guard_name="retries_per_failure",
result=result,
reason=reason,
context={
"current_retries": current_retries,
"max_retries_per_failure": (
guardrails.actor_limits.max_retries_per_failure
),
},
)
self._record_enforcement_locked(plan_id, entry)
if not allowed:
logger.warning(
"Retry limit exceeded for plan %s: %s",
plan_id,
reason,
)
return allowed
def check_confirmation_required(
self,
plan_id: str,
operation: str,
) -> bool:
"""Check if an operation requires human confirmation.
Args:
plan_id: The plan identifier.
operation: The operation name to check.
Returns:
True if confirmation is required, False otherwise.
"""
with self._lock:
guardrails = self._guardrails.get(plan_id)
if guardrails is None:
return False
needs_confirmation, reason = guardrails.check_confirmation_required(
operation,
)
if needs_confirmation:
event_type = GuardrailEventType.CONFIRMATION_REQUIRED
result = GuardrailResult.DENIED
else:
event_type = GuardrailEventType.CONFIRMATION_GRANTED
result = GuardrailResult.ALLOWED
entry = GuardrailAuditEntry(
event_type=event_type,
guard_name="confirmation",
result=result,
reason=reason,
context={"operation": operation},
)
self._record_enforcement_locked(plan_id, entry)
if needs_confirmation:
logger.info(
"Confirmation required for plan %s operation '%s': %s",
plan_id,
operation,
reason,
)
return needs_confirmation
def record_enforcement(
self,
plan_id: str,
entry: GuardrailAuditEntry,
) -> None:
"""Persist an audit entry to the plan's audit trail.
Args:
plan_id: The plan identifier.
entry: The audit entry to record.
"""
with self._lock:
self._record_enforcement_locked(plan_id, entry)
def get_audit_trail(self, plan_id: str) -> GuardrailAuditTrail:
"""Retrieve the audit trail for a plan.
Args:
plan_id: The plan identifier.
Returns:
The audit trail for the plan. Returns an empty trail
if no events have been recorded.
"""
with self._lock:
return self._audit_trails.get(plan_id, GuardrailAuditTrail())
def get_audit_trail_as_metadata(
self,
plan_id: str,
) -> dict[str, Any]:
"""Serialize the audit trail for persistence in plan metadata.
Args:
plan_id: The plan identifier.
Returns:
A dictionary suitable for storing in plan metadata.
"""
with self._lock:
trail = self._audit_trails.get(plan_id, GuardrailAuditTrail())
guardrails = self._guardrails.get(plan_id)
metadata: dict[str, Any] = {
_AUDIT_TRAIL_KEY: trail.model_dump(),
}
if guardrails is not None:
metadata[_GUARDRAILS_KEY] = guardrails.model_dump()
return metadata
def load_from_metadata(
self,
plan_id: str,
metadata: dict[str, Any],
) -> None:
"""Restore guardrail state from plan metadata.
Applies size guards to prevent memory exhaustion from
oversized or malicious payloads.
Args:
plan_id: The plan identifier.
metadata: The plan metadata dictionary.
Raises:
ValueError: If the metadata contains oversized payloads.
"""
with self._lock:
if _GUARDRAILS_KEY in metadata:
raw_guardrails = metadata[_GUARDRAILS_KEY]
if isinstance(raw_guardrails, dict):
confirmations = raw_guardrails.get(
"required_confirmations",
[],
)
if (
isinstance(confirmations, list)
and len(confirmations) > _MAX_CONFIRMATIONS
):
raise ValueError(
f"required_confirmations exceeds maximum of "
f"{_MAX_CONFIRMATIONS} entries"
)
self._guardrails[plan_id] = AutonomyGuardrails.model_validate(
raw_guardrails,
)
if _AUDIT_TRAIL_KEY in metadata:
raw_trail = metadata[_AUDIT_TRAIL_KEY]
if isinstance(raw_trail, dict):
entries = raw_trail.get("entries", [])
if (
isinstance(entries, list)
and len(entries) > _MAX_METADATA_ENTRIES
):
raise ValueError(
f"Audit trail exceeds maximum of "
f"{_MAX_METADATA_ENTRIES} entries"
)
self._audit_trails[plan_id] = GuardrailAuditTrail.model_validate(
raw_trail
)
def remove_plan(self, plan_id: str) -> None:
"""Remove all guardrail state for a plan.
Args:
plan_id: The plan identifier.
"""
with self._lock:
self._guardrails.pop(plan_id, None)
self._audit_trails.pop(plan_id, None)
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _record_enforcement_locked(
self,
plan_id: str,
entry: GuardrailAuditEntry,
) -> None:
"""Record an audit entry (caller must hold ``_lock``)."""
if plan_id not in self._audit_trails:
self._audit_trails[plan_id] = GuardrailAuditTrail()
self._audit_trails[plan_id].add_entry(entry)
logger.debug(
"Recorded guardrail event for plan %s: %s (%s)",
plan_id,
entry.event_type.value,
entry.result.value,
)
@@ -19,6 +19,9 @@ import structlog
from pydantic import BaseModel, ConfigDict, Field
from ulid import ULID
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.application.services.plan_execution_context import (
PlanExecutionContext,
RuntimeExecuteActor,
@@ -259,6 +262,7 @@ class PlanExecutor:
execution_context: PlanExecutionContext | None = None,
error_recovery_service: ErrorRecoveryService | None = None,
checkpoint_manager: CheckpointManager | None = None,
guardrail_service: AutonomyGuardrailService | None = None,
) -> None:
"""Initialize the plan executor.
@@ -272,6 +276,8 @@ class PlanExecutor:
checkpoint_manager: Optional checkpoint manager for
sandbox state snapshots during execute. When ``None``,
checkpoint hooks are silently skipped.
guardrail_service: Optional autonomy guardrail service for
enforcing step limits, budgets, and wall-clock time.
"""
if lifecycle_service is None:
raise ValidationError("lifecycle_service must not be None")
@@ -281,6 +287,7 @@ class PlanExecutor:
self._execution_context = execution_context
self._error_recovery = error_recovery_service
self._checkpoint_manager = checkpoint_manager
self._guardrail_service = guardrail_service
self._strategize_actor = StrategizeStubActor()
self._execute_actor = ExecuteStubActor()
self._logger = logger.bind(service="plan_executor")
@@ -520,8 +527,49 @@ class PlanExecutor:
f"Plan {plan_id} has no decision tree. "
"Strategize must complete before Execute."
)
self._enforce_guardrails(plan_id)
return plan
def _enforce_guardrails(self, plan_id: str) -> None:
"""Pre-flight guardrail checks before execution begins.
Marks the execution start time (for wall-clock tracking) if
not already set, then checks wall-clock time and current step
limit. Raises ``PlanError`` if any guardrail blocks execution.
"""
if self._guardrail_service is None:
return
guardrails = self._guardrail_service.get_guardrails(plan_id)
if guardrails is not None and guardrails.start_time is None:
guardrails.mark_started()
if not self._guardrail_service.check_wall_clock(plan_id):
raise PlanError(f"Guardrail wall-clock limit exceeded for plan {plan_id}")
if guardrails is not None:
current_step = guardrails.step_count
if not self._guardrail_service.check_step_limit(plan_id, current_step):
raise PlanError(
f"Guardrail step limit already reached for plan {plan_id}"
)
def _enforce_guardrails_per_step(self, plan_id: str) -> None:
"""Per-decision guardrail enforcement.
Increments the step counter and checks step limit and
wall-clock time. Raises ``PlanError`` if blocked.
"""
if self._guardrail_service is None:
return
guardrails = self._guardrail_service.get_guardrails(plan_id)
if guardrails is None:
return # pragma: no cover - defensive
next_step = guardrails.step_count + 1
if not self._guardrail_service.check_step_limit(plan_id, next_step):
raise PlanError(
f"Guardrail step limit reached for plan {plan_id} at step {next_step}"
)
if not self._guardrail_service.check_wall_clock(plan_id):
raise PlanError(f"Guardrail wall-clock limit exceeded for plan {plan_id}")
def _run_execute_with_runtime(
self,
plan_id: str,
@@ -542,6 +590,10 @@ class PlanExecutor:
self._lifecycle.start_execute(plan_id)
self._try_create_checkpoint(plan_id, "pre_execute")
try:
# Enforce per-step guardrails for each decision
for _decision in decisions:
self._enforce_guardrails_per_step(plan_id)
result = runtime_actor.execute(
decisions=decisions, stream_callback=stream_callback
)
@@ -600,6 +652,10 @@ class PlanExecutor:
for attempt in range(max_attempts):
try:
# Enforce per-step guardrails for each decision
for _decision in decisions:
self._enforce_guardrails_per_step(plan_id)
result = self._execute_actor.execute(
plan_id=plan_id,
decisions=decisions,
@@ -8,6 +8,14 @@ from cleveragents.domain.models.core.automation_profile import (
GuardResult,
get_builtin_profile,
)
from cleveragents.domain.models.core.autonomy_guardrails import (
ActorLimits,
AutonomyGuardrails,
GuardrailAuditEntry,
GuardrailAuditTrail,
GuardrailEventType,
GuardrailResult,
)
from cleveragents.domain.models.core.change import (
Change,
ChangeEntry,
@@ -224,8 +232,10 @@ __all__ = [
"ROLE_PERMISSIONS",
"ActionState",
"Actor",
"ActorLimits",
"AutomationGuard",
"AutomationProfile",
"AutonomyGuardrails",
"BindingMode",
"BindingResult",
"Change",
@@ -266,6 +276,10 @@ __all__ = [
"ErrorRecord",
"ErrorRecoveryPolicy",
"GuardResult",
"GuardrailAuditEntry",
"GuardrailAuditTrail",
"GuardrailEventType",
"GuardrailResult",
"InMemoryChangeSetStore",
"InMemoryInvocationTracker",
"Invariant",
@@ -0,0 +1,458 @@
"""Autonomy guardrails and audit trail models.
Extends the automation guard framework with:
- **AutonomyGuardrails** runtime constraints for max steps, tool
budget, wall-clock time, per-actor limits, and required confirmations
during plan execution.
- **GuardrailAuditEntry** a single audit trail entry recording a
guardrail enforcement event with timestamp, event type, result, and
contextual metadata.
- **GuardrailAuditTrail** a bounded, ordered collection of audit
entries persisted to plan metadata with configurable maximum size
and oldest-first eviction.
These models support the M6 autonomy hardening milestone by providing
a structured, auditable record of every guardrail decision made during
plan execution.
Based on ``docs/specification.md`` Section "Automation Profiles"
(autonomy guardrails and audit trail) and "Cost and Rate Limits"
(per-plan budgets including wall-clock time, per-actor limits).
"""
from __future__ import annotations
import threading
from datetime import UTC, datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class GuardrailEventType(StrEnum):
"""Types of guardrail enforcement events."""
STEP_BLOCKED = "step_blocked"
BUDGET_BLOCKED = "budget_blocked"
CONFIRMATION_REQUIRED = "confirmation_required"
STEP_ALLOWED = "step_allowed"
BUDGET_ALLOWED = "budget_allowed"
CONFIRMATION_GRANTED = "confirmation_granted"
TIME_BLOCKED = "time_blocked"
TIME_ALLOWED = "time_allowed"
ACTOR_LIMIT_BLOCKED = "actor_limit_blocked"
ACTOR_LIMIT_ALLOWED = "actor_limit_allowed"
RETRY_BLOCKED = "retry_blocked"
RETRY_ALLOWED = "retry_allowed"
class GuardrailResult(StrEnum):
"""Outcome of a guardrail enforcement check."""
ALLOWED = "allowed"
DENIED = "denied"
class GuardrailAuditEntry(BaseModel):
"""A single guardrail enforcement audit trail entry.
Records the outcome of a guardrail check including the event type,
which guard was evaluated, whether the action was allowed or denied,
and optional contextual metadata.
"""
timestamp: str = Field(
default_factory=lambda: datetime.now(tz=UTC).isoformat(),
description="ISO-8601 timestamp of the enforcement event",
)
event_type: GuardrailEventType = Field(
...,
description="Type of guardrail event",
)
guard_name: str = Field(
...,
min_length=1,
description="Name of the guard that was evaluated",
)
result: GuardrailResult = Field(
...,
description="Outcome of the guard check (allowed/denied)",
)
reason: str | None = Field(
default=None,
description="Human-readable reason for the decision",
)
context: dict[str, str | int | float | bool | None] = Field(
default_factory=dict,
description="Additional context about the enforcement event",
)
model_config = ConfigDict(
str_strip_whitespace=True,
)
_DEFAULT_MAX_AUDIT_ENTRIES = 10_000
class GuardrailAuditTrail(BaseModel):
"""Bounded, ordered collection of guardrail audit entries.
Persisted to plan metadata to provide a complete record of
autonomy-related decisions during plan execution.
When the number of entries exceeds ``max_entries``, the oldest
entries are evicted to keep the trail bounded.
"""
entries: list[GuardrailAuditEntry] = Field(
default_factory=list,
description="Ordered list of guardrail enforcement entries",
)
max_entries: int = Field(
default=_DEFAULT_MAX_AUDIT_ENTRIES,
ge=1,
le=1_000_000,
description="Maximum number of audit entries to retain",
)
_allowed_count: int = 0
_denied_count: int = 0
def model_post_init(self, _context: object) -> None:
"""Recompute counters from pre-existing entries after init."""
self._allowed_count = sum(
1 for e in self.entries if e.result == GuardrailResult.ALLOWED
)
self._denied_count = sum(
1 for e in self.entries if e.result == GuardrailResult.DENIED
)
def add_entry(self, entry: GuardrailAuditEntry) -> None:
"""Append an audit entry, evicting the oldest if at capacity."""
if len(self.entries) >= self.max_entries:
evicted = self.entries.pop(0)
if evicted.result == GuardrailResult.ALLOWED:
self._allowed_count -= 1
else:
self._denied_count -= 1
self.entries.append(entry)
if entry.result == GuardrailResult.ALLOWED:
self._allowed_count += 1
else:
self._denied_count += 1
@property
def denied_count(self) -> int:
"""Count of denied enforcement events."""
return self._denied_count
@property
def allowed_count(self) -> int:
"""Count of allowed enforcement events."""
return self._allowed_count
model_config = ConfigDict(
str_strip_whitespace=True,
)
class ActorLimits(BaseModel):
"""Per-actor runtime limits (spec line 27891).
Controls the maximum number of tool calls per actor invocation and
the maximum number of retries per tool failure within a single actor
step.
"""
max_tool_calls_per_invocation: int | None = Field(
default=None,
description=(
"Maximum tool calls allowed per actor invocation. None means unlimited."
),
)
max_retries_per_failure: int | None = Field(
default=None,
description=(
"Maximum retries per tool failure within an actor step. "
"None means unlimited."
),
)
@field_validator("max_tool_calls_per_invocation")
@classmethod
def validate_max_tool_calls(
cls: type[ActorLimits],
v: int | None,
) -> int | None:
"""Validate max_tool_calls_per_invocation is positive if set."""
if v is not None and v < 1:
raise ValueError(f"max_tool_calls_per_invocation must be >= 1, got {v}")
return v
@field_validator("max_retries_per_failure")
@classmethod
def validate_max_retries(
cls: type[ActorLimits],
v: int | None,
) -> int | None:
"""Validate max_retries_per_failure is non-negative if set."""
if v is not None and v < 0:
raise ValueError(f"max_retries_per_failure must be >= 0, got {v}")
return v
model_config = ConfigDict(
str_strip_whitespace=True,
)
class AutonomyGuardrails(BaseModel):
"""Runtime autonomy constraints for plan execution.
Extends the automation guard framework with step limits, tool
budgets, wall-clock time limits, per-actor limits, and required
confirmation gates. Tracks current step count and budget spent
for enforcement decisions.
"""
max_steps: int | None = Field(
default=None,
description=(
"Maximum number of execution steps allowed. None means unlimited."
),
)
tool_budget: float | None = Field(
default=None,
description=("Maximum cost budget for tool invocations. None means unlimited."),
)
max_wall_clock_seconds: float | None = Field(
default=None,
description=(
"Maximum wall-clock time in seconds for plan execution. "
"None means unlimited."
),
)
actor_limits: ActorLimits = Field(
default_factory=ActorLimits,
description="Per-actor runtime limits",
)
required_confirmations: list[str] = Field(
default_factory=list,
description=(
"List of operation names requiring human confirmation before execution."
),
)
step_count: int = Field(
default=0,
ge=0,
description="Current execution step counter",
)
budget_spent: float = Field(
default=0.0,
ge=0.0,
description="Current cumulative budget spent on tool invocations",
)
start_time: str | None = Field(
default=None,
description="ISO-8601 timestamp of when plan execution started",
)
@field_validator("start_time")
@classmethod
def validate_start_time(
cls: type[AutonomyGuardrails],
v: str | None,
) -> str | None:
"""Validate start_time is a parseable ISO-8601 string if set."""
if v is not None:
try:
datetime.fromisoformat(v)
except (ValueError, TypeError) as exc:
raise ValueError(
f"start_time must be a valid ISO-8601 timestamp, got {v!r}"
) from exc
return v
@field_validator("max_steps")
@classmethod
def validate_max_steps(
cls: type[AutonomyGuardrails],
v: int | None,
) -> int | None:
"""Validate max_steps is positive if set."""
if v is not None and v < 1:
raise ValueError(f"max_steps must be >= 1, got {v}")
return v
@field_validator("tool_budget")
@classmethod
def validate_tool_budget(
cls: type[AutonomyGuardrails],
v: float | None,
) -> float | None:
"""Validate tool_budget is non-negative if set."""
if v is not None and v < 0.0:
raise ValueError(f"tool_budget must be >= 0.0, got {v}")
return v
@field_validator("max_wall_clock_seconds")
@classmethod
def validate_max_wall_clock(
cls: type[AutonomyGuardrails],
v: float | None,
) -> float | None:
"""Validate max_wall_clock_seconds is positive if set."""
if v is not None and v <= 0.0:
raise ValueError(f"max_wall_clock_seconds must be > 0.0, got {v}")
return v
@model_validator(mode="after")
def _normalise_confirmations(self) -> AutonomyGuardrails:
"""Normalise required_confirmations to lowercase for matching."""
lowered = [c.lower() for c in self.required_confirmations]
if lowered != self.required_confirmations:
object.__setattr__(self, "required_confirmations", lowered)
return self
def check_step_limit(self) -> tuple[bool, str | None]:
"""Check if the current step count exceeds the max step limit.
Returns:
A tuple of (allowed, reason). If allowed is False, reason
contains a human-readable explanation.
"""
if self.max_steps is not None and self.step_count >= self.max_steps:
return (
False,
f"Step limit reached: {self.step_count}/{self.max_steps}",
)
return (True, None)
def check_tool_budget(self, tool_cost: float) -> tuple[bool, str | None]:
"""Check if the budget allows a tool invocation of the given cost.
Args:
tool_cost: The cost of the proposed tool invocation.
Returns:
A tuple of (allowed, reason).
"""
if tool_cost < 0.0:
raise ValueError(f"tool_cost must be >= 0.0, got {tool_cost}")
if self.tool_budget is not None:
projected = self.budget_spent + tool_cost
if projected > self.tool_budget:
return (
False,
f"Budget exceeded: {projected:.2f} > {self.tool_budget:.2f}",
)
return (True, None)
def check_wall_clock(self) -> tuple[bool, str | None]:
"""Check if the plan has exceeded its wall-clock time limit.
Returns:
A tuple of (allowed, reason).
"""
if self.max_wall_clock_seconds is None or self.start_time is None:
return (True, None)
try:
elapsed = (
datetime.now(UTC) - datetime.fromisoformat(self.start_time)
).total_seconds()
except (ValueError, TypeError):
return (
False,
f"Wall-clock check failed: malformed start_time {self.start_time!r}",
)
if elapsed >= self.max_wall_clock_seconds:
return (
False,
f"Wall-clock limit reached: {elapsed:.1f}s >= "
f"{self.max_wall_clock_seconds:.1f}s",
)
return (True, None)
def check_actor_tool_calls(
self,
current_calls: int,
) -> tuple[bool, str | None]:
"""Check if the actor has exceeded its per-invocation tool call limit.
Args:
current_calls: Number of tool calls made so far in this invocation.
Returns:
A tuple of (allowed, reason).
"""
limit = self.actor_limits.max_tool_calls_per_invocation
if limit is not None and current_calls >= limit:
return (
False,
f"Actor tool-call limit reached: {current_calls}/{limit}",
)
return (True, None)
def check_retries_per_failure(
self,
current_retries: int,
) -> tuple[bool, str | None]:
"""Check if the current retry count exceeds the per-failure retry limit.
Args:
current_retries: Number of retries attempted so far for this failure.
Returns:
A tuple of (allowed, reason).
"""
limit = self.actor_limits.max_retries_per_failure
if limit is not None and current_retries >= limit:
return (
False,
f"Retry limit reached: {current_retries}/{limit}",
)
return (True, None)
def check_confirmation_required(self, operation: str) -> tuple[bool, str | None]:
"""Check if an operation requires human confirmation.
Args:
operation: The operation name to check (case-insensitive).
Returns:
A tuple of (confirmation_required, reason). If True, the
operation needs human confirmation before proceeding.
"""
if operation.lower() in self.required_confirmations:
return (
True,
f"Operation '{operation}' requires human confirmation",
)
return (False, None)
def increment_step(self) -> None:
"""Increment the step counter by one."""
self.step_count += 1
def record_cost(self, cost: float) -> None:
"""Record a tool invocation cost.
Args:
cost: The cost to add to the budget spent.
"""
if cost < 0.0:
raise ValueError(f"cost must be >= 0.0, got {cost}")
self.budget_spent += cost
def mark_started(self) -> None:
"""Record the plan execution start time for wall-clock tracking."""
self.start_time = datetime.now(UTC).isoformat()
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# Re-export threading lock for service-level thread safety
GuardrailLock = threading.Lock
+42
View File
@@ -413,3 +413,45 @@ is_local_mode # noqa: B018, F821
get_role_bindings # noqa: B018, F821
add_binding # noqa: B018, F821
remove_binding # noqa: B018, F821
# Autonomy guardrails — public API surface
ActorLimits # noqa: B018, F821
AutonomyGuardrails # noqa: B018, F821
GuardrailAuditEntry # noqa: B018, F821
GuardrailAuditTrail # noqa: B018, F821
GuardrailEventType # noqa: B018, F821
GuardrailResult # noqa: B018, F821
GuardrailLock # noqa: B018, F821
AutonomyGuardrailService # noqa: B018, F821
autonomy_guardrail_service # noqa: B018, F821
check_step_limit # noqa: B018, F821
check_tool_budget # noqa: B018, F821
check_wall_clock # noqa: B018, F821
check_actor_tool_calls # noqa: B018, F821
check_confirmation_required # noqa: B018, F821
record_enforcement # noqa: B018, F821
get_audit_trail # noqa: B018, F821
get_audit_trail_as_metadata # noqa: B018, F821
load_from_metadata # noqa: B018, F821
remove_plan # noqa: B018, F821
configure_guardrails # noqa: B018, F821
increment_step # noqa: B018, F821
record_cost # noqa: B018, F821
mark_started # noqa: B018, F821
denied_count # noqa: B018, F821
allowed_count # noqa: B018, F821
add_entry # noqa: B018, F821
max_entries # noqa: B018, F821
max_wall_clock_seconds # noqa: B018, F821
actor_limits # noqa: B018, F821
max_tool_calls_per_invocation # noqa: B018, F821
max_retries_per_failure # noqa: B018, F821
start_time # noqa: B018, F821
_DEFAULT_MAX_AUDIT_ENTRIES # noqa: B018, F821
check_retries_per_failure # noqa: B018, F821
RETRY_BLOCKED # noqa: B018, F821
RETRY_ALLOWED # noqa: B018, F821
validate_start_time # noqa: B018, F821
_enforce_guardrails # noqa: B018, F821
_enforce_guardrails_per_step # noqa: B018, F821
guardrail_service # noqa: B018, F821