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

This commit is contained in:
2026-02-13 18:49:02 +05:30
parent a9d3672e8a
commit 8da7bd1e56
14 changed files with 1902 additions and 19 deletions
+134
View File
@@ -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()
+227
View File
@@ -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.
+46
View File
@@ -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"
+59
View File
@@ -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
+50
View File
@@ -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"
+28
View File
@@ -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
+19
View File
@@ -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
+232
View File
@@ -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"
+458
View File
@@ -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
View File
@@ -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
+52
View File
@@ -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
+56
View File
@@ -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())
+15
View File
@@ -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"]
+481
View File
@@ -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))