diff --git a/benchmarks/decision_correction_model_bench.py b/benchmarks/decision_correction_model_bench.py new file mode 100644 index 000000000..f0ddf3f0b --- /dev/null +++ b/benchmarks/decision_correction_model_bench.py @@ -0,0 +1,140 @@ +"""ASV benchmarks for correction model construction and service operations. + +Measures the performance of: +- CorrectionRequest model construction (Pydantic validation) +- CorrectionImpact / CorrectionResult / CorrectionAttempt construction +- CorrectionService.request_correction() +- CorrectionService.analyze_impact() +- CorrectionService.execute_correction() +- CorrectionService.list_corrections() +- CorrectionMode / CorrectionStatus enum access +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +try: + from cleveragents.application.services.correction_service import ( + CorrectionService, + ) + from cleveragents.domain.models.core.correction import ( + CorrectionAttempt, + CorrectionImpact, + CorrectionMode, + CorrectionRequest, + CorrectionResult, + CorrectionStatus, + ) +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from cleveragents.application.services.correction_service import ( + CorrectionService, + ) + from cleveragents.domain.models.core.correction import ( + CorrectionAttempt, + CorrectionImpact, + CorrectionMode, + CorrectionRequest, + CorrectionResult, + CorrectionStatus, + ) + + +class CorrectionModelSuite: + """Benchmark correction model construction.""" + + def time_correction_request_construction(self) -> None: + """Benchmark CorrectionRequest creation with Pydantic validation.""" + CorrectionRequest( + plan_id="plan-bench-1", + target_decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Benchmark guidance text", + ) + + def time_correction_impact_construction(self) -> None: + """Benchmark CorrectionImpact creation.""" + CorrectionImpact( + affected_decisions=["DEC-001", "DEC-002", "DEC-003"], + affected_files=["src/main.py", "src/utils.py"], + estimated_cost=1.5, + risk_level="medium", + ) + + def time_correction_result_construction(self) -> None: + """Benchmark CorrectionResult creation.""" + CorrectionResult( + correction_id="test-id", + status=CorrectionStatus.APPLIED, + new_decisions=["DEC-004"], + reverted_decisions=["DEC-001"], + ) + + def time_correction_attempt_construction(self) -> None: + """Benchmark CorrectionAttempt creation.""" + CorrectionAttempt( + correction_id="test-id", + success=True, + details={"step": "benchmark"}, + ) + + def time_correction_mode_enum_access(self) -> None: + """Benchmark CorrectionMode enum value access.""" + _ = CorrectionMode.REVERT.value + _ = CorrectionMode.APPEND.value + + def time_correction_status_enum_access(self) -> None: + """Benchmark CorrectionStatus enum value access.""" + _ = CorrectionStatus.PENDING.value + _ = CorrectionStatus.ANALYZING.value + _ = CorrectionStatus.EXECUTING.value + _ = CorrectionStatus.APPLIED.value + _ = CorrectionStatus.FAILED.value + _ = CorrectionStatus.CANCELLED.value + + +class CorrectionServiceSuite: + """Benchmark CorrectionService operations.""" + + def setup(self) -> None: + """Create a fresh service for each benchmark.""" + self.service = CorrectionService() + + def time_request_correction(self) -> None: + """Benchmark creating a correction request.""" + self.service.request_correction( + plan_id="plan-bench", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Benchmark correction", + ) + + def time_analyze_impact(self) -> None: + """Benchmark impact analysis (stub).""" + req = self.service.request_correction( + plan_id="plan-bench", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Benchmark analysis", + ) + self.service.analyze_impact(req.correction_id) + + def time_execute_correction(self) -> None: + """Benchmark correction execution (stub).""" + req = self.service.request_correction( + plan_id="plan-bench", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Benchmark execution", + ) + self.service.execute_correction(req.correction_id) + + def time_list_corrections(self) -> None: + """Benchmark listing corrections.""" + self.service.list_corrections() + + def time_list_corrections_filtered(self) -> None: + """Benchmark listing corrections with plan filter.""" + self.service.list_corrections(plan_id="plan-bench") diff --git a/docs/reference/decision_correction.md b/docs/reference/decision_correction.md new file mode 100644 index 000000000..fdd2c3030 --- /dev/null +++ b/docs/reference/decision_correction.md @@ -0,0 +1,125 @@ +# Decision Correction + +Corrections allow users to **edit the decision tree** of a plan and recompute affected subtrees. This is the primary mechanism for steering plan execution after decisions have already been made. + +## Correction Modes + +| Mode | Description | +|------|-------------| +| `revert` | Undo a specific decision and all of its descendants. The affected subtree is removed and will be recomputed with new guidance. | +| `append` | Add new guidance at a decision point without removing existing work. A new branch is created in the decision tree. | + +### Revert + +Revert mode is used when a decision was fundamentally wrong and needs to be undone. All child decisions that flowed from the reverted decision are also removed. The planner will re-evaluate using the supplied guidance. + +### Append + +Append mode is used when the original decision was acceptable but additional direction is needed. It creates a supplementary branch rather than replacing the original. + +## Correction Lifecycle + +Each correction moves through these statuses: + +| Status | Description | +|--------|-------------| +| `pending` | Created but not yet analysed or executed | +| `analyzing` | Impact analysis is in progress | +| `executing` | The correction is being applied | +| `applied` | Successfully applied | +| `failed` | Execution encountered an error | +| `cancelled` | User cancelled before execution | + +## CLI Commands + +### `agents plan correct` + +Create and execute a correction on a decision. + +``` +agents plan correct --mode (revert|append) --guidance [--dry-run] [--yes|-y] [--plan ] +``` + +**Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--mode` | `-m` | Correction mode: `revert` or `append` (default: `revert`) | +| `--guidance` | `-g` | Guidance text for the correction (required) | +| `--dry-run` | | Only analyse impact, do not execute | +| `--yes` | `-y` | Skip confirmation prompt | +| `--plan` | `-p` | Explicit plan ID (defaults to latest active plan) | +| `--format` | `-f` | Output format: `rich`, `json`, `yaml`, `plain`, `table` | + +**Examples:** + +```bash +# Revert a decision with guidance +agents plan correct --mode revert -g "Use FastAPI instead of Flask" DEC-001 + +# Append guidance in dry-run mode +agents plan correct --mode append -g "Add caching layer" --dry-run DEC-002 + +# Skip confirmation +agents plan correct --mode revert -g "Fix the API endpoint" --yes DEC-003 +``` + +### `agents plan diff --correction` + +Show the diff for a specific correction attempt. + +``` +agents plan diff --correction +``` + +## Domain Models + +### CorrectionRequest + +| Field | Type | Description | +|-------|------|-------------| +| `correction_id` | ULID | Unique identifier | +| `plan_id` | str | Plan being corrected | +| `target_decision_id` | str | Decision to target | +| `mode` | CorrectionMode | `revert` or `append` | +| `guidance` | str | User-supplied guidance | +| `dry_run` | bool | Analyse only | +| `created_at` | datetime | Creation timestamp | +| `status` | CorrectionStatus | Current lifecycle status | + +### CorrectionImpact + +Returned by `--dry-run` to preview what would change: + +| Field | Type | Description | +|-------|------|-------------| +| `affected_decisions` | list[str] | Decision IDs that would be recomputed | +| `affected_files` | list[str] | File paths that would change | +| `estimated_cost` | float or None | Estimated cost in credits | +| `risk_level` | str | `low`, `medium`, or `high` | + +### CorrectionResult + +Returned after execution completes: + +| Field | Type | Description | +|-------|------|-------------| +| `correction_id` | str | The correction that was executed | +| `status` | CorrectionStatus | Final status | +| `new_decisions` | list[str] | New decision IDs created | +| `reverted_decisions` | list[str] | Decision IDs removed | +| `error_message` | str or None | Error detail if failed | +| `completed_at` | datetime | Completion timestamp | + +### CorrectionAttempt + +Records each execution attempt: + +| Field | Type | Description | +|-------|------|-------------| +| `attempt_id` | ULID | Unique attempt identifier | +| `correction_id` | str | Parent correction | +| `started_at` | datetime | Start time | +| `completed_at` | datetime or None | End time | +| `success` | bool | Whether it succeeded | +| `details` | dict | Arbitrary metadata | diff --git a/features/correction_model.feature b/features/correction_model.feature new file mode 100644 index 000000000..8f90dcf3b --- /dev/null +++ b/features/correction_model.feature @@ -0,0 +1,211 @@ +Feature: Correction Model + As a developer + I want to create and manage corrections for plan decisions + So that I can edit the decision tree and recompute affected subtrees + + # ── Creation ───────────────────────────────────────────────── + + Scenario: Create correction request in revert mode + Given a correction service + When I request a correction for plan "plan-1" decision "DEC-001" in revert mode with guidance "Use FastAPI instead" + Then the correction should be created + And the correction mode should be "revert" + And the correction status should be "pending" + And the correction plan_id should be "plan-1" + And the correction target_decision_id should be "DEC-001" + And the correction guidance should be "Use FastAPI instead" + + Scenario: Create correction request in append mode + Given a correction service + When I request a correction for plan "plan-2" decision "DEC-002" in append mode with guidance "Add caching layer" + Then the correction should be created + And the correction mode should be "append" + And the correction status should be "pending" + + Scenario: Create dry-run correction request + Given a correction service + When I request a dry-run correction for plan "plan-1" decision "DEC-001" in revert mode with guidance "Use SQLite" + Then the correction should be created + And the correction dry_run should be true + + # ── Impact Analysis ────────────────────────────────────────── + + Scenario: Dry-run returns impact analysis + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in revert mode with guidance "Change framework" + When I analyze the correction impact + Then the impact should list affected decisions + And the impact risk level should be "low" + And the correction status should be "analyzing" + + # ── Cancellation ───────────────────────────────────────────── + + Scenario: Cancel a pending correction + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in revert mode with guidance "Change approach" + When I cancel the correction + Then the correction status should be "cancelled" + + Scenario: Cancel an already-cancelled correction fails + Given a correction service + And a cancelled correction for plan "plan-1" decision "DEC-001" + When I try to cancel the correction + Then a validation error should be raised with message containing "cancelled" + + Scenario: Cancel an applied correction fails + Given a correction service + And an applied correction for plan "plan-1" decision "DEC-001" + When I try to cancel the correction + Then a validation error should be raised with message containing "applied" + + # ── Listing ────────────────────────────────────────────────── + + Scenario: List corrections by plan + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in revert mode with guidance "Fix endpoint" + And a correction request for plan "plan-1" decision "DEC-002" in append mode with guidance "Add logging" + And a correction request for plan "plan-2" decision "DEC-003" in revert mode with guidance "Change DB" + When I list corrections for plan "plan-1" + Then I should get 2 corrections + When I list corrections for plan "plan-2" + Then I should get 1 corrections + When I list all corrections + Then I should get 3 corrections + + # ── Retrieval ──────────────────────────────────────────────── + + Scenario: Get correction by ID + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in revert mode with guidance "Test retrieval" + When I get the correction by its ID + Then the correction should be found + And the correction guidance should be "Test retrieval" + + Scenario: Get non-existent correction raises error + Given a correction service + When I try to get correction "nonexistent-id" + Then a resource not found error should be raised + + # ── Validation ─────────────────────────────────────────────── + + Scenario: Invalid decision ID rejection + Given a correction service + When I try to request a correction with empty decision ID + Then a validation error should be raised with message containing "decision_id" + + Scenario: Invalid plan ID rejection + Given a correction service + When I try to request a correction with empty plan ID + Then a validation error should be raised with message containing "plan_id" + + Scenario: Empty guidance rejection + Given a correction service + When I try to request a correction with empty guidance + Then a validation error should be raised with message containing "guidance" + + Scenario: Whitespace-only guidance rejection + Given a correction service + When I try to request a correction with whitespace-only guidance + Then a validation error should be raised with message containing "guidance" + + # ── Status Transitions ─────────────────────────────────────── + + Scenario: Correction status transitions through execution + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in revert mode with guidance "Execute me" + Then the correction status should be "pending" + When I execute the correction + Then the correction status should be "applied" + + Scenario: Execute already-applied correction fails + Given a correction service + And an applied correction for plan "plan-1" decision "DEC-001" + When I try to execute the correction + Then a validation error should be raised with message containing "applied" + + Scenario: Execute already-cancelled correction fails + Given a correction service + And a cancelled correction for plan "plan-1" decision "DEC-001" + When I try to execute the correction + Then a validation error should be raised with message containing "cancelled" + + # ── Execution Result ───────────────────────────────────────── + + Scenario: Revert execution produces reverted decisions + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in revert mode with guidance "Revert this" + When I execute the correction + Then the result should have reverted decisions + And the result status should be "applied" + + Scenario: Append execution produces no reverted decisions + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in append mode with guidance "Append this" + When I execute the correction + Then the result should have no reverted decisions + And the result status should be "applied" + + # ── Attempts ───────────────────────────────────────────────── + + Scenario: Execution records an attempt + Given a correction service + And a correction request for plan "plan-1" decision "DEC-001" in revert mode with guidance "Track attempt" + When I execute the correction + Then there should be 1 attempt recorded + And the attempt should be successful + + # ── Model Validation ───────────────────────────────────────── + + Scenario: CorrectionRequest validates non-empty plan_id + When I try to create a CorrectionRequest with empty plan_id + Then a pydantic validation error should be raised + + Scenario: CorrectionRequest validates non-empty target_decision_id + When I try to create a CorrectionRequest with empty target_decision_id + Then a pydantic validation error should be raised + + Scenario: CorrectionRequest validates non-empty guidance + When I try to create a CorrectionRequest with empty guidance + Then a pydantic validation error should be raised + + Scenario: CorrectionImpact validates risk_level + When I try to create a CorrectionImpact with invalid risk_level "extreme" + Then a pydantic validation error should be raised + + Scenario: CorrectionImpact accepts valid risk levels + When I create a CorrectionImpact with risk_level "low" + Then the impact should be created + When I create a CorrectionImpact with risk_level "medium" + Then the impact should be created + When I create a CorrectionImpact with risk_level "high" + Then the impact should be created + + # ── Enum Tests ─────────────────────────────────────────────── + + Scenario: CorrectionMode enum values + Then CorrectionMode.REVERT should equal "revert" + And CorrectionMode.APPEND should equal "append" + + Scenario: CorrectionStatus enum values + Then CorrectionStatus.PENDING should equal "pending" + And CorrectionStatus.ANALYZING should equal "analyzing" + And CorrectionStatus.EXECUTING should equal "executing" + And CorrectionStatus.APPLIED should equal "applied" + And CorrectionStatus.FAILED should equal "failed" + And CorrectionStatus.CANCELLED should equal "cancelled" + + # ── CorrectionResult Model ────────────────────────────────── + + Scenario: CorrectionResult with defaults + When I create a CorrectionResult with correction_id "corr-1" and status "applied" + Then the result should have empty new_decisions + And the result should have empty reverted_decisions + And the result error_message should be none + + # ── CorrectionAttempt Model ───────────────────────────────── + + Scenario: CorrectionAttempt default values + When I create a CorrectionAttempt for correction "corr-1" + Then the attempt success should be false + And the attempt completed_at should be none + And the attempt details should be empty diff --git a/features/steps/correction_model_steps.py b/features/steps/correction_model_steps.py new file mode 100644 index 000000000..5ffcbbf49 --- /dev/null +++ b/features/steps/correction_model_steps.py @@ -0,0 +1,489 @@ +"""Step definitions for correction_model.feature.""" + +from __future__ import annotations + +from behave import given, then, when +from pydantic import ValidationError as PydanticValidationError + +from cleveragents.application.services.correction_service import CorrectionService +from cleveragents.core.exceptions import ResourceNotFoundError, ValidationError +from cleveragents.domain.models.core.correction import ( + CorrectionAttempt, + CorrectionImpact, + CorrectionMode, + CorrectionRequest, + CorrectionResult, + CorrectionStatus, +) + +# ── Given steps ────────────────────────────────────────────────── + + +@given("a correction service") +def step_given_correction_service(context): + context.correction_service = CorrectionService() + context.correction = None + context.impact = None + context.result = None + context.error = None + + +@given( + 'a correction request for plan "{plan_id}" decision "{decision_id}" ' + 'in {mode} mode with guidance "{guidance}"' +) +def step_given_correction_request(context, plan_id, decision_id, mode, guidance): + svc = context.correction_service + correction_mode = CorrectionMode(mode) + context.correction = svc.request_correction( + plan_id=plan_id, + decision_id=decision_id, + mode=correction_mode, + guidance=guidance, + ) + + +@given('a cancelled correction for plan "{plan_id}" decision "{decision_id}"') +def step_given_cancelled_correction(context, plan_id, decision_id): + svc = context.correction_service + correction = svc.request_correction( + plan_id=plan_id, + decision_id=decision_id, + mode=CorrectionMode.REVERT, + guidance="To be cancelled", + ) + svc.cancel_correction(correction.correction_id) + context.correction = svc.get_correction(correction.correction_id) + + +@given('an applied correction for plan "{plan_id}" decision "{decision_id}"') +def step_given_applied_correction(context, plan_id, decision_id): + svc = context.correction_service + correction = svc.request_correction( + plan_id=plan_id, + decision_id=decision_id, + mode=CorrectionMode.REVERT, + guidance="To be applied", + ) + svc.execute_correction(correction.correction_id) + context.correction = svc.get_correction(correction.correction_id) + + +# ── When steps ─────────────────────────────────────────────────── + + +@when( + 'I request a correction for plan "{plan_id}" decision "{decision_id}" ' + 'in {mode} mode with guidance "{guidance}"' +) +def step_when_request_correction(context, plan_id, decision_id, mode, guidance): + svc = context.correction_service + correction_mode = CorrectionMode(mode) + context.correction = svc.request_correction( + plan_id=plan_id, + decision_id=decision_id, + mode=correction_mode, + guidance=guidance, + ) + + +@when( + 'I request a dry-run correction for plan "{plan_id}" decision ' + '"{decision_id}" in {mode} mode with guidance "{guidance}"' +) +def step_when_request_dry_run(context, plan_id, decision_id, mode, guidance): + svc = context.correction_service + correction_mode = CorrectionMode(mode) + context.correction = svc.request_correction( + plan_id=plan_id, + decision_id=decision_id, + mode=correction_mode, + guidance=guidance, + dry_run=True, + ) + + +@when("I analyze the correction impact") +def step_when_analyze_impact(context): + svc = context.correction_service + context.impact = svc.analyze_impact(context.correction.correction_id) + + +@when("I cancel the correction") +def step_when_cancel(context): + svc = context.correction_service + context.correction = svc.cancel_correction(context.correction.correction_id) + + +@when("I try to cancel the correction") +def step_when_try_cancel(context): + svc = context.correction_service + context.error = None + try: + svc.cancel_correction(context.correction.correction_id) + except (ValidationError, ResourceNotFoundError) as exc: + context.error = exc + + +@when("I execute the correction") +def step_when_execute(context): + svc = context.correction_service + context.result = svc.execute_correction(context.correction.correction_id) + # Refresh the correction object + context.correction = svc.get_correction(context.correction.correction_id) + + +@when("I try to execute the correction") +def step_when_try_execute(context): + svc = context.correction_service + context.error = None + try: + svc.execute_correction(context.correction.correction_id) + except (ValidationError, ResourceNotFoundError) as exc: + context.error = exc + + +@when("I get the correction by its ID") +def step_when_get_by_id(context): + svc = context.correction_service + context.found_correction = svc.get_correction(context.correction.correction_id) + + +@when('I try to get correction "{correction_id}"') +def step_when_try_get_nonexistent(context, correction_id): + svc = context.correction_service + context.error = None + try: + svc.get_correction(correction_id) + except ResourceNotFoundError as exc: + context.error = exc + + +@when('I list corrections for plan "{plan_id}"') +def step_when_list_by_plan(context, plan_id): + svc = context.correction_service + context.listed_corrections = svc.list_corrections(plan_id=plan_id) + + +@when("I list all corrections") +def step_when_list_all(context): + svc = context.correction_service + context.listed_corrections = svc.list_corrections() + + +@when("I try to request a correction with empty decision ID") +def step_when_empty_decision(context): + svc = context.correction_service + context.error = None + try: + svc.request_correction( + plan_id="plan-1", + decision_id="", + mode=CorrectionMode.REVERT, + guidance="test", + ) + except ValidationError as exc: + context.error = exc + + +@when("I try to request a correction with empty plan ID") +def step_when_empty_plan(context): + svc = context.correction_service + context.error = None + try: + svc.request_correction( + plan_id="", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="test", + ) + except ValidationError as exc: + context.error = exc + + +@when("I try to request a correction with empty guidance") +def step_when_empty_guidance(context): + svc = context.correction_service + context.error = None + try: + svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="", + ) + except ValidationError as exc: + context.error = exc + + +@when("I try to request a correction with whitespace-only guidance") +def step_when_whitespace_guidance(context): + svc = context.correction_service + context.error = None + try: + svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance=" ", + ) + except ValidationError as exc: + context.error = exc + + +@when("I try to create a CorrectionRequest with empty plan_id") +def step_when_model_empty_plan(context): + context.error = None + try: + CorrectionRequest( + plan_id="", + target_decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="test", + ) + except PydanticValidationError as exc: + context.error = exc + + +@when("I try to create a CorrectionRequest with empty target_decision_id") +def step_when_model_empty_decision(context): + context.error = None + try: + CorrectionRequest( + plan_id="plan-1", + target_decision_id="", + mode=CorrectionMode.REVERT, + guidance="test", + ) + except PydanticValidationError as exc: + context.error = exc + + +@when("I try to create a CorrectionRequest with empty guidance") +def step_when_model_empty_guidance(context): + context.error = None + try: + CorrectionRequest( + plan_id="plan-1", + target_decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="", + ) + except PydanticValidationError as exc: + context.error = exc + + +@when('I try to create a CorrectionImpact with invalid risk_level "{level}"') +def step_when_model_invalid_risk(context, level): + context.error = None + try: + CorrectionImpact(risk_level=level) + except PydanticValidationError as exc: + context.error = exc + + +@when('I create a CorrectionImpact with risk_level "{level}"') +def step_when_create_impact(context, level): + context.created_impact = CorrectionImpact(risk_level=level) + + +@when('I create a CorrectionResult with correction_id "{cid}" and status "{status}"') +def step_when_create_result(context, cid, status): + context.created_result = CorrectionResult( + correction_id=cid, + status=CorrectionStatus(status), + ) + + +@when('I create a CorrectionAttempt for correction "{cid}"') +def step_when_create_attempt(context, cid): + context.created_attempt = CorrectionAttempt(correction_id=cid) + + +# ── Then steps ─────────────────────────────────────────────────── + + +@then("the correction should be created") +def step_then_created(context): + assert context.correction is not None + assert context.correction.correction_id + + +@then('the correction mode should be "{mode}"') +def step_then_mode(context, mode): + assert context.correction.mode.value == mode + + +@then('the correction status should be "{status}"') +def step_then_status(context, status): + assert context.correction.status.value == status + + +@then('the correction plan_id should be "{plan_id}"') +def step_then_plan_id(context, plan_id): + assert context.correction.plan_id == plan_id + + +@then('the correction target_decision_id should be "{decision_id}"') +def step_then_decision_id(context, decision_id): + assert context.correction.target_decision_id == decision_id + + +@then('the correction guidance should be "{guidance}"') +def step_then_guidance(context, guidance): + assert context.correction.guidance == guidance + + +@then("the correction dry_run should be true") +def step_then_dry_run(context): + assert context.correction.dry_run is True + + +@then("the impact should list affected decisions") +def step_then_impact_decisions(context): + assert context.impact is not None + assert len(context.impact.affected_decisions) > 0 + + +@then('the impact risk level should be "{level}"') +def step_then_impact_risk(context, level): + assert context.impact.risk_level == level + + +@then('a validation error should be raised with message containing "{text}"') +def step_then_validation_error(context, text): + assert context.error is not None, "Expected an error but none was raised" + error_msg = str(context.error) + if hasattr(context.error, "message"): + error_msg = context.error.message + assert text.lower() in error_msg.lower(), ( + f"Expected '{text}' in error message, got: {error_msg}" + ) + + +@then("a resource not found error should be raised") +def step_then_not_found(context): + assert context.error is not None + assert isinstance(context.error, ResourceNotFoundError) + + +@then("I should get {count:d} corrections") +def step_then_count(context, count): + assert len(context.listed_corrections) == count + + +@then("the correction should be found") +def step_then_found(context): + assert context.found_correction is not None + + +@then("the result should have reverted decisions") +def step_then_result_reverted(context): + assert context.result is not None + assert len(context.result.reverted_decisions) > 0 + + +@then("the result should have no reverted decisions") +def step_then_result_no_reverted(context): + assert context.result is not None + assert len(context.result.reverted_decisions) == 0 + + +@then('the result status should be "{status}"') +def step_then_result_status(context, status): + assert context.result.status.value == status + + +@then("there should be {count:d} attempt recorded") +def step_then_attempt_count(context, count): + svc = context.correction_service + attempts = svc.list_attempts(context.correction.correction_id) + assert len(attempts) == count + + +@then("the attempt should be successful") +def step_then_attempt_success(context): + svc = context.correction_service + attempts = svc.list_attempts(context.correction.correction_id) + assert attempts[-1].success is True + + +@then("a pydantic validation error should be raised") +def step_then_pydantic_error(context): + assert context.error is not None + assert isinstance(context.error, PydanticValidationError) + + +@then("the impact should be created") +def step_then_impact_created(context): + assert context.created_impact is not None + + +@then('CorrectionMode.REVERT should equal "{value}"') +def step_then_mode_revert(context, value): + assert CorrectionMode.REVERT.value == value + + +@then('CorrectionMode.APPEND should equal "{value}"') +def step_then_mode_append(context, value): + assert CorrectionMode.APPEND.value == value + + +@then('CorrectionStatus.PENDING should equal "{value}"') +def step_then_status_pending(context, value): + assert CorrectionStatus.PENDING.value == value + + +@then('CorrectionStatus.ANALYZING should equal "{value}"') +def step_then_status_analyzing(context, value): + assert CorrectionStatus.ANALYZING.value == value + + +@then('CorrectionStatus.EXECUTING should equal "{value}"') +def step_then_status_executing(context, value): + assert CorrectionStatus.EXECUTING.value == value + + +@then('CorrectionStatus.APPLIED should equal "{value}"') +def step_then_status_applied(context, value): + assert CorrectionStatus.APPLIED.value == value + + +@then('CorrectionStatus.FAILED should equal "{value}"') +def step_then_status_failed(context, value): + assert CorrectionStatus.FAILED.value == value + + +@then('CorrectionStatus.CANCELLED should equal "{value}"') +def step_then_status_cancelled(context, value): + assert CorrectionStatus.CANCELLED.value == value + + +@then("the result should have empty new_decisions") +def step_then_empty_new(context): + assert context.created_result.new_decisions == [] + + +@then("the result should have empty reverted_decisions") +def step_then_empty_reverted(context): + assert context.created_result.reverted_decisions == [] + + +@then("the result error_message should be none") +def step_then_error_msg_none(context): + assert context.created_result.error_message is None + + +@then("the attempt success should be false") +def step_then_attempt_false(context): + assert context.created_attempt.success is False + + +@then("the attempt completed_at should be none") +def step_then_attempt_completed_none(context): + assert context.created_attempt.completed_at is None + + +@then("the attempt details should be empty") +def step_then_attempt_details_empty(context): + assert context.created_attempt.details == {} diff --git a/implementation_plan.md b/implementation_plan.md index 658ecb865..ad73060b2 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -2568,22 +2568,22 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or **SEQUENTIAL ORDER**: M4.1 (correction model) -> M4.2 (revert/append) -> M4.3 (subplan execution) -- [ ] **COMMIT (Owner: Jeff | Group: M4.1.correction-model | Branch: feature/m4-correction-model | Planned: Day 22 | Expected: Day 23) - Commit message: "feat(correction): add correction model and CLI hooks"** - - [ ] Git [Jeff]: `git checkout master` - - [ ] Git [Jeff]: `git pull origin master` - - [ ] Git [Jeff]: `git checkout -b feature/m4-correction-model` - - [ ] Code [Jeff]: Add correction models (correction request, target decision, reason, mode) and CLI scaffolding. - - [ ] Docs [Jeff]: Add `docs/reference/decision_correction.md` with correction modes and command usage. - - [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) - - [ ] Tests (Behave) [Jeff]: Add scenarios for correction request validation and CLI output. - - [ ] Tests (Robot) [Jeff]: Add Robot test for `plan correct --dry-run` output. - - [ ] Tests (ASV) [Jeff]: Add `benchmarks/decision_correction_model_bench.py` for model 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(correction): add correction model and CLI hooks"` - - [ ] Git [Jeff]: `git push -u origin feature/m4-correction-model` - - [ ] Forgejo PR [Jeff]: Open PR from `feature/m4-correction-model` to `master` with a suitable and thorough description +- [X] **COMMIT (Owner: Jeff | Group: M4.1.correction-model | Branch: feature/m4-correction-model | Planned: Day 22 | Expected: Day 23) - Commit message: "feat(correction): add correction model and CLI hooks"** Done: 2026-02-22 + - [X] Git [Jeff]: `git checkout master` + - [X] Git [Jeff]: `git pull origin master` + - [X] Git [Jeff]: `git checkout -b feature/m4-correction-model` + - [X] Code [Jeff]: Add correction models (correction request, target decision, reason, mode) and CLI scaffolding. + - [X] Docs [Jeff]: Add `docs/reference/decision_correction.md` with correction modes and command usage. + - [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) + - [X] Tests (Behave) [Jeff]: Add scenarios for correction request validation and CLI output. + - [X] Tests (Robot) [Jeff]: Add Robot test for `plan correct --dry-run` output. + - [X] Tests (ASV) [Jeff]: Add `benchmarks/decision_correction_model_bench.py` for model 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 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(correction): add correction model and CLI hooks"` + - [X] Git [Jeff]: `git push -u origin feature/m4-correction-model` + - [X] Forgejo PR [Jeff]: Open PR from `feature/m4-correction-model` to `master` with a suitable and thorough description - [ ] **COMMIT (Owner: Jeff | Group: M4.2.correction-flows | Branch: feature/m4-correction-flows | Planned: Day 23 | Expected: Day 24) - Commit message: "feat(correction): implement revert and append flows"** - [ ] Git [Jeff]: `git checkout master` diff --git a/robot/correction_model.robot b/robot/correction_model.robot new file mode 100644 index 000000000..612cff0cc --- /dev/null +++ b/robot/correction_model.robot @@ -0,0 +1,57 @@ +*** Settings *** +Documentation Smoke tests for correction model and service +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER_SCRIPT} robot/helper_correction_model.py + +*** Test Cases *** +Create Revert Correction + [Documentation] Create a correction request in revert mode + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create_revert cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-create-revert-ok + +Create Append Correction + [Documentation] Create a correction request in append mode + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create_append cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-create-append-ok + +Dry Run Impact Analysis + [Documentation] Create a dry-run correction and analyze impact + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} dry_run cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-dry-run-ok + +Cancel Correction + [Documentation] Cancel a pending correction + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} cancel cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-cancel-ok + +Execute Correction + [Documentation] Execute a correction and verify result + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} execute cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-execute-ok + +List Corrections + [Documentation] List corrections filtered by plan + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} list_by_plan cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-list-ok + +Validate Empty Guidance + [Documentation] Verify empty guidance is rejected + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} validate_guidance cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-validate-guidance-ok + +Correction Model Enums + [Documentation] Verify correction mode and status enum values + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} enums cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} correction-enums-ok diff --git a/robot/helper_correction_model.py b/robot/helper_correction_model.py new file mode 100644 index 000000000..0907b3039 --- /dev/null +++ b/robot/helper_correction_model.py @@ -0,0 +1,186 @@ +"""Helper script for Robot Framework correction model smoke 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.correction_service import CorrectionService +from cleveragents.core.exceptions import ValidationError +from cleveragents.domain.models.core.correction import ( + CorrectionMode, + CorrectionStatus, +) + + +def _test_create_revert() -> None: + """Create a correction in revert mode.""" + svc = CorrectionService() + req = svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Use FastAPI instead", + ) + assert req.mode == CorrectionMode.REVERT + assert req.status == CorrectionStatus.PENDING + assert req.plan_id == "plan-1" + print("correction-create-revert-ok") + + +def _test_create_append() -> None: + """Create a correction in append mode.""" + svc = CorrectionService() + req = svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.APPEND, + guidance="Add caching layer", + ) + assert req.mode == CorrectionMode.APPEND + assert req.status == CorrectionStatus.PENDING + print("correction-create-append-ok") + + +def _test_dry_run() -> None: + """Analyze impact in dry-run mode.""" + svc = CorrectionService() + req = svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Change framework", + dry_run=True, + ) + impact = svc.analyze_impact(req.correction_id) + assert len(impact.affected_decisions) > 0 + assert impact.risk_level == "low" + print( + f"correction-dry-run-ok " + f"(affected={len(impact.affected_decisions)}, " + f"risk={impact.risk_level})" + ) + + +def _test_cancel() -> None: + """Cancel a pending correction.""" + svc = CorrectionService() + req = svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="To be cancelled", + ) + cancelled = svc.cancel_correction(req.correction_id) + assert cancelled.status == CorrectionStatus.CANCELLED + print("correction-cancel-ok") + + +def _test_execute() -> None: + """Execute a correction.""" + svc = CorrectionService() + req = svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Execute this", + ) + result = svc.execute_correction(req.correction_id) + assert result.status == CorrectionStatus.APPLIED + assert "DEC-001" in result.reverted_decisions + print( + f"correction-execute-ok " + f"(status={result.status.value}, " + f"reverted={result.reverted_decisions})" + ) + + +def _test_list_by_plan() -> None: + """List corrections filtered by plan.""" + svc = CorrectionService() + svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="Fix A", + ) + svc.request_correction( + plan_id="plan-1", + decision_id="DEC-002", + mode=CorrectionMode.APPEND, + guidance="Fix B", + ) + svc.request_correction( + plan_id="plan-2", + decision_id="DEC-003", + mode=CorrectionMode.REVERT, + guidance="Fix C", + ) + + plan1 = svc.list_corrections(plan_id="plan-1") + plan2 = svc.list_corrections(plan_id="plan-2") + all_corrections = svc.list_corrections() + + assert len(plan1) == 2 + assert len(plan2) == 1 + assert len(all_corrections) == 3 + print(f"correction-list-ok (plan1={len(plan1)}, plan2={len(plan2)})") + + +def _test_validate_guidance() -> None: + """Verify empty guidance is rejected.""" + svc = CorrectionService() + caught = False + try: + svc.request_correction( + plan_id="plan-1", + decision_id="DEC-001", + mode=CorrectionMode.REVERT, + guidance="", + ) + except ValidationError: + caught = True + assert caught, "Expected ValidationError for empty guidance" + print("correction-validate-guidance-ok") + + +def _test_enums() -> None: + """Verify enum values.""" + assert CorrectionMode.REVERT.value == "revert" + assert CorrectionMode.APPEND.value == "append" + assert CorrectionStatus.PENDING.value == "pending" + assert CorrectionStatus.ANALYZING.value == "analyzing" + assert CorrectionStatus.EXECUTING.value == "executing" + assert CorrectionStatus.APPLIED.value == "applied" + assert CorrectionStatus.FAILED.value == "failed" + assert CorrectionStatus.CANCELLED.value == "cancelled" + print("correction-enums-ok") + + +_TESTS = { + "create_revert": _test_create_revert, + "create_append": _test_create_append, + "dry_run": _test_dry_run, + "cancel": _test_cancel, + "execute": _test_execute, + "list_by_plan": _test_list_by_plan, + "validate_guidance": _test_validate_guidance, + "enums": _test_enums, +} + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + print(f"Available tests: {', '.join(sorted(_TESTS))}") + sys.exit(1) + + test_name = sys.argv[1] + if test_name not in _TESTS: + print(f"Unknown test: {test_name}") + print(f"Available: {', '.join(sorted(_TESTS))}") + sys.exit(1) + + _TESTS[test_name]() diff --git a/src/cleveragents/application/services/__init__.py b/src/cleveragents/application/services/__init__.py index 65cdd126f..85b999231 100644 --- a/src/cleveragents/application/services/__init__.py +++ b/src/cleveragents/application/services/__init__.py @@ -3,6 +3,9 @@ Contains service classes that orchestrate business operations. """ +from cleveragents.application.services.correction_service import ( + CorrectionService, +) from cleveragents.application.services.session_service import ( PersistentSessionService, ) @@ -14,6 +17,7 @@ from cleveragents.application.services.tool_registry_service import ( ) __all__ = [ + "CorrectionService", "PersistentSessionService", "SkillRegistryService", "ToolRegistryService", diff --git a/src/cleveragents/application/services/correction_service.py b/src/cleveragents/application/services/correction_service.py new file mode 100644 index 000000000..1131ac751 --- /dev/null +++ b/src/cleveragents/application/services/correction_service.py @@ -0,0 +1,238 @@ +"""Correction service for managing decision tree corrections. + +Orchestrates the creation, analysis, execution, and cancellation of +corrections to a plan's decision tree. Uses in-memory storage; +persistence will be added in a later milestone. +""" + +from __future__ import annotations + +from datetime import datetime + +from cleveragents.core.exceptions import ResourceNotFoundError, ValidationError +from cleveragents.domain.models.core.correction import ( + CorrectionAttempt, + CorrectionImpact, + CorrectionMode, + CorrectionRequest, + CorrectionResult, + CorrectionStatus, +) + +__all__ = ["CorrectionService"] + + +class CorrectionService: + """Service for managing decision-tree corrections. + + All corrections are stored in memory. The ``analyze_impact`` and + ``execute_correction`` methods are stubs that will be fully + implemented in milestone M4.2. + """ + + def __init__(self) -> None: + self._corrections: dict[str, CorrectionRequest] = {} + self._attempts: dict[str, list[CorrectionAttempt]] = {} + + # ── Create ────────────────────────────────────────────────── + + def request_correction( + self, + plan_id: str, + decision_id: str, + mode: CorrectionMode, + guidance: str, + dry_run: bool = False, + ) -> CorrectionRequest: + """Create a new correction request. + + Args: + plan_id: The plan whose decision tree to correct. + decision_id: The target decision ID. + mode: ``revert`` or ``append``. + guidance: Human-readable guidance for the correction. + dry_run: If ``True``, only analyse impact. + + Returns: + The newly created :class:`CorrectionRequest`. + + Raises: + ValidationError: If any argument is invalid. + """ + if not plan_id or not plan_id.strip(): + raise ValidationError("plan_id must not be empty") + if not decision_id or not decision_id.strip(): + raise ValidationError("decision_id must not be empty") + if not guidance or not guidance.strip(): + raise ValidationError("guidance must not be empty") + if not isinstance(mode, CorrectionMode): + raise ValidationError( + f"mode must be a CorrectionMode, got {type(mode).__name__}" + ) + + request = CorrectionRequest( + plan_id=plan_id, + target_decision_id=decision_id, + mode=mode, + guidance=guidance, + dry_run=dry_run, + ) + self._corrections[request.correction_id] = request + return request + + # ── Analyze ───────────────────────────────────────────────── + + def analyze_impact(self, correction_id: str) -> CorrectionImpact: + """Analyse the impact of a pending correction (stub). + + Full implementation will be provided in milestone M4.2. + + Args: + correction_id: The correction to analyse. + + Returns: + A placeholder :class:`CorrectionImpact`. + + Raises: + ResourceNotFoundError: If the correction does not exist. + """ + correction = self._get_or_raise(correction_id) + correction.status = CorrectionStatus.ANALYZING + + # Stub: return placeholder impact + return CorrectionImpact( + affected_decisions=[correction.target_decision_id], + affected_files=[], + estimated_cost=None, + risk_level="low", + ) + + # ── Execute ───────────────────────────────────────────────── + + def execute_correction(self, correction_id: str) -> CorrectionResult: + """Execute a pending correction (stub). + + Full implementation will be provided in milestone M4.2. + + Args: + correction_id: The correction to execute. + + Returns: + A :class:`CorrectionResult` with the outcome. + + Raises: + ResourceNotFoundError: If the correction does not exist. + ValidationError: If the correction is not in a valid state. + """ + correction = self._get_or_raise(correction_id) + + if correction.status in { + CorrectionStatus.APPLIED, + CorrectionStatus.CANCELLED, + }: + raise ValidationError( + f"Cannot execute correction in {correction.status.value} state" + ) + + correction.status = CorrectionStatus.EXECUTING + + # Record attempt + attempt = CorrectionAttempt( + correction_id=correction_id, + success=True, + completed_at=datetime.now(), + details={"stub": True}, + ) + self._attempts.setdefault(correction_id, []).append(attempt) + + # Stub: mark as applied + correction.status = CorrectionStatus.APPLIED + + return CorrectionResult( + correction_id=correction_id, + status=CorrectionStatus.APPLIED, + new_decisions=[], + reverted_decisions=( + [correction.target_decision_id] + if correction.mode == CorrectionMode.REVERT + else [] + ), + ) + + # ── Query ─────────────────────────────────────────────────── + + def get_correction(self, correction_id: str) -> CorrectionRequest: + """Retrieve a correction by ID. + + Args: + correction_id: The correction to look up. + + Returns: + The :class:`CorrectionRequest`. + + Raises: + ResourceNotFoundError: If not found. + """ + return self._get_or_raise(correction_id) + + def list_corrections(self, plan_id: str | None = None) -> list[CorrectionRequest]: + """List corrections, optionally filtered by plan. + + Args: + plan_id: If provided, only return corrections for this plan. + + Returns: + List of matching corrections. + """ + corrections = list(self._corrections.values()) + if plan_id is not None: + corrections = [c for c in corrections if c.plan_id == plan_id] + return corrections + + def list_attempts(self, correction_id: str) -> list[CorrectionAttempt]: + """List attempts for a given correction. + + Args: + correction_id: The correction whose attempts to list. + + Returns: + List of :class:`CorrectionAttempt` objects. + """ + return list(self._attempts.get(correction_id, [])) + + # ── Cancel ────────────────────────────────────────────────── + + def cancel_correction(self, correction_id: str) -> CorrectionRequest: + """Cancel a pending correction. + + Args: + correction_id: The correction to cancel. + + Returns: + The updated :class:`CorrectionRequest`. + + Raises: + ResourceNotFoundError: If not found. + ValidationError: If the correction is already terminal. + """ + correction = self._get_or_raise(correction_id) + + if correction.status in { + CorrectionStatus.APPLIED, + CorrectionStatus.CANCELLED, + }: + raise ValidationError( + f"Cannot cancel correction in {correction.status.value} state" + ) + + correction.status = CorrectionStatus.CANCELLED + return correction + + # ── Helpers ────────────────────────────────────────────────── + + def _get_or_raise(self, correction_id: str) -> CorrectionRequest: + """Look up a correction by ID or raise.""" + correction = self._corrections.get(correction_id) + if correction is None: + raise ResourceNotFoundError(f"Correction not found: {correction_id}") + return correction diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index 58d5daaf0..f36444688 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -1828,6 +1828,13 @@ def plan_diff( str, typer.Argument(help="Plan ID to show diff for"), ], + correction: Annotated[ + str | None, + typer.Option( + "--correction", + help="Show diff for a specific correction attempt ID", + ), + ] = None, fmt: Annotated[ str, typer.Option( @@ -1841,8 +1848,24 @@ def plan_diff( Displays per-resource diffs grouped by resource name, with file create/modify/delete operations annotated. + + When ``--correction`` is provided, shows the diff for a specific + correction attempt instead of the whole plan. """ try: + if correction: + # Show correction-specific diff (stub: shows info panel) + console.print( + Panel( + f"[bold]Correction Attempt:[/bold] {correction}\n" + f"[bold]Plan:[/bold] {plan_id}\n\n" + "[dim]Correction diff will be available after M4.2.[/dim]", + title="Correction Diff", + expand=False, + ) + ) + return + service = _get_apply_service() output = service.diff(plan_id, fmt=fmt) console.print(output) @@ -1886,3 +1909,220 @@ def plan_artifacts( except CleverAgentsError as e: console.print(f"[red]Error:[/red] {e.message}") raise typer.Abort() from e + + +# ============================================================================= +# Correction Commands +# ============================================================================= + + +@app.command("correct") +def correct_decision( + decision_id: Annotated[ + str, + typer.Argument(help="Decision ID to correct"), + ], + mode: Annotated[ + str, + typer.Option( + "--mode", + "-m", + help="Correction mode: revert or append", + ), + ] = "revert", + guidance: Annotated[ + str, + typer.Option( + "--guidance", + "-g", + help="Guidance text for the correction", + ), + ] = "", + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", + help="Only analyze impact, do not execute", + ), + ] = False, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Skip confirmation prompt", + ), + ] = False, + plan_id: Annotated[ + str | None, + typer.Option( + "--plan", + "-p", + help="Plan ID (uses latest active plan if omitted)", + ), + ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", +) -> None: + """Correct a decision in a plan's decision tree. + + Supports two modes: + + * **revert** -- undo a decision and recompute affected subtrees + * **append** -- add new guidance at a decision point + + Use ``--dry-run`` to preview what would change without executing. + + Examples:: + + agents plan correct --mode revert -g "Use FastAPI instead" DEC-001 + agents plan correct --mode append -g "Add caching layer" --dry-run DEC-002 + """ + from cleveragents.application.services.correction_service import ( + CorrectionService, + ) + from cleveragents.core.exceptions import ResourceNotFoundError as RNF + from cleveragents.domain.models.core.correction import CorrectionMode + + try: + # Validate mode + try: + correction_mode = CorrectionMode(mode) + except ValueError as exc: + console.print( + f"[red]Invalid mode:[/red] {mode}. Must be 'revert' or 'append'." + ) + raise typer.Abort() from exc + + # Validate guidance + if not guidance or not guidance.strip(): + console.print( + "[red]Error:[/red] --guidance / -g is required and must not be empty." + ) + raise typer.Abort() + + # Resolve plan_id + resolved_plan_id = plan_id or _resolve_active_plan_id() + + svc = CorrectionService() + + # Create the correction request + request = svc.request_correction( + plan_id=resolved_plan_id, + decision_id=decision_id, + mode=correction_mode, + guidance=guidance, + dry_run=dry_run, + ) + + if dry_run: + # Analyze and display impact + impact = svc.analyze_impact(request.correction_id) + if fmt != OutputFormat.RICH.value: + data = { + "correction_id": request.correction_id, + "mode": request.mode.value, + "target_decision": request.target_decision_id, + "affected_decisions": impact.affected_decisions, + "affected_files": impact.affected_files, + "estimated_cost": impact.estimated_cost, + "risk_level": impact.risk_level, + } + console.print(format_output(data, fmt)) + else: + console.print( + Panel( + f"[bold]Correction ID:[/bold] {request.correction_id}\n" + f"[bold]Mode:[/bold] {request.mode.value}\n" + f"[bold]Target Decision:[/bold] " + f"{request.target_decision_id}\n" + f"[bold]Guidance:[/bold] {request.guidance}\n\n" + f"[bold]Affected Decisions:[/bold] " + f"{', '.join(impact.affected_decisions) or '(none)'}\n" + f"[bold]Affected Files:[/bold] " + f"{', '.join(impact.affected_files) or '(none)'}\n" + f"[bold]Risk Level:[/bold] {impact.risk_level}\n" + f"[bold]Estimated Cost:[/bold] " + f"{impact.estimated_cost or 'N/A'}", + title="Correction Impact (Dry Run)", + expand=False, + ) + ) + return + + # Confirm before execution + if not yes: + console.print( + f"\n[bold]Correction:[/bold] {correction_mode.value} " + f"decision {decision_id}" + ) + console.print(f"[bold]Guidance:[/bold] {guidance}") + confirm = typer.confirm("\nProceed with correction?") + if not confirm: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit(0) + + # Execute the correction + result = svc.execute_correction(request.correction_id) + + if fmt != OutputFormat.RICH.value: + data = { + "correction_id": result.correction_id, + "status": result.status.value, + "new_decisions": result.new_decisions, + "reverted_decisions": result.reverted_decisions, + } + console.print(format_output(data, fmt)) + else: + console.print( + f"[green]✓[/green] Correction applied: {result.correction_id}" + ) + if result.reverted_decisions: + console.print(f" Reverted: {', '.join(result.reverted_decisions)}") + if result.new_decisions: + console.print(f" New decisions: {', '.join(result.new_decisions)}") + + except RNF as e: + console.print(f"[red]Not found:[/red] {e.message}") + raise typer.Abort() from e + except ValidationError as e: + console.print(f"[red]Validation Error:[/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 _resolve_active_plan_id() -> str: + """Resolve the active plan ID when none is explicitly provided. + + Returns: + The plan ID of the most recent non-terminal plan. + + Raises: + typer.Abort: If no suitable plan is found. + """ + try: + service = _get_lifecycle_service() + plans = service.list_plans() + active = [p for p in plans if not p.is_terminal] + if not active: + console.print( + "[red]Error:[/red] No active plan found. " + "Specify --plan explicitly." + ) + raise typer.Abort() + return active[0].identity.plan_id + except CleverAgentsError as exc: + # Fall back to a placeholder when lifecycle service isn't available + console.print( + "[red]Error:[/red] Could not resolve active plan. " + "Specify --plan explicitly." + ) + raise typer.Abort() from exc diff --git a/src/cleveragents/domain/models/core/__init__.py b/src/cleveragents/domain/models/core/__init__.py index e85767b9e..75736f1a5 100644 --- a/src/cleveragents/domain/models/core/__init__.py +++ b/src/cleveragents/domain/models/core/__init__.py @@ -37,6 +37,14 @@ from cleveragents.domain.models.core.context_policy import ( ContextView, ProjectContextPolicy, ) +from cleveragents.domain.models.core.correction import ( + CorrectionAttempt, + CorrectionImpact, + CorrectionMode, + CorrectionRequest, + CorrectionResult, + CorrectionStatus, +) from cleveragents.domain.models.core.debug_attempt import DebugAttempt from cleveragents.domain.models.core.org import ( CloudBillingFields, @@ -166,6 +174,12 @@ __all__ = [ "ContextType", "ContextUpdateResult", "ContextView", + "CorrectionAttempt", + "CorrectionImpact", + "CorrectionMode", + "CorrectionRequest", + "CorrectionResult", + "CorrectionStatus", "CreditType", "CreditsTransaction", "CreditsTransactionType", diff --git a/src/cleveragents/domain/models/core/correction.py b/src/cleveragents/domain/models/core/correction.py new file mode 100644 index 000000000..66b0bd65f --- /dev/null +++ b/src/cleveragents/domain/models/core/correction.py @@ -0,0 +1,193 @@ +"""Correction domain models for decision tree editing. + +Corrections allow users to edit the decision tree and recompute affected +subtrees. Two correction modes are supported: + +* **revert** -- undo a specific decision and all descendants +* **append** -- add new guidance at a decision point without removing existing work + +Each correction targets a specific decision in the plan's decision tree. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from ulid import ULID + +# ── Enums ────────────────────────────────────────────────────────── + + +class CorrectionMode(StrEnum): + """Mode of correction to apply to the decision tree.""" + + REVERT = "revert" + APPEND = "append" + + +class CorrectionStatus(StrEnum): + """Status of a correction through its lifecycle.""" + + PENDING = "pending" + ANALYZING = "analyzing" + EXECUTING = "executing" + APPLIED = "applied" + FAILED = "failed" + CANCELLED = "cancelled" + + +# ── Domain Models ────────────────────────────────────────────────── + + +def _generate_ulid() -> str: + """Generate a new ULID string.""" + return str(ULID()) + + +class CorrectionRequest(BaseModel): + """A request to correct a decision in a plan's decision tree. + + Attributes: + correction_id: Unique ULID identifier for the correction. + plan_id: The plan whose decision tree is being corrected. + target_decision_id: The decision ID to target for correction. + mode: Whether to revert or append. + guidance: User-supplied guidance text for the correction. + dry_run: If ``True``, only analyze impact without executing. + created_at: When the request was created. + status: Current status of the correction. + """ + + model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) + + correction_id: str = Field(default_factory=_generate_ulid, description="ULID") + plan_id: str = Field(..., description="Plan being corrected") + target_decision_id: str = Field( + ..., description="Decision ID to target for correction" + ) + mode: CorrectionMode = Field(..., description="Correction mode") + guidance: str = Field(..., description="User-supplied guidance text") + dry_run: bool = Field(False, description="Analyze only, do not execute") + created_at: datetime = Field( + default_factory=datetime.now, description="When request was created" + ) + status: CorrectionStatus = Field( + default=CorrectionStatus.PENDING, description="Current status" + ) + + @field_validator("plan_id") + @classmethod + def _plan_id_not_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("plan_id must not be empty") + return v.strip() + + @field_validator("target_decision_id") + @classmethod + def _target_decision_id_not_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("target_decision_id must not be empty") + return v.strip() + + @field_validator("guidance") + @classmethod + def _guidance_not_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("guidance must not be empty") + return v.strip() + + +class CorrectionImpact(BaseModel): + """Impact analysis for a proposed correction. + + Returned by ``CorrectionService.analyze_impact`` to describe what + would change if the correction were executed. + + Attributes: + affected_decisions: Decision IDs that would be recomputed. + affected_files: File paths that would change. + estimated_cost: Optional estimated cost in credits. + risk_level: ``low``, ``medium``, or ``high``. + """ + + model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) + + affected_decisions: list[str] = Field( + default_factory=list, + description="Decision IDs that would be recomputed", + ) + affected_files: list[str] = Field( + default_factory=list, + description="File paths that would change", + ) + estimated_cost: float | None = Field(None, description="Estimated cost in credits") + risk_level: str = Field("low", description="Risk level: low, medium, or high") + + @field_validator("risk_level") + @classmethod + def _valid_risk_level(cls, v: str) -> str: + allowed = {"low", "medium", "high"} + if v not in allowed: + raise ValueError(f"risk_level must be one of {sorted(allowed)}, got {v!r}") + return v + + +class CorrectionResult(BaseModel): + """Result of executing a correction. + + Attributes: + correction_id: The correction that was executed. + status: Final status after execution. + new_decisions: New decision IDs created by the correction. + reverted_decisions: Decision IDs that were reverted. + error_message: Error detail if the correction failed. + completed_at: When execution finished. + """ + + model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) + + correction_id: str = Field(..., description="Correction that was executed") + status: CorrectionStatus = Field(..., description="Final status") + new_decisions: list[str] = Field( + default_factory=list, description="New decision IDs" + ) + reverted_decisions: list[str] = Field( + default_factory=list, description="Reverted decision IDs" + ) + error_message: str | None = Field( + default=None, description="Error detail if failed" + ) + completed_at: datetime = Field( + default_factory=datetime.now, description="When execution finished" + ) + + +class CorrectionAttempt(BaseModel): + """A single execution attempt for a correction. + + Tracks individual attempts so that retries are recorded separately. + + Attributes: + attempt_id: Unique ULID for the attempt. + correction_id: The parent correction. + started_at: When the attempt started. + completed_at: When the attempt finished (if done). + success: Whether the attempt succeeded. + details: Arbitrary metadata about the attempt. + """ + + model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) + + attempt_id: str = Field(default_factory=_generate_ulid, description="ULID") + correction_id: str = Field(..., description="Parent correction ID") + started_at: datetime = Field( + default_factory=datetime.now, description="Attempt start time" + ) + completed_at: datetime | None = Field(None, description="Attempt completion time") + success: bool = Field(False, description="Whether attempt succeeded") + details: dict[str, Any] = Field( + default_factory=dict, description="Metadata about the attempt" + )