Files
cleveragents-core/features/steps/m6_guardrails_steps.py
T
CoreRasurae 007af498b8
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / helm (pull_request) Successful in 33s
CI / lint (pull_request) Successful in 3m42s
CI / security (pull_request) Successful in 4m8s
CI / quality (pull_request) Successful in 4m9s
CI / typecheck (pull_request) Successful in 4m20s
CI / integration_tests (pull_request) Successful in 7m2s
CI / unit_tests (pull_request) Successful in 7m55s
CI / docker (pull_request) Successful in 1m19s
CI / coverage (pull_request) Successful in 8m46s
CI / e2e_tests (pull_request) Successful in 16m1s
CI / status-check (pull_request) Successful in 1s
CI / build (push) Successful in 17s
CI / helm (push) Successful in 22s
CI / quality (push) Successful in 31s
CI / lint (push) Successful in 3m28s
CI / typecheck (push) Successful in 3m54s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 4m5s
CI / integration_tests (push) Successful in 6m13s
CI / unit_tests (push) Successful in 6m28s
CI / docker (push) Successful in 1m34s
CI / coverage (push) Successful in 12m5s
CI / e2e_tests (push) Successful in 18m47s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 28m0s
CI / benchmark-regression (pull_request) Successful in 59m48s
refactor(autonomy): rename automation profile task flags to spec names
Renamed all 11 task-type confidence threshold fields in AutomationProfile
from phase-transition semantics to spec-defined task-type semantics.
Updated all 8 built-in profiles, CLI formatting, YAML schema, services,
and all Behave/Robot tests referencing the old field names.

Post-review fixes:
- Fixed 24 stale old field names in M6 fixture files
  (automation_profiles.json, autonomy_guardrails.json)
- Added model_validator(mode='before') to detect legacy field names
  and raise actionable ValueError with rename mapping
- Added semantic bridge comments in PlanLifecycleService mapping
  task-type thresholds to phase-transition gates
- Added threshold_field to structured log messages for observability
- Restored categorised CLI automation-profile show output to match
  spec (Phase Transitions / Decision Automation / Self-Repair /
  Execution Controls) instead of flat list
- Added missing access_network field to spec show output examples
  (Rich, Plain, JSON, YAML variants)
- Aligned ADR-017 profile fields table to all 11 fields with
  descriptions matching spec Automatable Tasks table
- Aligned automation_profiles.md threshold descriptions with spec
- Added spec section references in phase_reversion.md, error_recovery.md,
  and plan_execute.md for field naming context
- Extended repository roundtrip test to assert all 11 threshold fields
- Fixed benchmark _make_profile() passing safety fields as top-level
  kwargs instead of via SafetyProfile sub-model (incompatible with
  extra="forbid")
- Aligned CLI JSON/YAML output structure for automation-profile show
  with the specification grouped format (phase_transitions,
  decision_automation, self_repair, execution_controls)
- Moved safety boolean fields into the Execution Controls section
  of Rich output per spec examples
- Reverted auto profile description to "Fully automatic except apply"
  per specification (line 16703, line 28406)
- Improved bridge comments in test steps with semantic context for
  threshold-to-gate mappings

ISSUES CLOSED: #902
2026-03-30 13:18:07 +01:00

400 lines
13 KiB
Python

"""Step definitions for M6 autonomy guardrails, profiles, and guard enforcement.
Split from ``m6_autonomy_acceptance_steps.py`` to stay under the project's
500-line guideline. All step names keep the ``m6 smoke`` prefix to avoid
``AmbiguousStep`` conflicts.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import yaml
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.automation_profile_service import (
AutomationProfileService,
)
from cleveragents.domain.models.core.automation_guard import (
AutomationGuard,
)
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
AutomationProfile,
)
_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m6"
# -----------------------------------------------------------------------
# Fixture loading — guardrails and profiles
# -----------------------------------------------------------------------
@when("I m6 smoke load the autonomy guardrails fixture")
def step_m6_smoke_load_guardrails(context: Context) -> None:
with open(_FIXTURES_DIR / "autonomy_guardrails.json") as f:
context.m6_guardrails_data = json.load(f)
@then("the m6 smoke guardrails fixture should have a denylist entry")
def step_m6_smoke_guardrails_denylist(context: Context) -> None:
names = [f["name"] for f in context.m6_guardrails_data["fixtures"]]
assert "denylist_guard" in names
@then("the m6 smoke guardrails fixture should have an allowlist entry")
def step_m6_smoke_guardrails_allowlist(context: Context) -> None:
names = [f["name"] for f in context.m6_guardrails_data["fixtures"]]
assert "allowlist_guard" in names
@then("the m6 smoke guardrails fixture should have a cost budget entry")
def step_m6_smoke_guardrails_budget(context: Context) -> None:
names = [f["name"] for f in context.m6_guardrails_data["fixtures"]]
assert "cost_budget_guard" in names
@when("I m6 smoke load the automation profiles fixture")
def step_m6_smoke_load_profiles_fixture(context: Context) -> None:
with open(_FIXTURES_DIR / "automation_profiles.json") as f:
context.m6_profiles_data = json.load(f)
@then("the m6 smoke profiles fixture should list all 8 built-in names")
def step_m6_smoke_profiles_builtin_count(context: Context) -> None:
entry = next(
f
for f in context.m6_profiles_data["fixtures"]
if f["name"] == "builtin_profiles"
)
assert len(entry["expected_names"]) == 8
@then("the m6 smoke profiles fixture should have a custom profile entry")
def step_m6_smoke_profiles_custom(context: Context) -> None:
names = [f["name"] for f in context.m6_profiles_data["fixtures"]]
assert "custom_profile" in names
# -----------------------------------------------------------------------
# Automation profiles built-in
# -----------------------------------------------------------------------
@when("I m6 smoke list all built-in profiles")
def step_m6_smoke_list_builtins(context: Context) -> None:
context.m6_profiles_list = list(BUILTIN_PROFILES.keys())
@then("the m6 smoke profile count should be {count:d}")
def step_m6_smoke_profile_count(context: Context, count: int) -> None:
assert len(context.m6_profiles_list) == count
@then('the m6 smoke profiles should include "{name}"')
def step_m6_smoke_profiles_include(context: Context, name: str) -> None:
assert name in context.m6_profiles_list
@when('I m6 smoke load built-in profile "{name}"')
def step_m6_smoke_load_builtin(context: Context, name: str) -> None:
context.m6_profile = BUILTIN_PROFILES[name]
@then("the m6 smoke profile decompose_task should be {value:g}")
def step_m6_smoke_profile_decompose_task(
context: Context,
value: float,
) -> None:
assert context.m6_profile.decompose_task == value
@then("the m6 smoke profile create_tool should be {value:g}")
def step_m6_smoke_profile_create_tool(
context: Context,
value: float,
) -> None:
assert context.m6_profile.create_tool == value
@then("the m6 smoke profile select_tool should be {value:g}")
def step_m6_smoke_profile_select_tool(
context: Context,
value: float,
) -> None:
assert context.m6_profile.select_tool == value
@then("the m6 smoke profile require_sandbox should be true")
def step_m6_smoke_profile_sandbox_true(context: Context) -> None:
assert context.m6_profile.safety.require_sandbox is True
@then("the m6 smoke profile require_sandbox should be false")
def step_m6_smoke_profile_sandbox_false(context: Context) -> None:
assert context.m6_profile.safety.require_sandbox is False
@then("the m6 smoke profile allow_unsafe_tools should be true")
def step_m6_smoke_profile_unsafe_true(context: Context) -> None:
assert context.m6_profile.safety.allow_unsafe_tools is True
# -----------------------------------------------------------------------
# Automation profile creation and validation
# -----------------------------------------------------------------------
@when('I m6 smoke create a profile named "{name}" with select_tool {value:g}')
def step_m6_smoke_create_profile(
context: Context,
name: str,
value: float,
) -> None:
context.m6_profile = AutomationProfile(name=name, select_tool=value)
@then('the m6 smoke created profile name should be "{name}"')
def step_m6_smoke_created_name(context: Context, name: str) -> None:
assert context.m6_profile.name == name
@then("the m6 smoke created profile select_tool should be {value:g}")
def step_m6_smoke_created_select_tool(context: Context, value: float) -> None:
assert context.m6_profile.select_tool == value
@when('I m6 smoke create a profile with invalid name "{name}"')
def step_m6_smoke_invalid_name(context: Context, name: str) -> None:
try:
AutomationProfile(name=name)
context.m6_error = None
except ValueError as exc:
context.m6_error = exc
@then("the m6 smoke creation should raise ValueError")
def step_m6_smoke_error_value(context: Context) -> None:
assert isinstance(context.m6_error, (ValueError,))
@when("I m6 smoke create a profile with decompose_task {value:g}")
def step_m6_smoke_invalid_threshold(context: Context, value: float) -> None:
try:
AutomationProfile(name="test-bad-threshold", decompose_task=value)
context.m6_error = None
except ValueError as exc:
context.m6_error = exc
@given("a m6 smoke temporary profile YAML file")
def step_m6_smoke_yaml_file(context: Context) -> None:
config = {
"name": "test-yaml-profile",
"description": "A profile loaded from YAML",
"decompose_task": 0.5,
"create_tool": 0.5,
"select_tool": 1.0,
}
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".yaml",
delete=False,
) as tmp:
yaml.dump(config, tmp)
context.m6_yaml_path = tmp.name
@when("I m6 smoke load profile from the temp YAML")
def step_m6_smoke_load_yaml(context: Context) -> None:
context.m6_profile = AutomationProfile.from_yaml(context.m6_yaml_path)
@then('the m6 smoke loaded profile name should be "{name}"')
def step_m6_smoke_loaded_name(context: Context, name: str) -> None:
assert context.m6_profile.name == name
# -----------------------------------------------------------------------
# Guard enforcement
# -----------------------------------------------------------------------
@given('a m6 smoke profile with denylist guard for "{tool}"')
def step_m6_smoke_denylist_guard(context: Context, tool: str) -> None:
guard = AutomationGuard(tool_denylist=[tool])
context.m6_profile = AutomationProfile(name="test-deny", guards=guard)
@when('I m6 smoke check guard for tool "{tool}"')
def step_m6_smoke_check_guard(context: Context, tool: str) -> None:
context.m6_guard_result = context.m6_profile.check_guard(tool_name=tool)
@then("the m6 smoke guard result should not be allowed")
def step_m6_smoke_guard_not_allowed(context: Context) -> None:
assert context.m6_guard_result.allowed is False
@then("the m6 smoke guard result should be allowed")
def step_m6_smoke_guard_allowed(context: Context) -> None:
assert context.m6_guard_result.allowed is True
@then('the m6 smoke guard reason should contain "{text}"')
def step_m6_smoke_guard_reason(context: Context, text: str) -> None:
assert context.m6_guard_result.reason is not None
assert text in context.m6_guard_result.reason
@given('a m6 smoke profile with allowlist guard for "{t1}" and "{t2}"')
def step_m6_smoke_allowlist_guard(
context: Context,
t1: str,
t2: str,
) -> None:
guard = AutomationGuard(tool_allowlist=[t1, t2])
context.m6_profile = AutomationProfile(name="test-allow", guards=guard)
@given("a m6 smoke profile with max {limit:d} tool calls per step")
def step_m6_smoke_max_calls_guard(context: Context, limit: int) -> None:
guard = AutomationGuard(max_tool_calls_per_step=limit)
context.m6_profile = AutomationProfile(name="test-maxcall", guards=guard)
@when('I m6 smoke check guard for tool "{tool}" with {calls:d} calls so far')
def step_m6_smoke_check_guard_calls(
context: Context,
tool: str,
calls: int,
) -> None:
context.m6_guard_result = context.m6_profile.check_guard(
tool_name=tool,
calls_so_far=calls,
)
@given("a m6 smoke profile with max cost {budget:g}")
def step_m6_smoke_cost_guard(context: Context, budget: float) -> None:
guard = AutomationGuard(max_total_cost=budget)
context.m6_profile = AutomationProfile(name="test-budget", guards=guard)
@when('I m6 smoke check guard for tool "{tool}" with cost {cost:g}')
def step_m6_smoke_check_guard_cost(
context: Context,
tool: str,
cost: float,
) -> None:
context.m6_guard_result = context.m6_profile.check_guard(
tool_name=tool,
cost_so_far=cost,
)
@given("a m6 smoke profile with write approval required")
def step_m6_smoke_write_guard(context: Context) -> None:
guard = AutomationGuard(require_approval_for_writes=True)
context.m6_profile = AutomationProfile(
name="test-writeapproval",
guards=guard,
)
@when('I m6 smoke check guard for tool "{tool}" as a write operation')
def step_m6_smoke_check_guard_write(context: Context, tool: str) -> None:
context.m6_guard_result = context.m6_profile.check_guard(
tool_name=tool,
is_write=True,
)
@given("a m6 smoke profile with apply approval required")
def step_m6_smoke_apply_guard(context: Context) -> None:
guard = AutomationGuard(require_approval_for_apply=True)
context.m6_profile = AutomationProfile(
name="test-applyapproval",
guards=guard,
)
@given("a m6 smoke profile with no guards")
def step_m6_smoke_no_guards(context: Context) -> None:
context.m6_profile = AutomationProfile(name="test-noguards")
# -----------------------------------------------------------------------
# Profile service resolution
# -----------------------------------------------------------------------
@given("a m6 smoke automation profile service")
def step_m6_smoke_profile_service(context: Context) -> None:
context.m6_profile_service = AutomationProfileService()
@when(
"I m6 smoke resolve profile with plan "
'"{plan}" action "{action}" project "{project}"'
)
def step_m6_smoke_resolve(
context: Context,
plan: str,
action: str,
project: str,
) -> None:
context.m6_profile = context.m6_profile_service.resolve_profile(
plan_profile=plan if plan != "null" else None,
action_profile=action if action != "null" else None,
project_profile=project if project != "null" else None,
)
@when('I m6 smoke resolve profile with plan null action "{action}" project "{project}"')
def step_m6_smoke_resolve_no_plan(
context: Context,
action: str,
project: str,
) -> None:
context.m6_profile = context.m6_profile_service.resolve_profile(
plan_profile=None,
action_profile=action if action != "null" else None,
project_profile=project if project != "null" else None,
)
@when("I m6 smoke resolve profile with plan null action null project null")
def step_m6_smoke_resolve_all_null(context: Context) -> None:
context.m6_profile = context.m6_profile_service.resolve_profile(
plan_profile=None,
action_profile=None,
project_profile=None,
)
@then('the m6 smoke resolved profile name should be "{name}"')
def step_m6_smoke_resolved_name(context: Context, name: str) -> None:
assert context.m6_profile.name == name
# -----------------------------------------------------------------------
# Profile service guard evaluation
# -----------------------------------------------------------------------
@when('I m6 smoke evaluate guard for profile "{profile}" and tool "{tool}"')
def step_m6_smoke_evaluate_guard(
context: Context,
profile: str,
tool: str,
) -> None:
context.m6_guard_result = context.m6_profile_service.evaluate_guard(
profile_name=profile,
tool_name=tool,
)