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
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
400 lines
13 KiB
Python
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,
|
|
)
|