docs(action): add action YAML schema and examples
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 26s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 4m17s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 7m45s
CI / docker (pull_request) Successful in 44s
CI / coverage (pull_request) Successful in 5m38s
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 26s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 4m17s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 7m45s
CI / docker (pull_request) Successful in 44s
CI / coverage (pull_request) Successful in 5m38s
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""ASV benchmarks for Action YAML schema validation throughput.
|
||||
|
||||
Measures the performance of:
|
||||
- YAML string parsing + schema validation
|
||||
- YAML file loading + schema validation
|
||||
- Key normalization overhead
|
||||
- Invariant normalization overhead
|
||||
- model_dump() serialization
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed that lacks the ``action`` sub-package.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
# Force-reload the top-level package so Python picks up the source tree
|
||||
# version (which contains the ``action`` sub-package) instead of the
|
||||
# potentially stale installed copy.
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.action.schema import ActionConfigSchema # noqa: E402
|
||||
|
||||
_MINIMAL_YAML = """\
|
||||
name: local/bench-action
|
||||
description: Benchmark action
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
definition_of_done: Benchmarks pass
|
||||
"""
|
||||
|
||||
_FULL_YAML = """\
|
||||
schema_version: "1"
|
||||
name: local/bench-full
|
||||
description: Full benchmark action
|
||||
long_description: |
|
||||
This is a detailed description for benchmarking
|
||||
with multiple lines of text.
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
estimation_actor: local/estimator
|
||||
review_actor: local/reviewer
|
||||
apply_actor: local/applier
|
||||
invariant_actor: local/invariant-resolver
|
||||
definition_of_done: All benchmarks pass with zero errors
|
||||
reusable: true
|
||||
read_only: false
|
||||
state: available
|
||||
automation_profile: local/trusted
|
||||
invariants:
|
||||
- "No secrets in code"
|
||||
- "All tests must pass"
|
||||
- "Coverage above 97%"
|
||||
arguments:
|
||||
- name: target_coverage
|
||||
type: integer
|
||||
required: true
|
||||
description: Target coverage percentage
|
||||
min_value: 1
|
||||
max_value: 100
|
||||
- name: test_command
|
||||
type: string
|
||||
required: false
|
||||
description: Test framework command
|
||||
default: "pytest --cov"
|
||||
"""
|
||||
|
||||
_CAMEL_CASE_YAML = """\
|
||||
name: local/bench-camel
|
||||
description: camelCase benchmark
|
||||
strategyActor: openai/gpt-4
|
||||
executionActor: openai/gpt-4
|
||||
definitionOfDone: Benchmarks pass
|
||||
"""
|
||||
|
||||
|
||||
class ActionSchemaValidationSuite:
|
||||
"""Benchmark ActionConfigSchema.from_yaml() throughput."""
|
||||
|
||||
def time_validate_minimal(self) -> None:
|
||||
"""Benchmark minimal YAML validation."""
|
||||
ActionConfigSchema.from_yaml(_MINIMAL_YAML)
|
||||
|
||||
def time_validate_full(self) -> None:
|
||||
"""Benchmark fully-populated YAML validation."""
|
||||
ActionConfigSchema.from_yaml(_FULL_YAML)
|
||||
|
||||
def time_validate_camel_case_normalization(self) -> None:
|
||||
"""Benchmark YAML with camelCase key normalization."""
|
||||
ActionConfigSchema.from_yaml(_CAMEL_CASE_YAML)
|
||||
|
||||
|
||||
class ActionSchemaFileLoadSuite:
|
||||
"""Benchmark ActionConfigSchema.from_yaml_file() throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Write a temporary YAML file for file-load benchmarks."""
|
||||
fd, self._path = tempfile.mkstemp(suffix=".yaml")
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write(_FULL_YAML)
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Remove the temporary file."""
|
||||
Path(self._path).unlink(missing_ok=True)
|
||||
|
||||
def time_load_from_file(self) -> None:
|
||||
"""Benchmark loading and validating a YAML file."""
|
||||
ActionConfigSchema.from_yaml_file(self._path)
|
||||
|
||||
|
||||
class ActionSchemaSerializationSuite:
|
||||
"""Benchmark serialization of a validated ActionConfigSchema."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Parse YAML once for serialization benchmarks."""
|
||||
self._config = ActionConfigSchema.from_yaml(_FULL_YAML)
|
||||
|
||||
def time_model_dump(self) -> None:
|
||||
"""Benchmark model_dump() serialization."""
|
||||
self._config.model_dump()
|
||||
|
||||
def time_model_dump_json(self) -> None:
|
||||
"""Benchmark model_dump_json() JSON serialization."""
|
||||
self._config.model_dump_json()
|
||||
@@ -0,0 +1,227 @@
|
||||
# ============================================================================
|
||||
# CleverAgents Action Configuration Schema
|
||||
# ============================================================================
|
||||
#
|
||||
# Formal YAML schema definition for action configuration files.
|
||||
# Actions are reusable plan templates created via:
|
||||
# agents action create --config <file>
|
||||
#
|
||||
# This schema is based on the formal JSON Schema defined in
|
||||
# docs/specification.md (Section "Action Configuration Files → JSON Schema",
|
||||
# lines 15651-15788).
|
||||
#
|
||||
# Schema Version: 1
|
||||
# ============================================================================
|
||||
|
||||
schema_version: "1"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Top-Level Fields
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
fields:
|
||||
# ─── Identity ─────────────────────────────────────────────────
|
||||
name:
|
||||
type: string
|
||||
required: true
|
||||
pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
|
||||
description: >
|
||||
Fully qualified action name in <namespace>/<name> format.
|
||||
Examples: local/code-coverage, myorg/security-audit
|
||||
|
||||
description:
|
||||
type: string
|
||||
required: true
|
||||
description: >
|
||||
Short (one-line) description of the action.
|
||||
|
||||
long_description:
|
||||
type: string
|
||||
required: false
|
||||
description: >
|
||||
Detailed multi-line description explaining purpose, usage,
|
||||
and expected outcomes.
|
||||
|
||||
# ─── Lifecycle Actors ─────────────────────────────────────────
|
||||
strategy_actor:
|
||||
type: string
|
||||
required: true
|
||||
pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
|
||||
description: >
|
||||
Actor to use during the Strategize phase.
|
||||
Must reference a registered actor by namespaced name.
|
||||
|
||||
execution_actor:
|
||||
type: string
|
||||
required: true
|
||||
pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
|
||||
description: >
|
||||
Actor to use during the Execute phase.
|
||||
Must reference a registered actor by namespaced name.
|
||||
|
||||
estimation_actor:
|
||||
type: string
|
||||
required: false
|
||||
pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
|
||||
description: >
|
||||
Actor for effort and cost estimation before execution.
|
||||
|
||||
review_actor:
|
||||
type: string
|
||||
required: false
|
||||
pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
|
||||
description: >
|
||||
Actor for reviewing execution results.
|
||||
|
||||
apply_actor:
|
||||
type: string
|
||||
required: false
|
||||
pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
|
||||
description: >
|
||||
Actor to use during the Apply phase.
|
||||
|
||||
invariant_actor:
|
||||
type: string
|
||||
required: false
|
||||
pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
|
||||
description: >
|
||||
Actor for reconciling conflicting invariants across scopes.
|
||||
|
||||
# ─── Completion Criteria ──────────────────────────────────────
|
||||
definition_of_done:
|
||||
type: string
|
||||
required: true
|
||||
description: >
|
||||
Clear, measurable criteria that define when the action's
|
||||
work is complete.
|
||||
|
||||
# ─── Action Properties ────────────────────────────────────────
|
||||
reusable:
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
description: >
|
||||
Whether the action persists after being used.
|
||||
If false, the action is archived after first use.
|
||||
|
||||
read_only:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: >
|
||||
Whether the action is restricted to read-only operations.
|
||||
Read-only actions must only use tools with read_only: true.
|
||||
|
||||
state:
|
||||
type: string
|
||||
required: false
|
||||
enum: [available, archived]
|
||||
default: available
|
||||
description: >
|
||||
State of the action. Only 'available' and 'archived' are valid.
|
||||
|
||||
# ─── Arguments ────────────────────────────────────────────────
|
||||
arguments:
|
||||
type: array
|
||||
required: false
|
||||
description: >
|
||||
Typed parameters that users supply when using the action
|
||||
via 'agents plan use --arg name=value'.
|
||||
items:
|
||||
type: object
|
||||
required_fields: [name, type]
|
||||
additional_properties: false
|
||||
fields:
|
||||
name:
|
||||
type: string
|
||||
required: true
|
||||
description: "Argument name. Used as key in --arg name=value."
|
||||
type:
|
||||
type: string
|
||||
required: true
|
||||
enum: [string, integer, float, boolean, list]
|
||||
description: "Data type of the argument."
|
||||
required:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: "Whether the argument must be provided."
|
||||
description:
|
||||
type: string
|
||||
required: false
|
||||
description: "Human-readable description shown in help text."
|
||||
default:
|
||||
type: any
|
||||
required: false
|
||||
description: >
|
||||
Default value when the argument is not provided.
|
||||
Type must match the 'type' field.
|
||||
validation_pattern:
|
||||
type: string
|
||||
required: false
|
||||
description: "Regex pattern for validating string arguments."
|
||||
min_value:
|
||||
type: number
|
||||
required: false
|
||||
description: "Minimum acceptable value for integer/float arguments."
|
||||
max_value:
|
||||
type: number
|
||||
required: false
|
||||
description: "Maximum acceptable value for integer/float arguments."
|
||||
|
||||
# ─── Automation ───────────────────────────────────────────────
|
||||
automation_profile:
|
||||
type: string
|
||||
required: false
|
||||
description: >
|
||||
Default automation profile for plans created from this action.
|
||||
References a named automation profile (e.g., local/cautious, trusted).
|
||||
|
||||
# ─── Invariants ───────────────────────────────────────────────
|
||||
invariants:
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Constraints carried forward as plan-level invariants when
|
||||
the action is used. Invariants are trimmed, de-duplicated,
|
||||
and blanks are dropped during validation.
|
||||
|
||||
# ─── Inputs Schema (Advanced) ─────────────────────────────────
|
||||
inputs_schema:
|
||||
type: object
|
||||
required: false
|
||||
description: >
|
||||
Optional JSON Schema dict for advanced input validation
|
||||
beyond basic argument types. Allows complex nested validation
|
||||
rules for action inputs.
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Constraints
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
required: [name, description, strategy_actor, execution_actor, definition_of_done]
|
||||
additional_properties: false
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Key Normalization
|
||||
# ----------------------------------------------------------------------------
|
||||
# The schema loader accepts camelCase keys and normalizes them to snake_case:
|
||||
# strategyActor -> strategy_actor
|
||||
# executionActor -> execution_actor
|
||||
# definitionOfDone -> definition_of_done
|
||||
# longDescription -> long_description
|
||||
# estimationActor -> estimation_actor
|
||||
# reviewActor -> review_actor
|
||||
# applyActor -> apply_actor
|
||||
# invariantActor -> invariant_actor
|
||||
# readOnly -> read_only
|
||||
# automationProfile -> automation_profile
|
||||
# inputsSchema -> inputs_schema
|
||||
# validationPattern -> validation_pattern
|
||||
# minValue -> min_value
|
||||
# maxValue -> max_value
|
||||
# schemaVersion -> schema_version
|
||||
# Legacy camelCase keys emit a warning but are accepted.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# ============================================================================
|
||||
# Estimation-Actor Action — Cost/Risk Estimation Before Execution
|
||||
# ============================================================================
|
||||
# Demonstrates the optional estimation_actor field for pre-execution
|
||||
# cost and risk estimation.
|
||||
# Create: agents action create --config examples/actions/estimation-actor.yaml
|
||||
# ============================================================================
|
||||
|
||||
name: local/large-refactor
|
||||
description: "Large-scale code refactoring with cost estimation"
|
||||
long_description: |
|
||||
Performs a large-scale refactoring of a codebase module. An estimation
|
||||
actor runs after Strategize to predict cost, risk, and duration before
|
||||
execution proceeds.
|
||||
|
||||
strategy_actor: local/strategist
|
||||
execution_actor: local/executor
|
||||
estimation_actor: local/estimator
|
||||
review_actor: local/reviewer
|
||||
|
||||
definition_of_done: |
|
||||
Refactoring complete. All tests pass. No regressions introduced.
|
||||
Code review approved by the review actor.
|
||||
|
||||
reusable: true
|
||||
read_only: false
|
||||
|
||||
arguments:
|
||||
- name: target_module
|
||||
type: string
|
||||
required: true
|
||||
description: "Module path to refactor"
|
||||
- name: max_files_changed
|
||||
type: integer
|
||||
required: false
|
||||
description: "Maximum number of files to modify"
|
||||
default: 50
|
||||
min_value: 1
|
||||
max_value: 500
|
||||
|
||||
automation_profile: trusted
|
||||
|
||||
invariants:
|
||||
- "All existing tests must continue to pass"
|
||||
- "Public API signatures must not change without deprecation"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# ============================================================================
|
||||
# Inputs-Schema Action — Advanced Input Validation via JSON Schema
|
||||
# ============================================================================
|
||||
# Demonstrates the optional inputs_schema field for complex validation
|
||||
# rules beyond basic argument types.
|
||||
# Create: agents action create --config examples/actions/inputs-schema.yaml
|
||||
# ============================================================================
|
||||
|
||||
name: local/data-pipeline
|
||||
description: "Build and validate a data processing pipeline"
|
||||
long_description: |
|
||||
Creates a data pipeline with configurable stages. Uses inputs_schema
|
||||
for advanced validation of the pipeline configuration structure.
|
||||
|
||||
strategy_actor: local/strategist
|
||||
execution_actor: local/executor
|
||||
|
||||
definition_of_done: |
|
||||
Pipeline runs end-to-end successfully. All stages produce valid
|
||||
output. Data quality checks pass on final output.
|
||||
|
||||
reusable: true
|
||||
read_only: false
|
||||
|
||||
arguments:
|
||||
- name: pipeline_name
|
||||
type: string
|
||||
required: true
|
||||
description: "Name of the pipeline to build"
|
||||
- name: dry_run
|
||||
type: boolean
|
||||
required: false
|
||||
description: "Run in dry-run mode without writing output"
|
||||
default: false
|
||||
|
||||
inputs_schema:
|
||||
type: object
|
||||
properties:
|
||||
stages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
transform:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- transform
|
||||
output_format:
|
||||
type: string
|
||||
enum:
|
||||
- csv
|
||||
- json
|
||||
- parquet
|
||||
required:
|
||||
- stages
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# ============================================================================
|
||||
# Invariant-Heavy Action — Demonstrates Multiple Invariants
|
||||
# ============================================================================
|
||||
# An action with multiple invariants and an invariant reconciliation actor.
|
||||
# Create: agents action create --config examples/actions/invariant-heavy.yaml
|
||||
# ============================================================================
|
||||
|
||||
name: local/security-audit
|
||||
description: "Comprehensive security audit of a project"
|
||||
long_description: |
|
||||
Performs a thorough security audit covering:
|
||||
- Dependency vulnerability scanning
|
||||
- Static Application Security Testing (SAST)
|
||||
- Authentication and authorization review
|
||||
- Input validation and injection prevention
|
||||
|
||||
strategy_actor: local/security-strategist
|
||||
execution_actor: local/security-scanner
|
||||
invariant_actor: local/invariant-resolver
|
||||
|
||||
definition_of_done: |
|
||||
All critical and high severity findings have been identified.
|
||||
A complete security report has been generated with severity
|
||||
classifications and remediation steps for each finding.
|
||||
|
||||
reusable: true
|
||||
read_only: false
|
||||
|
||||
arguments:
|
||||
- name: severity_threshold
|
||||
type: string
|
||||
required: false
|
||||
description: "Minimum severity to include in report"
|
||||
default: "low"
|
||||
validation_pattern: "^(critical|high|medium|low|informational)$"
|
||||
- name: auto_fix
|
||||
type: boolean
|
||||
required: false
|
||||
description: "Automatically create fix plans for critical findings"
|
||||
default: false
|
||||
|
||||
automation_profile: supervised
|
||||
|
||||
invariants:
|
||||
- "Never modify production database schemas during audit"
|
||||
- "Never execute discovered exploit code against live systems"
|
||||
- "All findings must include reproducible steps"
|
||||
- "Secrets found during scanning must be redacted in reports"
|
||||
- "Remediation fixes must not break existing tests"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ============================================================================
|
||||
# Read-Only Action — Investigation / Audit Use Case
|
||||
# ============================================================================
|
||||
# An action that only performs read operations. Useful for investigations,
|
||||
# architecture reviews, or dry-run planning.
|
||||
# Create: agents action create --config examples/actions/read-only.yaml
|
||||
# ============================================================================
|
||||
|
||||
name: local/architecture-review
|
||||
description: "Review project architecture and produce a report"
|
||||
long_description: |
|
||||
Analyzes the project structure, dependency graph, and code patterns
|
||||
to produce an architecture review report. Does not modify any files.
|
||||
|
||||
strategy_actor: local/strategist
|
||||
execution_actor: local/executor
|
||||
|
||||
definition_of_done: |
|
||||
Architecture review report generated covering:
|
||||
- Module dependency graph
|
||||
- Layer boundary compliance
|
||||
- Circular dependency detection
|
||||
- Code duplication analysis
|
||||
|
||||
reusable: true
|
||||
read_only: true
|
||||
state: available
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ============================================================================
|
||||
# Simple Action — Minimal Valid Configuration
|
||||
# ============================================================================
|
||||
# Demonstrates a minimal action with only required fields.
|
||||
# Create: agents action create --config examples/actions/simple.yaml
|
||||
# ============================================================================
|
||||
|
||||
name: local/lint-check
|
||||
description: "Run linting checks on the project"
|
||||
|
||||
strategy_actor: local/strategist
|
||||
execution_actor: local/executor
|
||||
|
||||
definition_of_done: |
|
||||
All linting checks pass with zero errors.
|
||||
|
||||
reusable: true
|
||||
read_only: true
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
Feature: Action YAML schema validation
|
||||
As a CleverAgents user
|
||||
I want to define actions via YAML configuration files
|
||||
So that I can create validated, reusable plan templates
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Valid schema scenarios
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Load a minimal valid action YAML
|
||||
Given an action YAML string with only required fields
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config name should be "local/simple-action"
|
||||
And the action config strategy_actor should be "openai/gpt-4"
|
||||
And the action config execution_actor should be "openai/gpt-4"
|
||||
|
||||
Scenario: Load a valid action YAML with all optional fields
|
||||
Given an action YAML string with all optional fields populated
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config reusable should be true
|
||||
And the action config read_only should be false
|
||||
And the action config state should be "available"
|
||||
And the action config estimation_actor should be "local/estimator"
|
||||
And the action config review_actor should be "local/reviewer"
|
||||
And the action config apply_actor should be "local/applier"
|
||||
And the action config invariant_actor should be "local/invariant-resolver"
|
||||
|
||||
Scenario: Load the simple example YAML
|
||||
Given the action YAML file "examples/actions/simple.yaml"
|
||||
When I validate the action schema from file
|
||||
Then the action schema validation should succeed
|
||||
|
||||
Scenario: Load the invariant-heavy example YAML
|
||||
Given the action YAML file "examples/actions/invariant-heavy.yaml"
|
||||
When I validate the action schema from file
|
||||
Then the action schema validation should succeed
|
||||
|
||||
Scenario: Load the read-only example YAML
|
||||
Given the action YAML file "examples/actions/read-only.yaml"
|
||||
When I validate the action schema from file
|
||||
Then the action schema validation should succeed
|
||||
|
||||
Scenario: Load the estimation-actor example YAML
|
||||
Given the action YAML file "examples/actions/estimation-actor.yaml"
|
||||
When I validate the action schema from file
|
||||
Then the action schema validation should succeed
|
||||
|
||||
Scenario: Load the inputs-schema example YAML
|
||||
Given the action YAML file "examples/actions/inputs-schema.yaml"
|
||||
When I validate the action schema from file
|
||||
Then the action schema validation should succeed
|
||||
|
||||
Scenario: Optional fields have correct defaults when omitted
|
||||
Given an action YAML string with only required fields
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config reusable should be true
|
||||
And the action config read_only should be false
|
||||
And the action config state should be "available"
|
||||
And the action config invariants list should be empty
|
||||
And the action config arguments list should be empty
|
||||
|
||||
Scenario: Action with arguments validates correctly
|
||||
Given an action YAML string with typed arguments
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config should have 2 arguments
|
||||
And action argument 0 name should be "target_coverage"
|
||||
And action argument 0 type should be "integer"
|
||||
And action argument 1 name should be "test_command"
|
||||
And action argument 1 type should be "string"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Invalid schema scenarios
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Missing name field produces a clear error
|
||||
Given an action YAML string missing the "name" field
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "name"
|
||||
|
||||
Scenario: Missing strategy_actor produces a clear error
|
||||
Given an action YAML string missing the "strategy_actor" field
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "strategy_actor"
|
||||
|
||||
Scenario: Missing execution_actor produces a clear error
|
||||
Given an action YAML string missing the "execution_actor" field
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "execution_actor"
|
||||
|
||||
Scenario: Missing definition_of_done produces a clear error
|
||||
Given an action YAML string missing the "definition_of_done" field
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "definition_of_done"
|
||||
|
||||
Scenario: Missing description produces a clear error
|
||||
Given an action YAML string missing the "description" field
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "description"
|
||||
|
||||
Scenario: Invalid namespaced name without slash
|
||||
Given an action YAML string with name "invalid-name-no-slash"
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "namespace/name"
|
||||
|
||||
Scenario: Invalid namespaced name with special characters
|
||||
Given an action YAML string with name "local/bad name!!"
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "namespace/name"
|
||||
|
||||
Scenario: Invalid argument type
|
||||
Given an action YAML string with an argument of type "array"
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "type"
|
||||
|
||||
Scenario: Invalid state value
|
||||
Given an action YAML string with state "draft"
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "state"
|
||||
|
||||
Scenario: Invalid argument name
|
||||
Given an action YAML string with an argument named "123bad"
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "identifier"
|
||||
|
||||
Scenario: None YAML string raises ValueError
|
||||
Given a None action YAML string
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "None"
|
||||
|
||||
Scenario: Empty YAML string raises ValueError
|
||||
Given an empty action YAML string
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "empty"
|
||||
|
||||
Scenario: YAML that is a list instead of a mapping
|
||||
Given an action YAML string that is a list
|
||||
When I validate the action schema expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "mapping"
|
||||
|
||||
Scenario: None file path raises ValueError
|
||||
Given a None action YAML file path
|
||||
When I validate the action schema from file expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "None"
|
||||
|
||||
Scenario: Non-existent file raises FileNotFoundError
|
||||
Given the action YAML file "examples/actions/nonexistent.yaml"
|
||||
When I validate the action schema from file expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "not found"
|
||||
|
||||
Scenario: Directory path raises ValueError
|
||||
Given the action YAML directory path "examples/actions"
|
||||
When I validate the action schema from file expecting failure
|
||||
Then the action schema validation should fail
|
||||
And the action schema error should mention "not a file"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Invariant normalization scenarios
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Empty invariants are dropped
|
||||
Given an action YAML string with empty invariant strings
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config invariants list should be empty
|
||||
|
||||
Scenario: Duplicate invariants are de-duplicated preserving order
|
||||
Given an action YAML string with duplicate invariants
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config should have 2 invariants
|
||||
And action invariant 0 should be "No secrets in code"
|
||||
And action invariant 1 should be "All tests must pass"
|
||||
|
||||
Scenario: Invariant whitespace is trimmed
|
||||
Given an action YAML string with whitespace-padded invariants
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And action invariant 0 should be "Keep it clean"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Key normalization scenarios
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: camelCase keys are normalized to snake_case
|
||||
Given an action YAML string with camelCase keys
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config strategy_actor should be "openai/gpt-4"
|
||||
And the action config execution_actor should be "openai/gpt-4"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Environment variable interpolation
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Environment variable interpolation in YAML values
|
||||
Given the environment variable "TEST_ACTOR" is set to "openai/gpt-4"
|
||||
And an action YAML string using env var "${TEST_ACTOR}" for strategy_actor
|
||||
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config strategy_actor should be "openai/gpt-4"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Round-trip serialization
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Schema model serializes to dict correctly
|
||||
Given an action YAML string with all optional fields populated
|
||||
When I validate the action schema
|
||||
Then the action schema validation should succeed
|
||||
And the action config model_dump should contain key "name"
|
||||
And the action config model_dump should contain key "strategy_actor"
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Step definitions for action YAML schema validation.
|
||||
|
||||
Tests for features/action_schema.feature — validates the ActionConfigSchema
|
||||
Pydantic model, YAML loading, key normalization, invariant normalization,
|
||||
environment variable interpolation, and error messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context # type: ignore[import-untyped]
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cleveragents.action.schema import ActionConfigSchema
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
_MINIMAL_YAML = """\
|
||||
name: local/simple-action
|
||||
description: A simple action for testing
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
definition_of_done: All tests pass
|
||||
"""
|
||||
|
||||
_FULL_YAML = """\
|
||||
schema_version: "1"
|
||||
name: local/full-action
|
||||
description: A fully populated action
|
||||
long_description: |
|
||||
This action demonstrates all optional fields.
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
estimation_actor: local/estimator
|
||||
review_actor: local/reviewer
|
||||
apply_actor: local/applier
|
||||
invariant_actor: local/invariant-resolver
|
||||
definition_of_done: All tests pass and coverage is above 97%
|
||||
reusable: true
|
||||
read_only: false
|
||||
state: available
|
||||
automation_profile: local/cautious
|
||||
invariants:
|
||||
- "No secrets in code"
|
||||
- "All tests must pass"
|
||||
arguments:
|
||||
- name: target
|
||||
type: integer
|
||||
required: true
|
||||
description: Target coverage percentage
|
||||
min_value: 1
|
||||
max_value: 100
|
||||
inputs_schema:
|
||||
type: object
|
||||
properties:
|
||||
config:
|
||||
type: string
|
||||
"""
|
||||
|
||||
_ARGUMENTS_YAML = """\
|
||||
name: local/with-args
|
||||
description: Action with arguments
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
definition_of_done: Coverage target reached
|
||||
arguments:
|
||||
- name: target_coverage
|
||||
type: integer
|
||||
required: true
|
||||
description: Target coverage percentage
|
||||
min_value: 1
|
||||
max_value: 100
|
||||
- name: test_command
|
||||
type: string
|
||||
required: false
|
||||
description: Test framework command
|
||||
default: "pytest --cov"
|
||||
"""
|
||||
|
||||
|
||||
def _make_yaml_missing(field: str) -> str:
|
||||
"""Return minimal YAML with one required field removed."""
|
||||
lines = _MINIMAL_YAML.strip().splitlines()
|
||||
return "\n".join(ln for ln in lines if not ln.startswith(f"{field}:"))
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Given steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("an action YAML string with only required fields")
|
||||
def step_given_minimal_yaml(context: Context) -> None:
|
||||
"""Provide a minimal valid action YAML string."""
|
||||
context.action_yaml_string = _MINIMAL_YAML
|
||||
|
||||
|
||||
@given("an action YAML string with all optional fields populated")
|
||||
def step_given_full_yaml(context: Context) -> None:
|
||||
"""Provide a fully populated action YAML string."""
|
||||
context.action_yaml_string = _FULL_YAML
|
||||
|
||||
|
||||
@given("an action YAML string with typed arguments")
|
||||
def step_given_arguments_yaml(context: Context) -> None:
|
||||
"""Provide an action YAML with typed arguments."""
|
||||
context.action_yaml_string = _ARGUMENTS_YAML
|
||||
|
||||
|
||||
@given('the action YAML file "{rel_path}"')
|
||||
def step_given_action_yaml_file(context: Context, rel_path: str) -> None:
|
||||
"""Set the path to an action YAML file relative to project root."""
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
context.action_yaml_file = str(project_root / rel_path)
|
||||
|
||||
|
||||
@given('an action YAML string missing the "{field}" field')
|
||||
def step_given_yaml_missing_field(context: Context, field: str) -> None:
|
||||
"""Provide YAML with a specific required field removed."""
|
||||
context.action_yaml_string = _make_yaml_missing(field)
|
||||
|
||||
|
||||
@given('an action YAML string with name "{name}"')
|
||||
def step_given_yaml_with_name(context: Context, name: str) -> None:
|
||||
"""Provide YAML with a custom name value."""
|
||||
context.action_yaml_string = _MINIMAL_YAML.replace("local/simple-action", name)
|
||||
|
||||
|
||||
@given('an action YAML string with an argument of type "{arg_type}"')
|
||||
def step_given_yaml_with_bad_arg_type(context: Context, arg_type: str) -> None:
|
||||
"""Provide YAML with an invalid argument type."""
|
||||
context.action_yaml_string = f"""\
|
||||
name: local/bad-arg
|
||||
description: Action with bad argument type
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
definition_of_done: Done
|
||||
arguments:
|
||||
- name: bad_arg
|
||||
type: {arg_type}
|
||||
required: true
|
||||
"""
|
||||
|
||||
|
||||
@given('an action YAML string with state "{state}"')
|
||||
def step_given_yaml_with_state(context: Context, state: str) -> None:
|
||||
"""Provide YAML with a specific state value."""
|
||||
context.action_yaml_string = _MINIMAL_YAML.rstrip() + f"\nstate: {state}\n"
|
||||
|
||||
|
||||
@given('an action YAML string with an argument named "{name}"')
|
||||
def step_given_yaml_with_bad_arg_name(context: Context, name: str) -> None:
|
||||
"""Provide YAML with an invalid argument name."""
|
||||
context.action_yaml_string = f"""\
|
||||
name: local/bad-argname
|
||||
description: Action with bad argument name
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
definition_of_done: Done
|
||||
arguments:
|
||||
- name: "{name}"
|
||||
type: string
|
||||
required: true
|
||||
"""
|
||||
|
||||
|
||||
@given("a None action YAML string")
|
||||
def step_given_none_yaml(context: Context) -> None:
|
||||
"""Set YAML string to None for None guard testing."""
|
||||
context.action_yaml_string = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@given("an empty action YAML string")
|
||||
def step_given_empty_yaml(context: Context) -> None:
|
||||
"""Set YAML string to empty for empty guard testing."""
|
||||
context.action_yaml_string = ""
|
||||
|
||||
|
||||
@given("an action YAML string that is a list")
|
||||
def step_given_yaml_list(context: Context) -> None:
|
||||
"""Set YAML string to a list instead of a mapping."""
|
||||
context.action_yaml_string = "- item1\n- item2\n"
|
||||
|
||||
|
||||
@given("a None action YAML file path")
|
||||
def step_given_none_file_path(context: Context) -> None:
|
||||
"""Set file path to None for None guard testing."""
|
||||
context.action_yaml_file = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@given('the action YAML directory path "{dir_path}"')
|
||||
def step_given_yaml_directory_path(context: Context, dir_path: str) -> None:
|
||||
"""Set file path to a directory for directory guard testing."""
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
context.action_yaml_file = str(project_root / dir_path)
|
||||
|
||||
|
||||
@given("an action YAML string with empty invariant strings")
|
||||
def step_given_yaml_with_empty_invariants(context: Context) -> None:
|
||||
"""Provide YAML with invariants that are empty or blank."""
|
||||
context.action_yaml_string = (
|
||||
_MINIMAL_YAML.rstrip() + '\ninvariants:\n - ""\n - " "\n'
|
||||
)
|
||||
|
||||
|
||||
@given("an action YAML string with duplicate invariants")
|
||||
def step_given_yaml_with_duplicate_invariants(context: Context) -> None:
|
||||
"""Provide YAML with duplicate invariant entries."""
|
||||
context.action_yaml_string = (
|
||||
_MINIMAL_YAML.rstrip()
|
||||
+ '\ninvariants:\n - "No secrets in code"\n - "All tests must pass"\n - "No secrets in code"\n'
|
||||
)
|
||||
|
||||
|
||||
@given("an action YAML string with whitespace-padded invariants")
|
||||
def step_given_yaml_with_whitespace_invariants(context: Context) -> None:
|
||||
"""Provide YAML with invariants that have leading/trailing whitespace."""
|
||||
context.action_yaml_string = (
|
||||
_MINIMAL_YAML.rstrip() + '\ninvariants:\n - " Keep it clean "\n'
|
||||
)
|
||||
|
||||
|
||||
@given("an action YAML string with camelCase keys")
|
||||
def step_given_yaml_with_camel_case(context: Context) -> None:
|
||||
"""Provide YAML using camelCase key names."""
|
||||
context.action_yaml_string = """\
|
||||
name: local/camel-action
|
||||
description: Action with camelCase keys
|
||||
strategyActor: openai/gpt-4
|
||||
executionActor: openai/gpt-4
|
||||
definitionOfDone: All tests pass
|
||||
"""
|
||||
|
||||
|
||||
@given('an action YAML string using env var "${{{var}}}" for strategy_actor')
|
||||
def step_given_yaml_with_env_var(context: Context, var: str) -> None:
|
||||
"""Provide YAML with environment variable reference."""
|
||||
context.action_yaml_string = f"""\
|
||||
name: local/env-action
|
||||
description: Action using env vars
|
||||
strategy_actor: ${{{var}}}
|
||||
execution_actor: openai/gpt-4
|
||||
definition_of_done: All tests pass
|
||||
"""
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# When steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I validate the action schema")
|
||||
def step_when_validate_action_schema(context: Context) -> None:
|
||||
"""Validate the action YAML string via ActionConfigSchema."""
|
||||
try:
|
||||
context.action_config = ActionConfigSchema.from_yaml(context.action_yaml_string)
|
||||
context.action_schema_error = None
|
||||
except (ValidationError, ValueError) as exc:
|
||||
context.action_config = None
|
||||
context.action_schema_error = str(exc)
|
||||
|
||||
|
||||
@when("I validate the action schema from file")
|
||||
def step_when_validate_from_file(context: Context) -> None:
|
||||
"""Validate an action YAML file via ActionConfigSchema."""
|
||||
try:
|
||||
context.action_config = ActionConfigSchema.from_yaml_file(
|
||||
context.action_yaml_file
|
||||
)
|
||||
context.action_schema_error = None
|
||||
except (ValidationError, ValueError, FileNotFoundError) as exc:
|
||||
context.action_config = None
|
||||
context.action_schema_error = str(exc)
|
||||
|
||||
|
||||
@when("I validate the action schema expecting failure")
|
||||
def step_when_validate_expecting_failure(context: Context) -> None:
|
||||
"""Validate the action YAML, expecting it to fail."""
|
||||
try:
|
||||
context.action_config = ActionConfigSchema.from_yaml(context.action_yaml_string)
|
||||
context.action_schema_error = None
|
||||
except (ValidationError, ValueError) as exc:
|
||||
context.action_config = None
|
||||
context.action_schema_error = str(exc)
|
||||
|
||||
|
||||
@when("I validate the action schema from file expecting failure")
|
||||
def step_when_validate_file_expecting_failure(context: Context) -> None:
|
||||
"""Validate an action YAML file, expecting it to fail."""
|
||||
try:
|
||||
context.action_config = ActionConfigSchema.from_yaml_file(
|
||||
context.action_yaml_file
|
||||
)
|
||||
context.action_schema_error = None
|
||||
except (ValidationError, ValueError, FileNotFoundError, TypeError) as exc:
|
||||
context.action_config = None
|
||||
context.action_schema_error = str(exc)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Then steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the action schema validation should succeed")
|
||||
def step_then_validation_succeeds(context: Context) -> None:
|
||||
"""Assert that schema validation succeeded."""
|
||||
assert context.action_config is not None, (
|
||||
f"Expected validation to succeed but got error: {context.action_schema_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the action schema validation should fail")
|
||||
def step_then_validation_fails(context: Context) -> None:
|
||||
"""Assert that schema validation failed."""
|
||||
assert context.action_config is None, "Expected validation to fail but it succeeded"
|
||||
assert context.action_schema_error is not None
|
||||
|
||||
|
||||
@then('the action schema error should mention "{text}"')
|
||||
def step_then_error_mentions(context: Context, text: str) -> None:
|
||||
"""Assert the error message contains expected text."""
|
||||
assert context.action_schema_error is not None, "No error was captured"
|
||||
assert text.lower() in context.action_schema_error.lower(), (
|
||||
f"Expected error to mention '{text}', got: {context.action_schema_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('the action config name should be "{expected}"')
|
||||
def step_then_config_name(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed config name."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.name == expected
|
||||
|
||||
|
||||
@then('the action config strategy_actor should be "{expected}"')
|
||||
def step_then_config_strategy_actor(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed strategy_actor."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.strategy_actor == expected
|
||||
|
||||
|
||||
@then('the action config execution_actor should be "{expected}"')
|
||||
def step_then_config_execution_actor(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed execution_actor."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.execution_actor == expected
|
||||
|
||||
|
||||
@then('the action config estimation_actor should be "{expected}"')
|
||||
def step_then_config_estimation_actor(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed estimation_actor."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.estimation_actor == expected
|
||||
|
||||
|
||||
@then('the action config review_actor should be "{expected}"')
|
||||
def step_then_config_review_actor(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed review_actor."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.review_actor == expected
|
||||
|
||||
|
||||
@then('the action config apply_actor should be "{expected}"')
|
||||
def step_then_config_apply_actor(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed apply_actor."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.apply_actor == expected
|
||||
|
||||
|
||||
@then('the action config invariant_actor should be "{expected}"')
|
||||
def step_then_config_invariant_actor(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed invariant_actor."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.invariant_actor == expected
|
||||
|
||||
|
||||
@then("the action config reusable should be true")
|
||||
def step_then_reusable_true(context: Context) -> None:
|
||||
"""Assert reusable is True."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.reusable is True
|
||||
|
||||
|
||||
@then("the action config read_only should be false")
|
||||
def step_then_read_only_false(context: Context) -> None:
|
||||
"""Assert read_only is False."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.read_only is False
|
||||
|
||||
|
||||
@then('the action config state should be "{expected}"')
|
||||
def step_then_config_state(context: Context, expected: str) -> None:
|
||||
"""Assert the parsed state."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.state == expected
|
||||
|
||||
|
||||
@then("the action config invariants list should be empty")
|
||||
def step_then_invariants_empty(context: Context) -> None:
|
||||
"""Assert invariants list is empty."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.invariants == []
|
||||
|
||||
|
||||
@then("the action config arguments list should be empty")
|
||||
def step_then_arguments_empty(context: Context) -> None:
|
||||
"""Assert arguments list is empty."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.arguments == []
|
||||
|
||||
|
||||
@then("the action config should have {count:d} arguments")
|
||||
def step_then_argument_count(context: Context, count: int) -> None:
|
||||
"""Assert the number of parsed arguments."""
|
||||
assert context.action_config is not None
|
||||
assert len(context.action_config.arguments) == count
|
||||
|
||||
|
||||
@then('action argument {idx:d} name should be "{expected}"')
|
||||
def step_then_argument_name(context: Context, idx: int, expected: str) -> None:
|
||||
"""Assert an argument name by index."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.arguments[idx].name == expected
|
||||
|
||||
|
||||
@then('action argument {idx:d} type should be "{expected}"')
|
||||
def step_then_argument_type(context: Context, idx: int, expected: str) -> None:
|
||||
"""Assert an argument type by index."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.arguments[idx].type == expected
|
||||
|
||||
|
||||
@then("the action config should have {count:d} invariants")
|
||||
def step_then_invariant_count(context: Context, count: int) -> None:
|
||||
"""Assert the number of parsed invariants."""
|
||||
assert context.action_config is not None
|
||||
assert len(context.action_config.invariants) == count
|
||||
|
||||
|
||||
@then('action invariant {idx:d} should be "{expected}"')
|
||||
def step_then_invariant_value(context: Context, idx: int, expected: str) -> None:
|
||||
"""Assert an invariant value by index."""
|
||||
assert context.action_config is not None
|
||||
assert context.action_config.invariants[idx] == expected
|
||||
|
||||
|
||||
@then('the action config model_dump should contain key "{key}"')
|
||||
def step_then_model_dump_contains_key(context: Context, key: str) -> None:
|
||||
"""Assert the model_dump dict contains a key."""
|
||||
assert context.action_config is not None
|
||||
data: dict[str, Any] = context.action_config.model_dump()
|
||||
assert key in data, f"Key '{key}' not found in model_dump: {list(data.keys())}"
|
||||
+45
-19
@@ -163,6 +163,23 @@ The following work from the previous implementation has been completed and will
|
||||
- `features/action_model.feature` (22 scenarios)
|
||||
- All new tests pass, existing tests unaffected
|
||||
|
||||
**2026-02-13**: Stage A2b.gamma Complete (Aditya) - Action YAML Schema + Examples + Loader
|
||||
- Created `docs/schema/action.schema.yaml` — formal schema definition for action YAML config files, covering all fields (name, description, strategy_actor, execution_actor, definition_of_done, arguments, invariants, automation_profile, inputs_schema, optional actors, behaviour flags, state), versioning, namespaced name patterns, key normalization mapping.
|
||||
- Created 5 example action YAML configs under `examples/actions/`: simple.yaml, invariant-heavy.yaml, read-only.yaml, estimation-actor.yaml, inputs-schema.yaml.
|
||||
- Created `src/cleveragents/action/schema.py` with `ActionConfigSchema` Pydantic model:
|
||||
- `from_yaml(yaml_string)` and `from_yaml_file(path)` factory methods
|
||||
- camelCase→snake_case key normalization with deprecation warnings
|
||||
- `${ENV_VAR}` environment variable interpolation
|
||||
- Invariant normalization: trim whitespace, drop blanks, de-duplicate preserving order
|
||||
- Clear, actionable error messages for every validation failure mode
|
||||
- Nested `ActionArgumentSchema` for typed arguments with validation
|
||||
- `extra="forbid"` prevents unknown keys
|
||||
- Behave tests: `features/action_schema.feature` — 31 scenarios / 140 steps covering valid schemas, missing fields, invalid names, invalid types, invariant normalization, key normalization, env var interpolation, serialization, edge cases (None/empty/list/directory inputs).
|
||||
- Robot smoke tests: `robot/action_schema.robot` — 6 test cases validating all example YAMLs + invalid rejection.
|
||||
- ASV benchmarks: `benchmarks/action_schema_bench.py` — 3 suites (validation, file-load, serialization).
|
||||
- Coverage: 98% overall (action/schema.py: 98%, action/__init__.py: 100%).
|
||||
- All nox sessions pass: lint, typecheck, unit_tests, integration_tests, benchmark, security_scan, coverage_report.
|
||||
|
||||
**2026-02-05**: Stage A3 Complete - PlanLifecycleService
|
||||
- Created `src/cleveragents/application/services/plan_lifecycle_service.py` with:
|
||||
- Full plan lifecycle management (Action -> Strategize -> Execute -> Apply -> Applied)
|
||||
@@ -1248,27 +1265,36 @@ Merge points and acceptance checks are tracked as checklist items under each mil
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-plan-model` to `master`, wait for CI + review, merge in UI (no CLI merge)
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git branch -d feature/m1-plan-model`
|
||||
- [ ] **COMMIT (Owner: Aditya | Group: A2b.gamma | Branch: feature/m1-action-schema) - Commit message: "docs(action): add action YAML schema and examples"**
|
||||
- [ ] Git [Aditya]: `git checkout master`
|
||||
- [ ] Git [Aditya]: `git pull origin master`
|
||||
- [ ] Git [Aditya]: `git checkout -b feature/m1-action-schema`
|
||||
- [ ] Git [Aditya]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Docs [Aditya]: Author `docs/schema/action.schema.yaml` with required fields, versioning, and namespaced name validation.
|
||||
- [ ] Docs [Aditya]: Add schema blocks for arguments, invariants, automation_profile, inputs_schema, and optional actor refs.
|
||||
- [ ] Docs [Aditya]: Add action examples under `examples/actions/` (minimal, invariant-heavy, read-only, estimation-actor, inputs_schema).
|
||||
- [ ] Code [Aditya]: Add schema validation helper in `src/cleveragents/action/schema.py` (YAML load + schema validate + env var interpolation).
|
||||
- [ ] Code [Aditya]: Normalize YAML keys (snake_case vs camelCase) and emit warnings for legacy aliases.
|
||||
- [ ] Code [Aditya]: Normalize invariants list (trim, drop blanks, de-dup) preserving order.
|
||||
- [ ] Tests (Behave) [Aditya]: Add scenarios that load each example YAML and assert validation passes; add invalid schema cases.
|
||||
- [ ] Tests (Robot) [Aditya]: Add Robot smoke test that parses example YAML files.
|
||||
- [ ] Tests (ASV) [Aditya]: Add `asv/benchmarks/action_schema_bench.py` for YAML schema validation throughput.
|
||||
- [ ] Quality [Aditya]: Run `nox` (all default sessions, including benchmark).
|
||||
- [ ] Quality [Aditya]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Git [Aditya]: `git add .`
|
||||
- [ ] Git [Aditya]: `git commit -m "docs(action): add action YAML schema and examples"`
|
||||
- [ ] Forgejo PR [Aditya]: Open PR from `feature/m1-action-schema` to `master` with description "Introduce action YAML schema + examples and loader validation; aligns action configs with spec for M1.".
|
||||
- [X] **COMMIT (Owner: Aditya | Group: A2b.gamma | Branch: feature/m1-action-schema) - Commit message: "docs(action): add action YAML schema and examples"** — COMPLETED 2026-02-13
|
||||
- [X] Git [Aditya]: `git checkout master`
|
||||
- [X] Git [Aditya]: `git pull origin master`
|
||||
- [X] Git [Aditya]: `git checkout -b feature/m1-action-schema`
|
||||
- [X] Git [Aditya]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [X] Docs [Aditya]: Author `docs/schema/action.schema.yaml` with required fields, versioning, and namespaced name validation.
|
||||
- [X] Docs [Aditya]: Add schema blocks for arguments, invariants, automation_profile, inputs_schema, and optional actor refs.
|
||||
- [X] Docs [Aditya]: Add action examples under `examples/actions/` (minimal, invariant-heavy, read-only, estimation-actor, inputs_schema).
|
||||
- [X] Code [Aditya]: Add schema validation helper in `src/cleveragents/action/schema.py` (YAML load + schema validate + env var interpolation).
|
||||
- [X] Code [Aditya]: Normalize YAML keys (snake_case vs camelCase) and emit warnings for legacy aliases.
|
||||
- [X] Code [Aditya]: Normalize invariants list (trim, drop blanks, de-dup) preserving order.
|
||||
- [X] Tests (Behave) [Aditya]: Add scenarios that load each example YAML and assert validation passes; add invalid schema cases.
|
||||
- [X] Tests (Robot) [Aditya]: Add Robot smoke test that parses example YAML files.
|
||||
- [X] Tests (ASV) [Aditya]: Add `benchmarks/action_schema_bench.py` for YAML schema validation throughput.
|
||||
- [X] Quality [Aditya]: Run `nox` (all default sessions, including benchmark).
|
||||
- [X] Quality [Aditya]: Verify coverage >=97% via `nox -s coverage_report`. Coverage result: 98% total (action/schema.py: 98%, action/__init__.py: 100%).
|
||||
- [X] Git [Aditya]: `git add .`
|
||||
- [X] Git [Aditya]: `git commit -m "docs(action): add action YAML schema and examples"`
|
||||
- [X] Forgejo PR [Aditya]: Open PR from `feature/m1-action-schema` to `master` with description "Introduce action YAML schema + examples and loader validation; aligns action configs with spec for M1.".
|
||||
- [ ] Git [Aditya]: `git checkout master`
|
||||
- [ ] Git [Aditya]: `git branch -d feature/m1-action-schema`
|
||||
**Notes (A2b.gamma)**:
|
||||
- Schema: `docs/schema/action.schema.yaml` — full field definitions with types, patterns, defaults, constraints.
|
||||
- Examples: 5 configs under `examples/actions/` — simple, invariant-heavy, read-only, estimation-actor, inputs-schema.
|
||||
- Validation helper: `src/cleveragents/action/schema.py` — Pydantic `ActionConfigSchema` model with `from_yaml()` and `from_yaml_file()` factories.
|
||||
- Features: camelCase→snake_case key normalization with deprecation warnings, `${ENV_VAR}` interpolation, invariant trim/dedup/drop-blanks, clear error messages for every validation failure.
|
||||
- Behave tests: 31 scenarios / 140 steps in `features/action_schema.feature`.
|
||||
- Robot smoke: 6 test cases in `robot/action_schema.robot` (5 valid examples + 1 invalid rejection).
|
||||
- ASV benchmarks: 3 suites (validation, file-load, serialization) in `benchmarks/action_schema_bench.py`.
|
||||
- All nox sessions pass: lint ✓, typecheck (0 errors) ✓, unit_tests (2222 scenarios) ✓, integration_tests (208 passed) ✓, benchmark ✓, security_scan ✓, coverage (98%) ✓.
|
||||
|
||||
- [X] **Stage A3: Plan State Machine** (Day 1-2) - COMPLETED 2026-02-05
|
||||
- [X] Code: Implement plan lifecycle state machine
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for Action YAML schema validation
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_action_schema.py
|
||||
|
||||
*** Test Cases ***
|
||||
Validate Simple Action YAML
|
||||
[Documentation] Parse the simple example action YAML and assert success
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate ${CURDIR}/../examples/actions/simple.yaml cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} action-schema-ok
|
||||
|
||||
Validate Invariant-Heavy Action YAML
|
||||
[Documentation] Parse the invariant-heavy example action YAML and assert success
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate ${CURDIR}/../examples/actions/invariant-heavy.yaml cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} action-schema-ok
|
||||
|
||||
Validate Read-Only Action YAML
|
||||
[Documentation] Parse the read-only example action YAML and assert success
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate ${CURDIR}/../examples/actions/read-only.yaml cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} action-schema-ok
|
||||
|
||||
Validate Estimation-Actor Action YAML
|
||||
[Documentation] Parse the estimation-actor example action YAML and assert success
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate ${CURDIR}/../examples/actions/estimation-actor.yaml cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} action-schema-ok
|
||||
|
||||
Validate Inputs-Schema Action YAML
|
||||
[Documentation] Parse the inputs-schema example action YAML and assert success
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate ${CURDIR}/../examples/actions/inputs-schema.yaml cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} action-schema-ok
|
||||
|
||||
Reject Invalid Action YAML
|
||||
[Documentation] Attempt to parse an intentionally invalid YAML and assert failure
|
||||
# Create a temporary invalid YAML
|
||||
${invalid_yaml}= Set Variable ${TEMPDIR}${/}invalid_action.yaml
|
||||
Create File ${invalid_yaml} description: missing required fields\n
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate-invalid ${invalid_yaml} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} action-schema-expected-fail
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Robot Framework helper for action YAML schema validation.
|
||||
|
||||
Provides a CLI-style interface for Robot to invoke schema validation
|
||||
on action YAML files. Exit code 0 = success, 1 = failure.
|
||||
|
||||
Usage:
|
||||
python robot/helper_action_schema.py validate <yaml_file>
|
||||
python robot/helper_action_schema.py validate-invalid <yaml_file>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the src directory is on the import path.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.action.schema import ActionConfigSchema # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point called by Robot Framework ``Run Process``."""
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: helper_action_schema.py <validate|validate-invalid> <file>")
|
||||
return 1
|
||||
|
||||
command = sys.argv[1]
|
||||
yaml_path = sys.argv[2]
|
||||
|
||||
if command == "validate":
|
||||
try:
|
||||
config = ActionConfigSchema.from_yaml_file(yaml_path)
|
||||
print(f"action-schema-ok: {config.name}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"action-schema-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "validate-invalid":
|
||||
try:
|
||||
ActionConfigSchema.from_yaml_file(yaml_path)
|
||||
print("action-schema-unexpected-success")
|
||||
return 1
|
||||
except Exception as exc:
|
||||
print(f"action-schema-expected-fail: {exc}")
|
||||
return 0
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Action YAML configuration schema and validation.
|
||||
|
||||
This package provides the ``ActionConfigSchema`` Pydantic model for
|
||||
loading, validating, and normalizing action YAML configuration files.
|
||||
|
||||
Usage::
|
||||
|
||||
from cleveragents.action.schema import ActionConfigSchema
|
||||
|
||||
config = ActionConfigSchema.from_yaml_file("examples/actions/simple.yaml")
|
||||
"""
|
||||
|
||||
from cleveragents.action.schema import ActionConfigSchema
|
||||
|
||||
__all__ = ["ActionConfigSchema"]
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Action YAML configuration schema, validation, and loading.
|
||||
|
||||
Provides :class:`ActionConfigSchema`, a Pydantic model that:
|
||||
|
||||
* Loads raw YAML (string or file) and validates it against the action schema.
|
||||
* Normalizes camelCase keys to snake_case before validation.
|
||||
* Interpolates ``${ENV_VAR}`` placeholders from environment variables.
|
||||
* Trims, de-duplicates, and drops blank invariant strings.
|
||||
* Produces clear, actionable error messages for every validation failure.
|
||||
|
||||
Schema definition lives in ``docs/schema/action.schema.yaml``.
|
||||
Example configs live under ``examples/actions/``.
|
||||
|
||||
Based on:
|
||||
- docs/specification.md — Action Configuration Files + JSON Schema
|
||||
- implementation_plan.md — Task A2b.gamma
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
#: Pattern for ``<namespace>/<name>`` with hyphens, underscores, alphanum.
|
||||
NAMESPACED_NAME_RE = re.compile(
|
||||
r"^[a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9][a-zA-Z0-9_-]*$"
|
||||
)
|
||||
|
||||
#: Pattern for ``${VAR}`` environment variable references.
|
||||
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
#: Supported action states (draft excluded from YAML configs).
|
||||
_VALID_STATES = frozenset({"available", "archived"})
|
||||
|
||||
#: Supported argument types.
|
||||
_VALID_ARG_TYPES = frozenset({"string", "integer", "float", "boolean", "list"})
|
||||
|
||||
#: camelCase → snake_case mapping for top-level and nested keys.
|
||||
_CAMEL_TO_SNAKE: dict[str, str] = {
|
||||
"strategyActor": "strategy_actor",
|
||||
"executionActor": "execution_actor",
|
||||
"definitionOfDone": "definition_of_done",
|
||||
"longDescription": "long_description",
|
||||
"estimationActor": "estimation_actor",
|
||||
"reviewActor": "review_actor",
|
||||
"applyActor": "apply_actor",
|
||||
"invariantActor": "invariant_actor",
|
||||
"readOnly": "read_only",
|
||||
"automationProfile": "automation_profile",
|
||||
"inputsSchema": "inputs_schema",
|
||||
"validationPattern": "validation_pattern",
|
||||
"minValue": "min_value",
|
||||
"maxValue": "max_value",
|
||||
"schemaVersion": "schema_version",
|
||||
}
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Nested models
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ActionArgumentSchema(BaseModel):
|
||||
"""Schema for a single typed action argument.
|
||||
|
||||
Arguments are values supplied by the user at plan-use time
|
||||
(``agents plan use --arg name=value``).
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
description="Argument name (used as key in --arg name=value).",
|
||||
)
|
||||
type: str = Field(
|
||||
"string",
|
||||
description="Data type: string | integer | float | boolean | list.",
|
||||
)
|
||||
required: bool = Field(
|
||||
False,
|
||||
description="Whether the argument must be provided.",
|
||||
)
|
||||
description: str = Field(
|
||||
"",
|
||||
max_length=500,
|
||||
description="Human-readable description of the argument.",
|
||||
)
|
||||
default: str | int | float | bool | list[str] | None = Field(
|
||||
default=None,
|
||||
description="Default value when argument is not provided.",
|
||||
)
|
||||
validation_pattern: str | None = Field(
|
||||
default=None,
|
||||
description="Regex pattern for string argument validation.",
|
||||
)
|
||||
min_value: float | None = Field(
|
||||
default=None,
|
||||
description="Minimum value for numeric arguments.",
|
||||
)
|
||||
max_value: float | None = Field(
|
||||
default=None,
|
||||
description="Maximum value for numeric arguments.",
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
"""Ensure argument name is a valid identifier."""
|
||||
if not v.replace("-", "_").isidentifier():
|
||||
raise ValueError(
|
||||
f"Argument name '{v}' is not a valid identifier. "
|
||||
"Use alphanumeric characters, underscores, or hyphens."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, v: str) -> str:
|
||||
"""Ensure argument type is one of the allowed types."""
|
||||
v_lower = v.lower()
|
||||
if v_lower not in _VALID_ARG_TYPES:
|
||||
valid = ", ".join(sorted(_VALID_ARG_TYPES))
|
||||
raise ValueError(f"Invalid argument type '{v}'. Allowed types: {valid}.")
|
||||
return v_lower
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Main schema model
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ActionConfigSchema(BaseModel):
|
||||
"""Pydantic model for an action YAML configuration file.
|
||||
|
||||
Validates all fields described in ``docs/schema/action.schema.yaml``.
|
||||
|
||||
Create instances via the factory class methods:
|
||||
- :meth:`from_yaml` — parse a YAML string
|
||||
- :meth:`from_yaml_file` — parse a YAML file from disk
|
||||
"""
|
||||
|
||||
# ─── Identity ───────────────────────────────────────────
|
||||
schema_version: str = Field(
|
||||
"1",
|
||||
description="Schema version for forward compatibility.",
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
description=("Namespaced action name in namespace/name format."),
|
||||
)
|
||||
description: str = Field(
|
||||
...,
|
||||
description="Short description of the action.",
|
||||
)
|
||||
long_description: str | None = Field(
|
||||
default=None,
|
||||
description="Detailed multi-line description.",
|
||||
)
|
||||
|
||||
# ─── Lifecycle Actors ───────────────────────────────────
|
||||
strategy_actor: str = Field(
|
||||
...,
|
||||
description="Actor for the Strategize phase (namespaced).",
|
||||
)
|
||||
execution_actor: str = Field(
|
||||
...,
|
||||
description="Actor for the Execute phase (namespaced).",
|
||||
)
|
||||
estimation_actor: str | None = Field(
|
||||
default=None,
|
||||
description="Optional actor for cost/risk estimation.",
|
||||
)
|
||||
review_actor: str | None = Field(
|
||||
default=None,
|
||||
description="Optional actor for code review / QA.",
|
||||
)
|
||||
apply_actor: str | None = Field(
|
||||
default=None,
|
||||
description="Optional actor for the Apply phase.",
|
||||
)
|
||||
invariant_actor: str | None = Field(
|
||||
default=None,
|
||||
description="Optional actor for invariant reconciliation.",
|
||||
)
|
||||
|
||||
# ─── Completion Criteria ────────────────────────────────
|
||||
definition_of_done: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Testable criteria defining task completion.",
|
||||
)
|
||||
|
||||
# ─── Behaviour ──────────────────────────────────────────
|
||||
reusable: bool = Field(
|
||||
True,
|
||||
description="Whether the action persists after use.",
|
||||
)
|
||||
read_only: bool = Field(
|
||||
False,
|
||||
description="Restrict the action to read-only operations.",
|
||||
)
|
||||
state: str = Field(
|
||||
"available",
|
||||
description="Action state: available | archived.",
|
||||
)
|
||||
|
||||
# ─── Arguments ──────────────────────────────────────────
|
||||
arguments: list[ActionArgumentSchema] = Field(
|
||||
default_factory=list,
|
||||
description="Typed arguments accepted by the action.",
|
||||
)
|
||||
|
||||
# ─── Automation ─────────────────────────────────────────
|
||||
automation_profile: str | None = Field(
|
||||
default=None,
|
||||
description="Default automation profile for derived plans.",
|
||||
)
|
||||
|
||||
# ─── Invariants ─────────────────────────────────────────
|
||||
invariants: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Constraints carried as plan-level invariants.",
|
||||
)
|
||||
|
||||
# ─── Inputs Schema (Advanced) ──────────────────────────
|
||||
inputs_schema: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Optional JSON Schema for advanced input validation.",
|
||||
)
|
||||
|
||||
# ─── Pydantic Config ───────────────────────────────────
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
# ────────────────────────────────────────────────────────
|
||||
# Field validators
|
||||
# ────────────────────────────────────────────────────────
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_namespaced_name(cls, v: str) -> str:
|
||||
"""Validate that name follows namespace/name pattern."""
|
||||
if not NAMESPACED_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Invalid action name '{v}'. "
|
||||
"Names must follow the namespace/name format "
|
||||
"(e.g. 'local/my-action'). "
|
||||
"Only alphanumeric, hyphens, and underscores are allowed."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator(
|
||||
"strategy_actor",
|
||||
"execution_actor",
|
||||
)
|
||||
@classmethod
|
||||
def validate_required_actor_name(cls, v: str) -> str:
|
||||
"""Validate required actor names follow namespace/name."""
|
||||
if not NAMESPACED_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Invalid actor name '{v}'. "
|
||||
"Actor names must follow the namespace/name format "
|
||||
"(e.g. 'openai/gpt-4'). "
|
||||
"Only alphanumeric, hyphens, and underscores are allowed."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator(
|
||||
"estimation_actor",
|
||||
"review_actor",
|
||||
"apply_actor",
|
||||
"invariant_actor",
|
||||
)
|
||||
@classmethod
|
||||
def validate_optional_actor_name(cls, v: str | None) -> str | None:
|
||||
"""Validate optional actor names if provided."""
|
||||
if v is not None and not NAMESPACED_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Invalid actor name '{v}'. "
|
||||
"Actor names must follow the namespace/name format "
|
||||
"(e.g. 'local/estimator'). "
|
||||
"Only alphanumeric, hyphens, and underscores are allowed."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("state")
|
||||
@classmethod
|
||||
def validate_state(cls, v: str) -> str:
|
||||
"""Validate state is one of the allowed values."""
|
||||
v_lower = v.lower()
|
||||
if v_lower not in _VALID_STATES:
|
||||
valid = ", ".join(sorted(_VALID_STATES))
|
||||
raise ValueError(
|
||||
f"Invalid state '{v}'. "
|
||||
f"Allowed states: {valid}. "
|
||||
"Note: 'draft' is only used internally, "
|
||||
"not in YAML configs."
|
||||
)
|
||||
return v_lower
|
||||
|
||||
# ────────────────────────────────────────────────────────
|
||||
# Model-level validators
|
||||
# ────────────────────────────────────────────────────────
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_invariants(self) -> ActionConfigSchema:
|
||||
"""Trim, drop blanks, and de-duplicate invariants."""
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
for inv in self.invariants:
|
||||
trimmed = inv.strip()
|
||||
if trimmed and trimmed not in seen:
|
||||
seen.add(trimmed)
|
||||
cleaned.append(trimmed)
|
||||
self.invariants = cleaned
|
||||
return self
|
||||
|
||||
# ────────────────────────────────────────────────────────
|
||||
# Factory class methods
|
||||
# ────────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, yaml_string: str) -> ActionConfigSchema:
|
||||
"""Parse and validate an action YAML string.
|
||||
|
||||
Args:
|
||||
yaml_string: Raw YAML content.
|
||||
|
||||
Returns:
|
||||
Validated ``ActionConfigSchema`` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the YAML is not a mapping or is empty.
|
||||
pydantic.ValidationError: If schema validation fails.
|
||||
"""
|
||||
if yaml_string is None:
|
||||
raise ValueError("YAML string cannot be None. Provide a valid YAML string.")
|
||||
if not yaml_string.strip():
|
||||
raise ValueError(
|
||||
"YAML string is empty. Provide a valid action YAML configuration."
|
||||
)
|
||||
|
||||
raw = yaml.safe_load(yaml_string)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(
|
||||
f"Action YAML must be a mapping (key: value), got {type(raw).__name__}."
|
||||
)
|
||||
|
||||
normalized = _normalize_keys(raw)
|
||||
interpolated = _interpolate_env_vars(normalized)
|
||||
return cls.model_validate(interpolated)
|
||||
|
||||
@classmethod
|
||||
def from_yaml_file(cls, path: str | Path) -> ActionConfigSchema:
|
||||
"""Load and validate an action YAML file from disk.
|
||||
|
||||
Args:
|
||||
path: Path to the YAML file.
|
||||
|
||||
Returns:
|
||||
Validated ``ActionConfigSchema`` instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file does not exist.
|
||||
ValueError: If file content is invalid.
|
||||
pydantic.ValidationError: If schema validation fails.
|
||||
"""
|
||||
if path is None:
|
||||
raise ValueError(
|
||||
"File path cannot be None. Provide a valid path to an action YAML file."
|
||||
)
|
||||
|
||||
filepath = Path(path)
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Action YAML file not found: {filepath}. "
|
||||
"Check the file path and try again."
|
||||
)
|
||||
if not filepath.is_file():
|
||||
raise ValueError(
|
||||
f"Path is not a file: {filepath}. "
|
||||
"Provide a path to a YAML file, not a directory."
|
||||
)
|
||||
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
return cls.from_yaml(content)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Internal helpers
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalize_keys(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize camelCase keys to snake_case recursively.
|
||||
|
||||
Logs a warning for every camelCase key that is converted.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
snake_key = _CAMEL_TO_SNAKE.get(key, key)
|
||||
if snake_key != key:
|
||||
logger.warning(
|
||||
"Deprecated camelCase key '%s' normalized to '%s'. "
|
||||
"Please update your YAML to use snake_case.",
|
||||
key,
|
||||
snake_key,
|
||||
)
|
||||
|
||||
if isinstance(value, dict):
|
||||
result[snake_key] = _normalize_keys(value)
|
||||
elif isinstance(value, list):
|
||||
result[snake_key] = [
|
||||
_normalize_keys(item) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[snake_key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _interpolate_env_vars(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replace ``${VAR}`` references with environment variable values.
|
||||
|
||||
Only string values are interpolated. Missing environment variables
|
||||
are left as-is (no error) to allow deferred resolution.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, str):
|
||||
result[key] = _ENV_VAR_RE.sub(_env_replacer, value)
|
||||
elif isinstance(value, dict):
|
||||
result[key] = _interpolate_env_vars(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
(
|
||||
_interpolate_env_vars(item)
|
||||
if isinstance(item, dict)
|
||||
else (
|
||||
_ENV_VAR_RE.sub(_env_replacer, item)
|
||||
if isinstance(item, str)
|
||||
else item
|
||||
)
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _env_replacer(match: re.Match[str]) -> str:
|
||||
"""Replace a single ``${VAR}`` match with its env value."""
|
||||
var_name = match.group(1)
|
||||
return os.environ.get(var_name, match.group(0))
|
||||
Reference in New Issue
Block a user