8953449dc2
CI / lint (pull_request) Successful in 30s
CI / typecheck (pull_request) Successful in 1m15s
CI / security (pull_request) Successful in 1m18s
CI / quality (pull_request) Successful in 30s
CI / build (pull_request) Successful in 37s
CI / helm (pull_request) Successful in 29s
CI / push-validation (pull_request) Successful in 26s
CI / e2e_tests (pull_request) Successful in 3m58s
CI / integration_tests (pull_request) Successful in 6m49s
CI / unit_tests (pull_request) Successful in 8m39s
CI / docker (pull_request) Successful in 1m23s
CI / coverage (pull_request) Successful in 13m32s
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / status-check (pull_request) Successful in 1s
CI / lint (push) Successful in 25s
CI / typecheck (push) Successful in 1m1s
CI / quality (push) Successful in 55s
CI / security (push) Successful in 1m9s
CI / build (push) Successful in 24s
CI / helm (push) Successful in 30s
CI / push-validation (push) Successful in 20s
CI / e2e_tests (push) Successful in 5m13s
CI / integration_tests (push) Successful in 7m17s
CI / unit_tests (push) Successful in 8m50s
CI / docker (push) Successful in 1m30s
CI / coverage (push) Successful in 11m57s
CI / status-check (push) Successful in 2s
- schema.py: provider field changed to Optional[str] with model validator
validate_provider_required_for_llm_graph() that requires it only for LLM
and GRAPH actor types; TOOL actors do not require provider
- schema.py: is_v3_yaml() uses version_str == "3" or version_str.startswith("3.")
to avoid false positives from "30" or "300" version strings
- schema.py: tool namespace validation uses strict 2-part split to reject
empty namespace or empty name (e.g. "/tool", "ns/", "a/b/c")
- cli/commands/actor.py: schema_version extraction uses raw_version pattern
(no # type: ignore[assignment]) for clean static typing
- actor/__init__.py: is_v3_yaml removed from __all__ and _LAZY_IMPORTS
since it is a module-private helper, not a public API
- robot/actor_add_v3_schema_validation.robot: YAML fixtures for 'Reject v3
LLM Actor Without Model Field' and 'Reject v3 TOOL Actor Without Tools
Field' now include required provider field (and model for TOOL fixture)
- robot/helper_actor_add_v3_schema_validation.py: except clauses unified to
catch (subprocess.TimeoutExpired, FileNotFoundError) in both add_actor()
and update_actor() functions
- features/actor_add_v3_schema_validation.feature: 'Update a v3 actor with
valid YAML succeeds' scenario now includes 'And the actor should be
validated via ActorConfigSchema'; error assertions tightened to exact
messages (e.g. "Input should be 'llm', 'tool' or 'graph'", "Node ID must
be alphanumeric", "must be namespaced")
- features/steps/actor_add_v3_schema_validation_steps.py: step_run_actor_update
now spies on ActorConfigSchema.model_validate; failure paths assert
isinstance(result.exception, SystemExit)
ISSUES CLOSED: #5869
365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""Step definitions for Plan Generation Pre-flight Guardrails scenarios.
|
|
|
|
All step patterns are prefixed with ``pfg-`` to avoid AmbiguousStep
|
|
collisions with other feature step definitions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.actor.schema import ActorConfigSchema
|
|
from cleveragents.application.services.plan_preflight_guardrail import (
|
|
PlanPreflightGuardrail,
|
|
PreflightRejection,
|
|
)
|
|
|
|
# -- Givens ----------------------------------------------------------------
|
|
|
|
|
|
@given("a plan preflight guardrail")
|
|
def step_pfg_given_guardrail(context: Context) -> None:
|
|
context.guardrail = PlanPreflightGuardrail()
|
|
# Reset state between scenarios
|
|
context.pfg_action_registry = {}
|
|
context.pfg_actor_registry = {}
|
|
context.pfg_tool_registry = {}
|
|
context.pfg_skill_registry = {}
|
|
context.pfg_automation_profile = None
|
|
context.pfg_require_checkpoints = False
|
|
context.pfg_tool_capabilities = {}
|
|
context.pfg_resource_paths = ()
|
|
context.pfg_validation_registry = {}
|
|
context.pfg_check_result = None
|
|
context.pfg_report = None
|
|
context.pfg_rejection = None
|
|
|
|
|
|
@given('an action registry containing "{name}"')
|
|
def step_pfg_action_registry_with(context: Context, name: str) -> None:
|
|
context.pfg_action_registry[name] = {"name": name}
|
|
|
|
|
|
@given("an empty action registry")
|
|
def step_pfg_empty_action_registry(context: Context) -> None:
|
|
context.pfg_action_registry = {}
|
|
|
|
|
|
@given("an actor registry with all 4 roles registered")
|
|
def step_pfg_actor_all_roles(context: Context) -> None:
|
|
for role in PlanPreflightGuardrail.ACTOR_ROLES:
|
|
context.pfg_actor_registry[role] = {"role": role}
|
|
|
|
|
|
@given('an actor registry missing "{role}" role')
|
|
def step_pfg_actor_missing_role(context: Context, role: str) -> None:
|
|
for r in PlanPreflightGuardrail.ACTOR_ROLES:
|
|
if r != role:
|
|
context.pfg_actor_registry[r] = {"role": r}
|
|
|
|
|
|
@given('a tool registry containing "{t1}" and "{t2}"')
|
|
def step_pfg_tool_registry_two(context: Context, t1: str, t2: str) -> None:
|
|
context.pfg_tool_registry[t1] = {"name": t1}
|
|
context.pfg_tool_registry[t2] = {"name": t2}
|
|
|
|
|
|
@given('a tool registry containing "{name}"')
|
|
def step_pfg_tool_registry_one(context: Context, name: str) -> None:
|
|
context.pfg_tool_registry[name] = {"name": name}
|
|
|
|
|
|
@given("a valid automation profile")
|
|
def step_pfg_valid_automation(context: Context) -> None:
|
|
context.pfg_automation_profile = {"name": "auto"}
|
|
|
|
|
|
@given("no automation profile")
|
|
def step_pfg_no_automation(context: Context) -> None:
|
|
context.pfg_automation_profile = None
|
|
|
|
|
|
@given("require_checkpoints is true")
|
|
def step_pfg_require_checkpoints_true(context: Context) -> None:
|
|
context.pfg_require_checkpoints = True
|
|
|
|
|
|
@given("require_checkpoints is false")
|
|
def step_pfg_require_checkpoints_false(context: Context) -> None:
|
|
context.pfg_require_checkpoints = False
|
|
|
|
|
|
@given("tool capabilities showing all tools checkpointable")
|
|
def step_pfg_all_checkpointable(context: Context) -> None:
|
|
context.pfg_tool_capabilities = {"tool-a": True, "tool-b": True}
|
|
|
|
|
|
@given('tool capabilities with "{name}" not checkpointable')
|
|
def step_pfg_non_checkpointable(context: Context, name: str) -> None:
|
|
context.pfg_tool_capabilities = {"safe-tool": True, name: False}
|
|
|
|
|
|
@given("resource paths that exist on filesystem")
|
|
def step_pfg_existing_resources(context: Context) -> None:
|
|
# Use a temp file to guarantee it exists
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp:
|
|
tmp_path = tmp.name
|
|
context.pfg_resource_paths = (tmp_path,)
|
|
context._pfg_tmp_file = tmp_path
|
|
|
|
|
|
@given('resource path "{path}"')
|
|
def step_pfg_resource_path(context: Context, path: str) -> None:
|
|
context.pfg_resource_paths = (path,)
|
|
|
|
|
|
@given('a validation registry containing "{v1}" and "{v2}"')
|
|
def step_pfg_validation_registry_two(context: Context, v1: str, v2: str) -> None:
|
|
context.pfg_validation_registry[v1] = {"name": v1}
|
|
context.pfg_validation_registry[v2] = {"name": v2}
|
|
|
|
|
|
@given('a validation registry containing "{name}"')
|
|
def step_pfg_validation_registry_one(context: Context, name: str) -> None:
|
|
context.pfg_validation_registry[name] = {"name": name}
|
|
|
|
|
|
@given("a plan preflight guardrail with all registries populated")
|
|
def step_pfg_all_populated(context: Context) -> None:
|
|
context.guardrail = PlanPreflightGuardrail()
|
|
context.pfg_action_registry = {"test-action": {"name": "test-action"}}
|
|
context.pfg_actor_registry = {
|
|
role: {"role": role} for role in PlanPreflightGuardrail.ACTOR_ROLES
|
|
}
|
|
context.pfg_tool_registry = {"tool-a": {"name": "tool-a"}}
|
|
context.pfg_skill_registry = {}
|
|
context.pfg_automation_profile = {"name": "auto"}
|
|
context.pfg_require_checkpoints = False
|
|
context.pfg_tool_capabilities = {}
|
|
context.pfg_resource_paths = ()
|
|
context.pfg_validation_registry = {}
|
|
context.pfg_rejection = None
|
|
context.pfg_report = None
|
|
|
|
|
|
@given("an estimation actor registry entry without response_format")
|
|
def step_pfg_estimation_actor_missing_response_format(context: Context) -> None:
|
|
context.pfg_actor_registry["estimation"] = {
|
|
"name": "local/estimator",
|
|
"context_view": "strategist",
|
|
}
|
|
|
|
|
|
@given("an estimation actor registry entry with non-strategist context_view")
|
|
def step_pfg_estimation_actor_wrong_context_view(context: Context) -> None:
|
|
context.pfg_actor_registry["estimation"] = {
|
|
"name": "local/estimator",
|
|
"context_view": "executor",
|
|
"response_format": {"type": "object"},
|
|
}
|
|
|
|
|
|
@given(
|
|
"an estimation actor registry entry as ActorConfigSchema without response_format"
|
|
)
|
|
def step_pfg_estimation_actor_model_missing_response_format(context: Context) -> None:
|
|
context.pfg_actor_registry["estimation"] = ActorConfigSchema(
|
|
name="local/estimator-model",
|
|
type="llm",
|
|
description="Model-based estimation actor",
|
|
provider="openai",
|
|
model="gpt-4",
|
|
role_hint="estimation",
|
|
context_view="strategist",
|
|
)
|
|
|
|
|
|
@given("a plan preflight guardrail with missing action")
|
|
def step_pfg_missing_action(context: Context) -> None:
|
|
context.guardrail = PlanPreflightGuardrail()
|
|
context.pfg_action_registry = {}
|
|
context.pfg_actor_registry = {
|
|
role: {"role": role} for role in PlanPreflightGuardrail.ACTOR_ROLES
|
|
}
|
|
context.pfg_tool_registry = {}
|
|
context.pfg_skill_registry = {}
|
|
context.pfg_automation_profile = {"name": "auto"}
|
|
context.pfg_require_checkpoints = False
|
|
context.pfg_tool_capabilities = {}
|
|
context.pfg_resource_paths = ()
|
|
context.pfg_validation_registry = {}
|
|
context.pfg_rejection = None
|
|
context.pfg_report = None
|
|
|
|
|
|
# -- Whens ----------------------------------------------------------------
|
|
|
|
|
|
@when('I check action schema for "{name}"')
|
|
def step_pfg_check_action(context: Context, name: str) -> None:
|
|
context.pfg_check_result = context.guardrail.check_action_schema(
|
|
name, context.pfg_action_registry
|
|
)
|
|
|
|
|
|
@when("I check actor availability")
|
|
def step_pfg_check_actors(context: Context) -> None:
|
|
context.pfg_check_result = context.guardrail.check_actor_availability(
|
|
context.pfg_actor_registry
|
|
)
|
|
|
|
|
|
@when('I check skill/tool existence for tools "{tools_csv}"')
|
|
def step_pfg_check_tools(context: Context, tools_csv: str) -> None:
|
|
tool_names = tuple(t.strip() for t in tools_csv.split(","))
|
|
context.pfg_check_result = context.guardrail.check_skill_tool_existence(
|
|
tool_names,
|
|
context.pfg_tool_registry,
|
|
(),
|
|
context.pfg_skill_registry,
|
|
)
|
|
|
|
|
|
@when("I check automation policy")
|
|
def step_pfg_check_automation(context: Context) -> None:
|
|
context.pfg_check_result = context.guardrail.check_automation_policy(
|
|
context.pfg_automation_profile
|
|
)
|
|
|
|
|
|
@when("I check rollback feasibility")
|
|
def step_pfg_check_rollback(context: Context) -> None:
|
|
context.pfg_check_result = context.guardrail.check_rollback_feasibility(
|
|
context.pfg_require_checkpoints,
|
|
context.pfg_tool_capabilities,
|
|
)
|
|
|
|
|
|
@when("I check resource accessibility")
|
|
def step_pfg_check_resources(context: Context) -> None:
|
|
context.pfg_check_result = context.guardrail.check_resource_accessibility(
|
|
context.pfg_resource_paths
|
|
)
|
|
|
|
|
|
@when('I check validation resolution for "{names_csv}"')
|
|
def step_pfg_check_validation(context: Context, names_csv: str) -> None:
|
|
validation_names = tuple(v.strip() for v in names_csv.split(","))
|
|
context.pfg_check_result = context.guardrail.check_validation_resolution(
|
|
validation_names,
|
|
context.pfg_validation_registry,
|
|
)
|
|
|
|
|
|
@when("I run all preflight checks")
|
|
def step_pfg_run_all(context: Context) -> None:
|
|
try:
|
|
context.pfg_report = context.guardrail.run_all_checks(
|
|
action_name="test-action",
|
|
action_registry=context.pfg_action_registry,
|
|
actor_registry=context.pfg_actor_registry,
|
|
tool_names=tuple(context.pfg_tool_registry.keys()),
|
|
tool_registry=context.pfg_tool_registry,
|
|
skill_names=tuple(context.pfg_skill_registry.keys()),
|
|
skill_registry=context.pfg_skill_registry,
|
|
automation_profile=context.pfg_automation_profile,
|
|
require_checkpoints=context.pfg_require_checkpoints,
|
|
tool_capabilities=context.pfg_tool_capabilities,
|
|
resource_paths=context.pfg_resource_paths,
|
|
validation_names=(),
|
|
validation_registry=context.pfg_validation_registry,
|
|
)
|
|
context.pfg_rejection = None
|
|
except PreflightRejection as exc:
|
|
context.pfg_rejection = exc
|
|
context.pfg_report = None
|
|
|
|
|
|
@when("I run all preflight checks expecting rejection")
|
|
def step_pfg_run_all_expecting_rejection(context: Context) -> None:
|
|
try:
|
|
context.pfg_report = context.guardrail.run_all_checks(
|
|
action_name="missing-action",
|
|
action_registry=context.pfg_action_registry,
|
|
actor_registry=context.pfg_actor_registry,
|
|
automation_profile=context.pfg_automation_profile,
|
|
)
|
|
context.pfg_rejection = None
|
|
except PreflightRejection as exc:
|
|
context.pfg_rejection = exc
|
|
context.pfg_report = None
|
|
|
|
|
|
# -- Thens ----------------------------------------------------------------
|
|
|
|
|
|
@then('the check should pass with message containing "{text}"')
|
|
def step_pfg_then_pass_with_msg(context: Context, text: str) -> None:
|
|
result = context.pfg_check_result
|
|
assert result is not None, "No check result recorded"
|
|
assert result.passed is True, f"Expected PASS but got FAIL: {result.message}"
|
|
assert text in result.message, (
|
|
f"Expected '{text}' in message, got: {result.message}"
|
|
)
|
|
|
|
|
|
@then("the check should pass")
|
|
def step_pfg_then_pass(context: Context) -> None:
|
|
result = context.pfg_check_result
|
|
assert result is not None, "No check result recorded"
|
|
assert result.passed is True, f"Expected PASS but got FAIL: {result.message}"
|
|
|
|
|
|
@then('the check should fail with message containing "{text}"')
|
|
def step_pfg_then_fail_with_msg(context: Context, text: str) -> None:
|
|
result = context.pfg_check_result
|
|
assert result is not None, "No check result recorded"
|
|
assert result.passed is False, f"Expected FAIL but got PASS: {result.message}"
|
|
assert text in result.message, (
|
|
f"Expected '{text}' in message, got: {result.message}"
|
|
)
|
|
|
|
|
|
@then("the report should show all checks passed")
|
|
def step_pfg_then_report_all_passed(context: Context) -> None:
|
|
report = context.pfg_report
|
|
assert report is not None, "No report recorded"
|
|
assert report.all_passed, (
|
|
f"Expected all checks passed, failures: {[r.message for r in report.failures]}"
|
|
)
|
|
|
|
|
|
@then("no PreflightRejection should be raised")
|
|
def step_pfg_then_no_rejection(context: Context) -> None:
|
|
assert context.pfg_rejection is None, (
|
|
f"Expected no rejection but got: {context.pfg_rejection}"
|
|
)
|
|
|
|
|
|
@then("a PreflightRejection should be raised")
|
|
def step_pfg_then_rejection(context: Context) -> None:
|
|
assert context.pfg_rejection is not None, (
|
|
"Expected PreflightRejection but none was raised"
|
|
)
|
|
|
|
|
|
@then('the rejection should identify "{check_name}"')
|
|
def step_pfg_then_rejection_check(context: Context, check_name: str) -> None:
|
|
rejection = context.pfg_rejection
|
|
assert rejection is not None, "No rejection recorded"
|
|
assert rejection.check.value == check_name, (
|
|
f"Expected check '{check_name}', got '{rejection.check.value}'"
|
|
)
|
|
|
|
|
|
@then('the preflight warnings should contain "{text}"')
|
|
def step_pfg_then_warnings_contain(context: Context, text: str) -> None:
|
|
report = context.pfg_report
|
|
assert report is not None, "No report recorded"
|
|
assert any(text in warning.message for warning in report.warnings), (
|
|
f"Expected warning containing '{text}', got {report.warnings}"
|
|
)
|