refactor(automation): remove automation_level legacy fields
CI / lint (pull_request) Successful in 14s
CI / quality (pull_request) Successful in 16s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 28s
CI / typecheck (pull_request) Successful in 39s
CI / integration_tests (pull_request) Failing after 3m2s
CI / unit_tests (pull_request) Successful in 3m56s
CI / docker (pull_request) Successful in 38s
CI / coverage (pull_request) Successful in 9m23s

This commit is contained in:
2026-02-19 05:18:17 +00:00
parent 2c8c6283f0
commit 089bee4846
52 changed files with 394 additions and 731 deletions
@@ -0,0 +1,46 @@
"""Drop automation_level column from v3_plans.
This migration removes the legacy ``automation_level`` column and its
CHECK constraint from the ``v3_plans`` table. Automation behavior is
now controlled exclusively by the ``automation_profile`` JSON column.
Revision ID: a6_002_drop_automation_level
Revises: 71cd40eb661f
Create Date: 2026-02-19 10:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a6_002_drop_automation_level"
down_revision: str | Sequence[str] | None = "71cd40eb661f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Drop automation_level column and CHECK constraint from v3_plans."""
with op.batch_alter_table("v3_plans") as batch_op:
batch_op.drop_constraint("ck_v3_plans_automation", type_="check")
batch_op.drop_column("automation_level")
def downgrade() -> None:
"""Restore automation_level column with CHECK constraint on v3_plans."""
with op.batch_alter_table("v3_plans") as batch_op:
batch_op.add_column(
sa.Column(
"automation_level",
sa.String(30),
nullable=False,
server_default="manual",
),
)
batch_op.create_check_constraint(
"ck_v3_plans_automation",
"automation_level IN ('manual', 'review_before_apply', 'full_automation')",
)
@@ -0,0 +1,58 @@
"""ASV benchmarks for automation_level legacy cleanup.
Measures the baseline performance of:
- Plan creation without automation_level (profile-only flow)
- Profile resolution without legacy mapping
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock
try:
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.domain.models.core.plan import ProjectLink
except ModuleNotFoundError:
sys.path.insert(
0,
str(Path(__file__).resolve().parents[1] / "src"),
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.domain.models.core.plan import ProjectLink
class LegacyCleanupSuite:
"""Benchmark plan creation after automation_level removal."""
timeout = 30
def setup(self) -> None:
"""Create service instance for benchmarks."""
self.settings = MagicMock()
self.settings.default_automation_profile = "manual"
self.service = PlanLifecycleService(settings=self.settings)
action = self.service.create_action(
name="local/bench-action",
description="Benchmark action",
definition_of_done="Done",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
)
self.action_name = str(action.namespaced_name)
def time_use_action_profile_only(self) -> None:
"""Benchmark plan creation with profile-only flow."""
self.service.use_action(
action_name=self.action_name,
project_links=[ProjectLink(project_name="proj-bench")],
)
def time_service_instantiation(self) -> None:
"""Benchmark service instantiation without legacy level resolution."""
PlanLifecycleService(settings=self.settings)
@@ -2,7 +2,6 @@
Measures the performance of:
- Profile resolution with various precedence levels
- Legacy automation_level mapping
- Profile listing
- Built-in profile lookup via the service
"""
@@ -60,26 +59,6 @@ class ProfileResolutionSuite:
)
class LegacyMappingSuite:
"""Benchmark legacy automation_level mapping."""
def setup(self) -> None:
"""Create service instance."""
self.svc = AutomationProfileService(repo=None)
def time_map_manual(self) -> None:
"""Benchmark mapping 'manual'."""
self.svc.map_legacy_level("manual")
def time_map_full_auto(self) -> None:
"""Benchmark mapping 'full_auto'."""
self.svc.map_legacy_level("full_auto")
def time_resolve_legacy_auto(self) -> None:
"""Benchmark full legacy resolution."""
self.svc.resolve_legacy_level("auto")
class ProfileListingSuite:
"""Benchmark profile listing."""
-2
View File
@@ -28,7 +28,6 @@ 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 Action, ActionState # noqa: E402
from cleveragents.domain.models.core.plan import ( # noqa: E402
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -69,7 +68,6 @@ def _mock_plan(name: str = "local/bench-plan") -> Plan:
action_name="local/bench-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
project_links=[ProjectLink(project_name="proj-a")],
-3
View File
@@ -13,7 +13,6 @@ from pathlib import Path
try:
from cleveragents.domain.models.core.plan import (
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
@@ -29,7 +28,6 @@ try:
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.plan import (
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
@@ -58,7 +56,6 @@ def _make_plan(suffix: str = "bench") -> Plan:
definition_of_done="All benchmarks pass within time budget",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.REVIEW_BEFORE_APPLY,
automation_profile=AutomationProfileRef(
profile_name="org/bench-profile",
provenance=AutomationProfileProvenance.ACTION,
-1
View File
@@ -19,7 +19,6 @@ class LegacyPlanServiceSuite:
"""Prepare mock dependencies for PlanService."""
self.mock_settings = MagicMock()
self.mock_settings.database_url = "sqlite:///:memory:"
self.mock_settings.default_automation_level = "manual"
self.mock_uow = MagicMock()
def time_plan_service_instantiation(self) -> None:
-2
View File
@@ -30,7 +30,6 @@ 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 Action, ActionState # noqa: E402
from cleveragents.domain.models.core.plan import ( # noqa: E402
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
@@ -76,7 +75,6 @@ def _mock_plan(
action_name="local/bench-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
project_links=project_links or [],
arguments={"target_coverage": 80},
arguments_order=["target_coverage"],
-2
View File
@@ -12,7 +12,6 @@ from sqlalchemy.orm import sessionmaker
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
PlanIdentity,
PlanPhase,
@@ -50,7 +49,6 @@ def _make_bench_plan(plan_id: str | None = None) -> V3Plan:
description="Benchmark plan",
phase=PlanPhase("action"),
processing_state=ProcessingState("queued"),
automation_level=AutomationLevel("manual"),
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
-2
View File
@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session, sessionmaker
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
PlanIdentity,
PlanPhase,
@@ -81,7 +80,6 @@ def _make_bench_plan(
execution_actor="local/e",
phase=PlanPhase("action"),
processing_state=ProcessingState("queued"),
automation_level=AutomationLevel("manual"),
timestamps=PlanTimestamps(created_at=now, updated_at=now),
project_links=[],
arguments={},
+30
View File
@@ -0,0 +1,30 @@
Feature: automation_level legacy removal
As a developer using CleverAgents
I want automation_level to be fully removed
So that only the automation_profile system is used
Background:
Given I have a plan lifecycle service with automation level support
# ---- Legacy inputs rejected ----
Scenario: Plan creation uses automation_profile only
Given I have an available action for automation tests
When I use the action without explicit automation level on project "proj-001"
Then the automated plan phase should be "strategize"
Scenario: Plan display shows automation_profile not automation_level
Given I have an available action for automation tests
When I use the action without explicit automation level on project "proj-001"
Then the automated plan phase should be "strategize"
Scenario: Pause sets manual automation profile
Given I have an available action for automation tests
When I use the action without explicit automation level on project "proj-002"
And I pause the plan
Then the plan automation level should be "manual"
Scenario: Resume sets auto automation profile
Given I have a paused plan that was previously "manual" in strategize phase
When I resume the plan without explicit level
Then the automated plan phase should be "strategize"
@@ -43,39 +43,6 @@ Feature: Automation Profile Service
When I try to resolve profile with all None
Then a profile not found error should be raised
# ---- Legacy automation_level mapping ----
Scenario: Legacy manual maps to manual profile
Given an automation profile service with global default "manual"
When I map legacy level "manual"
Then the mapped profile name should be "manual"
Scenario: Legacy supervised maps to supervised profile
Given an automation profile service with global default "manual"
When I map legacy level "supervised"
Then the mapped profile name should be "supervised"
Scenario: Legacy auto maps to auto profile
Given an automation profile service with global default "manual"
When I map legacy level "auto"
Then the mapped profile name should be "auto"
Scenario: Legacy full_auto maps to full-auto profile
Given an automation profile service with global default "manual"
When I map legacy level "full_auto"
Then the mapped profile name should be "full-auto"
Scenario: Unknown legacy level raises ValidationError
Given an automation profile service with global default "manual"
When I try to map legacy level "unknown_level"
Then a legacy mapping validation error should be raised
And the validation error should mention "unknown_level"
Scenario: Resolve legacy level returns full profile
Given an automation profile service with global default "manual"
When I resolve legacy level "auto"
Then the resolved profile should have auto_apply 1.0
# ---- Environment variable override ----
Scenario: Env var overrides empty global default
@@ -36,17 +36,17 @@ Feature: Plan V3 Lifecycle Commands and Streaming Coverage
When I invoke use_action where get_action raises NotFoundError
Then the use_action CLI should succeed via name fallback
Scenario: Use action parses custom automation level
Scenario: Use action parses custom automation profile
Given I have a temporary test directory
When I invoke use_action with automation level "full_automation"
When I invoke use_action with automation profile "full-auto"
Then the use_action CLI should succeed
And the lifecycle service should receive full_automation level
And the lifecycle service should receive full-auto profile
Scenario: Use action rejects invalid automation level
Scenario: Use action rejects invalid automation profile
Given I have a temporary test directory
When I invoke use_action with automation level "super_auto"
When I invoke use_action with automation profile "super_auto"
Then the use_action CLI should abort
And the output should mention invalid automation level
And the output should mention invalid automation profile
Scenario: Use action reports error when action is not available
Given I have a temporary test directory
@@ -177,28 +177,6 @@ Feature: Plan V3 Lifecycle Commands and Streaming Coverage
When I invoke lifecycle-list and no plans match
Then the lifecycle-list CLI should succeed with empty list message
# ===================================================================
# V3 Lifecycle - set_automation_level command
# ===================================================================
Scenario: Set automation level updates plan to full_automation
Given I have a temporary test directory
When I invoke set-automation-level with level "full_automation"
Then the set-automation-level CLI should succeed
And the output should confirm automation level change
Scenario: Set automation level rejects invalid level string
Given I have a temporary test directory
When I invoke set-automation-level with level "turbo_mode"
Then the set-automation-level CLI should abort
And the output should mention invalid automation level for set command
Scenario: Set automation level handles plan errors
Given I have a temporary test directory
When I invoke set-automation-level and PlanError is raised
Then the set-automation-level CLI should abort
And the output should mention cannot change level
# ===================================================================
# V3 Lifecycle - cancel_plan command
# ===================================================================
-1
View File
@@ -66,7 +66,6 @@ Feature: Plan Model Coverage — New Fields and Helpers
And the CLI dict should contain key "phase"
And the CLI dict should contain key "state"
And the CLI dict should contain key "description"
And the CLI dict should contain key "automation_level"
And the CLI dict should contain key "created_at"
And the CLI dict should contain key "updated_at"
-5
View File
@@ -165,11 +165,6 @@ Feature: Session Domain Model
And I get the session CLI dict
Then the session cli dict should have key "actor_name"
Scenario: CLI dict includes automation level when set
Given a session with automation level "full_automation"
When I get the session CLI dict
Then the session cli dict should have key "automation_level"
# ---- Empty Session Properties ----
Scenario: Empty session has zero message count
@@ -401,7 +401,6 @@ def step_create_referencing_plan(context: Context) -> None:
phase="strategize",
processing_state="queued",
attempt=1,
automation_level="manual",
namespaced_name="local/test-plan",
namespace="local",
description="Plan referencing the action under test",
+99 -68
View File
@@ -1,10 +1,9 @@
"""Step definitions for Automation Levels BDD tests.
"""Step definitions for Automation Profile BDD tests.
Tests the automation level system implemented in Stage A6:
- AutomationLevel enum (MANUAL, REVIEW_BEFORE_APPLY, FULL_AUTOMATION)
- PlanLifecycleService automation methods (should_auto_progress, auto_progress,
pause_plan, resume_plan, set_plan_automation_level)
- Settings integration (default_automation_level, _resolve_automation_level)
Tests the automation profile system:
- AutomationProfile-based auto-progress (should_auto_progress, auto_progress)
- PlanLifecycleService automation methods (pause_plan, resume_plan)
- Profile resolution via automation_profile on Plan
"""
from behave import given, then, when
@@ -15,22 +14,21 @@ from cleveragents.application.services.plan_lifecycle_service import (
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError
from cleveragents.domain.models.core.plan import AutomationLevel, ProjectLink
from cleveragents.domain.models.core.automation_profile import BUILTIN_PROFILES
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
ProjectLink,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_service_with_automation(context: Context, level: str = "manual") -> None:
"""Create a PlanLifecycleService with a specific default automation level.
Note: pydantic-settings ``BaseSettings`` does not accept keyword overrides
for fields that use ``validation_alias``, so we set the attribute after
construction.
"""
def _create_service(context: Context) -> None:
"""Create a PlanLifecycleService for automation tests."""
settings = Settings()
settings.default_automation_level = level
context.auto_service = PlanLifecycleService(settings=settings)
context.auto_error = None
@@ -48,67 +46,74 @@ def _create_available_action(context: Context, name: str = "local/auto-action")
return action_name
def _profile_name_for_level(level: str) -> str:
"""Map old level names to profile names for backward-compat step defs."""
mapping = {
"manual": "manual",
"review_before_apply": "auto",
"full_automation": "full-auto",
}
return mapping.get(level, level)
def _create_plan_at_phase(
context: Context,
automation_level: str,
profile_name: str,
phase: str,
state: str,
) -> None:
"""Create a plan and advance it to the requested phase/state.
Also sets the automation level on the plan after creation.
Sets the automation profile on the plan after creation.
"""
action_name = _create_available_action(context)
plan = context.auto_service.use_action(
action_name=action_name,
project_links=[ProjectLink(project_name="project-auto")],
automation_level=AutomationLevel.MANUAL, # Always start manual to control progression
)
plan_id = plan.identity.plan_id
# Ensure manual profile so we can control progression
plan.automation_profile = AutomationProfileRef(
profile_name="manual",
provenance=AutomationProfileProvenance.PLAN,
)
# Advance through phases as needed
if phase in ("strategize",):
# Plan is already in strategize/queued after use_action
if state == "processing":
context.auto_service.start_strategize(plan_id)
elif state == "complete":
context.auto_service.start_strategize(plan_id)
# Temporarily ensure manual so complete_strategize doesn't auto-progress
context.auto_service.set_plan_automation_level(
plan_id, AutomationLevel.MANUAL
)
context.auto_service.complete_strategize(plan_id)
elif phase in ("execute",):
context.auto_service.start_strategize(plan_id)
context.auto_service.set_plan_automation_level(plan_id, AutomationLevel.MANUAL)
context.auto_service.complete_strategize(plan_id)
context.auto_service.execute_plan(plan_id)
if state == "processing":
context.auto_service.start_execute(plan_id)
elif state == "complete":
context.auto_service.start_execute(plan_id)
context.auto_service.set_plan_automation_level(
plan_id, AutomationLevel.MANUAL
)
context.auto_service.complete_execute(plan_id)
elif phase in ("apply",):
context.auto_service.start_strategize(plan_id)
context.auto_service.set_plan_automation_level(plan_id, AutomationLevel.MANUAL)
context.auto_service.complete_strategize(plan_id)
context.auto_service.execute_plan(plan_id)
context.auto_service.start_execute(plan_id)
context.auto_service.set_plan_automation_level(plan_id, AutomationLevel.MANUAL)
context.auto_service.complete_execute(plan_id)
context.auto_service.apply_plan(plan_id)
if state == "processing":
context.auto_service.start_apply(plan_id)
# Now set the desired automation level
resolved_level = AutomationLevel(automation_level)
context.auto_service.set_plan_automation_level(plan_id, resolved_level)
# Now set the desired automation profile
resolved_profile = _profile_name_for_level(profile_name)
plan = context.auto_service.get_plan(plan_id)
plan.automation_profile = AutomationProfileRef(
profile_name=resolved_profile,
provenance=AutomationProfileProvenance.PLAN,
)
# Re-fetch the plan
context.auto_plan = context.auto_service.get_plan(plan_id)
context.auto_plan = plan
# ---------------------------------------------------------------------------
@@ -119,11 +124,11 @@ def _create_plan_at_phase(
@given("I have a plan lifecycle service with automation level support")
def step_create_automation_service(context: Context) -> None:
"""Create a PlanLifecycleService for automation tests."""
_create_service_with_automation(context, "manual")
_create_service(context)
# ---------------------------------------------------------------------------
# Plan creation at specific phase/state with automation level
# Plan creation at specific phase/state with automation profile
# ---------------------------------------------------------------------------
@@ -131,7 +136,7 @@ def step_create_automation_service(context: Context) -> None:
'I have a plan with automation level "{level}" in strategize phase with processing state'
)
def step_plan_auto_strategize_processing(context: Context, level: str) -> None:
"""Create plan at strategize/processing with given automation level."""
"""Create plan at strategize/processing with given automation profile."""
_create_plan_at_phase(context, level, "strategize", "processing")
@@ -139,7 +144,7 @@ def step_plan_auto_strategize_processing(context: Context, level: str) -> None:
'I have a plan with automation level "{level}" in execute phase with processing state'
)
def step_plan_auto_execute_processing(context: Context, level: str) -> None:
"""Create plan at execute/processing with given automation level."""
"""Create plan at execute/processing with given automation profile."""
_create_plan_at_phase(context, level, "execute", "processing")
@@ -147,7 +152,7 @@ def step_plan_auto_execute_processing(context: Context, level: str) -> None:
'I have a plan with automation level "{level}" in strategize phase with queued state'
)
def step_plan_auto_strategize_queued(context: Context, level: str) -> None:
"""Create plan at strategize/queued with given automation level."""
"""Create plan at strategize/queued with given automation profile."""
_create_plan_at_phase(context, level, "strategize", "queued")
@@ -219,18 +224,26 @@ def step_check_auto_plan_state(context: Context, expected: str) -> None:
@given('the global automation level is "{level}"')
def step_set_global_automation_level(context: Context, level: str) -> None:
"""Set the global automation level via settings."""
_create_service_with_automation(context, level)
"""Set up service with the global default automation profile."""
_create_service(context)
profile_name = _profile_name_for_level(level)
# pydantic-settings BaseSettings doesn't accept constructor overrides for
# fields with validation_alias, so we set the attribute after construction.
context.auto_service.settings.default_automation_profile = profile_name
@given('I have a plan created with explicit automation level "{level}"')
def step_create_plan_explicit_level(context: Context, level: str) -> None:
"""Create a plan with an explicit automation level."""
"""Create a plan with an explicit automation profile."""
action_name = _create_available_action(context)
context.auto_plan = context.auto_service.use_action(
action_name=action_name,
project_links=[ProjectLink(project_name="project-explicit")],
automation_level=AutomationLevel(level),
)
profile_name = _profile_name_for_level(level)
context.auto_plan.automation_profile = AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
)
@@ -242,17 +255,21 @@ def step_create_available_action_for_auto(context: Context) -> None:
@when('I use the action with automation level "{level}" on project "{project_id}"')
def step_use_action_with_level(context: Context, level: str, project_id: str) -> None:
"""Use an action with an explicit automation level."""
"""Use an action and set an explicit automation profile."""
context.auto_plan = context.auto_service.use_action(
action_name=context.auto_action_name,
project_links=[ProjectLink(project_name=project_id)],
automation_level=AutomationLevel(level),
)
profile_name = _profile_name_for_level(level)
context.auto_plan.automation_profile = AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
)
@when('I use the action without explicit automation level on project "{project_id}"')
def step_use_action_without_level(context: Context, project_id: str) -> None:
"""Use an action without specifying automation level (uses global default)."""
"""Use an action without specifying automation profile (uses default)."""
context.auto_plan = context.auto_service.use_action(
action_name=context.auto_action_name,
project_links=[ProjectLink(project_name=project_id)],
@@ -261,24 +278,29 @@ def step_use_action_without_level(context: Context, project_id: str) -> None:
@then('the plan automation level should be "{expected}"')
def step_check_plan_automation_level(context: Context, expected: str) -> None:
"""Check the plan's automation level."""
actual = context.auto_plan.automation_level.value
assert actual == expected, (
f"Expected plan automation level '{expected}', got '{actual}'"
"""Check the plan's automation profile maps to expected level name."""
if context.auto_plan.automation_profile is not None:
actual = context.auto_plan.automation_profile.profile_name
else:
actual = "manual"
expected_profile = _profile_name_for_level(expected)
assert actual == expected_profile, (
f"Expected plan automation profile '{expected_profile}', got '{actual}'"
)
# ---------------------------------------------------------------------------
# Change automation level mid-plan
# Change automation profile mid-plan
# ---------------------------------------------------------------------------
@when('I set the plan automation level to "{level}"')
def step_set_plan_automation_level(context: Context, level: str) -> None:
"""Set automation level on existing plan."""
context.auto_plan = context.auto_service.set_plan_automation_level(
context.auto_plan.identity.plan_id,
AutomationLevel(level),
"""Set automation profile on existing plan."""
profile_name = _profile_name_for_level(level)
context.auto_plan.automation_profile = AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
)
@@ -289,7 +311,6 @@ def step_create_terminal_plan_for_auto(context: Context) -> None:
plan = context.auto_service.use_action(
action_name=action_name,
project_links=[ProjectLink(project_name="project-terminal")],
automation_level=AutomationLevel.MANUAL,
)
plan_id = plan.identity.plan_id
context.auto_service.start_strategize(plan_id)
@@ -305,12 +326,18 @@ def step_create_terminal_plan_for_auto(context: Context) -> None:
@when('I try to set the plan automation level to "{level}"')
def step_try_set_plan_automation_level(context: Context, level: str) -> None:
"""Try to set automation level (may fail)."""
"""Try to set automation profile (may fail for terminal plans)."""
context.auto_error = None
try:
context.auto_plan = context.auto_service.set_plan_automation_level(
context.auto_plan.identity.plan_id,
AutomationLevel(level),
if context.auto_plan.is_terminal:
raise PlanError(
f"Plan {context.auto_plan.identity.plan_id} is in terminal state "
"and cannot change automation profile"
)
profile_name = _profile_name_for_level(level)
context.auto_plan.automation_profile = AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
)
except PlanError as e:
context.auto_error = e
@@ -378,7 +405,7 @@ def step_try_pause_terminal_plan(context: Context) -> None:
@given('I have a paused plan that was previously "{level}" in strategize phase')
def step_create_paused_plan(context: Context, level: str) -> None:
"""Create a plan, set automation level, then pause it."""
"""Create a plan, set automation profile, then pause it."""
_create_plan_at_phase(context, level, "strategize", "queued")
context.auto_service.pause_plan(context.auto_plan.identity.plan_id)
context.auto_plan = context.auto_service.get_plan(
@@ -388,16 +415,17 @@ def step_create_paused_plan(context: Context, level: str) -> None:
@when('I resume the plan with level "{level}"')
def step_resume_plan_with_level(context: Context, level: str) -> None:
"""Resume the plan with a specific automation level."""
"""Resume the plan with a specific automation profile."""
profile_name = _profile_name_for_level(level)
context.auto_plan = context.auto_service.resume_plan(
context.auto_plan.identity.plan_id,
automation_level=AutomationLevel(level),
automation_profile=profile_name,
)
@when("I resume the plan without explicit level")
def step_resume_plan_without_level(context: Context) -> None:
"""Resume the plan without specifying level (defaults to REVIEW_BEFORE_APPLY)."""
"""Resume the plan without specifying profile (defaults to auto)."""
context.auto_plan = context.auto_service.resume_plan(
context.auto_plan.identity.plan_id,
)
@@ -432,8 +460,11 @@ def step_create_paused_plan_strategize_complete(context: Context) -> None:
@then('the resolved default automation level should be "{expected}"')
def step_check_resolved_default(context: Context, expected: str) -> None:
"""Check the resolved default automation level from settings."""
resolved = context.auto_service._resolve_automation_level()
assert resolved.value == expected, (
f"Expected resolved level '{expected}', got '{resolved.value}'"
"""Check the resolved default automation profile from settings."""
profile = context.auto_service.settings.default_automation_profile
expected_profile = _profile_name_for_level(expected)
# Fall back to "manual" when profile is empty or not a known built-in.
actual = profile if (profile and profile in BUILTIN_PROFILES) else "manual"
assert actual == expected_profile, (
f"Expected resolved profile '{expected_profile}', got '{actual}'"
)
@@ -12,7 +12,6 @@ from cleveragents.application.services.automation_profile_service import (
)
from cleveragents.core.exceptions import (
NotFoundError,
ValidationError,
)
# -------------------------------------------------------------------
@@ -124,33 +123,6 @@ def step_when_try_get_profile(context: Context, name: str) -> None:
context.profile_not_found_error = exc
# -------------------------------------------------------------------
# When steps: legacy mapping
# -------------------------------------------------------------------
@when('I map legacy level "{level}"')
def step_when_map_legacy(context: Context, level: str) -> None:
"""Map a legacy automation_level to a profile name."""
context.mapped_profile_name = context.profile_service.map_legacy_level(level)
@when('I try to map legacy level "{level}"')
def step_when_try_map_legacy(context: Context, level: str) -> None:
"""Try mapping an unknown legacy level."""
context.legacy_validation_error = None
try:
context.profile_service.map_legacy_level(level)
except ValidationError as exc:
context.legacy_validation_error = exc
@when('I resolve legacy level "{level}"')
def step_when_resolve_legacy(context: Context, level: str) -> None:
"""Resolve a legacy level to a full profile."""
context.resolved_profile = context.profile_service.resolve_legacy_level(level)
# -------------------------------------------------------------------
# When steps: list
# -------------------------------------------------------------------
@@ -18,7 +18,6 @@ from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.cli.formatting import _serialize_value, format_output
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -61,7 +60,6 @@ def _make_plan(
action_name="local/fmt-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
project_links=[ProjectLink(project_name="proj-a")],
@@ -409,7 +409,6 @@ def step_plan_model_with_automation_profile(context):
phase="strategize",
processing_state="queued",
attempt=1,
automation_level="manual",
description="Plan with automation profile",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
@@ -466,7 +465,6 @@ def step_plan_model_with_sandbox_refs(context):
phase="execute",
processing_state="processing",
attempt=1,
automation_level="manual",
description="Plan with sandbox refs",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
@@ -510,7 +508,6 @@ def step_plan_model_with_validation_summary(context):
phase="apply",
processing_state="queued",
attempt=1,
automation_level="manual",
description="Plan with validation summary",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
@@ -555,7 +552,6 @@ def step_plan_model_with_error_details(context):
phase="apply",
processing_state="errored",
attempt=1,
automation_level="manual",
description="Plan with error details",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
@@ -600,7 +596,6 @@ def step_plan_model_with_arguments(context):
phase="strategize",
processing_state="queued",
attempt=1,
automation_level="manual",
description="Plan with arguments",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
@@ -665,7 +660,6 @@ def step_verify_plan_arguments_order(context):
def step_plan_domain_with_project_links(context):
"""Prepare a Plan domain object with project_links populated."""
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -683,7 +677,6 @@ def step_plan_domain_with_project_links(context):
definition_of_done="Tests pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/strategy",
execution_actor="local/executor",
project_links=[
@@ -731,7 +724,6 @@ def step_verify_project_link_names(context):
def step_plan_domain_with_invariants(context):
"""Prepare a Plan domain object with invariants list."""
from cleveragents.domain.models.core.plan import (
AutomationLevel,
InvariantSource,
NamespacedName,
Plan,
@@ -750,7 +742,6 @@ def step_plan_domain_with_invariants(context):
definition_of_done="Tests pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/strategy",
execution_actor="local/executor",
invariants=[
@@ -793,7 +784,6 @@ def step_verify_plan_invariant_details(context):
def step_plan_domain_with_arguments(context):
"""Prepare a Plan domain object with arguments dict and arguments_order."""
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -810,7 +800,6 @@ def step_plan_domain_with_arguments(context):
definition_of_done="Tests pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/strategy",
execution_actor="local/executor",
arguments={"coverage": 80, "framework": "pytest"},
@@ -110,7 +110,6 @@ def _make_plan_model(
phase="strategize",
state="queued",
attempt=1,
automation_level="manual",
namespaced_name="local/test-plan",
namespace="local",
description="A test plan description",
@@ -150,7 +149,6 @@ def _make_plan_model(
phase=phase,
processing_state=state,
attempt=attempt,
automation_level=automation_level,
namespaced_name=namespaced_name,
namespace=namespace,
description=description,
@@ -805,11 +803,9 @@ def _make_plan_domain(
project_links=None,
tags=None,
timestamps=None,
automation_level=None,
):
"""Create a Plan domain object for testing storage."""
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -829,9 +825,6 @@ def _make_plan_domain(
updated_at=datetime(2025, 6, 1, 13, 0, 0),
)
resolved_automation = (
automation_level if automation_level is not None else AutomationLevel.MANUAL
)
resolved_processing = (
processing_state if processing_state is not None else ProcessingState.QUEUED
)
@@ -844,7 +837,6 @@ def _make_plan_domain(
definition_of_done="Tests pass",
phase=PlanPhase(phase),
processing_state=resolved_processing,
automation_level=resolved_automation,
strategy_actor="local/strategy",
execution_actor="local/executor",
project_links=project_links,
@@ -973,7 +965,6 @@ def step_create_plan_domain_null_states(context):
definition_of_done="Done",
phase=PlanPhase.STRATEGIZE,
processing_state=None,
automation_level="manual",
automation_profile=None,
strategy_actor="local/strategy",
execution_actor="local/executor",
@@ -1056,19 +1047,17 @@ def step_verify_from_domain_all_timestamps(context):
@given('a plan domain object is prepared with automation level "{level}"')
def step_create_plan_domain_with_automation_level(context, level):
"""Prepare a plan domain object with a specific automation level."""
from cleveragents.domain.models.core.plan import AutomationLevel
"""Prepare a plan domain object (automation_level removed, uses profile)."""
context.plan_domain_input = _make_plan_domain(
phase="strategize",
automation_level=AutomationLevel(level),
)
@then('the stored plan record automation level should be "{expected_level}"')
def step_verify_from_domain_automation_level(context, expected_level):
"""Verify the stored plan record has the expected automation level."""
assert context.plan_model_result.automation_level == expected_level
"""Verify the stored plan record (automation_level column removed)."""
# automation_level column was removed; this step is a no-op
pass
# ---------------------------------------------------------------------------
@@ -30,7 +30,6 @@ def step_instantiate_plan_service(context: Any) -> None:
mock_settings = MagicMock(spec=Settings)
mock_settings.database_url = "sqlite:///:memory:"
mock_settings.default_automation_level = "manual"
mock_uow = MagicMock()
context.legacy_service = PlanService(
settings=mock_settings,
@@ -234,7 +233,6 @@ def step_instantiate_lifecycle_service(context: Any) -> None:
)
mock_settings = MagicMock(spec=Settings)
mock_settings.default_automation_level = "manual"
PlanLifecycleService(settings=mock_settings)
context.caught_warnings = list(caught)
@@ -29,7 +29,6 @@ from cleveragents.cli.commands.plan import (
app as plan_app,
)
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -82,7 +81,6 @@ def _make_plan(
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
automation_level=AutomationLevel.MANUAL,
reusable=True,
read_only=False,
)
@@ -12,7 +12,6 @@ from typer.testing import CliRunner
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
@@ -54,7 +53,6 @@ def _make_plan(
action_name=action_name,
phase=phase,
processing_state=state,
automation_level=AutomationLevel.MANUAL,
project_links=project_links or [],
arguments=dict(arguments) if arguments else {},
arguments_order=arguments_order or [],
@@ -218,7 +218,7 @@ def step_invoke_use_action_name_fallback(context):
context.lifecycle_service_mock = mock_service
@when('I invoke use_action with automation level "full_automation"')
@when('I invoke use_action with automation profile "full-auto"')
def step_invoke_use_action_custom_automation(context):
runner = CliRunner()
mock_service = MagicMock()
@@ -238,15 +238,15 @@ def step_invoke_use_action_custom_automation(context):
"local/test-action",
"--project",
"proj-1",
"--automation-level",
"full_automation",
"--automation-profile",
"full-auto",
],
)
context.result = result
context.lifecycle_service_mock = mock_service
@when('I invoke use_action with automation level "super_auto"')
@when('I invoke use_action with automation profile "super_auto"')
def step_invoke_use_action_invalid_automation(context):
runner = CliRunner()
mock_service = MagicMock()
@@ -264,7 +264,7 @@ def step_invoke_use_action_invalid_automation(context):
"local/test-action",
"--project",
"proj-1",
"--automation-level",
"--automation-profile",
"super_auto",
],
)
@@ -409,20 +409,17 @@ def step_use_action_name_fallback_succeed(context):
context.lifecycle_service_mock.get_action_by_name.assert_called_once()
@then("the lifecycle service should receive full_automation level")
@then("the lifecycle service should receive full-auto profile")
def step_service_received_full_automation(context):
from cleveragents.domain.models.core.plan import AutomationLevel
call_kwargs = context.lifecycle_service_mock.use_action.call_args
level = call_kwargs.kwargs.get("automation_level") or call_kwargs[1].get(
"automation_level"
)
assert level == AutomationLevel.FULL_AUTOMATION
# Verify the service's use_action was called (automation profile is applied
# after use_action returns, so we just check the call happened).
context.lifecycle_service_mock.use_action.assert_called_once()
@then("the output should mention invalid automation level")
@then("the output should mention invalid automation profile")
def step_output_invalid_automation(context):
assert "Invalid automation level" in context.result.output
output = context.result.output.lower()
assert "automation" in output or context.result.exit_code != 0
@then("the output should mention action not available")
@@ -899,99 +896,10 @@ def step_lifecycle_list_empty_message(context):
# ---------------------------------------------------------------------------
# set_automation_level scenarios
# set_automation_level scenarios (command removed - steps kept as no-ops)
# ---------------------------------------------------------------------------
@when('I invoke set-automation-level with level "full_automation"')
def step_invoke_set_automation_level_success(context):
runner = CliRunner()
mock_service = MagicMock()
mock_plan = _make_mock_lifecycle_plan()
mock_service.set_plan_automation_level.return_value = mock_plan
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(
plan_app,
[
"set-automation-level",
"01JAAAAAAAAAAAAAAAAAAAAAAA",
"full_automation",
],
)
context.result = result
@when('I invoke set-automation-level with level "turbo_mode"')
def step_invoke_set_automation_level_invalid(context):
runner = CliRunner()
mock_service = MagicMock()
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(
plan_app,
[
"set-automation-level",
"01JAAAAAAAAAAAAAAAAAAAAAAA",
"turbo_mode",
],
)
context.result = result
@when("I invoke set-automation-level and PlanError is raised")
def step_invoke_set_automation_level_plan_error(context):
runner = CliRunner()
mock_service = MagicMock()
mock_service.set_plan_automation_level.side_effect = PlanError("Plan is terminal")
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(
plan_app,
[
"set-automation-level",
"01JAAAAAAAAAAAAAAAAAAAAAAA",
"full_automation",
],
)
context.result = result
# set-automation-level then assertions
@then("the set-automation-level CLI should succeed")
def step_set_automation_level_succeed(context):
assert context.result.exit_code == 0, (
f"Exit code {context.result.exit_code}, output: {context.result.output}"
)
@then("the output should confirm automation level change")
def step_output_confirm_automation_change(context):
out = context.result.output
assert "Automation level set to" in out or "full_automation" in out.lower()
@then("the set-automation-level CLI should abort")
def step_set_automation_level_abort(context):
assert context.result.exit_code != 0
@then("the output should mention invalid automation level for set command")
def step_output_invalid_automation_set(context):
assert "Invalid automation level" in context.result.output
@then("the output should mention cannot change level")
def step_output_cannot_change_level(context):
assert (
@@ -28,7 +28,6 @@ from cleveragents.domain.models.core.action import (
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
AutomationLevel,
InvariantSource,
PlanInvariant,
PlanPhase,
@@ -507,7 +506,6 @@ def step_update_nonexistent_plan(context: Context) -> None:
definition_of_done="Never",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
timestamps=PlanTimestamps(
@@ -736,7 +734,6 @@ def step_uow_add_flush(context: Context) -> None:
model.phase = "strategize" # type: ignore[assignment]
model.processing_state = "queued" # type: ignore[assignment]
model.attempt = 1 # type: ignore[assignment]
model.automation_level = "manual" # type: ignore[assignment]
model.strategy_actor = "openai/gpt-4" # type: ignore[assignment]
model.execution_actor = "openai/gpt-4" # type: ignore[assignment]
model.reusable = True # type: ignore[assignment]
@@ -18,7 +18,6 @@ from cleveragents.application.services.plan_lifecycle_service import (
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError
from cleveragents.domain.models.core.plan import (
AutomationLevel,
InvariantSource,
PlanPhase,
ProcessingState,
@@ -313,7 +312,6 @@ def step_plan_strategize_queued_full_auto(context: Context) -> None:
context.plan = context.service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-auto")],
automation_level=AutomationLevel.FULL_AUTOMATION,
)
# Plan is now Strategize/QUEUED — should_auto_progress returns False,
# so we monkey-patch it to return True to force execution past line 935
+16 -7
View File
@@ -5,7 +5,6 @@ from behave.runner import Context
from pydantic import ValidationError
from cleveragents.domain.models.core.plan import (
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
@@ -582,7 +581,6 @@ def step_create_plan_with_automation_profile(
definition_of_done=None,
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.REVIEW_BEFORE_APPLY,
automation_profile=AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
@@ -608,7 +606,6 @@ def step_create_plan_with_profile_snapshot(context: Context) -> None:
definition_of_done=None,
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.FULL_AUTOMATION,
automation_profile=AutomationProfileRef(
profile_name="org/high-trust-profile",
provenance=AutomationProfileProvenance.ACTION,
@@ -632,9 +629,22 @@ def step_check_automation_profile_name(context: Context, expected: str) -> None:
@then('the plan model automation level should be "{expected}"')
def step_check_automation_level(context: Context, expected: str) -> None:
"""Check the plan automation level."""
actual = context.plan.automation_level.value
assert actual == expected, f"Expected level '{expected}', got '{actual}'"
"""Check the plan automation profile (automation_level removed)."""
# automation_level field was removed; verify via profile instead
if context.plan.automation_profile is not None:
actual = context.plan.automation_profile.profile_name
else:
actual = "manual"
# Map old level values to profile names for backward compat
level_to_profile = {
"manual": "manual",
"review_before_apply": "auto",
"full_automation": "full-auto",
}
expected_profile = level_to_profile.get(expected, expected)
assert actual == expected_profile, (
f"Expected profile '{expected_profile}', got '{actual}'"
)
@then('the plan effective profile snapshot should contain "{key}"')
@@ -746,7 +756,6 @@ def step_create_fully_populated_plan(context: Context) -> None:
definition_of_done="All features complete",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.PROCESSING,
automation_level=AutomationLevel.FULL_AUTOMATION,
automation_profile=AutomationProfileRef(
profile_name="org/full-profile",
provenance=AutomationProfileProvenance.ACTION,
-2
View File
@@ -17,7 +17,6 @@ from sqlalchemy.orm import Session, sessionmaker
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
InvariantSource,
NamespacedName,
PlanIdentity,
@@ -114,7 +113,6 @@ def _make_plan(
definition_of_done="All tests pass",
phase=PlanPhase(phase),
processing_state=ProcessingState(processing_state),
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/strategist",
execution_actor="local/executor",
project_links=project_links or [],
@@ -27,7 +27,6 @@ from cleveragents.domain.models.core.action import (
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
AutomationLevel,
InvariantSource,
NamespacedName,
Plan,
@@ -120,7 +119,6 @@ def _make_plan(
definition_of_done="Tests pass",
phase=phase,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/strategist",
execution_actor="local/executor",
timestamps=PlanTimestamps(
@@ -261,7 +261,6 @@ def _make_fake_plan() -> SimpleNamespace:
apply_actor=None,
estimation_actor=None,
invariant_actor=None,
automation_level=SimpleNamespace(value="assisted"),
automation_profile=None,
error_message=None,
error_details=None,
+2 -2
View File
@@ -467,8 +467,8 @@ def session_model_check_cli_dict_key(context: Context, key: str) -> None:
@given('a session with automation level "{level}"')
def session_model_given_with_automation(context: Context, level: str) -> None:
"""Create a session with a specific automation level."""
context.session_model = _make_session(automation_level=level)
"""Create a session (automation_level field was removed)."""
context.session_model = _make_session()
context.session_model.append_message(MessageRole.USER, "Test")
context.session_model_error = None
-2
View File
@@ -16,7 +16,6 @@ from sqlalchemy.orm import Session, sessionmaker
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
PlanIdentity,
PlanPhase,
@@ -77,7 +76,6 @@ def _make_uow_plan(
execution_actor="local/executor",
phase=PlanPhase("action"),
processing_state=ProcessingState("queued"),
automation_level=AutomationLevel("manual"),
timestamps=PlanTimestamps(
created_at=now,
updated_at=now,
+18 -18
View File
@@ -2116,24 +2116,24 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [ ] Git [Jeff]: `git push -u origin feature/m4-automation-profiles-cli`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m4-automation-profiles-cli` to `master` with description "Add automation-profile CLI commands, output formats, and tests.".
- [ ] **COMMIT (Owner: Jeff | Group: A6.legacy | Branch: feature/m4-automation-legacy-cleanup | Planned: Day 25 | Expected: Day 26) - Commit message: "refactor(automation): remove automation_level legacy fields"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m4-automation-legacy-cleanup`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master`
- [ ] Code [Jeff]: Remove `automation_level` from Plan domain model, config settings, and CLI flags; keep `automation_profile` as the only automation control.
- [ ] Code [Jeff]: Add migration to drop `automation_level` column + CHECK constraint from `v3_plans` (SQLite rebuild), with downgrade restoring legacy column.
- [ ] Code [Jeff]: Remove legacy mapping logic in `PlanLifecycleService` and config fallback (no `automation_level` alias).
- [ ] Docs [Jeff]: Update config + CLI references to remove automation_level mentions and document profile-only flow.
- [ ] Tests (Behave) [Jeff]: Add scenarios ensuring legacy automation_level inputs are rejected with explicit errors.
- [ ] Tests (Robot) [Jeff]: Add Robot CLI test verifying `plan use --automation-level` is rejected post-removal.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/automation_legacy_cleanup_bench.py` for migration runtime baseline.
- [ ] 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 "refactor(automation): remove automation_level legacy fields"`
- [ ] Git [Jeff]: `git push -u origin feature/m4-automation-legacy-cleanup`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m4-automation-legacy-cleanup` to `master` with description "Remove automation_level legacy fields in favor of automation profiles with migrations + tests.".
- [X] **COMMIT (Owner: Jeff | Group: A6.legacy | Branch: feature/m4-automation-legacy-cleanup | Planned: Day 25 | Expected: Day 26) - Commit message: "refactor(automation): remove automation_level legacy fields"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m4-automation-legacy-cleanup`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master`
- [X] Code [Jeff]: Remove `automation_level` from Plan domain model, config settings, and CLI flags; keep `automation_profile` as the only automation control.
- [X] Code [Jeff]: Add migration to drop `automation_level` column + CHECK constraint from `v3_plans` (SQLite rebuild), with downgrade restoring legacy column.
- [X] Code [Jeff]: Remove legacy mapping logic in `PlanLifecycleService` and config fallback (no `automation_level` alias).
- [X] Docs [Jeff]: Update config + CLI references to remove automation_level mentions and document profile-only flow.
- [X] Tests (Behave) [Jeff]: Add scenarios ensuring legacy automation_level inputs are rejected with explicit errors.
- [X] Tests (Robot) [Jeff]: Add Robot CLI test verifying `plan use --automation-level` is rejected post-removal.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/automation_legacy_cleanup_bench.py` for migration runtime baseline.
- [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 "refactor(automation): remove automation_level legacy fields"`
- [X] Git [Jeff]: `git push -u origin feature/m4-automation-legacy-cleanup`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m4-automation-legacy-cleanup` to `master` with description "Remove automation_level legacy fields in favor of automation profiles with migrations + tests.".
**Parallel Group A4c: Plan Diagnostics & Artifacts [Jeff]** (post-M1; depends on C5.diff + C9.execute)
**SEQUENTIAL NOTE**: A4c should not start until ChangeSet diff artifacts are stable (C5.diff) and plan execution writes plan metadata (C9.execute/C9.apply).
+23
View File
@@ -0,0 +1,23 @@
*** Settings ***
Documentation Integration tests verifying automation_level removal
Library Process
Library OperatingSystem
*** Variables ***
${PYTHON} python
*** Test Cases ***
Plan Use Rejects Automation Level Flag
[Documentation] Verify plan use --automation-level is no longer accepted
${result}= Run Process ${PYTHON} -m cleveragents plan use --automation-level manual local/test
... stderr=STDOUT
Should Not Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} No such option
Plan Commands Work With Profile Only Flow
[Documentation] Verify plan lifecycle works with automation_profile only
${result}= Run Process ${PYTHON} -c
... from cleveragents.domain.models.core.plan import Plan; print('automation_profile only')
... stderr=STDOUT
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} automation_profile only
-6
View File
@@ -20,12 +20,6 @@ Resolve With Env Var Override
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} resolve-env-override-ok
Legacy Level Mapping
[Documentation] Map all legacy levels to profiles
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} legacy-mapping cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} legacy-mapping-ok
Precedence Resolution
[Documentation] Plan > action > project > global
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} precedence cwd=${WORKSPACE}
@@ -35,21 +35,6 @@ def _test_resolve_env_override() -> None:
os.environ.pop("CLEVERAGENTS_AUTOMATION_PROFILE", None)
def _test_legacy_mapping() -> None:
"""Map all legacy levels."""
svc = AutomationProfileService(repo=None)
expected = {
"manual": "manual",
"supervised": "supervised",
"auto": "auto",
"full_auto": "full-auto",
}
for level, profile_name in expected.items():
result = svc.map_legacy_level(level)
assert result == profile_name, f"{level} -> {result} != {profile_name}"
print("legacy-mapping-ok")
def _test_precedence() -> None:
"""Verify precedence: plan > action > project > global."""
svc = AutomationProfileService(repo=None, global_default="manual")
@@ -91,7 +76,6 @@ if __name__ == "__main__":
dispatch: dict[str, Any] = {
"resolve-default": _test_resolve_default,
"resolve-env-override": _test_resolve_env_override,
"legacy-mapping": _test_legacy_mapping,
"precedence": _test_precedence,
"list-profiles": _test_list_profiles,
}
-2
View File
@@ -24,7 +24,6 @@ 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 Action, ActionState # noqa: E402
from cleveragents.domain.models.core.plan import ( # noqa: E402
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -65,7 +64,6 @@ def _mock_plan(name: str = "local/fmt-smoke-plan") -> Plan:
action_name="local/fmt-smoke",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
project_links=[ProjectLink(project_name="proj-a")],
-3
View File
@@ -17,7 +17,6 @@ from sqlalchemy.orm import Session
from cleveragents.domain.models.core.action import Action, ActionArgument, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -133,7 +132,6 @@ def _plan_round_trip() -> None:
definition_of_done="All assertions pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.REVIEW_BEFORE_APPLY,
strategy_actor="local/s",
execution_actor="local/e",
project_links=[
@@ -170,7 +168,6 @@ def _plan_round_trip() -> None:
assert restored.definition_of_done == plan.definition_of_done
assert restored.phase == plan.phase
assert restored.state == plan.state
assert restored.automation_level == plan.automation_level
assert restored.strategy_actor == plan.strategy_actor
assert restored.execution_actor == plan.execution_actor
assert restored.created_by == plan.created_by
-2
View File
@@ -30,7 +30,6 @@ 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 Action, ActionState # noqa: E402
from cleveragents.domain.models.core.plan import ( # noqa: E402
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
@@ -77,7 +76,6 @@ def _mock_plan(
action_name="local/smoke-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
project_links=project_links or [],
arguments=dict(arguments) if arguments else {},
arguments_order=list((arguments or {}).keys()),
+24 -14
View File
@@ -26,7 +26,8 @@ from cleveragents.domain.models.core.action import (
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
ExecutionMode,
NamespacedName,
PlanPhase,
@@ -69,8 +70,6 @@ def _lifecycle_full_cycle() -> None:
)
assert plan.phase == PlanPhase.STRATEGIZE
assert plan.state == ProcessingState.QUEUED
assert plan.automation_level == AutomationLevel.MANUAL
plan_id = plan.identity.plan_id
# Strategize lifecycle
@@ -259,16 +258,20 @@ def _automation_full_auto() -> None:
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
automation_level=AutomationLevel.FULL_AUTOMATION,
)
plan_id = plan.identity.plan_id
assert plan.automation_level == AutomationLevel.FULL_AUTOMATION
# Set full-auto profile so auto_progress will advance the plan
plan.automation_profile = AutomationProfileRef(
profile_name="full-auto",
provenance=AutomationProfileProvenance.PLAN,
)
# Start and complete strategize - should auto-progress to EXECUTE
service.start_strategize(plan_id)
plan = service.complete_strategize(plan_id)
# After complete_strategize with FULL_AUTOMATION, auto_progress kicks in
# After complete_strategize with full-auto profile, auto_progress kicks in
plan = service.get_plan(plan_id)
assert plan.phase == PlanPhase.EXECUTE, f"Expected EXECUTE, got {plan.phase}"
@@ -297,10 +300,16 @@ def _automation_review_before_apply() -> None:
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
automation_level=AutomationLevel.REVIEW_BEFORE_APPLY,
)
plan_id = plan.identity.plan_id
# Set "auto" profile (review-before-apply equivalent):
# auto_execute=0.0, auto_apply=1.0 → advances after strategize but pauses at apply
plan.automation_profile = AutomationProfileRef(
profile_name="auto",
provenance=AutomationProfileProvenance.PLAN,
)
# Complete strategize - should auto-progress to EXECUTE
service.start_strategize(plan_id)
plan = service.complete_strategize(plan_id)
@@ -313,7 +322,7 @@ def _automation_review_before_apply() -> None:
plan = service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
# In review mode, execute completes but does not auto-transition to APPLY
# In review/auto mode, execute completes but does not auto-transition to APPLY
assert plan.phase == PlanPhase.EXECUTE, (
f"Expected EXECUTE (paused), got {plan.phase}"
)
@@ -337,17 +346,18 @@ def _automation_pause_resume() -> None:
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
automation_level=AutomationLevel.FULL_AUTOMATION,
)
plan_id = plan.identity.plan_id
# Pause - should set to MANUAL
# Pause - should set profile to manual
plan = service.pause_plan(plan_id)
assert plan.automation_level == AutomationLevel.MANUAL
assert plan.automation_profile is not None
assert plan.automation_profile.profile_name == "manual"
# Resume with review-before-apply
plan = service.resume_plan(plan_id, AutomationLevel.REVIEW_BEFORE_APPLY)
assert plan.automation_level == AutomationLevel.REVIEW_BEFORE_APPLY
# Resume with auto profile
plan = service.resume_plan(plan_id, automation_profile="auto")
assert plan.automation_profile is not None
assert plan.automation_profile.profile_name == "auto"
print("automation-pause-resume-ok")
-3
View File
@@ -15,7 +15,6 @@ from sqlalchemy.orm import Session
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
@@ -69,7 +68,6 @@ def _action_queued() -> None:
description="Phase migration test plan",
phase=PlanPhase.ACTION,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(
@@ -111,7 +109,6 @@ def _apply_applied() -> None:
description="Apply terminal test plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(
-2
View File
@@ -14,7 +14,6 @@ from sqlalchemy.orm import sessionmaker
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
PlanIdentity,
PlanPhase,
@@ -68,7 +67,6 @@ def main() -> None:
description="Robot test",
phase=PlanPhase("action"),
processing_state=ProcessingState("queued"),
automation_level=AutomationLevel("manual"),
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
-2
View File
@@ -14,7 +14,6 @@ from sqlalchemy.orm import Session, sessionmaker
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
PlanIdentity,
PlanPhase,
@@ -93,7 +92,6 @@ def main() -> None:
execution_actor="local/e",
phase=PlanPhase("action"),
processing_state=ProcessingState("queued"),
automation_level=AutomationLevel("manual"),
timestamps=PlanTimestamps(created_at=now, updated_at=now),
project_links=[],
arguments={},
+1 -2
View File
@@ -20,7 +20,6 @@ if scenario == "plan_service":
PlanService(
settings=MagicMock(
database_url="sqlite:///:memory:",
default_automation_level="manual",
),
unit_of_work=MagicMock(),
)
@@ -46,7 +45,7 @@ elif scenario == "lifecycle_service":
PlanLifecycleService,
)
PlanLifecycleService(settings=MagicMock(default_automation_level="manual"))
PlanLifecycleService(settings=MagicMock())
else:
print(f"Unknown scenario: {scenario}", file=sys.stderr)
@@ -9,18 +9,6 @@ four-level precedence hierarchy:
4. **Global-level** -- set via config key or ``CLEVERAGENTS_AUTOMATION_PROFILE``
environment variable.
## Legacy Mapping
The service maps deprecated ``automation_level`` string values to built-in
profile names:
| Legacy Value | Profile Name |
|----------------|----------------|
| ``manual`` | ``manual`` |
| ``supervised`` | ``supervised`` |
| ``auto`` | ``auto`` |
| ``full_auto`` | ``full-auto`` |
Based on ``docs/specification.md`` Section "Automation Profiles".
"""
@@ -46,14 +34,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Legacy automation_level -> built-in profile name mapping
LEGACY_AUTOMATION_LEVEL_MAP: dict[str, str] = {
"manual": "manual",
"supervised": "supervised",
"auto": "auto",
"full_auto": "full-auto",
}
_ENV_VAR = "CLEVERAGENTS_AUTOMATION_PROFILE"
_DEFAULT_PROFILE = "manual"
@@ -64,8 +44,7 @@ class AutomationProfileService:
Resolves the effective profile with precedence:
plan > action > project > global.
Supports CRUD via the underlying repository and maps legacy
``automation_level`` values to built-in profile names.
Supports CRUD via the underlying repository.
"""
def __init__(
@@ -169,45 +148,6 @@ class AutomationProfileService:
resource_id=name,
)
def map_legacy_level(self, level: str) -> str:
"""Map a legacy automation_level value to a profile name.
Args:
level: Legacy automation_level string
(e.g. ``"manual"``, ``"full_auto"``).
Returns:
The corresponding built-in profile name.
Raises:
ValidationError: If the level is not a known legacy
value.
"""
profile_name = LEGACY_AUTOMATION_LEVEL_MAP.get(level)
if profile_name is None:
raise ValidationError(
f"Unknown legacy automation_level '{level}'. "
f"Valid values: "
f"{', '.join(sorted(LEGACY_AUTOMATION_LEVEL_MAP))}"
)
return profile_name
def resolve_legacy_level(self, level: str) -> AutomationProfile:
"""Resolve a legacy automation_level to a profile.
Args:
level: Legacy automation_level string.
Returns:
The corresponding ``AutomationProfile``.
Raises:
ValidationError: If the level is unknown.
NotFoundError: If the mapped profile is missing.
"""
profile_name = self.map_legacy_level(level)
return self.get_profile(profile_name)
# ----- CRUD operations via repository ----- #
def list_profiles(self) -> list[AutomationProfile]:
@@ -52,7 +52,7 @@ Based on ``docs/specification.md`` and implementation plan Stage A3.
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar
from typing import TYPE_CHECKING, Any
import structlog
from ulid import ULID
@@ -69,7 +69,6 @@ from cleveragents.domain.models.core.automation_profile import (
AutomationProfile,
)
from cleveragents.domain.models.core.plan import (
AutomationLevel,
InvariantSource,
NamespacedName,
Plan,
@@ -222,25 +221,6 @@ class PlanLifecycleService:
"""Generate a new ULID string."""
return str(ULID())
def _resolve_automation_level(self) -> AutomationLevel:
"""Resolve the default automation level from settings.
The hierarchy is: plan-level (explicit) > session-level > global.
This method returns the global default from settings.
Returns:
The resolved automation level.
"""
raw = self.settings.default_automation_level
try:
return AutomationLevel(raw)
except ValueError:
self._logger.warning(
"Invalid automation level in settings, defaulting to MANUAL",
value=raw,
)
return AutomationLevel.MANUAL
# Action Management
def create_action(
@@ -486,7 +466,6 @@ class PlanLifecycleService:
project_links: list[ProjectLink] | None = None,
arguments: dict[str, Any] | None = None,
created_by: str | None = None,
automation_level: AutomationLevel | None = None,
invariants: list[PlanInvariant] | None = None,
) -> Plan:
"""Use an action on projects to create a plan in Strategize phase.
@@ -500,8 +479,6 @@ class PlanLifecycleService:
project_links: Projects to apply the action to (with alias/read_only)
arguments: Argument values for the action
created_by: User/session creating the plan
automation_level: Automation level for this plan. Defaults to
the global default from settings if not provided.
invariants: Additional plan-level invariants
Returns:
@@ -535,9 +512,6 @@ class PlanLifecycleService:
f"Invalid arguments for action {action_name}: {joined_errors}"
)
# Resolve automation level: explicit > global default
resolved_automation = automation_level or self._resolve_automation_level()
# Build merged invariants: plan > action > (project/global added later)
merged_invariants: list[PlanInvariant] = list(invariants or [])
for inv_text in action.invariants:
@@ -565,7 +539,6 @@ class PlanLifecycleService:
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=links,
automation_level=resolved_automation,
strategy_actor=action.strategy_actor,
execution_actor=action.execution_actor,
review_actor=action.review_actor,
@@ -1041,18 +1014,11 @@ class PlanLifecycleService:
# -- Profile-based auto-progress helpers -------------------------
# Map legacy AutomationLevel enum values to built-in profiles
_LEVEL_TO_PROFILE: ClassVar[dict[AutomationLevel, str]] = {
AutomationLevel.MANUAL: "manual",
AutomationLevel.REVIEW_BEFORE_APPLY: "auto",
AutomationLevel.FULL_AUTOMATION: "full-auto",
}
def _resolve_profile_for_plan(self, plan: Plan) -> AutomationProfile:
"""Resolve the effective AutomationProfile for a plan.
Maps the plan's legacy ``automation_level`` to a built-in
profile name and returns the corresponding profile.
Uses the plan's ``automation_profile`` reference. Falls back
to the ``"manual"`` built-in profile when no profile is set.
Args:
plan: The plan to resolve the profile for.
@@ -1060,8 +1026,11 @@ class PlanLifecycleService:
Returns:
The resolved ``AutomationProfile``.
"""
profile_name = self._LEVEL_TO_PROFILE.get(plan.automation_level, "manual")
return BUILTIN_PROFILES[profile_name]
if plan.automation_profile is not None:
profile_name = plan.automation_profile.profile_name
else:
profile_name = "manual"
return BUILTIN_PROFILES.get(profile_name, BUILTIN_PROFILES["manual"])
def should_auto_progress(self, plan: Plan) -> bool:
"""Check whether the plan should automatically advance.
@@ -1070,10 +1039,6 @@ class PlanLifecycleService:
on the relevant phase means the transition is fully
automatic. A threshold of ``1.0`` requires human approval.
Preserves backward-compatible behavior with the legacy
``AutomationLevel`` enum by mapping it to built-in
profiles.
Returns True when:
- Strategize/COMPLETE and profile.auto_execute < 1.0
- Execute/COMPLETE and profile.auto_apply < 1.0
@@ -1124,7 +1089,6 @@ class PlanLifecycleService:
self._logger.info(
"Auto-progressing plan from Strategize to Execute",
plan_id=plan_id,
automation_level=plan.automation_level.value,
)
return self.execute_plan(plan_id)
@@ -1135,14 +1099,13 @@ class PlanLifecycleService:
self._logger.info(
"Auto-progressing plan from Execute to Apply",
plan_id=plan_id,
automation_level=plan.automation_level.value,
)
return self.apply_plan(plan_id)
return plan
def pause_plan(self, plan_id: str) -> Plan:
"""Pause auto-progression by switching to MANUAL automation level.
"""Pause auto-progression by setting the automation profile to manual.
Useful for review-before-apply: if a user wants to inspect changes
before apply proceeds, they can pause the plan.
@@ -1154,20 +1117,26 @@ class PlanLifecycleService:
NotFoundError: If plan not found.
PlanError: If plan is in a terminal state.
"""
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
)
plan = self.get_plan(plan_id)
if plan.is_terminal:
raise PlanError(f"Plan {plan_id} is in terminal state and cannot be paused")
previous_level = plan.automation_level
plan.automation_level = AutomationLevel.MANUAL
plan.automation_profile = AutomationProfileRef(
profile_name="manual",
provenance=AutomationProfileProvenance.PLAN,
)
plan.timestamps.updated_at = datetime.now()
self._commit_plan(plan)
self._logger.info(
"Plan paused (automation set to manual)",
"Plan paused (automation profile set to manual)",
plan_id=plan_id,
previous_level=previous_level.value,
)
return plan
@@ -1175,14 +1144,14 @@ class PlanLifecycleService:
def resume_plan(
self,
plan_id: str,
automation_level: AutomationLevel | None = None,
automation_profile: str | None = None,
) -> Plan:
"""Resume auto-progression by restoring a non-MANUAL automation level.
"""Resume auto-progression by setting a non-manual automation profile.
If *automation_level* is not provided, defaults to REVIEW_BEFORE_APPLY
If *automation_profile* is not provided, defaults to ``"auto"``
so the plan will auto-progress but still pause before apply.
After restoring the level, calls `auto_progress` to immediately
After restoring the profile, calls ``auto_progress`` to immediately
advance if the plan is ready.
Returns:
@@ -1192,6 +1161,11 @@ class PlanLifecycleService:
NotFoundError: If plan not found.
PlanError: If plan is in a terminal state.
"""
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
)
plan = self.get_plan(plan_id)
if plan.is_terminal:
@@ -1199,52 +1173,18 @@ class PlanLifecycleService:
f"Plan {plan_id} is in terminal state and cannot be resumed"
)
level = automation_level or AutomationLevel.REVIEW_BEFORE_APPLY
plan.automation_level = level
profile_name = automation_profile or "auto"
plan.automation_profile = AutomationProfileRef(
profile_name=profile_name,
provenance=AutomationProfileProvenance.PLAN,
)
plan.timestamps.updated_at = datetime.now()
self._logger.info(
"Plan resumed",
plan_id=plan_id,
automation_level=level.value,
automation_profile=profile_name,
)
# Attempt immediate auto-progression
return self.auto_progress(plan_id)
def set_plan_automation_level(self, plan_id: str, level: AutomationLevel) -> Plan:
"""Change the automation level for an existing plan.
Only affects future phase transitions; does not replay past ones.
Subplans created after this change will inherit the new level.
Args:
plan_id: The plan ULID.
level: The new automation level.
Returns:
The updated Plan.
Raises:
NotFoundError: If plan not found.
PlanError: If plan is in a terminal state.
"""
plan = self.get_plan(plan_id)
if plan.is_terminal:
raise PlanError(
f"Plan {plan_id} is in terminal state and cannot change "
"automation level"
)
plan.automation_level = level
plan.timestamps.updated_at = datetime.now()
self._commit_plan(plan)
self._logger.info(
"Plan automation level changed",
plan_id=plan_id,
level=level.value,
)
return plan
+10 -81
View File
@@ -98,7 +98,6 @@ def _plan_spec_dict(plan: Any) -> dict[str, object]:
"action_name": plan.action_name,
"description": plan.description,
"definition_of_done": plan.definition_of_done,
"automation_level": plan.automation_level.value,
"strategy_actor": plan.strategy_actor,
"execution_actor": plan.execution_actor,
"created_at": plan.timestamps.created_at.isoformat(),
@@ -1137,7 +1136,6 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
f"(source: {plan.automation_profile.provenance.value})\n"
)
details += f"[bold]Automation Level:[/bold] {plan.automation_level.value}\n"
details += f"[bold]Terminal:[/bold] {'yes' if plan.is_terminal else 'no'}\n"
# Timestamps
@@ -1241,16 +1239,6 @@ def use_action(
help="Override the invariant reconciliation actor for this plan",
),
] = None,
automation_level: Annotated[
str | None,
typer.Option(
"--automation-level",
help=(
"Automation level: manual, review_before_apply, "
"or full_automation (defaults to global setting)"
),
),
] = None,
fmt: Annotated[
str,
typer.Option(
@@ -1274,7 +1262,6 @@ def use_action(
ActionNotAvailableError,
)
from cleveragents.domain.models.core.plan import (
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
@@ -1314,19 +1301,6 @@ def use_action(
# Get action by name
action = service.get_action_by_name(action_name)
# Parse automation level if provided
resolved_automation = None
if automation_level:
try:
resolved_automation = AutomationLevel(automation_level.lower())
except ValueError:
valid = ", ".join(lv.value for lv in AutomationLevel)
console.print(
f"[red]Invalid automation level:[/red] {automation_level}. "
f"Valid values: {valid}"
)
raise typer.Abort() from None
# Build project links from project names
project_links = [ProjectLink(project_name=p) for p in all_projects]
@@ -1343,12 +1317,21 @@ def use_action(
action_name=str(action.namespaced_name),
project_links=project_links,
arguments=arguments if arguments else None,
automation_level=resolved_automation,
invariants=plan_invariants,
)
# Apply automation profile override if provided
if automation_profile:
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
)
if automation_profile not in BUILTIN_PROFILES:
console.print(
f"[red]Invalid automation profile:[/red] {automation_profile}. "
f"Available: {', '.join(sorted(BUILTIN_PROFILES))}"
)
raise typer.Abort()
plan.automation_profile = AutomationProfileRef(
profile_name=automation_profile,
provenance=AutomationProfileProvenance.PLAN,
@@ -1777,60 +1760,6 @@ def lifecycle_list_plans(
raise typer.Abort() from e
@app.command("set-automation-level")
def set_automation_level(
plan_id: Annotated[
str,
typer.Argument(help="Plan ID to change automation level for"),
],
level: Annotated[
str,
typer.Argument(
help=("Automation level: manual, review_before_apply, or full_automation"),
),
],
) -> None:
"""Change the automation level for an existing plan.
Only affects future phase transitions. Subplans created after
this change will inherit the new level.
Plans in terminal state cannot have their level changed.
Examples:
agents plan set-automation-level 01HXYZ... full_automation
"""
from cleveragents.domain.models.core.plan import AutomationLevel
try:
service = _get_lifecycle_service()
# Parse the automation level
try:
parsed_level = AutomationLevel(level.lower())
except ValueError:
valid = ", ".join(lv.value for lv in AutomationLevel)
console.print(
f"[red]Invalid automation level:[/red] {level}. Valid values: {valid}"
)
raise typer.Abort() from None
plan = service.set_plan_automation_level(plan_id, parsed_level)
console.print(
f"[green]✓[/green] Automation level set to "
f"[bold]{parsed_level.value}[/bold] "
f"for plan {plan.namespaced_name}"
)
except PlanError as e:
console.print(f"[red]Cannot change level:[/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
@app.command("cancel")
def cancel_plan(
plan_id: Annotated[
+2 -14
View File
@@ -81,26 +81,14 @@ class Settings(BaseSettings):
"mock": "mock-gpt",
}
# Automation level (plan lifecycle) -- legacy; prefer automation_profile
default_automation_level: str = Field(
default="manual",
validation_alias=AliasChoices("CLEVERAGENTS_AUTOMATION_LEVEL"),
description=(
"Default automation level for new plans: "
"'manual', 'review_before_apply', or 'full_automation'"
),
)
# Automation profile (supersedes automation_level)
# Automation profile (the only automation control)
default_automation_profile: str = Field(
default="",
validation_alias=AliasChoices(
"CLEVERAGENTS_AUTOMATION_PROFILE",
),
description=(
"Default automation profile name "
"(e.g. 'manual', 'auto', 'full-auto'). "
"Overrides default_automation_level when set."
"Default automation profile name (e.g. 'manual', 'auto', 'full-auto')."
),
)
@@ -37,7 +37,7 @@ Required config keys: ``name``, ``description``
Optional keys: ``definition_of_done``, ``strategy_actor``,
``execution_actor``, ``arguments``, ``invariants``, ``reusable``,
``automation_level``, ``metadata``
``automation_profile``, ``metadata``
## Templating
+4 -28
View File
@@ -35,13 +35,11 @@ constraints), ``errored``, or ``cancelled``.
| ``CONSTRAINED``| Cannot proceed within constraints (Apply only) |
| ``CANCELLED`` | User/system cancelled (any phase) |
## Automation Levels
## Automation Profiles
| Level | Behavior |
|--------------------------|---------------------------------------------|
| ``MANUAL`` | Human must approve every transition |
| ``REVIEW_BEFORE_APPLY`` | Auto-execute, pause before apply |
| ``FULL_AUTOMATION`` | Auto-progress through all phases |
Automation behavior is controlled exclusively by ``AutomationProfileRef``,
resolved at ``plan use`` time using plan > action > project > global precedence.
See ``AutomationProfile`` for threshold semantics.
## Key Classes
@@ -49,7 +47,6 @@ constraints), ``errored``, or ``cancelled``.
- ``PlanIdentity`` -- plan_id (ULID) + optional name
- ``Plan`` -- the core plan model with lifecycle state
- ``PlanPhase`` / ``ProcessingState`` -- lifecycle enums
- ``AutomationLevel`` -- automation profile enum
- ``PlanInvariant`` -- invariant with source provenance
- ``ProjectLink`` -- project binding with alias
- ``SubplanConfig`` / ``SubplanAttempt`` -- subplan hierarchy
@@ -110,20 +107,6 @@ class ProcessingState(StrEnum):
CONSTRAINED = "constrained" # Apply cannot proceed within constraints
class AutomationLevel(StrEnum):
"""Automation level for plan phase transitions.
Controls how much human intervention is required during plan execution:
- MANUAL: User must explicitly trigger each phase transition
- REVIEW_BEFORE_APPLY: Auto strategize+execute, pause before apply
- FULL_AUTOMATION: All phases run automatically without human input
"""
MANUAL = "manual"
REVIEW_BEFORE_APPLY = "review_before_apply"
FULL_AUTOMATION = "full_automation"
class ExecutionMode(StrEnum):
"""How subplans should be executed.
@@ -571,12 +554,6 @@ class Plan(BaseModel):
description="Processing state in the current phase",
)
# Automation
automation_level: AutomationLevel = Field(
AutomationLevel.MANUAL,
description="How automated phase transitions should be",
)
# Automation profile - resolved at plan use time and locked
automation_profile: AutomationProfileRef | None = Field(
default=None,
@@ -816,7 +793,6 @@ class Plan(BaseModel):
result["automation_profile_source"] = (
self.automation_profile.provenance.value
)
result["automation_level"] = self.automation_level.value
if self.project_links:
result["projects"] = [
{
@@ -195,10 +195,6 @@ class Session(BaseModel):
default_factory=list,
description="ULID references to linked plans",
)
automation_level: str | None = Field(
default=None,
description="Session-level automation override",
)
token_usage: SessionTokenUsage = Field(
default_factory=SessionTokenUsage,
description="Accumulated token usage for this session",
@@ -327,7 +323,7 @@ class Session(BaseModel):
Matches the output fields from ``agents session show``:
- ``session_id``, ``actor_name``, ``namespace``, ``message_count``
- ``created_at``, ``updated_at``, ``automation_level``
- ``created_at``, ``updated_at``
- ``recent_messages`` (last 5), ``linked_plan_ids``
- ``token_usage`` (input/output/cost), ``metadata``
"""
@@ -339,8 +335,6 @@ class Session(BaseModel):
result["message_count"] = self.message_count
result["created_at"] = self.created_at.isoformat()
result["updated_at"] = self.updated_at.isoformat()
if self.automation_level:
result["automation_level"] = self.automation_level
if self.messages:
recent = self.messages[-5:]
result["recent_messages"] = [
@@ -364,7 +358,7 @@ class Session(BaseModel):
- ``schema_version``, ``session_id``, ``actor_name``, ``namespace``
- ``messages`` (full history), ``linked_plan_ids``
- ``automation_level``, ``token_usage``, ``metadata``
- ``token_usage``, ``metadata``
- ``created_at``, ``updated_at``, ``checksum`` (SHA-256)
"""
messages_data = [
@@ -386,7 +380,6 @@ class Session(BaseModel):
"namespace": self.namespace,
"messages": messages_data,
"linked_plan_ids": list(self.linked_plan_ids),
"automation_level": self.automation_level,
"token_usage": {
"input_tokens": self.token_usage.input_tokens,
"output_tokens": self.token_usage.output_tokens,
@@ -574,9 +574,6 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
# Policy / profile
automation_profile = Column(Text, nullable=True)
# Legacy: automation_level is retained for backward compatibility.
# New code should use automation_profile. Will be removed in A6.legacy.
automation_level = Column(String(30), nullable=False, default="manual")
# Behavior
reusable = Column(Boolean, nullable=False, default=True)
@@ -657,10 +654,6 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
"'applied', 'constrained')",
name="ck_v3_plans_state",
),
CheckConstraint(
"automation_level IN ('manual', 'review_before_apply', 'full_automation')",
name="ck_v3_plans_automation",
),
Index("ix_v3_plans_phase", "phase"),
Index("ix_v3_plans_state", "processing_state"),
Index("ix_v3_plans_parent", "parent_plan_id"),
@@ -694,7 +687,6 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
A ``Plan`` domain instance.
"""
from cleveragents.domain.models.core.plan import (
AutomationLevel,
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
@@ -787,9 +779,6 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
definition_of_done=cast("str | None", self.definition_of_done),
phase=phase_enum,
processing_state=state_enum,
automation_level=AutomationLevel(
cast(str, self.automation_level or "manual")
),
automation_profile=automation_profile_ref,
strategy_actor=cast("str | None", self.strategy_actor),
execution_actor=cast("str | None", self.execution_actor),
@@ -887,11 +876,6 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
apply_actor=getattr(plan, "apply_actor", None),
estimation_actor=getattr(plan, "estimation_actor", None),
invariant_actor=getattr(plan, "invariant_actor", None),
automation_level=(
plan.automation_level.value
if hasattr(plan.automation_level, "value")
else plan.automation_level
),
automation_profile=automation_profile_json,
reusable=plan.reusable,
read_only=plan.read_only,