feat: Jeff Day 23 combined — config service, phase reversion, CLI extensions #381

Merged
freemo merged 4 commits from feature/jeff-combined-day23 into master 2026-02-23 15:39:28 +00:00
27 changed files with 4972 additions and 78 deletions
+255
View File
@@ -0,0 +1,255 @@
"""ASV benchmarks for CLI extensions parsing overhead.
Measures the performance of:
- plan use with automation profile and invariant flags
- plan use with actor override flags and validation
- plan status / lifecycle-list rendering with extended fields
- action show rendering with optional actors/invariants/inputs_schema
"""
from __future__ import annotations
import importlib
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.action import app as action_app # noqa: E402
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
from cleveragents.domain.models.core.action import ( # noqa: E402
Action,
ActionState,
)
from cleveragents.domain.models.core.plan import ( # noqa: E402
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_runner = CliRunner()
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
def _mock_action(name: str = "local/bench-action") -> Action:
return Action(
namespaced_name=NamespacedName.parse(name),
description="Benchmark action",
definition_of_done="Benchmarks pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
estimation_actor="openai/gpt-4",
invariant_actor="anthropic/claude-3",
invariants=["No regressions", "Keep backward compat"],
inputs_schema={
"type": "object",
"properties": {"coverage": {"type": "integer"}},
},
state=ActionState.AVAILABLE,
created_at=datetime.now(),
updated_at=datetime.now(),
)
def _mock_plan(
name: str = "local/bench-plan",
*,
with_profile: bool = False,
with_invariants: bool = False,
) -> Plan:
now = datetime.now()
profile = None
if with_profile:
profile = AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
)
invariants: list[PlanInvariant] = []
if with_invariants:
invariants = [
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
PlanInvariant(text="Keep compat", source=InvariantSource.ACTION),
]
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse(name),
description="Benchmark plan",
definition_of_done="Benchmarks pass",
action_name="local/bench-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="my-project")],
arguments={},
arguments_order=[],
automation_profile=profile,
invariants=invariants,
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
class PlanUseWithProfileSuite:
"""Benchmark plan use with --automation-profile."""
def setup(self) -> None:
self._mock_service = MagicMock()
self._mock_service.get_action_by_name.return_value = _mock_action()
self._mock_service.use_action.return_value = _mock_plan(with_profile=True)
self._patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_plan_use_with_profile(self) -> None:
"""Benchmark plan use with automation profile."""
_runner.invoke(
plan_app,
[
"use",
"local/bench-action",
"--automation-profile",
"trusted",
],
)
class PlanUseWithInvariantsSuite:
"""Benchmark plan use with --invariant flags."""
def setup(self) -> None:
self._mock_service = MagicMock()
self._mock_service.get_action_by_name.return_value = _mock_action()
self._mock_service.use_action.return_value = _mock_plan(with_invariants=True)
self._patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_plan_use_with_invariants(self) -> None:
"""Benchmark plan use with invariant flags."""
_runner.invoke(
plan_app,
[
"use",
"local/bench-action",
"--invariant",
"No warnings",
"--invariant",
"Keep compat",
],
)
class PlanUseActorValidationSuite:
"""Benchmark plan use with actor override flags + validation."""
def setup(self) -> None:
self._mock_service = MagicMock()
self._mock_service.get_action_by_name.return_value = _mock_action()
self._mock_service.use_action.return_value = _mock_plan()
self._patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_plan_use_with_actor_overrides(self) -> None:
"""Benchmark plan use with all actor overrides."""
_runner.invoke(
plan_app,
[
"use",
"local/bench-action",
"--strategy-actor",
"openai/gpt-4",
"--execution-actor",
"anthropic/claude-3",
"--estimation-actor",
"openai/gpt-4",
"--invariant-actor",
"anthropic/claude-3",
],
)
class PlanStatusWithExtendedFieldsSuite:
"""Benchmark plan status with automation profile + invariants."""
def setup(self) -> None:
self._mock_service = MagicMock()
self._mock_service.list_plans.return_value = [
_mock_plan(f"local/plan-{i}", with_profile=True, with_invariants=True)
for i in range(20)
]
self._patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_plan_status_table(self) -> None:
"""Benchmark plan status table rendering with extended fields."""
_runner.invoke(plan_app, ["status"])
def time_plan_lifecycle_list_table(self) -> None:
"""Benchmark lifecycle-list table rendering with extended fields."""
_runner.invoke(plan_app, ["lifecycle-list"])
class ActionShowExtendedSuite:
"""Benchmark action show with optional actors/invariants/schema."""
def setup(self) -> None:
self._mock_service = MagicMock()
self._mock_service.get_action_by_name.return_value = _mock_action()
self._patcher = patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_action_show_rich(self) -> None:
"""Benchmark action show in rich format."""
_runner.invoke(action_app, ["show", "local/bench-action"])
def time_action_show_json(self) -> None:
"""Benchmark action show in JSON format."""
_runner.invoke(action_app, ["show", "local/bench-action", "--format", "json"])
+151
View File
@@ -0,0 +1,151 @@
"""ASV benchmarks for Config Service resolution chain performance.
Measures the performance of:
- Single key resolution (default, global, env var, CLI flag)
- Full registry resolution (resolve_all)
- Key validation and type coercion
"""
from __future__ import annotations
import importlib
import os
import shutil
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.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.application.services.config_service import ( # noqa: E402
ConfigService,
)
class ConfigResolutionDefaultSuite:
"""Benchmark default resolution (no overrides)."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_default(self) -> None:
"""Benchmark resolving a single key from defaults."""
self._service.resolve("core.log_level")
def time_resolve_default_verbose(self) -> None:
"""Benchmark resolving a single key with verbose chain."""
self._service.resolve("core.log_level", verbose=True)
class ConfigResolutionGlobalSuite:
"""Benchmark resolution from global config file."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
self._service.set_value("core.log_level", "DEBUG")
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_global(self) -> None:
"""Benchmark resolving from global config file."""
self._service.resolve("core.log_level")
class ConfigResolutionEnvSuite:
"""Benchmark resolution from env var."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
os.environ["CLEVERAGENTS_CORE_LOG_LEVEL"] = "WARNING"
def teardown(self) -> None:
os.environ.pop("CLEVERAGENTS_CORE_LOG_LEVEL", None)
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_env(self) -> None:
"""Benchmark resolving from environment variable."""
self._service.resolve("core.log_level")
class ConfigResolutionCLISuite:
"""Benchmark resolution with CLI flag."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_cli(self) -> None:
"""Benchmark resolving with CLI flag (highest priority)."""
self._service.resolve("core.log_level", cli_value="ERROR")
class ConfigResolutionBulkSuite:
"""Benchmark bulk operations."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_all(self) -> None:
"""Benchmark resolving all registered keys."""
self._service.resolve_all()
def time_registry_keys(self) -> None:
"""Benchmark listing all registered keys."""
ConfigService.registered_keys()
class ConfigValidationSuite:
"""Benchmark key validation and type coercion."""
def time_validate_known_key(self) -> None:
"""Benchmark validating a known key."""
ConfigService.validate_key("core.log_level")
def time_validate_type_str(self) -> None:
"""Benchmark type coercion for string."""
ConfigService.validate_type("core.log_level", "DEBUG")
def time_validate_type_int(self) -> None:
"""Benchmark type coercion for integer."""
ConfigService.validate_type("core.server_port", "8080")
def time_validate_type_bool(self) -> None:
"""Benchmark type coercion for boolean."""
ConfigService.validate_type("core.debug_enabled", "true")
+147
View File
@@ -0,0 +1,147 @@
"""ASV benchmarks for phase reversion state machine.
Measures the performance of:
- Plan.can_revert_to() validation
- PlanLifecycleService.revert_plan() overhead
- PlanLifecycleService.try_auto_revert_from_apply() overhead
- Decision recording during reversion
- Reversion loop guard check overhead
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
PlanPhase,
ProcessingState,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
PlanPhase,
ProcessingState,
)
def _make_service() -> PlanLifecycleService:
return PlanLifecycleService(settings=Settings())
def _create_plan_in_execute(
service: PlanLifecycleService,
action_name: str = "local/bench-action",
profile: str = "manual",
) -> str:
service.create_action(
name=action_name,
description="Benchmark action",
definition_of_done="Tests pass",
strategy_actor="local/strategist",
execution_actor="local/executor",
)
plan = service.use_action(action_name)
plan.automation_profile = AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
plan_id = plan.identity.plan_id
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
service.execute_plan(plan_id)
return plan_id
class CanRevertToSuite:
"""Benchmark Plan.can_revert_to() validation."""
def setup(self) -> None:
service = _make_service()
plan_id = _create_plan_in_execute(service, "local/bench-can-revert")
self.plan = service.get_plan(plan_id)
def time_can_revert_to_valid(self) -> None:
self.plan.can_revert_to(PlanPhase.STRATEGIZE)
def time_can_revert_to_invalid(self) -> None:
self.plan.can_revert_to(PlanPhase.APPLY)
class RevertPlanSuite:
"""Benchmark PlanLifecycleService.revert_plan() overhead."""
def setup(self) -> None:
self.service = _make_service()
self.counter = 0
def time_revert_plan(self) -> None:
self.counter += 1
name = f"local/bench-revert-{self.counter}"
plan_id = _create_plan_in_execute(self.service, name)
self.service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="benchmark")
class AutoRevertSuite:
"""Benchmark auto-reversion from constrained apply."""
def setup(self) -> None:
self.service = _make_service()
self.counter = 0
def time_auto_revert_from_apply(self) -> None:
self.counter += 1
name = f"local/bench-auto-{self.counter}"
self.service.create_action(
name=name,
description="Benchmark action",
definition_of_done="Tests pass",
strategy_actor="local/strategist",
execution_actor="local/executor",
)
plan = self.service.use_action(name)
plan.automation_profile = AutomationProfileRef(
profile_name="ci",
provenance=AutomationProfileProvenance.PLAN,
)
plan_id = plan.identity.plan_id
self.service.start_strategize(plan_id)
self.service.complete_strategize(plan_id)
self.service.execute_plan(plan_id)
self.service.start_execute(plan_id)
self.service.complete_execute(plan_id)
self.service.apply_plan(plan_id)
self.service.start_apply(plan_id)
plan = self.service.get_plan(plan_id)
plan.processing_state = ProcessingState.CONSTRAINED
self.service.try_auto_revert_from_apply(plan_id, "benchmark")
class LoopGuardSuite:
"""Benchmark loop guard check overhead."""
def setup(self) -> None:
self.service = _make_service()
plan_id = _create_plan_in_execute(self.service, "local/bench-guard")
self.plan = self.service.get_plan(plan_id)
def time_loop_guard_under_limit(self) -> None:
self.plan.reversion_count = 2
self.plan.can_revert_to(PlanPhase.STRATEGIZE)
def time_loop_guard_at_limit(self) -> None:
self.plan.reversion_count = 3
self.plan.can_revert_to(PlanPhase.STRATEGIZE)
+141
View File
@@ -0,0 +1,141 @@
# Action CLI Reference
The `agents action` command group manages actions (reusable plan templates)
in the CleverAgents v3 plan lifecycle.
## Commands
| Command | Description |
|--------------------------|------------------------------------------|
| `agents action create` | Create action from YAML config file |
| `agents action list` | List actions with optional filters |
| `agents action show` | Show full action details |
| `agents action archive` | Soft-delete (transition to `archived`) |
## `agents action create`
Create a new action from a YAML configuration file.
### Synopsis
```bash
agents action create --config <FILE>
```
Actions are created **exclusively** via YAML configuration files. Legacy
inline flags (`--name`, `--strategy-actor`, etc.) are not supported.
### YAML Configuration File
```yaml
name: local/code-coverage
description: Increase code coverage
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: |
Line coverage reaches the target percentage
for all modules under src/.
estimation_actor: openai/gpt-4 # optional
invariant_actor: anthropic/claude-3 # optional
automation_profile: trusted # optional
invariants: # optional
- "No reduction in existing coverage"
- "All new code must have tests"
inputs_schema: # optional JSON Schema
type: object
properties:
target_coverage:
type: integer
minimum: 0
maximum: 100
arguments: # optional
- name: target_coverage
type: integer
required: true
description: Target coverage percentage
```
## `agents action list`
List all actions with optional filtering.
### Synopsis
```bash
agents action list [REGEX] [OPTIONS]
```
### Options
| Flag | Description |
|---------------------|------------------------------------------|
| `--namespace`, `-n` | Filter by namespace |
| `--state`, `-s` | Filter by state (`available`, `archived`)|
| `--format`, `-f` | Output format |
### Examples
```bash
agents action list
agents action list --namespace local
agents action list --state available
agents action list "code-.*"
agents action list --format json
```
## `agents action show`
Show details for an action including all extended fields.
### Synopsis
```bash
agents action show <NAMESPACED_NAME> [--format FORMAT]
```
### Output Fields
The show command displays all action fields:
- **Namespaced Name**: Full `namespace/name` identifier
- **Short Name**: Name portion only
- **Description**: Action description
- **State**: `available` or `archived`
- **Strategy Actor**: Actor for Strategize phase
- **Execution Actor**: Actor for Execute phase
- **Estimation Actor**: Optional estimation actor (shown when set)
- **Invariant Actor**: Optional invariant reconciliation actor (shown when set)
- **Reusable**: Whether action stays available after use
- **Read Only**: Whether action only reads
- **Definition of Done**: Completion criteria
- **Arguments**: Typed argument definitions
- **Invariants**: Constraint list (shown when present)
- **Inputs Schema**: JSON Schema dict (shown when present)
- **Automation Profile**: Default profile name (shown when set)
- **Created**: Creation timestamp
### Examples
```bash
agents action show local/code-coverage
agents action show local/code-coverage --format json
agents action show local/code-coverage --format yaml
```
## `agents action archive`
Archive an action (soft delete). Archived actions cannot be used but are
preserved for history.
### Synopsis
```bash
agents action archive <NAMESPACED_NAME> [--format FORMAT]
```
### Examples
```bash
agents action archive local/old-action
agents action archive local/old-action --format json
```
+151
View File
@@ -0,0 +1,151 @@
# Configuration Resolution Chain
CleverAgents uses a multi-level configuration resolution chain that determines
the effective value for each configuration key. This document describes the
resolution order, all registered configuration keys, their types, defaults,
and environment variable mappings.
## Resolution Order
Values are resolved from **highest to lowest** priority. The first level that
provides a non-`None` value wins:
| Priority | Level | Description |
|----------|------------------|------------------------------------------------------|
| 1 | **CLI flag** | Value passed explicitly via `--key=value` on the CLI |
| 2 | **Env var** | Environment variable (`CLEVERAGENTS_<SECTION>_<KEY>`) |
| 3 | **Project scope**| Per-project override in `[project."<name>"]` TOML table |
| 4 | **Global config**| Value in `~/.cleveragents/config.toml` |
| 5 | **Default** | Built-in default from the key registry |
### Example
```
$ export CLEVERAGENTS_CORE_LOG_LEVEL=WARNING
$ agents config set core.log_level DEBUG # writes to global config
$ agents config get core.log_level
# -> WARNING (env var wins over global config)
```
## Environment Variable Convention
All registered keys map to environment variables following the pattern:
```
CLEVERAGENTS_<SECTION>_<KEY>
```
Where `<SECTION>` and `<KEY>` are uppercased. For example:
- `core.log_level` -> `CLEVERAGENTS_CORE_LOG_LEVEL`
- `plan.max_retries` -> `CLEVERAGENTS_PLAN_MAX_RETRIES`
- `provider.temperature` -> `CLEVERAGENTS_PROVIDER_TEMPERATURE`
## Configuration Keys
### `core.*` — Core Runtime
| Key | Type | Default | Env Var | Project-Scopable | Description |
|----------------------|--------|------------------------------|------------------------------------|-------------------|-------------------------------|
| `core.log_level` | `str` | `INFO` | `CLEVERAGENTS_CORE_LOG_LEVEL` | Yes | Logging verbosity level |
| `core.debug_enabled` | `bool` | `false` | `CLEVERAGENTS_CORE_DEBUG_ENABLED` | Yes | Enable debug mode |
| `core.env` | `str` | `development` | `CLEVERAGENTS_CORE_ENV` | Yes | Runtime environment name |
| `core.data_dir` | `str` | `data` | `CLEVERAGENTS_CORE_DATA_DIR` | Yes | Base data directory path |
| `core.database_url` | `str` | `sqlite:///cleveragents.db` | `CLEVERAGENTS_CORE_DATABASE_URL` | No | Primary database URL |
| `core.server_host` | `str` | `0.0.0.0` | `CLEVERAGENTS_CORE_SERVER_HOST` | No | Server bind host |
| `core.server_port` | `int` | `8080` | `CLEVERAGENTS_CORE_SERVER_PORT` | No | Server bind port |
### `plan.*` — Plan Execution
| Key | Type | Default | Env Var | Project-Scopable | Description |
|------------------------|--------|---------|--------------------------------------|-------------------|---------------------------------------|
| `plan.auto_apply` | `bool` | `false` | `CLEVERAGENTS_PLAN_AUTO_APPLY` | Yes | Auto-apply plans on completion |
| `plan.max_retries` | `int` | `3` | `CLEVERAGENTS_PLAN_MAX_RETRIES` | Yes | Maximum plan retry count |
| `plan.timeout_seconds` | `int` | `300` | `CLEVERAGENTS_PLAN_TIMEOUT_SECONDS` | Yes | Plan execution timeout in seconds |
### `provider.*` — LLM Provider
| Key | Type | Default | Env Var | Project-Scopable | Description |
|--------------------------|---------|---------|----------------------------------------|-------------------|-------------------------------|
| `provider.default_provider` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_PROVIDER` | No | Default LLM provider name |
| `provider.default_model` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_MODEL` | No | Default LLM model name |
| `provider.temperature` | `float` | `0.7` | `CLEVERAGENTS_PROVIDER_TEMPERATURE` | Yes | LLM sampling temperature |
| `provider.max_tokens` | `int` | `4096` | `CLEVERAGENTS_PROVIDER_MAX_TOKENS` | Yes | Max tokens per LLM response |
### `sandbox.*` — Sandbox Isolation
| Key | Type | Default | Env Var | Project-Scopable | Description |
|------------------------|--------|----------------|--------------------------------------|-------------------|---------------------------------------|
| `sandbox.strategy` | `str` | `git_worktree` | `CLEVERAGENTS_SANDBOX_STRATEGY` | Yes | Default sandbox strategy |
| `sandbox.auto_cleanup` | `bool` | `true` | `CLEVERAGENTS_SANDBOX_AUTO_CLEANUP` | Yes | Auto-clean sandbox after use |
| `sandbox.max_age_hours`| `int` | `48` | `CLEVERAGENTS_SANDBOX_MAX_AGE_HOURS` | Yes | Max sandbox age before cleanup |
### `context.*` — Context Management
| Key | Type | Default | Env Var | Project-Scopable | Description |
|--------------------------------|--------|----------|-------------------------------------------------|-------------------|--------------------------------------------|
| `context.max_files` | `int` | `100` | `CLEVERAGENTS_CONTEXT_MAX_FILES` | Yes | Maximum context files per session |
| `context.max_tokens` | `int` | `128000` | `CLEVERAGENTS_CONTEXT_MAX_TOKENS` | Yes | Maximum context token window |
| `context.auto_include_gitignore` | `bool` | `true` | `CLEVERAGENTS_CONTEXT_AUTO_INCLUDE_GITIGNORE` | Yes | Auto-include gitignore in context filtering |
### `index.*` — Vector Store Indexing
| Key | Type | Default | Env Var | Project-Scopable | Description |
|--------------------------|--------|---------|------------------------------------------|-------------------|---------------------------------|
| `index.enabled` | `bool` | `false` | `CLEVERAGENTS_INDEX_ENABLED` | Yes | Enable vector-store indexing |
| `index.backend` | `str` | `faiss` | `CLEVERAGENTS_INDEX_BACKEND` | Yes | Vector store backend name |
| `index.embedding_model` | `str` | `fake` | `CLEVERAGENTS_INDEX_EMBEDDING_MODEL` | Yes | Embedding model for indexing |
| `index.embedding_dimension` | `int` | `1536` | `CLEVERAGENTS_INDEX_EMBEDDING_DIMENSION` | Yes | Embedding vector dimension |
## Validation Rules
### Unknown Keys
Attempting to get or set an unregistered key will produce an actionable error:
```
ValueError: Unknown configuration key: 'bogus.key'.
Valid keys include: context.auto_include_gitignore, context.max_files, ...
```
### Type Mismatches
Values are coerced to the registered type. If coercion fails, an actionable
error is raised:
```
TypeError: Type mismatch for key 'core.server_port': expected int,
got str with value 'not_a_number'.
```
Boolean coercion accepts: `true`/`false`, `1`/`0`, `yes`/`no` (case-insensitive).
## TOML File Location
The global configuration file is stored at:
```
~/.cleveragents/config.toml
```
Parent directories are created automatically on first write. Project-scoped
overrides live under `[project."<name>"]` tables within the same file.
## Verbose Mode
When `--verbose` is passed to `config get`, the full resolution chain is
displayed, showing which level provided (or could provide) the value:
```
$ agents config get core.log_level --verbose
Key: core.log_level
Value: INFO
Source: default
Resolution chain (highest -> lowest priority):
cli_flag -> (not set)
env_var -> (not set) [CLEVERAGENTS_CORE_LOG_LEVEL]
project -> (not set)
global -> (not set) [~/.cleveragents/config.toml]
default -> INFO <-- active
```
+78
View File
@@ -0,0 +1,78 @@
# Phase Reversion State Machine
Phase reversion allows plans to return to an earlier lifecycle phase
when the current strategy proves unworkable. This enables adaptive
re-planning without discarding accumulated context.
## Reversion Paths
| Source Phase | Target Phase | Trigger |
|-------------|-------------|---------|
| Execute | Strategize | Validation failures block apply; constraints too restrictive |
| Apply (constrained) | Strategize | Cannot proceed within current strategy constraints |
## Automation Profile Thresholds
Automatic reversion is gated by the plan's resolved `AutomationProfile`:
| Threshold | Controls |
|-----------|----------|
| `auto_reversion_from_apply` | Auto-revert when Apply is constrained. `< 1.0` = automatic, `1.0` = manual only |
| `auto_strategy_revision` | Auto-revert from Execute to Strategize. `< 1.0` = automatic, `1.0` = manual only |
Built-in profile defaults:
| Profile | `auto_reversion_from_apply` | `auto_strategy_revision` |
|---------|---------------------------|------------------------|
| `manual` | 1.0 (never auto) | 1.0 |
| `review` | 1.0 | 1.0 |
| `trusted` | 1.0 | 1.0 |
| `auto` | 1.0 | 0.0 |
| `ci` | 0.0 (always auto) | 0.0 |
| `full-auto` | 0.0 | 0.0 |
## Loop Guard
Every plan tracks a `reversion_count` field. Each reversion increments
this counter. The maximum number of reversions per plan execution is
**3** (`Plan.MAX_REVERSIONS`). Once this limit is reached, both
automatic and manual reversions are blocked.
This prevents infinite strategize-execute-revert cycles when the
underlying problem cannot be resolved through re-planning alone.
## Decision Recording
Each reversion is recorded as a `reversion` decision in the plan's
`decisions` list with the following fields:
| Field | Type | Description |
|-------|------|-------------|
| `type` | `str` | Always `"reversion"` |
| `source_phase` | `str` | Phase the plan was in before reversion |
| `target_phase` | `str` | Phase the plan reverted to |
| `reason` | `str` | Human-readable reason for the reversion |
| `timestamp` | `str` | ISO 8601 timestamp |
| `reversion_number` | `int` | 1-based reversion counter |
## Manual Reversion
Plans can be manually reverted via the CLI:
```bash
agents plan revert <plan_id> --to-phase strategize --reason "constraints too strict"
```
Manual reversion is subject to the same loop guard (max 3 reversions)
but ignores automation profile thresholds.
## State Reset on Reversion
When a plan is reverted:
1. The `phase` is set to the target phase.
2. The `processing_state` is reset to `QUEUED`.
3. The `error_message` is cleared.
4. The `reversion_count` is incremented.
5. The `updated_at` timestamp is refreshed.
6. Context and sandbox state are preserved.
+160
View File
@@ -0,0 +1,160 @@
# Plan CLI Reference
The `agents plan` command group manages plans in the CleverAgents v3 plan lifecycle.
## Commands
| Command | Description |
|--------------------------------|-----------------------------------------|
| `agents plan use` | Create plan from action + project(s) |
| `agents plan lifecycle-list` | List plans with optional filters |
| `agents plan status` | Show plan status / details |
| `agents plan execute` | Transition to Execute phase |
| `agents plan lifecycle-apply` | Transition to Apply phase |
| `agents plan cancel` | Cancel a non-terminal plan |
| `agents plan diff` | Show ChangeSet as unified diff |
| `agents plan artifacts` | Show ChangeSet ID, sandbox refs, summary|
## `agents plan use`
Create a plan from an action template and one or more projects.
### Synopsis
```bash
agents plan use <ACTION_NAME> [PROJECTS...] [OPTIONS]
```
### Options
| Flag | Description |
|-------------------------|-------------------------------------------------------|
| `--project`, `-p` | Project name (repeatable for multiple projects) |
| `--arg`, `-a` | Argument value in `name=value` format (repeatable) |
| `--automation-profile` | Automation profile name (e.g., `trusted`, `manual`) |
| `--invariant` | Invariant constraint text (repeatable) |
| `--strategy-actor` | Override strategy actor (`namespace/name` format) |
| `--execution-actor` | Override execution actor (`namespace/name` format) |
| `--estimation-actor` | Override estimation actor (`namespace/name` format) |
| `--invariant-actor` | Override invariant reconciliation actor |
| `--format`, `-f` | Output format: json, yaml, plain, table, rich |
### Actor Override Validation
All actor override flags require **namespaced format**: `namespace/name`
(e.g., `openai/gpt-4`, `anthropic/claude-3`). Invalid formats are
rejected with a descriptive error.
### Examples
```bash
# Basic usage with one project
agents plan use local/code-coverage my-project --arg target_coverage=80
# Multiple projects
agents plan use local/lint proj-1 proj-2
# With automation profile and invariants
agents plan use local/refactor my-project \
--automation-profile trusted \
--invariant "No new warnings" \
--invariant "Maintain backward compatibility"
# With actor overrides
agents plan use local/code-coverage my-project \
--strategy-actor openai/gpt-4 \
--execution-actor anthropic/claude-3 \
--estimation-actor openai/gpt-4
# JSON output
agents plan use local/lint my-project --format json
```
## `agents plan status`
Show status of one or all v3 lifecycle plans.
### Synopsis
```bash
agents plan status [PLAN_ID] [--format FORMAT]
```
When called without a plan ID, displays a summary table of all active
plans including automation profile and invariant count columns.
### Output Fields
- **ID**: Truncated plan ULID
- **Name**: Namespaced plan name
- **Phase**: Current lifecycle phase
- **State**: Processing state
- **Profile**: Automation profile name (if set)
- **Invariants**: Count of attached invariants
- **Terminal**: Whether the plan is in a terminal state
## `agents plan lifecycle-list`
List v3 lifecycle plans with optional filtering.
### Synopsis
```bash
agents plan lifecycle-list [REGEX] [OPTIONS]
```
### Options
| Flag | Description |
|-------------------------|--------------------------------------------------|
| `--phase` | Filter by phase (strategize, execute, apply) |
| `--state` | Filter by processing state |
| `--processing-state` | Alias for `--state` |
| `--project`, `-p` | Filter by project name |
| `--action` | Filter by action name |
| `--format`, `-f` | Output format |
### Output Fields
Includes **Profile** and **Invariants** columns in all output formats
when present on the plan.
## `agents plan execute`
Transition a plan from Strategize to Execute phase.
```bash
agents plan execute [PLAN_ID] [--format FORMAT]
```
## `agents plan lifecycle-apply`
Transition a plan from Execute to Apply phase.
```bash
agents plan lifecycle-apply [PLAN_ID] [--format FORMAT]
```
## `agents plan cancel`
Cancel a non-terminal plan.
```bash
agents plan cancel PLAN_ID [--reason REASON] [--format FORMAT]
```
## `agents plan diff`
Show ChangeSet as unified diff for a plan.
```bash
agents plan diff PLAN_ID [--correction ID] [--format FORMAT]
```
## `agents plan artifacts`
Show plan artifacts including ChangeSet ID and sandbox references.
```bash
agents plan artifacts PLAN_ID [--format FORMAT]
```
+163
View File
@@ -0,0 +1,163 @@
Feature: CLI extensions for plan and action commands
As a developer using the CleverAgents CLI
I want automation profile, invariant, and actor override flags
So that I can customize plan creation and view extended details
Background:
Given a cli extensions test runner
And a cli extensions mocked lifecycle service
# --- plan use: automation profile ---
Scenario: Plan use with --automation-profile persists to plan metadata
Given a cli extensions action exists
When I run plan use with automation profile flag "trusted"
Then the cli extensions plan use should succeed
And the cli extensions plan should have automation profile "trusted"
Scenario: Plan use with invalid automation profile is rejected
Given a cli extensions action exists
When I run plan use with automation profile flag "nonexistent-profile"
Then the cli extensions plan use should fail
# --- plan use: invariant flags ---
Scenario: Plan use with --invariant flags persists to plan metadata
Given a cli extensions action exists
When I run plan use with invariant flags "No warnings" and "Keep compat"
Then the cli extensions plan use should succeed
And the cli extensions service received invariants "No warnings" and "Keep compat"
Scenario: Plan use with single invariant flag
Given a cli extensions action exists
When I run plan use with single invariant "All tests must pass"
Then the cli extensions plan use should succeed
And the cli extensions service received single invariant "All tests must pass"
# --- plan use: actor override validation (valid namespaced) ---
Scenario: Plan use with valid namespaced strategy-actor
Given a cli extensions action exists
When I run plan use with strategy actor flag "openai/gpt-4"
Then the cli extensions plan use should succeed
And the cli extensions plan has strategy actor "openai/gpt-4"
Scenario: Plan use with valid namespaced execution-actor
Given a cli extensions action exists
When I run plan use with execution actor flag "anthropic/claude-3"
Then the cli extensions plan use should succeed
And the cli extensions plan has execution actor "anthropic/claude-3"
Scenario: Plan use with valid namespaced estimation-actor
Given a cli extensions action exists
When I run plan use with estimation actor flag "openai/gpt-4"
Then the cli extensions plan use should succeed
And the cli extensions plan has estimation actor "openai/gpt-4"
Scenario: Plan use with valid namespaced invariant-actor
Given a cli extensions action exists
When I run plan use with invariant actor flag "openai/gpt-4"
Then the cli extensions plan use should succeed
And the cli extensions plan has invariant actor "openai/gpt-4"
# --- plan use: actor override validation (invalid format) ---
Scenario: Plan use rejects invalid strategy-actor format
Given a cli extensions action exists
When I run plan use with strategy actor flag "bad-format"
Then the cli extensions plan use should fail with actor validation error
Scenario: Plan use rejects invalid execution-actor format
Given a cli extensions action exists
When I run plan use with execution actor flag "no_slash"
Then the cli extensions plan use should fail with actor validation error
Scenario: Plan use rejects invalid estimation-actor format
Given a cli extensions action exists
When I run plan use with estimation actor flag "UPPERCASE/gpt"
Then the cli extensions plan use should fail with actor validation error
Scenario: Plan use rejects invalid invariant-actor format
Given a cli extensions action exists
When I run plan use with invariant actor flag "spaces are bad"
Then the cli extensions plan use should fail with actor validation error
# --- plan status: showing automation_profile ---
Scenario: Plan status shows automation profile in table
Given cli extensions plans exist with automation profile "trusted"
When I run cli extensions plan status
Then the cli extensions status output should contain "trusted"
Scenario: Plan status shows automation profile in JSON
Given cli extensions plans exist with automation profile "manual"
When I run cli extensions plan status with format "json"
Then the cli extensions status json output should contain automation profile "manual"
# --- plan list: showing invariants when present ---
Scenario: Plan lifecycle-list shows invariant count in table
Given cli extensions plans exist with invariants
When I run cli extensions plan lifecycle-list
Then the cli extensions list output should contain invariant count
Scenario: Plan lifecycle-list shows invariants in JSON
Given cli extensions plans exist with invariants
When I run cli extensions plan lifecycle-list with format "json"
Then the cli extensions list json output should contain invariants
# --- action show: optional actors ---
Scenario: Action show displays estimation actor when present
Given a cli extensions action with estimation actor "openai/gpt-4"
When I run cli extensions action show
Then the cli extensions action output should contain "Estimation Actor"
And the cli extensions action output should contain "openai/gpt-4"
Scenario: Action show displays invariant actor when present
Given a cli extensions action with invariant actor "anthropic/claude-3"
When I run cli extensions action show
Then the cli extensions action output should contain "Invariant Actor"
And the cli extensions action output should contain "anthropic/claude-3"
Scenario: Action show displays invariants when present
Given a cli extensions action with invariants
When I run cli extensions action show
Then the cli extensions action output should contain "Invariants"
Scenario: Action show displays inputs schema when present
Given a cli extensions action with inputs schema
When I run cli extensions action show
Then the cli extensions action output should contain "Inputs Schema"
Scenario: Action show in JSON includes optional actors
Given a cli extensions action with estimation actor "openai/gpt-4"
When I run cli extensions action show with format "json"
Then the cli extensions action json should contain "estimation_actor"
Scenario: Action show in JSON includes inputs schema
Given a cli extensions action with inputs schema
When I run cli extensions action show with format "json"
Then the cli extensions action json should contain "inputs_schema"
# --- plan use: automation_profile + invariants combined ---
Scenario: Plan use with automation profile and invariants together
Given a cli extensions action exists
When I run plan use with profile "trusted" and invariant "No regressions"
Then the cli extensions plan use should succeed
And the cli extensions plan should have automation profile "trusted"
And the cli extensions service received single invariant "No regressions"
# --- validate_namespaced_actor function ---
Scenario: validate_namespaced_actor accepts valid names
Then validate_namespaced_actor accepts "openai/gpt-4"
And validate_namespaced_actor accepts "anthropic/claude-3"
And validate_namespaced_actor accepts "local/my-actor"
Scenario: validate_namespaced_actor rejects invalid names
Then validate_namespaced_actor rejects "no-slash"
And validate_namespaced_actor rejects "UPPER/case"
And validate_namespaced_actor rejects "/leading-slash"
And validate_namespaced_actor rejects "trail/ing/"
+182
View File
@@ -0,0 +1,182 @@
Feature: Config service with multi-level resolution
As a developer
I want a config service that resolves values through a precedence chain
So that CLI flags, env vars, project scope, global config, and defaults all work correctly
Background:
Given a clean config service test environment
# --- Resolution levels ---
Scenario: Default value is returned when nothing else is set
When I resolve config key "core.log_level"
Then the resolved value should be "INFO"
And the resolved source should be "default"
Scenario: Global config file overrides default
Given I have written global config key "core.log_level" with value "DEBUG"
When I resolve config key "core.log_level"
Then the resolved value should be "DEBUG"
And the resolved source should be "global"
Scenario: Environment variable overrides global config
Given I have written global config key "core.log_level" with value "DEBUG"
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
When I resolve config key "core.log_level"
Then the resolved value should be "WARNING"
And the resolved source should be "env_var"
Scenario: CLI flag overrides environment variable
Given the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
When I resolve config key "core.log_level" with CLI value "ERROR"
Then the resolved value should be "ERROR"
And the resolved source should be "cli_flag"
Scenario: Project-scoped value overrides global config
Given I have written global config key "core.log_level" with value "DEBUG"
And I have written project "myproj" config key "core.log_level" with value "TRACE"
When I resolve config key "core.log_level" for project "myproj"
Then the resolved value should be "TRACE"
And the resolved source should be "project"
Scenario: Env var still overrides project-scoped value
Given I have written project "myproj" config key "core.log_level" with value "TRACE"
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "ERROR"
When I resolve config key "core.log_level" for project "myproj"
Then the resolved value should be "ERROR"
And the resolved source should be "env_var"
# --- Env var interpolation ---
Scenario: Env var name matches CLEVERAGENTS convention
When I look up the env var for key "core.log_level"
Then the env var name should be "CLEVERAGENTS_CORE_LOG_LEVEL"
Scenario: Env var interpolation for plan keys
When I look up the env var for key "plan.max_retries"
Then the env var name should be "CLEVERAGENTS_PLAN_MAX_RETRIES"
Scenario: Env var interpolation for provider keys
When I look up the env var for key "provider.temperature"
Then the env var name should be "CLEVERAGENTS_PROVIDER_TEMPERATURE"
# --- Unknown key rejection ---
Scenario: Resolving an unknown key raises an error
When I attempt to resolve unknown key "bogus.nonexistent"
Then the config ValueError message should contain "Unknown configuration key"
Scenario: Validating an unknown key raises an error
When I attempt to validate unknown key "invalid.key"
Then the config ValueError message should contain "Unknown configuration key"
# --- Type validation ---
Scenario: Integer value is coerced from string
Given the environment variable "CLEVERAGENTS_CORE_SERVER_PORT" is set to "9090"
When I resolve config key "core.server_port"
Then the resolved value should be integer 9090
Scenario: Boolean value true is coerced from string
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "true"
When I resolve config key "core.debug_enabled"
Then the resolved value should be boolean true
Scenario: Boolean value false is coerced from string
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "false"
When I resolve config key "core.debug_enabled"
Then the resolved value should be boolean false
Scenario: Float value is coerced from string
Given the environment variable "CLEVERAGENTS_PROVIDER_TEMPERATURE" is set to "0.9"
When I resolve config key "provider.temperature"
Then the resolved value should be float 0.9
Scenario: Invalid boolean string raises TypeError
When I attempt to validate type for key "core.debug_enabled" with value "notabool"
Then the config TypeError message should contain "Cannot convert"
Scenario: Invalid integer string raises TypeError
When I attempt to validate type for key "core.server_port" with value "notanumber"
Then the config TypeError message should contain "Type mismatch"
# --- Verbose resolution chain ---
Scenario: Verbose mode returns full resolution chain
When I resolve config key "core.log_level" with verbose mode
Then the resolution chain should have 5 entries
And the chain should include source "cli_flag"
And the chain should include source "env_var"
And the chain should include source "project"
And the chain should include source "global"
And the chain should include source "default"
# --- Key registry ---
Scenario: Registry contains core keys
Then the registry should contain key "core.log_level"
And the registry should contain key "core.debug_enabled"
And the registry should contain key "core.env"
Scenario: Registry contains plan keys
Then the registry should contain key "plan.auto_apply"
And the registry should contain key "plan.max_retries"
And the registry should contain key "plan.timeout_seconds"
Scenario: Registry contains provider keys
Then the registry should contain key "provider.default_provider"
And the registry should contain key "provider.default_model"
And the registry should contain key "provider.temperature"
Scenario: Registry contains sandbox keys
Then the registry should contain key "sandbox.strategy"
And the registry should contain key "sandbox.auto_cleanup"
And the registry should contain key "sandbox.max_age_hours"
Scenario: Registry contains context keys
Then the registry should contain key "context.max_files"
And the registry should contain key "context.max_tokens"
Scenario: Registry contains index keys
Then the registry should contain key "index.enabled"
And the registry should contain key "index.backend"
# --- TOML file management ---
Scenario: Writing and reading TOML config roundtrips
Given I write config data with key "core.log_level" and value "TRACE"
When I read the config file
Then the config data should contain key "core.log_level" with value "TRACE"
Scenario: Config directory is auto-created
When I write config data to a new directory
Then the config directory should exist
Scenario: Non-project-scopable keys ignore project scope
Given I have written project "proj" config key "core.database_url" with value "sqlite:///other.db"
When I resolve config key "core.database_url" for project "proj"
Then the resolved source should be "default"
# --- Resolve all ---
Scenario: Resolve all returns all registered keys
When I resolve all keys
Then the result should contain all registered keys
# --- Set value ---
Scenario: Set value persists to TOML file
When I set config value "core.log_level" to "CRITICAL"
And I read the config file
Then the config data should contain key "core.log_level" with value "CRITICAL"
# --- Entry lookup ---
Scenario: Get entry returns ConfigEntry for valid key
When I get entry for key "core.log_level"
Then the entry should have section "core"
And the entry should have python type "str"
Scenario: Get entry returns None for unknown key
When I get entry for key "nonexistent.key"
Then the entry should be None
+110
View File
@@ -0,0 +1,110 @@
Feature: Phase Reversion State Machine
As a plan lifecycle manager
I want to revert plans to earlier phases
So that plans can be re-strategized when constraints prove unworkable
Background:
Given a plan lifecycle service is initialized
Scenario: Auto-reversion from constrained Apply to Strategize
Given an action "local/test-action" exists
And a plan is created from action "local/test-action"
And the plan is advanced to Apply phase with constrained state
And the plan has automation profile "ci" allowing auto-reversion
When auto-reversion from apply is attempted with reason "constraints too strict"
Then the plan should be in Strategize phase
And the plan processing state should be queued
And the plan reversion count should be 1
And a reversion decision should be recorded with source "apply" and target "strategize"
Scenario: Manual reversion via revert_plan method
Given an action "local/manual-revert" exists
And a plan is created from action "local/manual-revert"
And the plan is advanced to Execute phase
When the plan is manually reverted to Strategize with reason "need different strategy"
Then the plan should be in Strategize phase
And the plan processing state should be queued
And the plan reversion count should be 1
And a reversion decision should be recorded with reason "need different strategy"
Scenario: Loop guard prevents more than 3 reversions
Given an action "local/loop-guard" exists
And a plan is created from action "local/loop-guard"
And the plan has been reverted 3 times
When a reversion is attempted on the maxed-out plan
Then a PlanError should be raised about max reversions
Scenario: Auto-reversion blocked by manual profile threshold
Given an action "local/profile-gate" exists
And a plan is created from action "local/profile-gate"
And the plan is advanced to Apply phase with constrained state
And the plan has automation profile "manual" blocking auto-reversion
When auto-reversion from apply is attempted with reason "profile blocks it"
Then the plan should remain in Apply phase
And the plan processing state should be constrained
Scenario: Auto-reversion from Execute to Strategize
Given an action "local/exec-revert" exists
And a plan is created from action "local/exec-revert"
And the plan is advanced to Execute phase
And the plan has automation profile "ci" allowing auto-reversion
When auto-reversion from execute is attempted with reason "validation failures"
Then the plan should be in Strategize phase
And the plan processing state should be queued
And the plan reversion count should be 1
Scenario: Invalid reversion on terminal plan (applied)
Given an action "local/terminal-plan" exists
And a plan is created from action "local/terminal-plan"
And the plan has reached applied terminal state
When a reversion is attempted on the terminal plan
Then a PlanError should be raised about terminal state
Scenario: Invalid reversion to wrong phase
Given an action "local/wrong-phase" exists
And a plan is created from action "local/wrong-phase"
And the plan is in Strategize phase
When a reversion to Apply phase is attempted
Then an InvalidPhaseTransitionError should be raised
Scenario: Reversion preserves plan context
Given an action "local/context-preserve" exists
And a plan is created from action "local/context-preserve" with arguments
And the plan is advanced to Execute phase
When the plan is manually reverted to Strategize with reason "context check"
Then the plan arguments should be preserved
And the plan project links should be preserved
And the plan invariants should be preserved
Scenario: Multiple reversions tracked in decisions list
Given an action "local/multi-revert" exists
And a plan is created from action "local/multi-revert"
And the plan is advanced to Execute phase
When the plan is manually reverted to Strategize with reason "first reversion"
And the plan is re-advanced to Execute phase
And the plan is manually reverted to Strategize with reason "second reversion"
Then the plan reversion count should be 2
And there should be 2 reversion decisions recorded
Scenario: can_revert_to validates reversion paths
Given an action "local/revert-validation" exists
And a plan is created from action "local/revert-validation"
Then the plan in Strategize phase cannot revert to Apply
And the plan in Strategize phase cannot revert to Action
And the plan in Execute phase can revert to Strategize
And the plan in Apply phase can revert to Strategize
Scenario: Auto-reversion from Execute blocked by profile threshold
Given an action "local/exec-blocked" exists
And a plan is created from action "local/exec-blocked"
And the plan is advanced to Execute phase
And the plan has automation profile "manual" blocking auto-reversion
When auto-reversion from execute is attempted with reason "blocked by profile"
Then the plan should remain in Execute phase
Scenario: Cancelled plan cannot be reverted
Given an action "local/cancelled-plan" exists
And a plan is created from action "local/cancelled-plan"
And the plan has been cancelled
When a reversion is attempted on the cancelled plan
Then a PlanError should be raised about terminal state
+567
View File
@@ -0,0 +1,567 @@
"""Step definitions for CLI extensions feature (plan + action)."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.action import app as action_app
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.cli.commands.plan import validate_namespaced_actor
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
def _make_plan(
*,
name: str = "local/test-plan",
action_name: str = "local/test-action",
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
arguments: dict[str, object] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
strategy_actor: str | None = "openai/gpt-4",
execution_actor: str | None = "openai/gpt-4",
estimation_actor: str | None = None,
invariant_actor: str | None = None,
) -> Plan:
"""Create a Plan instance for CLI extension tests."""
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse(name),
description="Test plan description",
definition_of_done="All tests pass",
action_name=action_name,
phase=phase,
processing_state=state,
project_links=project_links or [],
arguments=dict(arguments) if arguments else {},
arguments_order=[],
automation_profile=automation_profile,
invariants=invariants or [],
strategy_actor=strategy_actor,
execution_actor=execution_actor,
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _make_action(
name: str = "local/test-action",
*,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
invariants: list[str] | None = None,
inputs_schema: dict[str, object] | None = None,
automation_profile: str | None = None,
) -> Action:
"""Create an Action for CLI extension tests."""
return Action(
namespaced_name=NamespacedName.parse(name),
description="Test action description",
long_description=None,
definition_of_done="All tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
reusable=True,
read_only=False,
state=ActionState.AVAILABLE,
automation_profile=automation_profile,
invariants=invariants or [],
inputs_schema=inputs_schema,
created_by=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a cli extensions test runner")
def step_cli_ext_runner(context: Context) -> None:
"""Set up the CLI runner."""
context.runner = CliRunner()
@given("a cli extensions mocked lifecycle service")
def step_cli_ext_mock_service(context: Context) -> None:
"""Set up the mocked lifecycle service."""
context.mock_service = MagicMock()
context.plan_patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_service,
)
context.action_patcher = patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=context.mock_service,
)
context.plan_patcher.start()
context.action_patcher.start()
# Store the last plan created for inspection
context.last_plan = None
context.last_result = None
# ---------------------------------------------------------------------------
# plan use: action setup
# ---------------------------------------------------------------------------
@given("a cli extensions action exists")
def step_cli_ext_action_exists(context: Context) -> None:
"""Set up an action for plan use tests."""
context.mock_service.get_action_by_name.return_value = _make_action()
context.mock_service.use_action.return_value = _make_plan()
# ---------------------------------------------------------------------------
# plan use: automation profile
# ---------------------------------------------------------------------------
@when('I run plan use with automation profile flag "{profile}"')
def step_run_plan_use_with_profile(context: Context, profile: str) -> None:
"""Run plan use with --automation-profile."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--automation-profile", profile],
)
context.last_result = result
context.last_profile = profile
@then("the cli extensions plan use should succeed")
def step_plan_use_should_succeed(context: Context) -> None:
"""Verify plan use succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit code 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then("the cli extensions plan use should fail")
def step_plan_use_should_fail(context: Context) -> None:
"""Verify plan use failed."""
assert context.last_result is not None
assert context.last_result.exit_code != 0
@then('the cli extensions plan should have automation profile "{profile}"')
def step_plan_has_profile(context: Context, profile: str) -> None:
"""Verify plan output mentions automation profile."""
output = context.last_result.output
assert profile in output, f"Expected '{profile}' in output: {output}"
# ---------------------------------------------------------------------------
# plan use: invariant flags
# ---------------------------------------------------------------------------
@when('I run plan use with invariant flags "{inv1}" and "{inv2}"')
def step_run_plan_use_with_invariants(context: Context, inv1: str, inv2: str) -> None:
"""Run plan use with two --invariant flags."""
result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--invariant",
inv1,
"--invariant",
inv2,
],
)
context.last_result = result
@then('the cli extensions service received invariants "{inv1}" and "{inv2}"')
def step_service_received_invariants(context: Context, inv1: str, inv2: str) -> None:
"""Verify invariants were passed to the service."""
call_args = context.mock_service.use_action.call_args
assert call_args is not None, "use_action was not called"
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
assert invariants is not None, "No invariants passed to use_action"
texts = [inv.text for inv in invariants]
assert inv1 in texts, f"Expected '{inv1}' in {texts}"
assert inv2 in texts, f"Expected '{inv2}' in {texts}"
@when('I run plan use with single invariant "{inv_text}"')
def step_run_plan_use_with_single_invariant(context: Context, inv_text: str) -> None:
"""Run plan use with a single --invariant flag."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--invariant", inv_text],
)
context.last_result = result
@then('the cli extensions service received single invariant "{inv_text}"')
def step_service_received_single_invariant(context: Context, inv_text: str) -> None:
"""Verify single invariant was passed."""
call_args = context.mock_service.use_action.call_args
assert call_args is not None
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
assert invariants is not None
texts = [inv.text for inv in invariants]
assert inv_text in texts, f"Expected '{inv_text}' in {texts}"
# ---------------------------------------------------------------------------
# plan use: actor override validation (valid)
# ---------------------------------------------------------------------------
@when('I run plan use with strategy actor flag "{actor}"')
def step_run_plan_use_strategy_actor(context: Context, actor: str) -> None:
"""Run plan use with --strategy-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--strategy-actor", actor],
)
context.last_result = result
@when('I run plan use with execution actor flag "{actor}"')
def step_run_plan_use_execution_actor(context: Context, actor: str) -> None:
"""Run plan use with --execution-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--execution-actor", actor],
)
context.last_result = result
@when('I run plan use with estimation actor flag "{actor}"')
def step_run_plan_use_estimation_actor(context: Context, actor: str) -> None:
"""Run plan use with --estimation-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--estimation-actor", actor],
)
context.last_result = result
@when('I run plan use with invariant actor flag "{actor}"')
def step_run_plan_use_invariant_actor(context: Context, actor: str) -> None:
"""Run plan use with --invariant-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--invariant-actor", actor],
)
context.last_result = result
@then('the cli extensions plan has strategy actor "{actor}"')
def step_plan_has_strategy_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the strategy actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has execution actor "{actor}"')
def step_plan_has_execution_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the execution actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has estimation actor "{actor}"')
def step_plan_has_estimation_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the estimation actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has invariant actor "{actor}"')
def step_plan_has_invariant_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the invariant actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
# ---------------------------------------------------------------------------
# plan use: actor override validation (invalid format)
# ---------------------------------------------------------------------------
@then("the cli extensions plan use should fail with actor validation error")
def step_plan_use_fail_actor_validation(context: Context) -> None:
"""Verify plan use failed due to actor validation."""
assert context.last_result is not None
assert context.last_result.exit_code != 0, (
f"Expected non-zero exit, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
# ---------------------------------------------------------------------------
# plan status: automation profile
# ---------------------------------------------------------------------------
@given('cli extensions plans exist with automation profile "{profile}"')
def step_plans_with_profile(context: Context, profile: str) -> None:
"""Set up plans that have an automation profile."""
plan = _make_plan(
automation_profile=AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
)
context.mock_service.list_plans.return_value = [plan]
context.mock_service.get_plan.return_value = plan
@when("I run cli extensions plan status")
def step_run_plan_status(context: Context) -> None:
"""Run plan status (no plan_id = list all)."""
result = context.runner.invoke(plan_app, ["status"])
context.last_result = result
@then('the cli extensions status output should contain "{text}"')
def step_status_contains(context: Context, text: str) -> None:
"""Verify status output contains text."""
assert context.last_result.exit_code == 0
assert text in context.last_result.output, (
f"Expected '{text}' in: {context.last_result.output}"
)
@when('I run cli extensions plan status with format "{fmt}"')
def step_run_plan_status_fmt(context: Context, fmt: str) -> None:
"""Run plan status with --format."""
result = context.runner.invoke(plan_app, ["status", "--format", fmt])
context.last_result = result
@then(
"the cli extensions status json output should contain automation profile "
'"{profile}"'
)
def step_status_json_profile(context: Context, profile: str) -> None:
"""Verify JSON output contains automation profile."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert profile in output, f"Expected '{profile}' in: {output}"
# ---------------------------------------------------------------------------
# plan lifecycle-list: invariants
# ---------------------------------------------------------------------------
@given("cli extensions plans exist with invariants")
def step_plans_with_invariants(context: Context) -> None:
"""Set up plans that have invariants."""
plan = _make_plan(
invariants=[
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
PlanInvariant(text="Keep compat", source=InvariantSource.ACTION),
]
)
context.mock_service.list_plans.return_value = [plan]
@when("I run cli extensions plan lifecycle-list")
def step_run_lifecycle_list(context: Context) -> None:
"""Run plan lifecycle-list."""
result = context.runner.invoke(plan_app, ["lifecycle-list"])
context.last_result = result
@then("the cli extensions list output should contain invariant count")
def step_list_has_invariant_count(context: Context) -> None:
"""Verify list output contains invariant count."""
assert context.last_result.exit_code == 0
# The table should show "2" for invariant count
assert "2" in context.last_result.output, (
f"Expected '2' invariant count in: {context.last_result.output}"
)
@when('I run cli extensions plan lifecycle-list with format "{fmt}"')
def step_run_lifecycle_list_fmt(context: Context, fmt: str) -> None:
"""Run plan lifecycle-list with --format."""
result = context.runner.invoke(plan_app, ["lifecycle-list", "--format", fmt])
context.last_result = result
@then("the cli extensions list json output should contain invariants")
def step_list_json_invariants(context: Context) -> None:
"""Verify JSON output contains invariants field."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert "invariants" in output, f"Expected 'invariants' in: {output}"
assert "No warnings" in output, f"Expected 'No warnings' in: {output}"
# ---------------------------------------------------------------------------
# action show: optional fields
# ---------------------------------------------------------------------------
@given('a cli extensions action with estimation actor "{actor}"')
def step_action_with_estimation_actor(context: Context, actor: str) -> None:
"""Set up an action with estimation actor."""
action = _make_action(estimation_actor=actor)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given('a cli extensions action with invariant actor "{actor}"')
def step_action_with_invariant_actor(context: Context, actor: str) -> None:
"""Set up an action with invariant actor."""
action = _make_action(invariant_actor=actor)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given("a cli extensions action with invariants")
def step_action_with_invariants(context: Context) -> None:
"""Set up an action with invariants."""
action = _make_action(invariants=["No regressions", "Keep backward compat"])
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given("a cli extensions action with inputs schema")
def step_action_with_inputs_schema(context: Context) -> None:
"""Set up an action with inputs_schema."""
action = _make_action(
inputs_schema={
"type": "object",
"properties": {"coverage": {"type": "integer"}},
}
)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@when("I run cli extensions action show")
def step_run_action_show(context: Context) -> None:
"""Run action show."""
result = context.runner.invoke(action_app, ["show", "local/test-action"])
context.last_result = result
@when('I run cli extensions action show with format "{fmt}"')
def step_run_action_show_fmt(context: Context, fmt: str) -> None:
"""Run action show with --format."""
result = context.runner.invoke(
action_app, ["show", "local/test-action", "--format", fmt]
)
context.last_result = result
@then('the cli extensions action output should contain "{text}"')
def step_action_output_contains(context: Context, text: str) -> None:
"""Verify action output contains text."""
assert context.last_result.exit_code == 0, (
f"Exit code: {context.last_result.exit_code}, "
f"output: {context.last_result.output}"
)
assert text in context.last_result.output, (
f"Expected '{text}' in: {context.last_result.output}"
)
@then('the cli extensions action json should contain "{key}"')
def step_action_json_contains(context: Context, key: str) -> None:
"""Verify JSON output contains key."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert key in output, f"Expected '{key}' in: {output}"
# ---------------------------------------------------------------------------
# plan use: combined profile + invariant
# ---------------------------------------------------------------------------
@when('I run plan use with profile "{profile}" and invariant "{inv_text}"')
def step_run_plan_use_profile_invariant(
context: Context, profile: str, inv_text: str
) -> None:
"""Run plan use with both --automation-profile and --invariant."""
result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--automation-profile",
profile,
"--invariant",
inv_text,
],
)
context.last_result = result
# ---------------------------------------------------------------------------
# validate_namespaced_actor unit tests
# ---------------------------------------------------------------------------
@then('validate_namespaced_actor accepts "{actor}"')
def step_validate_actor_accepts(context: Context, actor: str) -> None:
"""Verify validate_namespaced_actor accepts a valid name."""
result = validate_namespaced_actor(actor, "--test")
assert result == actor
@then('validate_namespaced_actor rejects "{actor}"')
def step_validate_actor_rejects(context: Context, actor: str) -> None:
"""Verify validate_namespaced_actor rejects an invalid name."""
try:
validate_namespaced_actor(actor, "--test")
raise AssertionError(f"Expected ValidationError for '{actor}'")
except ValidationError:
pass # Expected
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
def after_scenario(context: Context, scenario: object) -> None:
"""Clean up patchers."""
for name in ("plan_patcher", "action_patcher"):
patcher = getattr(context, name, None)
if patcher:
patcher.stop()
+343
View File
@@ -0,0 +1,343 @@
"""Step definitions for config_resolution.feature."""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from cleveragents.application.services.config_service import ConfigService
use_step_matcher("re")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_service(context: Context) -> ConfigService:
"""Return the test-scoped ConfigService instance."""
svc: ConfigService = context._config_service
return svc
def _cleanup(context: Context) -> None:
"""Clean up temp dir and env vars."""
tmpdir = getattr(context, "_config_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
for var in getattr(context, "_env_vars_set", []):
os.environ.pop(var, None)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean config service test environment")
def step_clean_config_service_env(context: Context) -> None:
tmpdir = tempfile.mkdtemp()
context._config_tmpdir = tmpdir
tmp_path = Path(tmpdir)
context._config_service = ConfigService(
config_dir=tmp_path,
config_path=tmp_path / "config.toml",
)
context._env_vars_set = []
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: _cleanup(context))
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given(
r'I have written global config key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
)
def step_write_global_config(context: Context, key: str, value: str) -> None:
svc = _get_service(context)
data = svc.read_config()
data[key] = value
svc.write_config(data)
@given(
r'I have written project "(?P<project>[^"]+)" config key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
)
def step_write_project_config(
context: Context, project: str, key: str, value: str
) -> None:
svc = _get_service(context)
data = svc.read_config()
if "project" not in data:
data["project"] = {}
proj_data: dict[str, Any] = data["project"]
if project not in proj_data:
proj_data[project] = {}
proj_data[project][key] = value
svc.write_config(data)
@given(r'I write config data with key "(?P<key>[^"]+)" and value "(?P<value>[^"]+)"')
def step_write_config_data(context: Context, key: str, value: str) -> None:
svc = _get_service(context)
svc.set_value(key, value)
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when(r'I resolve config key "(?P<key>[^"]+)" with CLI value "(?P<cli_value>[^"]+)"')
def step_resolve_key_cli(context: Context, key: str, cli_value: str) -> None:
svc = _get_service(context)
context.resolved = svc.resolve(key, cli_value=cli_value)
@when(r'I resolve config key "(?P<key>[^"]+)" for project "(?P<project>[^"]+)"')
def step_resolve_key_project(context: Context, key: str, project: str) -> None:
svc = _get_service(context)
context.resolved = svc.resolve(key, project_name=project)
@when(r'I resolve config key "(?P<key>[^"]+)" with verbose mode')
def step_resolve_key_verbose(context: Context, key: str) -> None:
svc = _get_service(context)
context.resolved = svc.resolve(key, verbose=True)
@when(r'I resolve config key "(?P<key>[^"]+)"')
def step_resolve_key(context: Context, key: str) -> None:
svc = _get_service(context)
context.resolved = svc.resolve(key)
@when(r'I look up the env var for key "(?P<key>[^"]+)"')
def step_lookup_env_var(context: Context, key: str) -> None:
svc = _get_service(context)
context.env_var_name = svc.env_var_for_key(key)
@when(r'I attempt to resolve unknown key "(?P<key>[^"]+)"')
def step_attempt_resolve_unknown(context: Context, key: str) -> None:
svc = _get_service(context)
try:
svc.resolve(key)
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when(r'I attempt to validate unknown key "(?P<key>[^"]+)"')
def step_attempt_validate_unknown(context: Context, key: str) -> None:
try:
ConfigService.validate_key(key)
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when(
r'I attempt to validate type for key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
)
def step_attempt_validate_type(context: Context, key: str, value: str) -> None:
try:
ConfigService.validate_type(key, value)
context.raised_error = None
except (TypeError, ValueError) as exc:
context.raised_error = exc
@when("I read the config file")
def step_read_config_file(context: Context) -> None:
svc = _get_service(context)
context.config_data = svc.read_config()
@when("I write config data to a new directory")
def step_write_to_new_dir(context: Context) -> None:
base = Path(context._config_tmpdir) / "nested" / "subdir"
context._new_config_dir = base
svc = ConfigService(config_dir=base, config_path=base / "config.toml")
svc.write_config({"test_key": "test_value"})
@when("I resolve all keys")
def step_resolve_all(context: Context) -> None:
svc = _get_service(context)
context.all_resolved = svc.resolve_all()
@when(r'I set config value "(?P<key>[^"]+)" to "(?P<value>[^"]+)"')
def step_set_config_value(context: Context, key: str, value: str) -> None:
svc = _get_service(context)
svc.set_value(key, value)
@when(r'I get entry for key "(?P<key>[^"]+)"')
def step_get_entry(context: Context, key: str) -> None:
context.entry = ConfigService.get_entry(key)
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then(r'the resolved value should be "(?P<value>[^"]+)"')
def step_resolved_value(context: Context, value: str) -> None:
assert str(context.resolved.value) == value, (
f"Expected '{value}', got '{context.resolved.value}'"
)
@then(r'the resolved source should be "(?P<source>[^"]+)"')
def step_resolved_source(context: Context, source: str) -> None:
assert context.resolved.source.value == source, (
f"Expected source '{source}', got '{context.resolved.source.value}'"
)
@then(r'the env var name should be "(?P<expected>[^"]+)"')
def step_env_var_name(context: Context, expected: str) -> None:
assert context.env_var_name == expected, (
f"Expected '{expected}', got '{context.env_var_name}'"
)
@then(r'the config ValueError message should contain "(?P<fragment>[^"]+)"')
def step_config_value_error_raised(context: Context, fragment: str) -> None:
assert context.raised_error is not None, "Expected ValueError but none was raised"
assert isinstance(context.raised_error, ValueError), (
f"Expected ValueError, got {type(context.raised_error).__name__}"
)
assert fragment in str(context.raised_error), (
f"Expected '{fragment}' in message: {context.raised_error}"
)
@then(r'the config TypeError message should contain "(?P<fragment>[^"]+)"')
def step_config_type_error_raised(context: Context, fragment: str) -> None:
assert context.raised_error is not None, "Expected TypeError but none was raised"
assert isinstance(context.raised_error, TypeError), (
f"Expected TypeError, got {type(context.raised_error).__name__}"
)
assert fragment in str(context.raised_error), (
f"Expected '{fragment}' in message: {context.raised_error}"
)
@then(r"the resolved value should be integer (?P<value>\d+)")
def step_resolved_int(context: Context, value: str) -> None:
expected = int(value)
assert context.resolved.value == expected, (
f"Expected {expected}, got {context.resolved.value}"
)
assert isinstance(context.resolved.value, int), (
f"Expected int, got {type(context.resolved.value).__name__}"
)
@then("the resolved value should be boolean true")
def step_resolved_bool_true(context: Context) -> None:
assert context.resolved.value is True, (
f"Expected True, got {context.resolved.value}"
)
@then("the resolved value should be boolean false")
def step_resolved_bool_false(context: Context) -> None:
assert context.resolved.value is False, (
f"Expected False, got {context.resolved.value}"
)
@then(r"the resolved value should be float (?P<value>[\d.]+)")
def step_resolved_float(context: Context, value: str) -> None:
expected = float(value)
assert abs(context.resolved.value - expected) < 0.001, (
f"Expected {expected}, got {context.resolved.value}"
)
@then(r"the resolution chain should have (?P<count>\d+) entries")
def step_chain_length(context: Context, count: str) -> None:
expected = int(count)
assert len(context.resolved.chain) == expected, (
f"Expected {expected} entries, got {len(context.resolved.chain)}"
)
@then(r'the chain should include source "(?P<source>[^"]+)"')
def step_chain_includes_source(context: Context, source: str) -> None:
sources = [entry["source"] for entry in context.resolved.chain]
assert source in sources, f"Expected source '{source}' in chain: {sources}"
@then(r'the registry should contain key "(?P<key>[^"]+)"')
def step_registry_contains(context: Context, key: str) -> None:
registry = ConfigService.registry()
assert key in registry, f"Key '{key}' not found in registry"
@then(
r'the config data should contain key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
)
def step_config_data_contains(context: Context, key: str, value: str) -> None:
data = context.config_data
assert key in data, f"Key '{key}' not in config data: {data}"
assert str(data[key]) == value, (
f"Expected '{value}' for key '{key}', got '{data[key]}'"
)
@then("the config directory should exist")
def step_config_dir_exists(context: Context) -> None:
assert context._new_config_dir.exists(), (
f"Config dir {context._new_config_dir} does not exist"
)
@then("the result should contain all registered keys")
def step_result_has_all_keys(context: Context) -> None:
registered = ConfigService.registered_keys()
for key in registered:
assert key in context.all_resolved, (
f"Key '{key}' missing from resolve_all result"
)
@then(r'the entry should have section "(?P<section>[^"]+)"')
def step_entry_section(context: Context, section: str) -> None:
assert context.entry is not None, "Entry should not be None"
assert context.entry.section == section, (
f"Expected section '{section}', got '{context.entry.section}'"
)
@then(r'the entry should have python type "(?P<type_name>[^"]+)"')
def step_entry_type(context: Context, type_name: str) -> None:
assert context.entry is not None, "Entry should not be None"
assert context.entry.python_type.__name__ == type_name, (
f"Expected type '{type_name}', got '{context.entry.python_type.__name__}'"
)
@then("the entry should be None")
def step_entry_is_none(context: Context) -> None:
assert context.entry is None, f"Expected None, got {context.entry}"
# Restore default step matcher for other step files
use_step_matcher("parse")
+457
View File
@@ -0,0 +1,457 @@
"""Step definitions for phase reversion state machine feature."""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError
from cleveragents.domain.models.core.action import ActionArgument
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
PlanInvariant,
PlanPhase,
ProcessingState,
ProjectLink,
)
@given("a plan lifecycle service is initialized")
def step_init_service(context: Context) -> None:
"""Initialize a PlanLifecycleService for testing."""
settings = Settings()
context.service = PlanLifecycleService(settings=settings)
@given('an action "{action_name}" exists')
def step_create_action(context: Context, action_name: str) -> None:
"""Create a test action."""
service: PlanLifecycleService = context.service
service.create_action(
name=action_name,
description=f"Test action {action_name}",
definition_of_done="Tests pass",
strategy_actor="local/test-strategist",
execution_actor="local/test-executor",
)
context.action_name = action_name
@given('a plan is created from action "{action_name}"')
def step_create_plan(context: Context, action_name: str) -> None:
"""Use an action to create a plan."""
service: PlanLifecycleService = context.service
plan = service.use_action(action_name)
context.plan = plan
context.plan_id = plan.identity.plan_id
@given('a plan is created from action "{action_name}" with arguments')
def step_create_plan_with_args(context: Context, action_name: str) -> None:
"""Use an action to create a plan with arguments and project links."""
service: PlanLifecycleService = context.service
# Re-create the action with an argument definition
service._actions.pop(str(action_name), None)
service.create_action(
name=action_name,
description=f"Test action {action_name}",
definition_of_done="Tests pass",
strategy_actor="local/test-strategist",
execution_actor="local/test-executor",
arguments=[
ActionArgument(name="target_coverage", arg_type="integer"),
],
)
plan = service.use_action(
action_name,
project_links=[ProjectLink(project_name="local/test-project")],
arguments={"target_coverage": 80},
invariants=[
PlanInvariant(text="No regressions", source=InvariantSource.PLAN),
],
)
context.plan = plan
context.plan_id = plan.identity.plan_id
@given("the plan is advanced to Apply phase with constrained state")
def step_advance_to_apply_constrained(context: Context) -> None:
"""Advance the plan through Strategize and Execute to Apply/constrained."""
service: PlanLifecycleService = context.service
plan_id: str = context.plan_id
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
plan = service.get_plan(plan_id)
# If auto-progressed past strategize, still need to handle execute
if plan.phase == PlanPhase.EXECUTE:
service.start_execute(plan_id)
service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
elif plan.phase == PlanPhase.STRATEGIZE:
service.execute_plan(plan_id)
service.start_execute(plan_id)
service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
if plan.phase == PlanPhase.EXECUTE:
service.apply_plan(plan_id)
if plan.phase != PlanPhase.APPLY:
# Force phase for testing
plan = service.get_plan(plan_id)
plan.phase = PlanPhase.APPLY
plan.processing_state = ProcessingState.CONSTRAINED
plan = service.get_plan(plan_id)
if plan.processing_state != ProcessingState.CONSTRAINED:
service.start_apply(plan_id)
plan = service.get_plan(plan_id)
plan.processing_state = ProcessingState.CONSTRAINED
plan.error_message = "Constraints too restrictive"
context.plan = service.get_plan(plan_id)
@given('the plan has automation profile "{profile_name}" allowing auto-reversion')
def step_set_profile_allowing(context: Context, profile_name: str) -> None:
"""Set a profile that allows auto-reversion."""
plan = context.service.get_plan(context.plan_id)
plan.automation_profile = AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
)
context.plan = plan
@given('the plan has automation profile "{profile_name}" blocking auto-reversion')
def step_set_profile_blocking(context: Context, profile_name: str) -> None:
"""Set a profile that blocks auto-reversion."""
plan = context.service.get_plan(context.plan_id)
plan.automation_profile = AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
)
context.plan = plan
@given("the plan is advanced to Execute phase")
def step_advance_to_execute(context: Context) -> None:
"""Advance the plan through Strategize to Execute phase."""
service: PlanLifecycleService = context.service
plan_id: str = context.plan_id
# Set manual profile to prevent auto-progress
plan = service.get_plan(plan_id)
plan.automation_profile = AutomationProfileRef(
profile_name="manual",
provenance=AutomationProfileProvenance.PLAN,
)
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
service.execute_plan(plan_id)
context.plan = service.get_plan(plan_id)
@given("the plan has been reverted 3 times")
def step_set_reversion_count(context: Context) -> None:
"""Set the reversion count to the maximum."""
plan = context.service.get_plan(context.plan_id)
plan.reversion_count = 3
# Advance to execute so reversion is structurally valid
plan.automation_profile = AutomationProfileRef(
profile_name="manual",
provenance=AutomationProfileProvenance.PLAN,
)
service: PlanLifecycleService = context.service
plan_id = context.plan_id
if (
plan.phase == PlanPhase.STRATEGIZE
and plan.processing_state == ProcessingState.QUEUED
):
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
service.execute_plan(plan_id)
plan = service.get_plan(plan_id)
plan.reversion_count = 3
context.plan = plan
@given("the plan has reached applied terminal state")
def step_set_applied(context: Context) -> None:
"""Move the plan to the applied terminal state."""
service: PlanLifecycleService = context.service
plan_id: str = context.plan_id
plan = service.get_plan(plan_id)
plan.automation_profile = AutomationProfileRef(
profile_name="manual",
provenance=AutomationProfileProvenance.PLAN,
)
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
service.execute_plan(plan_id)
service.start_execute(plan_id)
service.complete_execute(plan_id)
service.apply_plan(plan_id)
service.start_apply(plan_id)
service.complete_apply(plan_id)
context.plan = service.get_plan(plan_id)
@given("the plan is in Strategize phase")
def step_plan_in_strategize(context: Context) -> None:
"""Verify plan is in Strategize phase."""
plan = context.service.get_plan(context.plan_id)
assert plan.phase == PlanPhase.STRATEGIZE
@given("the plan has been cancelled")
def step_cancel_plan(context: Context) -> None:
"""Cancel the plan."""
service: PlanLifecycleService = context.service
service.cancel_plan(context.plan_id, reason="test cancellation")
context.plan = service.get_plan(context.plan_id)
@when('auto-reversion from apply is attempted with reason "{reason}"')
def step_try_auto_revert_apply(context: Context, reason: str) -> None:
"""Try auto-reversion from apply."""
service: PlanLifecycleService = context.service
context.plan = service.try_auto_revert_from_apply(context.plan_id, reason)
@when('the plan is manually reverted to Strategize with reason "{reason}"')
def step_manual_revert(context: Context, reason: str) -> None:
"""Manually revert the plan to Strategize."""
service: PlanLifecycleService = context.service
context.plan = service.revert_plan(
context.plan_id, PlanPhase.STRATEGIZE, reason=reason
)
@when("a reversion is attempted on the maxed-out plan")
def step_attempt_reversion_maxed(context: Context) -> None:
"""Attempt reversion on a plan that has hit the max."""
service: PlanLifecycleService = context.service
try:
context.plan = service.revert_plan(
context.plan_id, PlanPhase.STRATEGIZE, reason="should fail"
)
context.raised_error = None
except PlanError as e:
context.raised_error = e
@when('auto-reversion from execute is attempted with reason "{reason}"')
def step_try_auto_revert_execute(context: Context, reason: str) -> None:
"""Try auto-reversion from execute."""
service: PlanLifecycleService = context.service
context.plan = service.try_auto_revert_from_execute(context.plan_id, reason)
@when("a reversion is attempted on the terminal plan")
def step_attempt_reversion_terminal(context: Context) -> None:
"""Attempt reversion on a terminal plan."""
service: PlanLifecycleService = context.service
try:
context.plan = service.revert_plan(
context.plan_id, PlanPhase.STRATEGIZE, reason="should fail"
)
context.raised_error = None
except PlanError as e:
context.raised_error = e
@when("a reversion to Apply phase is attempted")
def step_attempt_reversion_wrong_phase(context: Context) -> None:
"""Attempt reversion to an invalid target phase."""
service: PlanLifecycleService = context.service
try:
context.plan = service.revert_plan(
context.plan_id, PlanPhase.APPLY, reason="should fail"
)
context.raised_error = None
except InvalidPhaseTransitionError as e:
context.raised_error = e
@when("the plan is re-advanced to Execute phase")
def step_re_advance_to_execute(context: Context) -> None:
"""Re-advance the plan to Execute after reversion."""
service: PlanLifecycleService = context.service
plan_id: str = context.plan_id
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
service.execute_plan(plan_id)
context.plan = service.get_plan(plan_id)
@when("a reversion is attempted on the cancelled plan")
def step_attempt_reversion_cancelled(context: Context) -> None:
"""Attempt reversion on a cancelled plan."""
service: PlanLifecycleService = context.service
try:
context.plan = service.revert_plan(
context.plan_id, PlanPhase.STRATEGIZE, reason="should fail"
)
context.raised_error = None
except PlanError as e:
context.raised_error = e
@then("the plan should be in Strategize phase")
def step_check_strategize(context: Context) -> None:
"""Verify plan is in Strategize phase."""
assert context.plan.phase == PlanPhase.STRATEGIZE
@then("the plan processing state should be queued")
def step_check_queued(context: Context) -> None:
"""Verify plan processing state is QUEUED."""
assert context.plan.processing_state == ProcessingState.QUEUED
@then("the plan reversion count should be {count:d}")
def step_check_reversion_count(context: Context, count: int) -> None:
"""Verify plan reversion count."""
assert context.plan.reversion_count == count
@then(
'a reversion decision should be recorded with source "{source}" and target "{target}"'
)
def step_check_reversion_decision(context: Context, source: str, target: str) -> None:
"""Verify a reversion decision was recorded."""
decisions = context.plan.decisions
reversion_decisions = [d for d in decisions if d["type"] == "reversion"]
assert len(reversion_decisions) > 0
latest = reversion_decisions[-1]
assert latest["source_phase"] == source
assert latest["target_phase"] == target
assert "timestamp" in latest
@then('a reversion decision should be recorded with reason "{reason}"')
def step_check_reversion_reason(context: Context, reason: str) -> None:
"""Verify a reversion decision with specific reason was recorded."""
decisions = context.plan.decisions
reversion_decisions = [d for d in decisions if d["type"] == "reversion"]
assert len(reversion_decisions) > 0
latest = reversion_decisions[-1]
assert reason in latest["reason"]
@then("a PlanError should be raised about max reversions")
def step_check_max_reversion_error(context: Context) -> None:
"""Verify a PlanError was raised about max reversions."""
assert context.raised_error is not None
assert isinstance(context.raised_error, PlanError)
assert (
"maximum reversion count" in str(context.raised_error).lower()
or "max" in str(context.raised_error).lower()
)
@then("the plan should remain in Apply phase")
def step_check_still_apply(context: Context) -> None:
"""Verify plan is still in Apply phase."""
assert context.plan.phase == PlanPhase.APPLY
@then("the plan processing state should be constrained")
def step_check_constrained(context: Context) -> None:
"""Verify plan processing state is CONSTRAINED."""
assert context.plan.processing_state == ProcessingState.CONSTRAINED
@then("a PlanError should be raised about terminal state")
def step_check_terminal_error(context: Context) -> None:
"""Verify a PlanError was raised about terminal state."""
assert context.raised_error is not None
assert isinstance(context.raised_error, PlanError)
assert "terminal" in str(context.raised_error).lower()
@then("an InvalidPhaseTransitionError should be raised")
def step_check_invalid_transition_error(context: Context) -> None:
"""Verify an InvalidPhaseTransitionError was raised."""
assert context.raised_error is not None
assert isinstance(context.raised_error, InvalidPhaseTransitionError)
@then("the plan arguments should be preserved")
def step_check_arguments_preserved(context: Context) -> None:
"""Verify plan arguments are preserved after reversion."""
assert context.plan.arguments == {"target_coverage": 80}
@then("the plan project links should be preserved")
def step_check_project_links_preserved(context: Context) -> None:
"""Verify plan project links are preserved after reversion."""
assert len(context.plan.project_links) == 1
assert context.plan.project_links[0].project_name == "local/test-project"
@then("the plan invariants should be preserved")
def step_check_invariants_preserved(context: Context) -> None:
"""Verify plan invariants are preserved after reversion."""
assert len(context.plan.invariants) >= 1
texts = [inv.text for inv in context.plan.invariants]
assert "No regressions" in texts
@then("there should be {count:d} reversion decisions recorded")
def step_check_decision_count(context: Context, count: int) -> None:
"""Verify the number of reversion decisions."""
decisions = context.plan.decisions
reversion_decisions = [d for d in decisions if d["type"] == "reversion"]
assert len(reversion_decisions) == count
@then("the plan in Strategize phase cannot revert to Apply")
def step_check_no_revert_strat_to_apply(context: Context) -> None:
"""Verify Strategize cannot revert to Apply."""
plan = context.service.get_plan(context.plan_id)
plan.phase = PlanPhase.STRATEGIZE
assert not plan.can_revert_to(PlanPhase.APPLY)
@then("the plan in Strategize phase cannot revert to Action")
def step_check_no_revert_strat_to_action(context: Context) -> None:
"""Verify Strategize cannot revert to Action."""
plan = context.service.get_plan(context.plan_id)
plan.phase = PlanPhase.STRATEGIZE
assert not plan.can_revert_to(PlanPhase.ACTION)
@then("the plan in Execute phase can revert to Strategize")
def step_check_revert_exec_to_strat(context: Context) -> None:
"""Verify Execute can revert to Strategize."""
plan = context.service.get_plan(context.plan_id)
plan.phase = PlanPhase.EXECUTE
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
@then("the plan in Apply phase can revert to Strategize")
def step_check_revert_apply_to_strat(context: Context) -> None:
"""Verify Apply can revert to Strategize."""
plan = context.service.get_plan(context.plan_id)
plan.phase = PlanPhase.APPLY
plan.processing_state = ProcessingState.CONSTRAINED
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
@then("the plan should remain in Execute phase")
def step_check_still_execute(context: Context) -> None:
"""Verify plan is still in Execute phase."""
assert context.plan.phase == PlanPhase.EXECUTE
+106 -61
View File
@@ -2168,6 +2168,50 @@ Each schedule update entry must include: timeline reference, summary, milestone
| Total | 43/62 | 8/14 | 8/29 | 2/24 | 13/21 | 0/1 | 74/151 |
### A9.service: Config Service with Multi-Level Resolution
**Status: [X] COMPLETE** (Day 22, 2026-02-23)
#### A9.service Implementation Notes
- 2026-02-23 [Jeff]: Implemented `ConfigService` in `src/cleveragents/application/services/config_service.py` with:
- Five-level resolution chain: CLI flag > env var > project-scoped > global config > default
- Typed config key registry with 24 entries across 6 sections: `core.*` (7), `plan.*` (3), `provider.*` (4), `sandbox.*` (3), `context.*` (3), `index.*` (4)
- TOML file management (`~/.cleveragents/config.toml`) with auto-created parent directories via `tomlkit`
- `ResolvedValue` return type includes winning value, source level, and optional verbose chain
- Env var interpolation following `CLEVERAGENTS_<SECTION>_<KEY>` convention
- Validation: unknown keys raise `ValueError` with sample of valid keys; type mismatches raise `TypeError` with actionable messages
- Boolean coercion from string: true/false, 1/0, yes/no (case-insensitive)
- 2026-02-23 [Jeff]: Added `docs/reference/config_resolution.md` documenting all 24 config keys with types, defaults, env var mappings, and precedence rules
- 2026-02-23 [Jeff]: Added `features/config_resolution.feature` with 31 Behave scenarios covering every resolution level, env var overrides, type coercion, unknown key rejection, TOML roundtrips, and verbose chain output
- 2026-02-23 [Jeff]: Added `robot/config_resolution.robot` with 8 Robot Framework end-to-end tests exercising default/global/env/CLI resolution, unknown key rejection, type coercion, and env var convention
- 2026-02-23 [Jeff]: Added `benchmarks/config_resolution_bench.py` with 6 ASV benchmark suites for resolution performance at each level, bulk operations, and validation throughput
- 2026-02-23 [Jeff]: Exported `ConfigService`, `ConfigEntry`, `ConfigLevel`, `ResolvedValue` from `application.services.__init__`; updated vulture whitelist for new public API
### CLI1.alpha: Plan/Action CLI Extensions
**Status: [X] COMPLETE**
#### CLI1.alpha Notes
- 2026-02-23: Implemented CLI1.alpha (Jeff). Key changes:
- Added `validate_namespaced_actor()` to `plan.py` for strict `namespace/name` validation on actor override flags (`--strategy-actor`, `--execution-actor`, `--estimation-actor`, `--invariant-actor`)
- Updated `_plan_spec_dict()` to include `estimation_actor`, `invariant_actor`, and `invariants` fields in all output formats (json, yaml, plain, table)
- Updated `_print_lifecycle_plan()` rich panel to display invariants with source provenance tags
- Updated `plan_status` summary table to include Profile and Invariants columns
- Updated `lifecycle_list_plans` table to include Profile and Invariants columns
- Extended `action.py` `_action_spec_dict()` to conditionally include `estimation_actor`, `invariant_actor`, and `inputs_schema` fields
- Extended `_print_action()` rich panel to display estimation actor, invariant actor, invariants list, inputs_schema, and automation_profile
- `--automation-profile` flag on `plan use` validates against `BUILTIN_PROFILES` and persists to plan metadata
- `--invariant` flags on `plan use` are passed through to `PlanLifecycleService.use_action()` as `PlanInvariant` objects with `InvariantSource.PLAN`
- Created `docs/reference/plan_cli.md` and `docs/reference/action_cli.md` reference documentation
- Created `features/cli_extensions.feature` with 25 Behave scenarios covering all new functionality
- Created `features/steps/cli_extensions_steps.py` with all step definitions
- Created `robot/cli_extensions.robot` with 5 Robot Framework integration tests
- Created `benchmarks/cli_extensions_bench.py` with 6 ASV benchmark suites
---
## Implementation Checklist
This comprehensive checklist tracks all implementation tasks for the CleverAgents project. Each phase item includes mandatory **Code**, **Document**, and **Tests** bullets. Only mark the parent complete when every sub-bullet (including any spawned `Fix - ...` remediation tasks) is checked.
@@ -2864,27 +2908,27 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or
- [ ] Git [Luis]: `git push -u origin feature/m4-subplan-execution`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m4-subplan-execution` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Jeff | Group: M4.5.phase-reversion | Branch: feature/m4-phase-reversion | Planned: Day 21 | Expected: Day 26) - Commit message: "feat(plan): add phase reversion state machine"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m4-phase-reversion`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Implement Execute-to-Strategize reversion in `PlanLifecycleService` when constraints are too restrictive (validation failures block apply and automation profile permits reversion).
- [ ] Code [Jeff]: Implement Apply-to-Strategize reversion from `constrained` terminal state, resetting phase to Strategize with preserved context and sandbox state.
- [ ] Code [Jeff]: Add `auto_reversion_from_apply` threshold check from the resolved `AutomationProfile` — only auto-revert if the profile's `auto_reversion_from_apply` flag is set.
- [ ] Code [Jeff]: Record each reversion as a `reversion` decision in the plan's decision tree with source phase, target phase, reason, and timestamp.
- [ ] Code [Jeff]: Wire reversion into `PlanLifecycleService` phase transition logic with explicit guard against infinite reversion loops (max 3 reversions per plan execution).
- [ ] Code [Jeff]: Add `plan revert <plan_id> --to-phase <phase>` CLI command for manual reversion.
- [ ] Docs [Jeff]: Add `docs/reference/phase_reversion.md` documenting reversion triggers, automation profile thresholds, and loop guards.
- [ ] Tests (Behave) [Jeff]: Add `features/phase_reversion.feature` with scenarios for auto-reversion, manual reversion, loop guard, and profile-gated behavior.
- [ ] Tests (Robot) [Jeff]: Add `robot/phase_reversion.robot` for end-to-end reversion flow.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/phase_reversion_bench.py` for reversion overhead.
- [ ] Quality [Jeff]: 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%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(plan): add phase reversion state machine"`
- [ ] Git [Jeff]: `git push -u origin feature/m4-phase-reversion`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m4-phase-reversion` to `master` with a suitable and thorough description
- [X] **COMMIT (Owner: Jeff | Group: M4.5.phase-reversion | Branch: feature/m4-phase-reversion | Planned: Day 21 | Expected: Day 26) - Commit message: "feat(plan): add phase reversion state machine"** Done: 2026-02-23
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m4-phase-reversion`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Jeff]: Implement Execute-to-Strategize reversion in `PlanLifecycleService` when constraints are too restrictive (validation failures block apply and automation profile permits reversion).
- [X] Code [Jeff]: Implement Apply-to-Strategize reversion from `constrained` terminal state, resetting phase to Strategize with preserved context and sandbox state.
- [X] Code [Jeff]: Add `auto_reversion_from_apply` threshold check from the resolved `AutomationProfile` — only auto-revert if the profile's `auto_reversion_from_apply` flag is set.
- [X] Code [Jeff]: Record each reversion as a `reversion` decision in the plan's decision tree with source phase, target phase, reason, and timestamp.
- [X] Code [Jeff]: Wire reversion into `PlanLifecycleService` phase transition logic with explicit guard against infinite reversion loops (max 3 reversions per plan execution).
- [X] Code [Jeff]: Add `plan revert <plan_id> --to-phase <phase>` CLI command for manual reversion.
- [X] Docs [Jeff]: Add `docs/reference/phase_reversion.md` documenting reversion triggers, automation profile thresholds, and loop guards.
- [X] Tests (Behave) [Jeff]: Add `features/phase_reversion.feature` with scenarios for auto-reversion, manual reversion, loop guard, and profile-gated behavior.
- [X] Tests (Robot) [Jeff]: Add `robot/phase_reversion.robot` for end-to-end reversion flow.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/phase_reversion_bench.py` for reversion overhead.
- [X] Quality [Jeff]: 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 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%.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(plan): add phase reversion state machine"`
- [X] Git [Jeff]: `git push -u origin feature/m4-phase-reversion`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m4-phase-reversion` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Luis | Group: M4.6.error-recovery | Branch: feature/m4-error-recovery | Planned: Day 22 | Expected: Day 26) - Commit message: "feat(plan): add error recovery patterns and CLI hints"**
- [ ] Git [Luis]: `git checkout master`
@@ -3607,6 +3651,7 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- 2026-02-13: A2b.beta (plan model alignment) completed on `feature/m1-plan-model`. Rebased onto A2b.alpha (`fd6d41b`), resolved 30 merge conflicts. Key decisions: `processing_state` field name (not `state`), `InvariantSource` enum (not `InvariantScope`), `project_links` (not `project_ids`), HEAD's `action.py` authoritative. All tests green: 130 Behave features (2246 scenarios), 206 Robot tests, plan.py 100% coverage. See Development Log entry for full details.
- 2026-02-13: New test artifacts created for plan model coverage: `features/plan_model_coverage.feature` (39 scenarios), `features/steps/plan_model_coverage_steps.py` (489 lines), `benchmarks/plan_model_bench.py` (15 benchmarks), `docs/reference/plan_model.md` (~270 lines).
- 2026-02-13: Robot integration test fixes applied post-rebase: `robot/helper_plan_lifecycle_v3.py` (missing `description` param), `robot/helper_db_lifecycle_models.py` (`state=``processing_state=`, `project_names``project_links`).
- 2026-02-23: M4.5.phase-reversion completed on `feature/m4-phase-reversion`. Added phase reversion state machine allowing plans to revert from Execute→Strategize and Apply→Strategize when constraints prove unworkable. Key implementation: `reversion_count` field + loop guard (max 3), `decisions` list for reversion decision recording, `can_revert_to()` validation method on Plan model. `PlanLifecycleService` gained `revert_plan()` (manual), `try_auto_revert_from_apply()`, and `try_auto_revert_from_execute()` (both gated by `AutomationProfile.auto_reversion_from_apply` threshold). Pre-existing `VALID_PHASE_TRANSITIONS` already included reversion paths. New artifacts: `features/phase_reversion.feature` (12 scenarios), `features/steps/phase_reversion_steps.py`, `robot/phase_reversion.robot` (8 tests), `benchmarks/phase_reversion_bench.py`, `docs/reference/phase_reversion.md`, CLI `plan revert` command. All nox stages pass: lint, typecheck, unit_tests (12/12 behave), integration_tests (8/8 robot).
**WEEK 1 - CRITICAL PATH**
@@ -4318,27 +4363,27 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
**PARALLEL SUBTRACK A9.project [Luis]**: Project-scoped config persistence
**SEQUENTIAL MERGE NOTE**: A9.service lands before A9.project.
- [ ] **COMMIT (Owner: Jeff | Group: A9.service | Branch: feature/m3-config-service | Planned: Day 14 | Expected: Day 22) - Commit message: "feat(config): add config service with multi-level resolution"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m3-config-service`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Implement `ConfigService` with TOML file management (`~/.cleveragents/config.toml`), read/write operations, and parent directory auto-creation.
- [ ] Code [Jeff]: Implement multi-level resolution chain: CLI flag > environment variable > project-scoped > global config > default. Each `config get` call returns the winning value plus the source level that provided it.
- [ ] Code [Jeff]: Add config key registry with typed entries: key name, Python type, default value, env var mapping (`CLEVERAGENTS_*`), project-scopability flag, and description. Register the complete catalog per spec (`core.*`, `plan.*`, `provider.*`, `sandbox.*`, `context.*`, `index.*`).
- [ ] Code [Jeff]: Wire `config get` output to display the resolution chain (which level provided the winning value) when `--verbose` is passed.
- [ ] Code [Jeff]: Add env var interpolation for all registered keys following the `CLEVERAGENTS_<SECTION>_<KEY>` convention.
- [ ] Code [Jeff]: Add validation that rejects unknown keys and type-mismatched values with actionable error messages.
- [ ] Docs [Jeff]: Add `docs/reference/config_resolution.md` documenting the resolution chain, all config keys, their types, defaults, and env var mappings.
- [ ] Tests (Behave) [Jeff]: Add `features/config_resolution.feature` with scenarios for each resolution level, env var overrides, unknown key rejection, and type validation.
- [ ] Tests (Robot) [Jeff]: Add `robot/config_resolution.robot` for end-to-end config get/set with env var interactions.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/config_resolution_bench.py` for resolution chain performance.
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark).
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(config): add config service with multi-level resolution"`
- [ ] Git [Jeff]: `git push -u origin feature/m3-config-service`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-config-service` to `master` with a suitable and thorough description
- [X] **COMMIT (Owner: Jeff | Group: A9.service | Branch: feature/m3-config-service | Planned: Day 14 | Expected: Day 22) - Commit message: "feat(config): add config service with multi-level resolution"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m3-config-service`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Jeff]: Implement `ConfigService` with TOML file management (`~/.cleveragents/config.toml`), read/write operations, and parent directory auto-creation.
- [X] Code [Jeff]: Implement multi-level resolution chain: CLI flag > environment variable > project-scoped > global config > default. Each `config get` call returns the winning value plus the source level that provided it.
- [X] Code [Jeff]: Add config key registry with typed entries: key name, Python type, default value, env var mapping (`CLEVERAGENTS_*`), project-scopability flag, and description. Register the complete catalog per spec (`core.*`, `plan.*`, `provider.*`, `sandbox.*`, `context.*`, `index.*`).
- [X] Code [Jeff]: Wire `config get` output to display the resolution chain (which level provided the winning value) when `--verbose` is passed.
- [X] Code [Jeff]: Add env var interpolation for all registered keys following the `CLEVERAGENTS_<SECTION>_<KEY>` convention.
- [X] Code [Jeff]: Add validation that rejects unknown keys and type-mismatched values with actionable error messages.
- [X] Docs [Jeff]: Add `docs/reference/config_resolution.md` documenting the resolution chain, all config keys, their types, defaults, and env var mappings.
- [X] Tests (Behave) [Jeff]: Add `features/config_resolution.feature` with scenarios for each resolution level, env var overrides, unknown key rejection, and type validation.
- [X] Tests (Robot) [Jeff]: Add `robot/config_resolution.robot` for end-to-end config get/set with env var interactions.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/config_resolution_bench.py` for resolution chain performance.
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark).
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(config): add config service with multi-level resolution"`
- [X] Git [Jeff]: `git push -u origin feature/m3-config-service`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-config-service` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Luis | Group: A9.project | Branch: feature/m4-config-project-scope | Planned: Day 18 | Expected: Day 26) - Commit message: "feat(config): add project-scoped config overrides"**
- [ ] Git [Luis]: `git checkout master`
@@ -6175,25 +6220,25 @@ By Day 30, the system must be able to (all in LOCAL MODE, server connectivity is
**PARALLEL SUBTRACK CLI1.beta [Brent]**: Extended CLI tests + output snapshots
**SEQUENTIAL MERGE NOTE**: CLI1.alpha depends on A6 automation profiles + D2 invariants; CLI1.beta runs after CLI1.alpha.
- [ ] **COMMIT (Owner: Jeff | Group: CLI1.alpha | Branch: feature/m4-cli-extensions | Planned: Day 11 | Expected: Day 26) - Commit message: "feat(cli): add action and plan CLI extensions"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m4-cli-extensions`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Add `--automation-profile` and `--invariant` flags to `plan use` and persist to plan metadata.
- [ ] Code [Jeff]: Add actor override flags (`--strategy-actor`, `--execution-actor`, `--estimation-actor`, `--invariant-actor`) with namespaced validation.
- [ ] Code [Jeff]: Update `plan status/list` output to include automation_profile + invariants when present.
- [ ] Code [Jeff]: Extend `action show` output with optional actors, invariants, and inputs_schema when present.
- [ ] Docs [Jeff]: Update `docs/reference/plan_cli.md` and `docs/reference/action_cli.md` with extended flags + examples.
- [ ] Tests (Behave) [Jeff]: Add scenarios for automation_profile/invariant flags and actor override validation.
- [ ] Tests (Robot) [Jeff]: Add Robot flow for `plan use` with invariants + automation profile.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/cli_extensions_bench.py` for parsing overhead.
- [ ] Quality [Jeff]: 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%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(cli): add action and plan CLI extensions"` (run after the coverage check below passes)
- [ ] Git [Jeff]: `git push -u origin feature/m4-cli-extensions`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m4-cli-extensions` to `master` with a suitable and thorough description
- [X] **COMMIT (Owner: Jeff | Group: CLI1.alpha | Branch: feature/m4-cli-extensions | Planned: Day 11 | Expected: Day 26) - Commit message: "feat(cli): add action and plan CLI extensions"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m4-cli-extensions`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Jeff]: Add `--automation-profile` and `--invariant` flags to `plan use` and persist to plan metadata.
- [X] Code [Jeff]: Add actor override flags (`--strategy-actor`, `--execution-actor`, `--estimation-actor`, `--invariant-actor`) with namespaced validation.
- [X] Code [Jeff]: Update `plan status/list` output to include automation_profile + invariants when present.
- [X] Code [Jeff]: Extend `action show` output with optional actors, invariants, and inputs_schema when present.
- [X] Docs [Jeff]: Update `docs/reference/plan_cli.md` and `docs/reference/action_cli.md` with extended flags + examples.
- [X] Tests (Behave) [Jeff]: Add scenarios for automation_profile/invariant flags and actor override validation.
- [X] Tests (Robot) [Jeff]: Add Robot flow for `plan use` with invariants + automation profile.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/cli_extensions_bench.py` for parsing overhead.
- [X] Quality [Jeff]: 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%.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(cli): add action and plan CLI extensions"` (run after the coverage check below passes)
- [X] Git [Jeff]: `git push -u origin feature/m4-cli-extensions`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m4-cli-extensions` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Brent | Group: CLI1.beta | Branch: feature/m4-cli-extension-tests | Planned: Day 12 | Expected: Day 26) - Commit message: "test(cli): cover action and plan extensions"**
- [ ] Git [Brent]: `git checkout master`
+49
View File
@@ -0,0 +1,49 @@
*** Settings ***
Documentation Smoke tests for plan use with invariants, automation profiles, and actor validation
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_cli_extensions.py
*** Test Cases ***
Plan Use With Invariants
[Documentation] Verify that ``plan use --invariant`` passes invariants correctly
${result}= Run Process ${PYTHON} ${HELPER} plan-use-invariants cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-ext-plan-use-invariants-ok
Plan Use With Automation Profile
[Documentation] Verify that ``plan use --automation-profile`` works
${result}= Run Process ${PYTHON} ${HELPER} plan-use-profile cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-ext-plan-use-profile-ok
Plan Use Actor Validation Valid
[Documentation] Verify valid namespaced actor format is accepted
${result}= Run Process ${PYTHON} ${HELPER} actor-valid cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-ext-actor-valid-ok
Plan Use Actor Validation Invalid
[Documentation] Verify invalid actor format is rejected
${result}= Run Process ${PYTHON} ${HELPER} actor-invalid cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-ext-actor-invalid-ok
Plan Use Combined Profile And Invariants
[Documentation] Verify plan use with both profile and invariants
${result}= Run Process ${PYTHON} ${HELPER} plan-combined cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-ext-plan-combined-ok
+73
View File
@@ -0,0 +1,73 @@
*** Settings ***
Documentation End-to-end tests for Config Service with multi-level resolution
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_config_resolution.py
*** Test Cases ***
Config Resolution Returns Default Value
[Documentation] Verify that resolving a key with no overrides returns the default
${result}= Run Process ${PYTHON} ${HELPER} resolve-default cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-default-ok
Config Resolution Uses Global Config File
[Documentation] Verify that a value set in the global TOML file overrides default
${result}= Run Process ${PYTHON} ${HELPER} resolve-global cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-global-ok
Config Resolution Uses Environment Variable
[Documentation] Verify that env var overrides global config
${result}= Run Process ${PYTHON} ${HELPER} resolve-env cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-env-ok
Config Resolution Uses CLI Flag
[Documentation] Verify that CLI flag has highest priority
${result}= Run Process ${PYTHON} ${HELPER} resolve-cli cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-cli-ok
Config Resolution Rejects Unknown Key
[Documentation] Verify that unknown keys raise ValueError
${result}= Run Process ${PYTHON} ${HELPER} resolve-unknown-key cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-unknown-key-ok
Config Resolution Performs Type Coercion
[Documentation] Verify that string env vars are coerced to proper types
${result}= Run Process ${PYTHON} ${HELPER} resolve-type-coercion cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-type-coercion-ok
Config Registry Contains Expected Keys
[Documentation] Verify that the registry has the expected key catalog
${result}= Run Process ${PYTHON} ${HELPER} registry-keys cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-registry-ok
Config Env Var Naming Convention
[Documentation] Verify CLEVERAGENTS_<SECTION>_<KEY> naming convention
${result}= Run Process ${PYTHON} ${HELPER} env-var-convention cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-env-convention-ok
+248
View File
@@ -0,0 +1,248 @@
"""Helper script for cli_extensions.robot smoke tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
from cleveragents.domain.models.core.action import ( # noqa: E402
Action,
ActionState,
)
from cleveragents.domain.models.core.plan import ( # noqa: E402
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
runner = CliRunner()
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
def _mock_plan(
*,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
) -> Plan:
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse("local/test-plan"),
description="Test plan for robot",
definition_of_done="All tests pass",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="my-project")],
arguments={},
arguments_order=[],
automation_profile=automation_profile,
invariants=invariants or [],
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _mock_action() -> Action:
return Action(
namespaced_name=NamespacedName.parse("local/test-action"),
description="Test action for robot",
definition_of_done="All tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
state=ActionState.AVAILABLE,
created_at=datetime.now(),
updated_at=datetime.now(),
)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def plan_use_with_invariants() -> None:
"""Verify plan use with --invariant flags works."""
mock_svc = MagicMock()
mock_svc.get_action_by_name.return_value = _mock_action()
mock_svc.use_action.return_value = _mock_plan(
invariants=[
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
]
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
result = runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--invariant",
"No warnings",
],
)
if result.exit_code == 0:
print("cli-ext-plan-use-invariants-ok")
else:
print(f"FAIL: exit={result.exit_code} output={result.output}")
sys.exit(1)
def plan_use_with_automation_profile() -> None:
"""Verify plan use with --automation-profile works."""
mock_svc = MagicMock()
mock_svc.get_action_by_name.return_value = _mock_action()
mock_svc.use_action.return_value = _mock_plan(
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
)
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
result = runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--automation-profile",
"trusted",
],
)
if result.exit_code == 0 and "trusted" in result.output:
print("cli-ext-plan-use-profile-ok")
else:
print(f"FAIL: exit={result.exit_code} output={result.output}")
sys.exit(1)
def plan_use_actor_validation_valid() -> None:
"""Verify valid namespaced actors are accepted."""
mock_svc = MagicMock()
mock_svc.get_action_by_name.return_value = _mock_action()
mock_svc.use_action.return_value = _mock_plan()
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
result = runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--strategy-actor",
"openai/gpt-4",
],
)
if result.exit_code == 0:
print("cli-ext-actor-valid-ok")
else:
print(f"FAIL: exit={result.exit_code} output={result.output}")
sys.exit(1)
def plan_use_actor_validation_invalid() -> None:
"""Verify invalid actor format is rejected."""
mock_svc = MagicMock()
mock_svc.get_action_by_name.return_value = _mock_action()
mock_svc.use_action.return_value = _mock_plan()
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
result = runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--strategy-actor",
"bad-format",
],
)
if result.exit_code != 0:
print("cli-ext-actor-invalid-ok")
else:
print(f"FAIL: expected non-zero exit, got {result.exit_code}")
sys.exit(1)
def plan_use_combined() -> None:
"""Verify plan use with profile + invariants together."""
mock_svc = MagicMock()
mock_svc.get_action_by_name.return_value = _mock_action()
mock_svc.use_action.return_value = _mock_plan(
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
),
invariants=[
PlanInvariant(text="No regressions", source=InvariantSource.PLAN),
],
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
result = runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--automation-profile",
"trusted",
"--invariant",
"No regressions",
],
)
if result.exit_code == 0 and "trusted" in result.output:
print("cli-ext-plan-combined-ok")
else:
print(f"FAIL: exit={result.exit_code} output={result.output}")
sys.exit(1)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, object] = {
"plan-use-invariants": plan_use_with_invariants,
"plan-use-profile": plan_use_with_automation_profile,
"actor-valid": plan_use_actor_validation_valid,
"actor-invalid": plan_use_actor_validation_invalid,
"plan-combined": plan_use_combined,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>")
sys.exit(1)
fn = _COMMANDS[sys.argv[1]]
fn() # type: ignore[operator]
+198
View File
@@ -0,0 +1,198 @@
"""Helper script for config_resolution.robot end-to-end tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.application.services.config_service import ( # noqa: E402
ConfigLevel,
ConfigService,
)
def _make_service() -> tuple[ConfigService, Path]:
"""Create a ConfigService with a temporary directory."""
tmpdir = Path(tempfile.mkdtemp())
svc = ConfigService(config_dir=tmpdir, config_path=tmpdir / "config.toml")
return svc, tmpdir
def resolve_default() -> None:
"""Verify default resolution works."""
svc, tmpdir = _make_service()
try:
result = svc.resolve("core.log_level")
if result.value == "INFO" and result.source == ConfigLevel.DEFAULT:
print("config-resolution-default-ok")
else:
print(
f"FAIL: expected INFO/default, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
shutil.rmtree(str(tmpdir), ignore_errors=True)
def resolve_global() -> None:
"""Verify global config file overrides default."""
svc, tmpdir = _make_service()
try:
svc.set_value("core.log_level", "DEBUG")
result = svc.resolve("core.log_level")
if result.value == "DEBUG" and result.source == ConfigLevel.GLOBAL:
print("config-resolution-global-ok")
else:
print(
f"FAIL: expected DEBUG/global, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
shutil.rmtree(str(tmpdir), ignore_errors=True)
def resolve_env() -> None:
"""Verify env var overrides global config."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
old_val = os.environ.get(env_key)
try:
svc.set_value("core.log_level", "DEBUG")
os.environ[env_key] = "WARNING"
result = svc.resolve("core.log_level")
if result.value == "WARNING" and result.source == ConfigLevel.ENV_VAR:
print("config-resolution-env-ok")
else:
print(
f"FAIL: expected WARNING/env_var, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
if old_val is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
shutil.rmtree(str(tmpdir), ignore_errors=True)
def resolve_cli() -> None:
"""Verify CLI flag overrides env var."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
old_val = os.environ.get(env_key)
try:
os.environ[env_key] = "WARNING"
result = svc.resolve("core.log_level", cli_value="ERROR")
if result.value == "ERROR" and result.source == ConfigLevel.CLI_FLAG:
print("config-resolution-cli-ok")
else:
print(
f"FAIL: expected ERROR/cli_flag, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
if old_val is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
shutil.rmtree(str(tmpdir), ignore_errors=True)
def resolve_unknown_key() -> None:
"""Verify unknown key raises ValueError."""
svc, tmpdir = _make_service()
try:
svc.resolve("bogus.nonexistent")
print("FAIL: expected ValueError", file=sys.stderr)
sys.exit(1)
except ValueError:
print("config-resolution-unknown-key-ok")
finally:
shutil.rmtree(str(tmpdir), ignore_errors=True)
def resolve_type_coercion() -> None:
"""Verify integer type coercion from env var."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_CORE_SERVER_PORT"
old_val = os.environ.get(env_key)
try:
os.environ[env_key] = "9090"
result = svc.resolve("core.server_port")
if result.value == 9090 and isinstance(result.value, int):
print("config-resolution-type-coercion-ok")
else:
print(
f"FAIL: expected 9090 (int), got {result.value} "
f"({type(result.value).__name__})",
file=sys.stderr,
)
sys.exit(1)
finally:
if old_val is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
shutil.rmtree(str(tmpdir), ignore_errors=True)
def registry_keys() -> None:
"""Verify the registry contains expected keys."""
registry = ConfigService.registry()
expected = ["core.log_level", "plan.max_retries", "provider.temperature"]
for key in expected:
if key not in registry:
print(f"FAIL: {key} not in registry", file=sys.stderr)
sys.exit(1)
print("config-resolution-registry-ok")
def env_var_convention() -> None:
"""Verify env var naming convention."""
env_name = ConfigService.env_var_for_key("core.log_level")
if env_name == "CLEVERAGENTS_CORE_LOG_LEVEL":
print("config-resolution-env-convention-ok")
else:
print(
f"FAIL: expected CLEVERAGENTS_CORE_LOG_LEVEL, got {env_name}",
file=sys.stderr,
)
sys.exit(1)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"resolve-default": resolve_default,
"resolve-global": resolve_global,
"resolve-env": resolve_env,
"resolve-cli": resolve_cli,
"resolve-unknown-key": resolve_unknown_key,
"resolve-type-coercion": resolve_type_coercion,
"registry-keys": registry_keys,
"env-var-convention": env_var_convention,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
_COMMANDS[sys.argv[1]]()
+282
View File
@@ -0,0 +1,282 @@
"""Helper script for Robot Framework phase reversion tests."""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError
from cleveragents.domain.models.core.action import (
ActionArgument,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
PlanInvariant,
PlanPhase,
ProcessingState,
ProjectLink,
)
def _make_service() -> PlanLifecycleService:
"""Create a fresh PlanLifecycleService."""
return PlanLifecycleService(settings=Settings())
def _create_plan(
service: PlanLifecycleService,
action_name: str = "local/test-action",
profile: str = "manual",
) -> str:
"""Create an action and plan, return plan_id."""
service.create_action(
name=action_name,
description=f"Test {action_name}",
definition_of_done="Tests pass",
strategy_actor="local/strategist",
execution_actor="local/executor",
)
plan = service.use_action(action_name)
plan.automation_profile = AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
return plan.identity.plan_id
def _advance_to_execute(service: PlanLifecycleService, plan_id: str) -> None:
"""Advance plan to Execute phase."""
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
plan = service.get_plan(plan_id)
if plan.phase != PlanPhase.EXECUTE:
service.execute_plan(plan_id)
def _advance_to_apply_constrained(
service: PlanLifecycleService,
plan_id: str,
) -> None:
"""Advance plan to Apply/constrained."""
_advance_to_execute(service, plan_id)
service.start_execute(plan_id)
service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
if plan.phase != PlanPhase.APPLY:
service.apply_plan(plan_id)
plan = service.get_plan(plan_id)
if plan.processing_state == ProcessingState.QUEUED:
service.start_apply(plan_id)
plan = service.get_plan(plan_id)
plan.processing_state = ProcessingState.CONSTRAINED
plan.error_message = "Constraints too restrictive"
def _advance_to_applied(service: PlanLifecycleService, plan_id: str) -> None:
"""Advance plan to Applied terminal state."""
_advance_to_execute(service, plan_id)
service.start_execute(plan_id)
service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
if plan.phase != PlanPhase.APPLY:
service.apply_plan(plan_id)
plan = service.get_plan(plan_id)
if plan.processing_state == ProcessingState.QUEUED:
service.start_apply(plan_id)
service.complete_apply(plan_id)
def test_manual_revert() -> None:
"""Test manual reversion from Execute to Strategize."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_execute(service, plan_id)
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="test revert")
assert plan.phase == PlanPhase.STRATEGIZE
assert plan.processing_state == ProcessingState.QUEUED
assert plan.reversion_count == 1
assert len(plan.decisions) == 1
assert plan.decisions[0]["type"] == "reversion"
print("reversion-manual-ok")
def test_auto_revert_apply() -> None:
"""Test auto-reversion from constrained Apply."""
service = _make_service()
plan_id = _create_plan(service, profile="ci")
_advance_to_apply_constrained(service, plan_id)
plan = service.try_auto_revert_from_apply(plan_id, "constraints too strict")
assert plan.phase == PlanPhase.STRATEGIZE
assert plan.reversion_count == 1
print("reversion-auto-apply-ok")
def test_loop_guard() -> None:
"""Test loop guard prevents more than 3 reversions."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_execute(service, plan_id)
plan = service.get_plan(plan_id)
plan.reversion_count = 3
try:
service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="should fail")
print("ERROR: should have raised PlanError")
sys.exit(1)
except PlanError as e:
assert "maximum reversion count" in str(e).lower() or "max" in str(e).lower()
print("loop-guard-ok")
def test_profile_gate() -> None:
"""Test that manual profile blocks auto-reversion."""
service = _make_service()
plan_id = _create_plan(service, profile="manual")
_advance_to_apply_constrained(service, plan_id)
plan = service.try_auto_revert_from_apply(plan_id, "should stay")
assert plan.phase == PlanPhase.APPLY
assert plan.processing_state == ProcessingState.CONSTRAINED
print("profile-gate-ok")
def test_terminal_block() -> None:
"""Test that applied terminal plan cannot be reverted."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_applied(service, plan_id)
try:
service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="should fail")
print("ERROR: should have raised PlanError")
sys.exit(1)
except PlanError as e:
assert "terminal" in str(e).lower()
print("terminal-block-ok")
def test_context_preserved() -> None:
"""Test that plan context is preserved through reversion."""
service = _make_service()
service.create_action(
name="local/ctx-test",
description="Context test",
definition_of_done="Tests pass",
strategy_actor="local/strategist",
execution_actor="local/executor",
arguments=[
ActionArgument(
name="coverage",
arg_type=ArgumentType.INTEGER,
requirement=ArgumentRequirement.OPTIONAL,
description="Coverage threshold",
),
],
)
plan = service.use_action(
"local/ctx-test",
project_links=[ProjectLink(project_name="local/my-project")],
arguments={"coverage": 95},
invariants=[PlanInvariant(text="No regressions", source=InvariantSource.PLAN)],
)
plan.automation_profile = AutomationProfileRef(
profile_name="manual",
provenance=AutomationProfileProvenance.PLAN,
)
plan_id = plan.identity.plan_id
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
service.execute_plan(plan_id)
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="context check")
assert plan.arguments == {"coverage": 95}
assert len(plan.project_links) == 1
assert plan.project_links[0].project_name == "local/my-project"
inv_texts = [inv.text for inv in plan.invariants]
assert "No regressions" in inv_texts
print("context-preserved-ok")
def test_decision_recording() -> None:
"""Test that each reversion is recorded as a decision."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_execute(service, plan_id)
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="first")
assert len(plan.decisions) == 1
assert plan.decisions[0]["type"] == "reversion"
assert plan.decisions[0]["source_phase"] == "execute"
assert plan.decisions[0]["target_phase"] == "strategize"
assert plan.decisions[0]["reversion_number"] == 1
assert "timestamp" in plan.decisions[0]
print("decision-recording-ok")
def test_can_revert_to() -> None:
"""Test can_revert_to validation."""
service = _make_service()
plan_id = _create_plan(service)
plan = service.get_plan(plan_id)
# Strategize cannot revert to Apply or Action
plan.phase = PlanPhase.STRATEGIZE
plan.processing_state = ProcessingState.QUEUED
assert not plan.can_revert_to(PlanPhase.APPLY)
assert not plan.can_revert_to(PlanPhase.ACTION)
# Execute can revert to Strategize
plan.phase = PlanPhase.EXECUTE
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
# Apply can revert to Strategize
plan.phase = PlanPhase.APPLY
plan.processing_state = ProcessingState.CONSTRAINED
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
# Applied cannot revert
plan.processing_state = ProcessingState.APPLIED
assert not plan.can_revert_to(PlanPhase.STRATEGIZE)
# Max reversions reached blocks reversion
plan.phase = PlanPhase.EXECUTE
plan.processing_state = ProcessingState.QUEUED
plan.reversion_count = 3
assert not plan.can_revert_to(PlanPhase.STRATEGIZE)
print("can-revert-to-ok")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "manual_revert"
dispatch = {
"manual_revert": test_manual_revert,
"auto_revert_apply": test_auto_revert_apply,
"loop_guard": test_loop_guard,
"profile_gate": test_profile_gate,
"terminal_block": test_terminal_block,
"context_preserved": test_context_preserved,
"decision_recording": test_decision_recording,
"can_revert_to": test_can_revert_to,
}
fn = dispatch.get(cmd)
if fn:
fn()
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
+57
View File
@@ -0,0 +1,57 @@
*** Settings ***
Documentation End-to-end tests for phase reversion state machine
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_phase_reversion.py
*** Test Cases ***
Manual Reversion From Execute To Strategize
[Documentation] Revert a plan from Execute to Strategize manually
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} manual_revert cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} reversion-manual-ok
Auto Reversion From Constrained Apply
[Documentation] Automatically revert when Apply is constrained and profile permits
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} auto_revert_apply cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} reversion-auto-apply-ok
Loop Guard Blocks Excessive Reversions
[Documentation] Verify the max 3 reversions guard
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} loop_guard cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} loop-guard-ok
Profile Gated Auto Reversion
[Documentation] Manual profile blocks auto-reversion
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} profile_gate cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} profile-gate-ok
Terminal Plan Cannot Be Reverted
[Documentation] Applied plan cannot be reverted
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} terminal_block cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} terminal-block-ok
Reversion Preserves Context
[Documentation] Arguments and project links survive reversion
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} context_preserved cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} context-preserved-ok
Decision Recording On Reversion
[Documentation] Each reversion is logged as a decision
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} decision_recording cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} decision-recording-ok
Can Revert To Validation
[Documentation] Validate allowed reversion paths
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} can_revert_to cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} can-revert-to-ok
@@ -3,6 +3,12 @@
Contains service classes that orchestrate business operations.
"""
from cleveragents.application.services.config_service import (
ConfigEntry,
ConfigLevel,
ConfigService,
ResolvedValue,
)
from cleveragents.application.services.correction_service import (
CorrectionService,
)
@@ -25,10 +31,14 @@ from cleveragents.application.services.tool_registry_service import (
)
__all__ = [
"ConfigEntry",
"ConfigLevel",
"ConfigService",
"CorrectionService",
"InvariantService",
"PersistentSessionService",
"PlanExecutionContext",
"ResolvedValue",
"RuntimeExecuteActor",
"RuntimeExecuteResult",
"SkillRegistryService",
@@ -0,0 +1,590 @@
"""Config service with multi-level resolution chain and typed key registry.
Provides a resolution layer on top of ``Settings`` (pydantic-settings) that
manages TOML file persistence and implements a five-level precedence chain:
CLI flag > environment variable > project-scoped > global config > default
Each ``get`` call returns the winning value **and** the source level that
provided it, enabling transparent debugging of configuration provenance.
"""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
import tomlkit
# ---------------------------------------------------------------------------
# Public types
# ---------------------------------------------------------------------------
class ConfigLevel(Enum):
"""Precedence levels from highest to lowest priority."""
CLI_FLAG = "cli_flag"
ENV_VAR = "env_var"
PROJECT = "project"
GLOBAL = "global"
DEFAULT = "default"
@dataclass(frozen=True, slots=True)
class ConfigEntry:
"""Metadata for a registered configuration key."""
key: str
python_type: type[Any]
default: Any
env_var: str
project_scopable: bool
description: str
section: str
@dataclass(frozen=True, slots=True)
class ResolvedValue:
"""Result of resolving a configuration key through the precedence chain."""
key: str
value: Any
source: ConfigLevel
chain: list[dict[str, Any]] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Config key registry catalog
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, ConfigEntry] = {}
def _env_name(section: str, key: str) -> str:
"""Build ``CLEVERAGENTS_<SECTION>_<KEY>`` environment variable name."""
return f"CLEVERAGENTS_{section.upper()}_{key.upper()}"
def _register(
section: str,
name: str,
python_type: type[Any],
default: Any,
*,
project_scopable: bool = True,
description: str = "",
env_var: str | None = None,
) -> None:
full_key = f"{section}.{name}"
resolved_env = env_var if env_var is not None else _env_name(section, name)
_REGISTRY[full_key] = ConfigEntry(
key=full_key,
python_type=python_type,
default=default,
env_var=resolved_env,
project_scopable=project_scopable,
description=description,
section=section,
)
def _build_catalog() -> None:
"""Register the complete configuration catalog per spec."""
# -- core.* ---------------------------------------------------------------
_register("core", "log_level", str, "INFO", description="Logging verbosity level.")
_register(
"core",
"debug_enabled",
bool,
False,
description="Enable debug mode.",
)
_register(
"core",
"env",
str,
"development",
description="Runtime environment name.",
)
_register(
"core",
"data_dir",
str,
"data",
description="Base data directory path.",
)
_register(
"core",
"database_url",
str,
"sqlite:///cleveragents.db",
project_scopable=False,
description="Primary database URL.",
)
_register(
"core",
"server_host",
str,
"0.0.0.0",
project_scopable=False,
description="Server bind host.",
)
_register(
"core",
"server_port",
int,
8080,
project_scopable=False,
description="Server bind port.",
)
# -- plan.* ---------------------------------------------------------------
_register(
"plan",
"auto_apply",
bool,
False,
description="Automatically apply plans on completion.",
)
_register(
"plan",
"max_retries",
int,
3,
description="Maximum plan retry count.",
)
_register(
"plan",
"timeout_seconds",
int,
300,
description="Plan execution timeout in seconds.",
)
# -- provider.* -----------------------------------------------------------
_register(
"provider",
"default_provider",
str,
"",
project_scopable=False,
description="Default LLM provider name.",
)
_register(
"provider",
"default_model",
str,
"",
project_scopable=False,
description="Default LLM model name.",
)
_register(
"provider",
"temperature",
float,
0.7,
description="LLM sampling temperature.",
)
_register(
"provider",
"max_tokens",
int,
4096,
description="Maximum tokens per LLM response.",
)
# -- sandbox.* ------------------------------------------------------------
_register(
"sandbox",
"strategy",
str,
"git_worktree",
description="Default sandbox strategy.",
)
_register(
"sandbox",
"auto_cleanup",
bool,
True,
description="Automatically clean up sandbox after use.",
)
_register(
"sandbox",
"max_age_hours",
int,
48,
description="Max sandbox age in hours before cleanup.",
)
# -- context.* ------------------------------------------------------------
_register(
"context",
"max_files",
int,
100,
description="Maximum context files per session.",
)
_register(
"context",
"max_tokens",
int,
128000,
description="Maximum context token window.",
)
_register(
"context",
"auto_include_gitignore",
bool,
True,
description="Auto-include gitignore patterns in context filtering.",
)
# -- index.* --------------------------------------------------------------
_register(
"index",
"enabled",
bool,
False,
description="Enable vector-store indexing.",
)
_register(
"index",
"backend",
str,
"faiss",
description="Vector store backend name.",
)
_register(
"index",
"embedding_model",
str,
"fake",
description="Embedding model for vector indexing.",
)
_register(
"index",
"embedding_dimension",
int,
1536,
description="Embedding vector dimension.",
)
# Build catalog on module import
_build_catalog()
# ---------------------------------------------------------------------------
# ConfigService
# ---------------------------------------------------------------------------
_DEFAULT_CONFIG_DIR: Path = Path.home() / ".cleveragents"
_DEFAULT_CONFIG_PATH: Path = _DEFAULT_CONFIG_DIR / "config.toml"
class ConfigService:
"""Service providing multi-level config resolution with TOML persistence.
Parameters
----------
config_dir:
Override for the configuration directory (default ``~/.cleveragents``).
config_path:
Override for the TOML file path (default ``config_dir / config.toml``).
"""
def __init__(
self,
*,
config_dir: Path | None = None,
config_path: Path | None = None,
) -> None:
self._config_dir: Path = config_dir or _DEFAULT_CONFIG_DIR
self._config_path: Path = config_path or (self._config_dir / "config.toml")
# -- public registry API --------------------------------------------------
@staticmethod
def registry() -> dict[str, ConfigEntry]:
"""Return a copy of the full key registry."""
return dict(_REGISTRY)
@staticmethod
def get_entry(key: str) -> ConfigEntry | None:
"""Look up a registry entry by dotted key (e.g. ``core.log_level``)."""
return _REGISTRY.get(key)
@staticmethod
def registered_keys() -> list[str]:
"""Return all registered key names sorted alphabetically."""
return sorted(_REGISTRY.keys())
# -- TOML file management -------------------------------------------------
def read_config(self) -> dict[str, Any]:
"""Read the global TOML config file and return its contents."""
if not self._config_path.exists():
return {}
with open(self._config_path, "rb") as fh:
return tomllib.load(fh)
def write_config(self, data: dict[str, Any]) -> None:
"""Write *data* to the TOML config file, creating dirs if needed."""
self._config_dir.mkdir(parents=True, exist_ok=True)
if self._config_path.exists():
with open(self._config_path) as fh:
doc = tomlkit.load(fh)
else:
doc = tomlkit.document()
for key, value in data.items():
doc[key] = value
with open(self._config_path, "w") as fh:
tomlkit.dump(doc, fh)
def set_value(self, key: str, value: Any) -> None:
"""Persist a single key-value pair into the global TOML config."""
data = self.read_config()
data[key] = value
self.write_config(data)
# -- validation -----------------------------------------------------------
@staticmethod
def validate_key(key: str) -> ConfigEntry:
"""Validate that *key* is a known registered key.
Raises
------
ValueError
With an actionable message when the key is unknown.
"""
entry = _REGISTRY.get(key)
if entry is None:
available = ", ".join(sorted(_REGISTRY.keys())[:8])
msg = (
f"Unknown configuration key: '{key}'. "
f"Valid keys include: {available} ..."
)
raise ValueError(msg)
return entry
@staticmethod
def validate_type(key: str, value: Any) -> Any:
"""Coerce *value* to the registered type for *key*.
Raises
------
TypeError
With an actionable message on type mismatch.
"""
entry = _REGISTRY.get(key)
if entry is None:
msg = f"Cannot validate type for unknown key: '{key}'"
raise ValueError(msg)
if isinstance(value, entry.python_type):
return value
# Coerce from string representations
try:
if entry.python_type is bool and isinstance(value, str):
if value.lower() in ("true", "1", "yes"):
return True
if value.lower() in ("false", "0", "no"):
return False
msg = (
f"Cannot convert '{value}' to bool for key '{key}'. "
f"Use 'true'/'false', '1'/'0', or 'yes'/'no'."
)
raise TypeError(msg)
if entry.python_type is int:
return int(value)
if entry.python_type is float:
return float(value)
if entry.python_type is str:
return str(value)
except (ValueError, TypeError) as exc:
if isinstance(exc, TypeError):
raise
msg = (
f"Type mismatch for key '{key}': expected "
f"{entry.python_type.__name__}, got {type(value).__name__} "
f"with value '{value}'."
)
raise TypeError(msg) from exc
msg = (
f"Type mismatch for key '{key}': expected "
f"{entry.python_type.__name__}, got {type(value).__name__}."
)
raise TypeError(msg)
# -- resolution chain -----------------------------------------------------
def resolve(
self,
key: str,
*,
cli_value: Any | None = None,
project_name: str | None = None,
verbose: bool = False,
) -> ResolvedValue:
"""Resolve *key* through the five-level precedence chain.
Parameters
----------
key:
Dotted config key (e.g. ``core.log_level``).
cli_value:
Value explicitly passed via CLI flag (highest priority).
project_name:
Optional project name for project-scoped lookup.
verbose:
When ``True``, the full resolution chain is populated.
Returns
-------
ResolvedValue
Contains the winning value, its source level, and (if
*verbose*) the full resolution chain for display.
"""
entry = self.validate_key(key)
chain: list[dict[str, Any]] = []
winner_value: Any | None = None
winner_source: ConfigLevel | None = None
# Level 1: CLI flag
if cli_value is not None:
coerced = self.validate_type(key, cli_value)
if verbose:
chain.append({"source": ConfigLevel.CLI_FLAG.value, "value": coerced})
if winner_source is None:
winner_value = coerced
winner_source = ConfigLevel.CLI_FLAG
elif verbose:
chain.append({"source": ConfigLevel.CLI_FLAG.value, "value": None})
# Level 2: Environment variable
env_val = os.environ.get(entry.env_var)
if env_val is not None and winner_source is None:
coerced = self.validate_type(key, env_val)
if verbose:
chain.append(
{
"source": ConfigLevel.ENV_VAR.value,
"value": coerced,
"env_name": entry.env_var,
}
)
winner_value = coerced
winner_source = ConfigLevel.ENV_VAR
elif verbose:
chain.append(
{
"source": ConfigLevel.ENV_VAR.value,
"value": None,
"env_name": entry.env_var,
}
)
# Level 3: Project-scoped config
project_val: Any | None = None
if project_name and entry.project_scopable and winner_source is None:
config_data = self.read_config()
project_section = config_data.get("project", {})
if isinstance(project_section, dict):
project_overrides = project_section.get(project_name, {})
if isinstance(project_overrides, dict):
project_val = project_overrides.get(key)
if project_val is not None and winner_source is None:
coerced = self.validate_type(key, project_val)
if verbose:
chain.append({"source": ConfigLevel.PROJECT.value, "value": coerced})
winner_value = coerced
winner_source = ConfigLevel.PROJECT
elif verbose:
chain.append({"source": ConfigLevel.PROJECT.value, "value": None})
# Level 4: Global config file
global_val: Any | None = None
if winner_source is None:
config_data = self.read_config()
global_val = config_data.get(key)
if global_val is not None and winner_source is None:
coerced = self.validate_type(key, global_val)
if verbose:
chain.append(
{
"source": ConfigLevel.GLOBAL.value,
"value": coerced,
"path": str(self._config_path),
}
)
winner_value = coerced
winner_source = ConfigLevel.GLOBAL
elif verbose:
chain.append(
{
"source": ConfigLevel.GLOBAL.value,
"value": None,
"path": str(self._config_path),
}
)
# Level 5: Default
if verbose:
chain.append({"source": ConfigLevel.DEFAULT.value, "value": entry.default})
if winner_source is None:
winner_value = entry.default
winner_source = ConfigLevel.DEFAULT
return ResolvedValue(
key=key,
value=winner_value,
source=winner_source,
chain=chain,
)
# -- env var interpolation ------------------------------------------------
@staticmethod
def env_var_for_key(key: str) -> str:
"""Return the env var name for a registered key.
Raises
------
ValueError
If the key is not registered.
"""
entry = _REGISTRY.get(key)
if entry is None:
msg = f"Unknown configuration key: '{key}'"
raise ValueError(msg)
return entry.env_var
# -- bulk helpers ---------------------------------------------------------
def resolve_all(
self,
*,
cli_overrides: dict[str, Any] | None = None,
project_name: str | None = None,
) -> dict[str, ResolvedValue]:
"""Resolve every registered key and return the results."""
overrides = cli_overrides or {}
results: dict[str, ResolvedValue] = {}
for key in _REGISTRY:
results[key] = self.resolve(
key,
cli_value=overrides.get(key),
project_name=project_name,
)
return results
@@ -1188,3 +1188,209 @@ class PlanLifecycleService:
# Attempt immediate auto-progression
return self.auto_progress(plan_id)
# -- Phase reversion methods -----------------------------------------
def revert_plan(
self,
plan_id: str,
to_phase: PlanPhase,
reason: str | None = None,
) -> Plan:
"""Revert a plan to a previous phase (manual reversion).
This is the manual reversion entry point, invoked by the CLI
``plan revert`` command. It validates that the reversion is
allowed, records a reversion decision, and resets the plan to
the target phase.
Args:
plan_id: The plan ULID.
to_phase: The target phase to revert to.
reason: Human-readable reason for the reversion.
Returns:
The updated Plan in the reverted phase.
Raises:
NotFoundError: If plan not found.
InvalidPhaseTransitionError: If reversion is not valid.
PlanError: If the plan has exceeded the max reversion count.
"""
plan = self.get_plan(plan_id)
if plan.processing_state in (
ProcessingState.APPLIED,
ProcessingState.CANCELLED,
):
raise PlanError(
f"Plan {plan_id} is in terminal state "
f"({plan.processing_state.value}) and cannot be reverted"
)
if plan.reversion_count >= plan.MAX_REVERSIONS:
raise PlanError(
f"Plan {plan_id} has exceeded the maximum reversion count "
f"({plan.MAX_REVERSIONS}). Manual intervention required."
)
if not can_transition(plan.phase, to_phase):
raise InvalidPhaseTransitionError(
plan.phase,
to_phase,
message=(f"Cannot revert from {plan.phase.value} to {to_phase.value}"),
)
return self._perform_reversion(plan, to_phase, reason or "manual reversion")
def try_auto_revert_from_apply(self, plan_id: str, reason: str) -> Plan:
"""Attempt automatic reversion from a constrained Apply phase.
Called after ``constrain_apply`` when the automation profile
permits automatic reversion (``auto_reversion_from_apply < 1.0``).
Args:
plan_id: The plan ULID.
reason: Why the apply was constrained.
Returns:
The (possibly reverted) Plan.
"""
plan = self.get_plan(plan_id)
if plan.phase != PlanPhase.APPLY:
return plan
if plan.processing_state != ProcessingState.CONSTRAINED:
return plan
profile = self._resolve_profile_for_plan(plan)
if profile.auto_reversion_from_apply >= 1.0:
self._logger.info(
"Auto-reversion blocked by profile threshold",
plan_id=plan_id,
threshold=profile.auto_reversion_from_apply,
)
return plan
if plan.reversion_count >= plan.MAX_REVERSIONS:
self._logger.warning(
"Auto-reversion blocked by loop guard",
plan_id=plan_id,
reversion_count=plan.reversion_count,
max_reversions=plan.MAX_REVERSIONS,
)
return plan
self._logger.info(
"Auto-reverting plan from Apply to Strategize",
plan_id=plan_id,
reason=reason,
)
return self._perform_reversion(
plan,
PlanPhase.STRATEGIZE,
f"auto-reversion: {reason}",
)
def try_auto_revert_from_execute(self, plan_id: str, reason: str) -> Plan:
"""Attempt automatic reversion from Execute to Strategize.
Called when validation failures block apply and the automation
profile permits reversion.
Args:
plan_id: The plan ULID.
reason: Why the execute needs reversion.
Returns:
The (possibly reverted) Plan.
"""
plan = self.get_plan(plan_id)
if plan.phase != PlanPhase.EXECUTE:
return plan
profile = self._resolve_profile_for_plan(plan)
if profile.auto_strategy_revision >= 1.0:
self._logger.info(
"Execute-to-Strategize reversion blocked by profile threshold",
plan_id=plan_id,
threshold=profile.auto_strategy_revision,
)
return plan
if plan.reversion_count >= plan.MAX_REVERSIONS:
self._logger.warning(
"Execute-to-Strategize reversion blocked by loop guard",
plan_id=plan_id,
reversion_count=plan.reversion_count,
max_reversions=plan.MAX_REVERSIONS,
)
return plan
self._logger.info(
"Auto-reverting plan from Execute to Strategize",
plan_id=plan_id,
reason=reason,
)
return self._perform_reversion(
plan,
PlanPhase.STRATEGIZE,
f"auto-reversion: {reason}",
)
def _perform_reversion(
self,
plan: Plan,
to_phase: PlanPhase,
reason: str,
) -> Plan:
"""Execute a phase reversion on a plan.
Records the reversion decision, increments the reversion count,
and resets the plan to the target phase in QUEUED state.
Args:
plan: The plan to revert.
to_phase: Target phase.
reason: Reason for the reversion.
Returns:
The updated Plan.
"""
source_phase = plan.phase
plan_id = plan.identity.plan_id
# Record reversion decision
decision: dict[str, Any] = {
"type": "reversion",
"source_phase": source_phase.value,
"target_phase": to_phase.value,
"reason": reason,
"timestamp": datetime.now().isoformat(),
"reversion_number": plan.reversion_count + 1,
}
plan.decisions.append(decision)
# Update plan state
plan.phase = to_phase
plan.processing_state = ProcessingState.QUEUED
plan.reversion_count += 1
plan.error_message = None
plan.timestamps.updated_at = datetime.now()
self._commit_plan(plan)
self._logger.info(
"Plan reverted",
plan_id=plan_id,
from_phase=source_phase.value,
to_phase=to_phase.value,
reason=reason,
reversion_count=plan.reversion_count,
)
return plan
+49 -16
View File
@@ -95,7 +95,8 @@ def _action_spec_dict(action: Action) -> dict[str, object]:
Keys: namespaced_name, short_name, state, description,
definition_of_done, strategy_actor, execution_actor,
automation_profile, arguments, invariants.
estimation_actor, invariant_actor, automation_profile,
arguments, inputs_schema, invariants.
"""
result: dict[str, object] = {
"namespaced_name": str(action.namespaced_name),
@@ -105,21 +106,27 @@ def _action_spec_dict(action: Action) -> dict[str, object]:
"definition_of_done": action.definition_of_done,
"strategy_actor": action.strategy_actor,
"execution_actor": action.execution_actor,
"automation_profile": action.automation_profile,
"arguments": [
{
"name": arg.name,
"type": arg.arg_type.value,
"required": arg.requirement.value == "required",
"description": arg.description,
}
for arg in action.arguments
],
"invariants": action.invariants,
"reusable": action.reusable,
"read_only": action.read_only,
"created_at": action.created_at.isoformat(),
}
if action.estimation_actor:
result["estimation_actor"] = action.estimation_actor
if action.invariant_actor:
result["invariant_actor"] = action.invariant_actor
result["automation_profile"] = action.automation_profile
result["arguments"] = [
{
"name": arg.name,
"type": arg.arg_type.value,
"required": arg.requirement.value == "required",
"description": arg.description,
}
for arg in action.arguments
]
if action.inputs_schema:
result["inputs_schema"] = action.inputs_schema
result["invariants"] = action.invariants
result["reusable"] = action.reusable
result["read_only"] = action.read_only
result["created_at"] = action.created_at.isoformat()
return result
@@ -158,13 +165,39 @@ def _print_action(
f"[bold]State:[/bold] {action.state.value}\n"
f"[bold]Strategy Actor:[/bold] {action.strategy_actor}\n"
f"[bold]Execution Actor:[/bold] {action.execution_actor}\n"
)
# Optional actors
if action.estimation_actor:
details += f"[bold]Estimation Actor:[/bold] {action.estimation_actor}\n"
if action.invariant_actor:
details += f"[bold]Invariant Actor:[/bold] {action.invariant_actor}\n"
details += (
f"[bold]Reusable:[/bold] {'yes' if action.reusable else 'no'}\n"
f"[bold]Read Only:[/bold] {'yes' if action.read_only else 'no'}\n"
f"[bold]Definition of Done:[/bold]\n {dod_summary}\n"
f"[bold]Arguments:[/bold]\n{args_display}\n"
f"[bold]Created:[/bold] {action.created_at}"
)
# Invariants
if action.invariants:
inv_lines = "\n".join(f"{inv}" for inv in action.invariants)
details += f"[bold]Invariants:[/bold]\n{inv_lines}\n"
# Inputs schema
if action.inputs_schema:
import json
schema_str = json.dumps(action.inputs_schema, indent=2)
details += f"[bold]Inputs Schema:[/bold]\n {schema_str}\n"
# Automation profile
if action.automation_profile:
details += f"[bold]Automation Profile:[/bold] {action.automation_profile}\n"
details += f"[bold]Created:[/bold] {action.created_at}"
console.print(Panel(details, title=title, expand=False))
+146 -1
View File
@@ -31,6 +31,7 @@ plan lifecycle.
from __future__ import annotations
import os
import re
import warnings
from contextlib import suppress
from typing import TYPE_CHECKING, Annotated, Any
@@ -48,6 +49,31 @@ from cleveragents.core.exceptions import (
ValidationError,
)
# Regex for validating namespaced actor names (namespace/name format)
_NAMESPACED_ACTOR_RE = re.compile(r"^[a-z][a-z0-9-]*/[a-z][a-z0-9._-]*$")
def validate_namespaced_actor(value: str, flag_name: str) -> str:
"""Validate that an actor name follows ``namespace/name`` format.
Args:
value: The actor name string to validate.
flag_name: CLI flag name for error messages.
Returns:
The validated actor name (unchanged).
Raises:
ValidationError: If the value does not match namespace/name.
"""
if not _NAMESPACED_ACTOR_RE.match(value):
raise ValidationError(
f"Actor '{value}' for {flag_name} must follow "
f"'namespace/name' format (e.g., 'openai/gpt-4')."
)
return value
_LEGACY_DEPRECATION_MSG = (
"This command uses the legacy plan workflow and is deprecated. "
"Use 'agents plan use <action> [project]' for the v3 lifecycle."
@@ -102,10 +128,17 @@ def _plan_spec_dict(plan: Any) -> dict[str, object]:
"definition_of_done": plan.definition_of_done,
"strategy_actor": plan.strategy_actor,
"execution_actor": plan.execution_actor,
"estimation_actor": plan.estimation_actor,
"invariant_actor": plan.invariant_actor,
"created_at": plan.timestamps.created_at.isoformat(),
"updated_at": plan.timestamps.updated_at.isoformat(),
"is_terminal": plan.is_terminal,
}
if plan.invariants:
result["invariants"] = [
{"text": inv.text, "source": inv.source.value}
for inv in plan.invariants
]
if plan.error_message:
result["error_message"] = plan.error_message
return result
@@ -1138,6 +1171,12 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
f"(source: {plan.automation_profile.provenance.value})\n"
)
# Invariants
if plan.invariants:
details += "[bold]Invariants:[/bold]\n"
for inv in plan.invariants:
details += f" [{inv.source.value}] {inv.text}\n"
details += f"[bold]Terminal:[/bold] {'yes' if plan.is_terminal else 'no'}\n"
# Timestamps
@@ -1339,14 +1378,18 @@ def use_action(
provenance=AutomationProfileProvenance.PLAN,
)
# Apply actor overrides if provided
# Validate and apply actor overrides if provided
if strategy_actor:
validate_namespaced_actor(strategy_actor, "--strategy-actor")
plan.strategy_actor = strategy_actor
if execution_actor:
validate_namespaced_actor(execution_actor, "--execution-actor")
plan.execution_actor = execution_actor
if estimation_actor:
validate_namespaced_actor(estimation_actor, "--estimation-actor")
plan.estimation_actor = estimation_actor
if invariant_actor:
validate_namespaced_actor(invariant_actor, "--invariant-actor")
plan.invariant_actor = invariant_actor
if fmt != OutputFormat.RICH.value:
@@ -1567,14 +1610,24 @@ def plan_status(
table.add_column("Name", style="white")
table.add_column("Phase", style="yellow")
table.add_column("State", style="magenta")
table.add_column("Profile", style="blue")
table.add_column("Invariants", style="dim")
table.add_column("Terminal", justify="center")
for plan in plans:
profile = (
plan.automation_profile.profile_name
if plan.automation_profile
else ""
)
inv_count = str(len(plan.invariants)) if plan.invariants else ""
table.add_row(
plan.identity.plan_id[:8] + "...",
str(plan.namespaced_name),
plan.phase.value,
plan.state.value if plan.state else "",
profile,
inv_count,
"" if plan.is_terminal else "",
)
@@ -1736,6 +1789,8 @@ def lifecycle_list_plans(
table.add_column("Action", style="blue")
table.add_column("Phase", style="yellow")
table.add_column("State", style="magenta")
table.add_column("Profile", style="blue")
table.add_column("Invariants", style="dim")
table.add_column("Projects", style="green")
table.add_column("Created", style="dim")
@@ -1745,12 +1800,19 @@ def lifecycle_list_plans(
if len(link_names) > 2:
projects += f" +{len(link_names) - 2} more"
profile = (
plan.automation_profile.profile_name if plan.automation_profile else ""
)
inv_count = str(len(plan.invariants)) if plan.invariants else ""
table.add_row(
plan.identity.plan_id[:8] + "...",
str(plan.namespaced_name),
plan.action_name,
plan.phase.value,
plan.state.value if plan.state else "",
profile,
inv_count,
projects or "(none)",
plan.timestamps.created_at.strftime("%Y-%m-%d %H:%M"),
)
@@ -1812,6 +1874,89 @@ def cancel_plan(
raise typer.Abort() from e
@app.command("revert")
def revert_plan(
plan_id: Annotated[
str,
typer.Argument(help="Plan ID to revert"),
],
to_phase: Annotated[
str,
typer.Option(
"--to-phase",
help="Target phase to revert to (e.g., strategize)",
),
] = "strategize",
reason: Annotated[
str | None,
typer.Option(
"--reason",
"-r",
help="Reason for the reversion",
),
] = None,
fmt: Annotated[
str,
typer.Option(
"--format",
"-f",
help=_FORMAT_HELP,
),
] = "rich",
) -> None:
"""Revert a plan to a previous phase.
This command reverts a plan back to a previous phase (typically
Strategize) so it can be re-planned with different constraints.
Examples:
agents plan revert PLAN123 --to-phase strategize
agents plan revert PLAN123 --to-phase strategize \
--reason "constraints too strict"
"""
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
)
from cleveragents.domain.models.core.plan import PlanPhase
try:
service = _get_lifecycle_service()
# Parse the target phase
try:
target_phase = PlanPhase(to_phase.lower())
except ValueError as exc:
valid_phases = ", ".join(p.value for p in PlanPhase)
console.print(
f"[red]Invalid phase:[/red] {to_phase}. Valid phases: {valid_phases}"
)
raise typer.Abort() from exc
plan = service.revert_plan(plan_id, target_phase, reason=reason)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
data["reversion_reason"] = reason
console.print(format_output(data, fmt))
else:
_print_lifecycle_plan(plan, title="Plan Reverted")
console.print(
"\n[dim]Plan reverted to "
f"{target_phase.value} phase. "
f"Reversion count: {plan.reversion_count}/{plan.MAX_REVERSIONS}[/dim]"
)
except InvalidPhaseTransitionError as e:
console.print(f"[red]Invalid reversion:[/red] {e}")
raise typer.Abort() from e
except PlanError as e:
console.print(f"[red]Cannot revert:[/red] {e.message}")
raise typer.Abort() from e
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
def _get_apply_service():
"""Get the PlanApplyService from the lifecycle service."""
from cleveragents.application.services.plan_apply_service import (
@@ -662,6 +662,19 @@ class Plan(BaseModel):
description="Status tracking for spawned subplans",
)
# Phase reversion tracking
reversion_count: int = Field(
default=0,
ge=0,
description="Number of phase reversions performed on this plan",
)
decisions: list[dict[str, Any]] = Field(
default_factory=list,
description="Decision log entries including reversions",
)
MAX_REVERSIONS: ClassVar[int] = 3
@model_validator(mode="after")
def validate_phase_state_consistency(self) -> Plan:
"""Ensure phase and state are consistent.
@@ -772,6 +785,29 @@ class Plan(BaseModel):
"""Check if this plan has spawned subplans."""
return len(self.subplan_statuses) > 0
def can_revert_to(self, phase: PlanPhase) -> bool:
"""Check if reversion to the given phase is valid.
Reversion is valid when:
- The plan is not in a permanently terminal state (APPLIED or CANCELLED).
- The target phase is a valid reversion target from the current phase.
- The reversion count has not exceeded MAX_REVERSIONS.
Args:
phase: The target phase to revert to.
Returns:
True if reversion is allowed.
"""
if self.processing_state in (
ProcessingState.APPLIED,
ProcessingState.CANCELLED,
):
return False
if self.reversion_count >= self.MAX_REVERSIONS:
return False
return can_transition(self.phase, phase)
def as_cli_dict(self) -> dict[str, Any]:
"""Return a stable dictionary representation for CLI output.
+17
View File
@@ -234,6 +234,23 @@ _get_apply_service # noqa: B018, F821
plan_diff # noqa: B018, F821
plan_artifacts # noqa: B018, F821
# Config service — public API for multi-level resolution
ConfigService # noqa: B018, F821
ConfigEntry # noqa: B018, F821
ConfigLevel # noqa: B018, F821
ResolvedValue # noqa: B018, F821
_build_catalog # noqa: B018, F821
_DEFAULT_CONFIG_DIR # noqa: B018, F821
_DEFAULT_CONFIG_PATH # noqa: B018, F821
resolve_all # noqa: B018, F821
env_var_for_key # noqa: B018, F821
registered_keys # noqa: B018, F821
validate_type # noqa: B018, F821
set_value # noqa: B018, F821
write_config # noqa: B018, F821
read_config # noqa: B018, F821
get_entry # noqa: B018, F821
# ToolCallRouter — public API for provider-agnostic tool routing
ToolCallRouter # noqa: B018, F821
ToolCallRequest # noqa: B018, F821