feat(plan): execute strategize and execute via actors

This commit is contained in:
2026-02-20 21:00:42 +00:00
parent a2a0659821
commit 7aa36759c6
8 changed files with 1798 additions and 24 deletions
+135
View File
@@ -0,0 +1,135 @@
"""ASV benchmarks for plan actor integration overhead.
Measures the cost of strategize and execute stub actor operations,
PlanExecutor orchestration, and streaming callback overhead.
"""
from typing import ClassVar
from ulid import ULID
from cleveragents.application.services.plan_executor import (
ExecuteStubActor,
PlanExecutor,
StrategizeStubActor,
StrategyDecision,
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.plan import InvariantSource, PlanInvariant
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
class StrategizeStubBench:
"""Benchmark StrategizeStubActor execution."""
params: ClassVar[list[int]] = [1, 5, 20]
param_names: ClassVar[list[str]] = ["num_steps"]
def setup(self, num_steps: int) -> None:
self.actor = StrategizeStubActor()
self.plan_id = str(ULID())
steps = [f"Step {i}" for i in range(num_steps)]
self.definition = "\n".join(steps)
self.invariants = [
PlanInvariant(text=f"Invariant {i}", source=InvariantSource.ACTION)
for i in range(3)
]
def time_strategize_execute(self, num_steps: int) -> None:
self.actor.execute(
plan_id=self.plan_id,
definition_of_done=self.definition,
invariants=self.invariants,
)
def time_strategize_with_streaming(self, num_steps: int) -> None:
events: list[tuple[str, dict[str, object]]] = []
self.actor.execute(
plan_id=self.plan_id,
definition_of_done=self.definition,
stream_callback=lambda t, d: events.append((t, d)),
)
class ExecuteStubBench:
"""Benchmark ExecuteStubActor execution."""
params: ClassVar[list[int]] = [1, 5, 20]
param_names: ClassVar[list[str]] = ["num_decisions"]
def setup(self, num_decisions: int) -> None:
self.actor = ExecuteStubActor()
self.plan_id = str(ULID())
root_id = str(ULID())
self.decisions = [
StrategyDecision(
decision_id=root_id if i == 0 else str(ULID()),
step_text=f"Decision {i}",
sequence=i,
parent_id=root_id if i > 0 else None,
)
for i in range(num_decisions)
]
def time_execute_stub(self, num_decisions: int) -> None:
self.actor.execute(
plan_id=self.plan_id,
decisions=self.decisions,
)
def time_execute_with_tool_runner(self, num_decisions: int) -> None:
runner = ToolRunner(registry=ToolRegistry())
self.actor.execute(
plan_id=self.plan_id,
decisions=self.decisions,
tool_runner=runner,
)
class PlanExecutorLifecycleBench:
"""Benchmark full PlanExecutor lifecycle."""
def setup(self) -> None:
settings = Settings()
self.lifecycle = PlanLifecycleService(settings=settings)
self.runner = ToolRunner(registry=ToolRegistry())
self.executor = PlanExecutor(
lifecycle_service=self.lifecycle,
tool_runner=self.runner,
)
def time_full_strategize_execute(self) -> None:
action = self.lifecycle.create_action(
name=f"local/bench-{ULID()}",
description="Benchmark action",
definition_of_done="Step 1\nStep 2\nStep 3",
strategy_actor="local/stub",
execution_actor="local/stub",
)
plan = self.lifecycle.use_action(
action_name=str(action.namespaced_name),
)
plan_id = plan.identity.plan_id
self.executor.run_strategize(plan_id)
self.lifecycle.execute_plan(plan_id)
self.executor.run_execute(plan_id)
class ParseStepsBench:
"""Benchmark definition_of_done parsing."""
params: ClassVar[list[int]] = [5, 50, 200]
param_names: ClassVar[list[str]] = ["num_lines"]
def setup(self, num_lines: int) -> None:
self.actor = StrategizeStubActor()
self.definition = "\n".join(
f"- Step {i}: do something important" for i in range(num_lines)
)
def time_parse_steps(self, num_lines: int) -> None:
self.actor._parse_steps(self.definition)
+139
View File
@@ -0,0 +1,139 @@
# Plan Execute: Strategize & Execute Integration
## Overview
The plan executor connects the `PlanLifecycleService` to stub actors that
drive plans through the **Strategize** and **Execute** phases. In M1, these
actors are local-only stubs (no LLM calls); future milestones will integrate
real AI providers.
## Architecture
```
PlanExecutor
├── StrategizeStubActor (read-only, produces decision tree)
├── ExecuteStubActor (sandbox + ToolRunner + ChangeSet capture)
└── PlanLifecycleService (phase transitions, persistence)
```
## Phase Lifecycle
| Phase | Actor | Mode | Output |
|-------------|---------------------|-----------|---------------------------------|
| Strategize | StrategizeStubActor | Read-only | Decision tree, invariant records|
| Execute | ExecuteStubActor | Sandbox | ChangeSet, execution metadata |
## Strategize Phase
The strategize phase is **read-only**: it produces a decision tree from the
action's `definition_of_done` without modifying any resources.
### Decision Tree
The stub actor parses `definition_of_done` into discrete steps, each
represented as a `StrategyDecision` node with a ULID identifier:
```python
from cleveragents.application.services.plan_executor import (
PlanExecutor,
StrategizeResult,
)
executor = PlanExecutor(lifecycle_service=lifecycle, tool_runner=runner)
result: StrategizeResult = executor.run_strategize(plan_id)
# result.decision_root_id -> ULID of root node
# result.decisions -> list[StrategyDecision]
# result.invariant_records -> list[dict] (stub enforcement records)
```
### Invariant Propagation
Project and action invariants are propagated into the strategize context.
In M1, enforcement is stubbed (all invariants are accepted). Full
reconciliation via the Invariant Reconciliation Actor lands in D2.
## Execute Phase
The execute phase uses sandbox resources with tool calls routed through
`ToolRunner` and captured by `ChangeSetCapture`.
### ChangeSet Capture
All tool mutations during execute are recorded in a `ChangeSet`:
```python
result: ExecuteResult = executor.run_execute(plan_id)
# result.changeset_id -> ULID of the changeset
# result.changeset -> ChangeSet with entries
# result.tool_calls_count -> int
# result.sandbox_refs -> list[str]
```
### Metadata Persistence
After execute completes, the following metadata is persisted on the Plan:
- `changeset_id`: The ChangeSet identifier
- `sandbox_refs`: List of sandbox reference paths
- `error_details`: Tool call count and sandbox ref count
## Phase Guards
- **Execute requires Strategize COMPLETE**: The executor validates that the
plan has completed strategize (has a `decision_root_id`) before allowing
execute to proceed.
- **Phase validation**: Both `run_strategize()` and `run_execute()` verify
the plan is in the correct phase before proceeding.
## Error Handling
Failures in either phase are captured with full error context:
- `error_message`: The exception message string
- `error_details`: Dict containing `exception_type` and `traceback`
- The plan transitions to `ERRORED` processing state
### Retry Guidance
Plans in `ERRORED` state can be retried by:
1. Resetting the plan's processing state back to `QUEUED`
2. Re-running the failed phase via the executor
Full retry automation is planned for D1b (Phase Reversion & Error Recovery).
## Streaming Hooks
Both phases accept an optional `stream_callback` parameter for real-time
status updates (used by the `--stream` CLI flag):
```python
def my_callback(event_type: str, data: dict) -> None:
print(f"[{event_type}] {data}")
executor.run_strategize(plan_id, stream_callback=my_callback)
```
### Event Types
| Event | Phase | Description |
|------------------------|------------|---------------------------------|
| `strategize_started` | Strategize | Phase processing began |
| `strategize_decisions` | Strategize | Decisions produced |
| `strategize_complete` | Strategize | Phase completed successfully |
| `execute_started` | Execute | Phase processing began |
| `execute_step` | Execute | Individual decision being executed |
| `execute_complete` | Execute | Phase completed successfully |
## Module Reference
- **`cleveragents.application.services.plan_executor`**: Core module
- `StrategizeStubActor`: Local-only strategize actor
- `ExecuteStubActor`: Local-only execute actor
- `PlanExecutor`: Orchestrator connecting lifecycle service to actors
- `StrategyDecision`: Decision node model
- `StrategizeResult`: Strategize output model
- `ExecuteResult`: Execute output model
- `StreamCallback`: Type alias for streaming callbacks
+157
View File
@@ -0,0 +1,157 @@
Feature: Plan Actor Integration
As a developer
I want strategize and execute stub actors to drive plan phases
So that plans can be processed through the full lifecycle with ChangeSet capture
Background:
Given I have a plan executor service
# Strategize stub actor tests
Scenario: Strategize stub produces decisions from definition of done
Given I have a plan in strategize queued state with definition "Tests pass\nCoverage >= 90%"
When I run the strategize phase
Then strategize should produce 2 decisions
And the decision root id should be set on the plan
And the plan should be in strategize complete state
Scenario: Strategize stub handles single-line definition of done
Given I have a plan in strategize queued state with definition "All tests pass"
When I run the strategize phase
Then strategize should produce 1 decisions
And the decision root id should be set on the plan
Scenario: Strategize stub handles empty definition of done
Given I have a plan in strategize queued state with empty definition
When I run the strategize phase
Then strategize should produce 1 decisions
And the first decision should be "Complete the plan objectives"
Scenario: Strategize stub handles bullet list definition of done
Given I have a plan in strategize queued state with definition "- Unit tests pass\n- Integration tests pass\n- Coverage above threshold"
When I run the strategize phase
Then strategize should produce 3 decisions
Scenario: Strategize stub records invariant enforcement
Given I have a plan with invariants in strategize queued state
When I run the strategize phase
Then the strategize result should include invariant records
And each invariant record should have enforcement status
Scenario: Strategize is read-only and does not modify resources
Given I have a plan in strategize queued state with definition "Tests pass"
When I run the strategize phase
Then no changeset should be produced during strategize
And the plan sandbox refs should be empty
# Execute stub actor tests
Scenario: Execute stub captures tool calls through ChangeSet
Given I have a plan that completed strategize
When I run the execute phase
Then the changeset id should be set on the plan
And the plan should be in execute complete state
Scenario: Execute stub records execution metadata
Given I have a plan that completed strategize
When I run the execute phase
Then the plan should have execution metadata
And the tool calls count should be recorded
Scenario: Execute stub uses sandbox when provided
Given I have a plan executor with sandbox root
And I have a plan that completed strategize
When I run the execute phase
Then the plan sandbox refs should not be empty
# Phase transition guards
Scenario: Execute is prevented when plan is not in Strategize COMPLETE state
Given I have a plan in strategize queued state with definition "Tests pass"
When I try to run the execute phase
Then a plan error should be raised about phase mismatch
Scenario: Execute is prevented when plan has no decision tree
Given I have a plan in execute queued state without decisions
When I try to run the execute phase on the executor
Then a plan error should be raised about missing decisions
Scenario: Strategize fails when plan is not in strategize phase
Given I have a plan in execute phase for executor
When I try to run the strategize phase
Then a plan error should be raised about phase mismatch
# Error handling and status updates
Scenario: Strategize failure captures error details
Given I have a plan in strategize queued state with definition "Tests pass"
And the strategize actor is configured to fail
When I try to run the strategize phase expecting failure
Then the plan should be in errored state
And the plan should have error details
Scenario: Execute failure captures error details
Given I have a plan that completed strategize
And the execute actor is configured to fail
When I try to run the execute phase expecting failure
Then the plan should be in errored state
And the plan should have error details
# Streaming hooks
Scenario: Strategize emits streaming events
Given I have a plan in strategize queued state with definition "Tests pass"
And I have a stream callback registered
When I run the strategize phase with streaming
Then the stream callback should have received strategize events
And the stream events should include strategize_started
And the stream events should include strategize_complete
Scenario: Execute emits streaming events
Given I have a plan that completed strategize
And I have a stream callback registered
When I run the execute phase with streaming
Then the stream callback should have received execute events
And the stream events should include execute_started
And the stream events should include execute_complete
# Validation
Scenario: Strategize rejects empty plan id
When I try to run strategize with empty plan id
Then a validation error should be raised for empty plan id
Scenario: Execute rejects empty plan id
When I try to run execute with empty plan id
Then a validation error should be raised for empty plan id
Scenario: PlanExecutor rejects None lifecycle service
When I try to create a PlanExecutor with None lifecycle service
Then a validation error should be raised for None lifecycle service
# Strategy decision model
Scenario: StrategyDecision model validates fields
When I create a strategy decision with valid fields
Then the strategy decision should have the correct attributes
Scenario: StrategizeResult model stores decisions
When I create a strategize result with 3 decisions
Then the result should contain 3 decisions
And the result should have a root id
Scenario: ExecuteResult model stores changeset
When I create an execute result with changeset
Then the result should have a changeset id
And the result should have a changeset object
# Full lifecycle integration
Scenario: Full strategize then execute lifecycle
Given I have a plan in strategize queued state with definition "Write tests\nRun linter\nVerify coverage"
When I run the strategize phase
And I transition the plan to execute
And I run the execute phase
Then the plan should be in execute complete state
And the changeset id should be set on the plan
And the decision root id should be set on the plan
@@ -0,0 +1,593 @@
"""Step definitions for Plan Actor Integration tests."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_executor import (
ExecuteResult,
PlanExecutor,
StrategizeResult,
StrategyDecision,
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.plan import (
ProcessingState,
)
from cleveragents.tool.builtins.changeset import ChangeSet
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
@given("I have a plan executor service")
def step_create_executor_service(context: Context) -> None:
"""Create a plan executor service with in-memory lifecycle."""
settings = Settings()
context.lifecycle_service = PlanLifecycleService(settings=settings)
context.tool_registry = ToolRegistry()
context.tool_runner = ToolRunner(registry=context.tool_registry)
context.executor = PlanExecutor(
lifecycle_service=context.lifecycle_service,
tool_runner=context.tool_runner,
)
context.error = None
context.strategize_result = None
context.execute_result = None
context.stream_events: list[tuple[str, dict[str, Any]]] = []
@given("I have a plan executor with sandbox root")
def step_create_executor_with_sandbox(context: Context) -> None:
"""Create a plan executor with sandbox root configured."""
import tempfile
context.sandbox_dir = tempfile.mkdtemp()
settings = Settings()
context.lifecycle_service = PlanLifecycleService(settings=settings)
context.tool_registry = ToolRegistry()
context.tool_runner = ToolRunner(registry=context.tool_registry)
context.executor = PlanExecutor(
lifecycle_service=context.lifecycle_service,
tool_runner=context.tool_runner,
sandbox_root=context.sandbox_dir,
)
context.error = None
context.strategize_result = None
context.execute_result = None
context.stream_events = []
def _create_plan_in_strategize(
context: Context, definition: str
) -> str:
"""Helper to create a plan in strategize/queued state."""
action = context.lifecycle_service.create_action(
name="local/test-executor-action",
description="Test action for executor",
definition_of_done=definition,
strategy_actor="local/stub-strategize",
execution_actor="local/stub-execute",
)
plan = context.lifecycle_service.use_action(
action_name=str(action.namespaced_name),
)
context.plan = plan
context.plan_id = plan.identity.plan_id
return plan.identity.plan_id
@given('I have a plan in strategize queued state with definition "{definition}"')
def step_plan_strategize_queued(context: Context, definition: str) -> None:
"""Create a plan in strategize/queued state."""
# Unescape newlines from feature file
definition = definition.replace("\\n", "\n")
_create_plan_in_strategize(context, definition)
@given("I have a plan in strategize queued state with empty definition")
def step_plan_strategize_empty_definition(context: Context) -> None:
"""Create a plan in strategize/queued state with empty definition.
The Action model requires non-empty definition_of_done, so we create
the action with a minimal definition, then clear it on the plan after
creation to test the empty-definition path in the strategize actor.
"""
plan_id = _create_plan_in_strategize(context, "placeholder")
plan = context.lifecycle_service.get_plan(plan_id)
plan.definition_of_done = None
context.lifecycle_service._commit_plan(plan)
@given("I have a plan with invariants in strategize queued state")
def step_plan_with_invariants(context: Context) -> None:
"""Create a plan with invariants in strategize/queued state."""
from cleveragents.domain.models.core.plan import (
InvariantSource,
PlanInvariant,
)
action = context.lifecycle_service.create_action(
name="local/test-invariant-action",
description="Test action with invariants",
definition_of_done="Tests pass",
strategy_actor="local/stub-strategize",
execution_actor="local/stub-execute",
invariants=["Must not exceed 100 lines", "Must have docstrings"],
)
plan = context.lifecycle_service.use_action(
action_name=str(action.namespaced_name),
invariants=[
PlanInvariant(text="Plan-level constraint", source=InvariantSource.PLAN)
],
)
context.plan = plan
context.plan_id = plan.identity.plan_id
@given("I have a plan that completed strategize")
def step_plan_completed_strategize(context: Context) -> None:
"""Create a plan that has completed the strategize phase."""
_create_plan_in_strategize(context, "Tests pass\nCoverage met")
context.strategize_result = context.executor.run_strategize(context.plan_id)
# Transition to execute phase
context.lifecycle_service.execute_plan(context.plan_id)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@given("I have a plan in execute phase for executor")
def step_plan_in_execute_phase(context: Context) -> None:
"""Create a plan already in execute phase."""
_create_plan_in_strategize(context, "Tests pass")
context.executor.run_strategize(context.plan_id)
context.lifecycle_service.execute_plan(context.plan_id)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@given("I have a plan in execute queued state without decisions")
def step_plan_execute_no_decisions(context: Context) -> None:
"""Create a plan in execute/queued without decision tree."""
_create_plan_in_strategize(context, "Tests pass")
# Manually force through strategize without setting decision_root_id
context.lifecycle_service.start_strategize(context.plan_id)
context.lifecycle_service.complete_strategize(context.plan_id)
context.lifecycle_service.execute_plan(context.plan_id)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
# Ensure no decision_root_id
context.plan.decision_root_id = None
context.lifecycle_service._commit_plan(context.plan)
@given("the strategize actor is configured to fail")
def step_strategize_actor_fails(context: Context) -> None:
"""Configure the strategize actor to raise an error."""
def _failing_execute(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("Strategize actor deliberate failure")
context.executor._strategize_actor.execute = _failing_execute # type: ignore[method-assign]
@given("the execute actor is configured to fail")
def step_execute_actor_fails(context: Context) -> None:
"""Configure the execute actor to raise an error."""
def _failing_execute(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("Execute actor deliberate failure")
context.executor._execute_actor.execute = _failing_execute # type: ignore[method-assign]
@given("I have a stream callback registered")
def step_register_stream_callback(context: Context) -> None:
"""Register a stream callback."""
context.stream_events = []
def _callback(event_type: str, data: dict[str, Any]) -> None:
context.stream_events.append((event_type, data))
context.stream_callback = _callback
# When steps
@when("I run the strategize phase")
def step_run_strategize(context: Context) -> None:
"""Run the strategize phase."""
context.strategize_result = context.executor.run_strategize(context.plan_id)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@when("I run the execute phase")
def step_run_execute(context: Context) -> None:
"""Run the execute phase."""
context.execute_result = context.executor.run_execute(context.plan_id)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@when("I try to run the execute phase")
def step_try_run_execute_wrong_phase(context: Context) -> None:
"""Try to run execute when plan is in wrong phase."""
context.error = None
try:
context.lifecycle_service.execute_plan(context.plan_id)
except (PlanError, Exception) as exc:
context.error = exc
@when("I try to run the execute phase on the executor")
def step_try_run_execute_on_executor(context: Context) -> None:
"""Try to run execute on the executor."""
context.error = None
try:
context.executor.run_execute(context.plan_id)
except (PlanError, Exception) as exc:
context.error = exc
@when("I try to run the strategize phase")
def step_try_run_strategize(context: Context) -> None:
"""Try to run strategize when plan is in wrong phase."""
context.error = None
try:
context.executor.run_strategize(context.plan_id)
except (PlanError, Exception) as exc:
context.error = exc
@when("I try to run the strategize phase expecting failure")
def step_try_run_strategize_failure(context: Context) -> None:
"""Try to run strategize expecting an actor failure."""
context.error = None
try:
context.executor.run_strategize(context.plan_id)
except Exception as exc:
context.error = exc
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@when("I try to run the execute phase expecting failure")
def step_try_run_execute_failure(context: Context) -> None:
"""Try to run execute expecting an actor failure."""
context.error = None
try:
context.executor.run_execute(context.plan_id)
except Exception as exc:
context.error = exc
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@when("I run the strategize phase with streaming")
def step_run_strategize_streaming(context: Context) -> None:
"""Run strategize with streaming callback."""
context.strategize_result = context.executor.run_strategize(
context.plan_id, stream_callback=context.stream_callback
)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@when("I run the execute phase with streaming")
def step_run_execute_streaming(context: Context) -> None:
"""Run execute with streaming callback."""
context.execute_result = context.executor.run_execute(
context.plan_id, stream_callback=context.stream_callback
)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
@when("I try to run strategize with empty plan id")
def step_try_strategize_empty_id(context: Context) -> None:
"""Try to run strategize with empty plan id."""
context.error = None
try:
context.executor.run_strategize("")
except ValidationError as exc:
context.error = exc
@when("I try to run execute with empty plan id")
def step_try_execute_empty_id(context: Context) -> None:
"""Try to run execute with empty plan id."""
context.error = None
try:
context.executor.run_execute("")
except ValidationError as exc:
context.error = exc
@when("I try to create a PlanExecutor with None lifecycle service")
def step_try_create_executor_none(context: Context) -> None:
"""Try to create a PlanExecutor with None lifecycle service."""
context.error = None
try:
PlanExecutor(lifecycle_service=None)
except ValidationError as exc:
context.error = exc
@when("I create a strategy decision with valid fields")
def step_create_strategy_decision(context: Context) -> None:
"""Create a StrategyDecision with valid fields."""
context.decision = StrategyDecision(
decision_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
step_text="Run unit tests",
sequence=0,
)
@when("I create a strategize result with 3 decisions")
def step_create_strategize_result(context: Context) -> None:
"""Create a StrategizeResult with 3 decisions."""
from ulid import ULID
root_id = str(ULID())
decisions = [
StrategyDecision(
decision_id=root_id if i == 0 else str(ULID()),
step_text=f"Step {i + 1}",
sequence=i,
parent_id=root_id if i > 0 else None,
)
for i in range(3)
]
context.strategize_result = StrategizeResult(
decision_root_id=root_id,
decisions=decisions,
)
@when("I create an execute result with changeset")
def step_create_execute_result(context: Context) -> None:
"""Create an ExecuteResult with a changeset."""
from ulid import ULID
cs_id = str(ULID())
changeset = ChangeSet(plan_id="test-plan", entries=[])
context.execute_result = ExecuteResult(
changeset_id=cs_id,
changeset=changeset,
tool_calls_count=5,
sandbox_refs=["/tmp/sandbox"],
)
@when("I transition the plan to execute")
def step_transition_to_execute(context: Context) -> None:
"""Transition the plan from strategize complete to execute."""
context.lifecycle_service.execute_plan(context.plan_id)
context.plan = context.lifecycle_service.get_plan(context.plan_id)
# Then steps
@then("strategize should produce {count:d} decisions")
def step_check_decision_count(context: Context, count: int) -> None:
"""Verify the number of decisions produced."""
assert context.strategize_result is not None
assert len(context.strategize_result.decisions) == count, (
f"Expected {count} decisions, got {len(context.strategize_result.decisions)}"
)
@then("the decision root id should be set on the plan")
def step_check_decision_root_id(context: Context) -> None:
"""Verify that decision_root_id is set on the plan."""
plan = context.lifecycle_service.get_plan(context.plan_id)
assert plan.decision_root_id is not None, "decision_root_id should be set"
@then("the plan should be in strategize complete state")
def step_check_strategize_complete(context: Context) -> None:
"""Verify plan is in strategize/complete state."""
plan = context.lifecycle_service.get_plan(context.plan_id)
# After complete_strategize, auto-progress may advance to execute
assert plan.processing_state in (
ProcessingState.COMPLETE,
ProcessingState.QUEUED,
), f"Expected complete or queued, got {plan.processing_state}"
@then('the first decision should be "{expected}"')
def step_check_first_decision(context: Context, expected: str) -> None:
"""Verify the first decision text."""
assert context.strategize_result is not None
assert len(context.strategize_result.decisions) > 0
assert context.strategize_result.decisions[0].step_text == expected
@then("the strategize result should include invariant records")
def step_check_invariant_records(context: Context) -> None:
"""Verify invariant records are present."""
assert context.strategize_result is not None
assert len(context.strategize_result.invariant_records) > 0
@then("each invariant record should have enforcement status")
def step_check_invariant_enforcement(context: Context) -> None:
"""Verify each invariant record has enforcement info."""
assert context.strategize_result is not None
for record in context.strategize_result.invariant_records:
assert "enforced" in record
assert "enforcement_note" in record
@then("no changeset should be produced during strategize")
def step_check_no_changeset_strategize(context: Context) -> None:
"""Verify strategize did not produce a changeset."""
plan = context.lifecycle_service.get_plan(context.plan_id)
assert plan.changeset_id is None, "Strategize should not produce a changeset"
@then("the plan sandbox refs should be empty")
def step_check_sandbox_refs_empty(context: Context) -> None:
"""Verify sandbox refs are empty."""
plan = context.lifecycle_service.get_plan(context.plan_id)
assert len(plan.sandbox_refs) == 0, "Sandbox refs should be empty"
@then("the changeset id should be set on the plan")
def step_check_changeset_id(context: Context) -> None:
"""Verify changeset_id is set on the plan."""
plan = context.lifecycle_service.get_plan(context.plan_id)
assert plan.changeset_id is not None, "changeset_id should be set"
@then("the plan should be in execute complete state")
def step_check_execute_complete(context: Context) -> None:
"""Verify plan is in execute/complete state."""
plan = context.lifecycle_service.get_plan(context.plan_id)
# After complete_execute, auto-progress may advance to apply
assert plan.processing_state in (
ProcessingState.COMPLETE,
ProcessingState.QUEUED,
), f"Expected complete or queued, got {plan.processing_state}"
@then("the plan should have execution metadata")
def step_check_execution_metadata(context: Context) -> None:
"""Verify execution metadata is present."""
assert context.execute_result is not None
assert context.execute_result.changeset_id is not None
@then("the tool calls count should be recorded")
def step_check_tool_calls_count(context: Context) -> None:
"""Verify tool calls count is recorded."""
assert context.execute_result is not None
assert context.execute_result.tool_calls_count >= 0
@then("the plan sandbox refs should not be empty")
def step_check_sandbox_refs_not_empty(context: Context) -> None:
"""Verify sandbox refs are not empty."""
assert context.execute_result is not None
assert len(context.execute_result.sandbox_refs) > 0
@then("a plan error should be raised about phase mismatch")
def step_check_plan_error_phase(context: Context) -> None:
"""Verify a PlanError was raised about phase."""
assert context.error is not None, "Expected a PlanError but none was raised"
@then("a plan error should be raised about missing decisions")
def step_check_plan_error_decisions(context: Context) -> None:
"""Verify a PlanError was raised about missing decisions."""
assert context.error is not None, "Expected a PlanError but none was raised"
assert "decision" in str(context.error).lower() or "strategize" in str(
context.error
).lower(), f"Error should mention decisions: {context.error}"
@then("the plan should be in errored state")
def step_check_plan_errored(context: Context) -> None:
"""Verify the plan is in errored state."""
plan = context.lifecycle_service.get_plan(context.plan_id)
assert plan.processing_state == ProcessingState.ERRORED, (
f"Expected errored, got {plan.processing_state}"
)
@then("the plan should have error details")
def step_check_error_details(context: Context) -> None:
"""Verify the plan has error details."""
plan = context.lifecycle_service.get_plan(context.plan_id)
assert plan.error_details is not None, "Plan should have error_details"
@then("the stream callback should have received strategize events")
def step_check_strategize_stream_events(context: Context) -> None:
"""Verify strategize streaming events were received."""
assert len(context.stream_events) > 0, "Should have received stream events"
@then("the stream events should include strategize_started")
def step_check_strategize_started_event(context: Context) -> None:
"""Verify strategize_started event was emitted."""
event_types = [e[0] for e in context.stream_events]
assert "strategize_started" in event_types
@then("the stream events should include strategize_complete")
def step_check_strategize_complete_event(context: Context) -> None:
"""Verify strategize_complete event was emitted."""
event_types = [e[0] for e in context.stream_events]
assert "strategize_complete" in event_types
@then("the stream callback should have received execute events")
def step_check_execute_stream_events(context: Context) -> None:
"""Verify execute streaming events were received."""
assert len(context.stream_events) > 0, "Should have received stream events"
@then("the stream events should include execute_started")
def step_check_execute_started_event(context: Context) -> None:
"""Verify execute_started event was emitted."""
event_types = [e[0] for e in context.stream_events]
assert "execute_started" in event_types
@then("the stream events should include execute_complete")
def step_check_execute_complete_event(context: Context) -> None:
"""Verify execute_complete event was emitted."""
event_types = [e[0] for e in context.stream_events]
assert "execute_complete" in event_types
@then("a validation error should be raised for empty plan id")
def step_check_validation_error_empty(context: Context) -> None:
"""Verify a ValidationError was raised for empty plan id."""
assert context.error is not None
assert isinstance(context.error, ValidationError)
@then("a validation error should be raised for None lifecycle service")
def step_check_validation_error_none(context: Context) -> None:
"""Verify a ValidationError was raised for None lifecycle service."""
assert context.error is not None
assert isinstance(context.error, ValidationError)
@then("the strategy decision should have the correct attributes")
def step_check_decision_attributes(context: Context) -> None:
"""Verify StrategyDecision attributes."""
assert context.decision.decision_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
assert context.decision.step_text == "Run unit tests"
assert context.decision.sequence == 0
assert context.decision.parent_id is None
@then("the result should contain {count:d} decisions")
def step_check_result_decisions(context: Context, count: int) -> None:
"""Verify the result contains expected decision count."""
assert context.strategize_result is not None
assert len(context.strategize_result.decisions) == count
@then("the result should have a root id")
def step_check_result_root_id(context: Context) -> None:
"""Verify the result has a root id."""
assert context.strategize_result is not None
assert context.strategize_result.decision_root_id is not None
@then("the result should have a changeset id")
def step_check_result_changeset_id(context: Context) -> None:
"""Verify the result has a changeset id."""
assert context.execute_result is not None
assert context.execute_result.changeset_id is not None
@then("the result should have a changeset object")
def step_check_result_changeset_obj(context: Context) -> None:
"""Verify the result has a changeset object."""
assert context.execute_result is not None
assert context.execute_result.changeset is not None
assert isinstance(context.execute_result.changeset, ChangeSet)
+41 -24
View File
@@ -135,6 +135,23 @@ The following work from the previous implementation has been completed and will
#### Phase 2 Notes (Preserved from Previous Work)
**2026-02-20**: D0b.execute — Plan Execute via Actors (M1 Critical Blocker)
- Created `src/cleveragents/application/services/plan_executor.py` with:
- `StrategizeStubActor`: Local-only stub that parses definition_of_done into decision tree
- `ExecuteStubActor`: Local-only stub with ToolRunner + ChangeSetCapture integration
- `PlanExecutor`: Orchestrator connecting PlanLifecycleService to stub actors
- `StrategyDecision`, `StrategizeResult`, `ExecuteResult` data models
- `StreamCallback` type alias for --stream CLI streaming hooks
- Strategize phase is read-only: produces decisions without modifying resources
- Execute phase uses sandbox resources and routes tool calls through ToolRunner
- Phase guards prevent Execute when Plan is not in Strategize COMPLETE state
- Error handling captures error_message + error_details with full traceback
- Invariant propagation stubbed (accepted without reconciliation until D2)
- Execution metadata (changeset_id, tool_calls_count, sandbox_refs) persisted to Plan
- Streaming hooks emit interim status updates (strategize_started/complete, execute_started/step/complete)
- Full test coverage: Behave features, Robot Framework integration, ASV benchmarks
- Documentation: docs/reference/plan_execute.md with architecture, lifecycle, and streaming API
**2025-11-22**: Week 12 Complete, Phase 2 Core Functionality DONE
- CLI Streaming Integration fully implemented
- AutoDebugGraph Implementation complete
@@ -3953,30 +3970,30 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
**PARALLEL SUBTRACK D0b.apply [Jeff]**: Review/diff CLI + apply integration
**SEQUENTIAL MERGE NOTE**: D0b.execute must land before D0b.apply.
- [ ] **COMMIT (Owner: Jeff | Group: D0b.execute | Branch: feature/m1-plan-execute | Planned: Day 11 | Expected: Day 13) - Commit message: "feat(plan): execute strategize and execute via actors"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m1-plan-execute`
- [ ] Code [Jeff]: Add local-only Strategize/Execute stub actors for M1 (no LLM) with interfaces compatible with the actor registry.
- [ ] Code [Jeff]: Connect PlanLifecycleService to actor execution for Strategize and Execute phases.
- [ ] Code [Jeff]: Ensure Strategize is read-only and records decisions without modifying resources.
- [ ] Code [Jeff]: Ensure Execute uses sandbox resources and tool calls routed through ToolRunner + ChangeSet capture; persist `changeset_id` in plan metadata.
- [ ] Code [Jeff]: Add phase start/complete/fail status updates with `error_message` + `error_details` capture on failures.
- [ ] Code [Jeff]: Persist decision tree root id and strategy decisions into Plan metadata after Strategize completes.
- [ ] Code [Jeff]: Propagate project/action invariants into strategize context and record enforcement decisions (stubbed until D2 reconciliation).
- [ ] Code [Jeff]: Capture execution metadata (tool_calls count, sandbox_refs) into Plan metadata.
- [ ] Code [Jeff]: Add streaming hooks for `--stream` to emit interim status updates during Strategize/Execute.
- [ ] Code [Jeff]: Add guard to prevent Execute when Plan is not in Strategize COMPLETE state.
- [ ] Docs [Jeff]: Add execute/strategize integration notes, error handling, and retry guidance to `docs/reference/plan_execute.md`.
- [ ] Tests (Behave) [Jeff]: Add `features/plan_actor_integration.feature` for strategy/execute flows.
- [ ] Tests (Robot) [Jeff]: Add `robot/plan_actor_integration.robot` for end-to-end actor execution.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/plan_actor_integration_bench.py` for execution overhead.
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(plan): execute strategize and execute via actors"`
- [ ] Git [Jeff]: `git push -u origin feature/m1-plan-execute`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-plan-execute` to `master` with a suitable and thorough description.
- [X] **COMMIT (Owner: Jeff | Group: D0b.execute | Branch: feature/m1-plan-execute | Planned: Day 11 | Expected: Day 13) - Commit message: "feat(plan): execute strategize and execute via actors"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m1-plan-execute`
- [X] Code [Jeff]: Add local-only Strategize/Execute stub actors for M1 (no LLM) with interfaces compatible with the actor registry.
- [X] Code [Jeff]: Connect PlanLifecycleService to actor execution for Strategize and Execute phases.
- [X] Code [Jeff]: Ensure Strategize is read-only and records decisions without modifying resources.
- [X] Code [Jeff]: Ensure Execute uses sandbox resources and tool calls routed through ToolRunner + ChangeSet capture; persist `changeset_id` in plan metadata.
- [X] Code [Jeff]: Add phase start/complete/fail status updates with `error_message` + `error_details` capture on failures.
- [X] Code [Jeff]: Persist decision tree root id and strategy decisions into Plan metadata after Strategize completes.
- [X] Code [Jeff]: Propagate project/action invariants into strategize context and record enforcement decisions (stubbed until D2 reconciliation).
- [X] Code [Jeff]: Capture execution metadata (tool_calls count, sandbox_refs) into Plan metadata.
- [X] Code [Jeff]: Add streaming hooks for `--stream` to emit interim status updates during Strategize/Execute.
- [X] Code [Jeff]: Add guard to prevent Execute when Plan is not in Strategize COMPLETE state.
- [X] Docs [Jeff]: Add execute/strategize integration notes, error handling, and retry guidance to `docs/reference/plan_execute.md`.
- [X] Tests (Behave) [Jeff]: Add `features/plan_actor_integration.feature` for strategy/execute flows.
- [X] Tests (Robot) [Jeff]: Add `robot/plan_actor_integration.robot` for end-to-end actor execution.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/plan_actor_integration_bench.py` for execution overhead.
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(plan): execute strategize and execute via actors"`
- [X] Git [Jeff]: `git push -u origin feature/m1-plan-execute`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-plan-execute` to `master` with a suitable and thorough description.
- [ ] **COMMIT (Owner: Jeff | Group: D0b.apply | Branch: feature/m2-plan-apply-review | Planned: Day 12 | Expected: Day 15) - Commit message: "feat(plan): add diff review and apply integration"**
- [ ] Git [Jeff]: `git checkout master`
+159
View File
@@ -0,0 +1,159 @@
*** Settings ***
Documentation Integration test: Plan actor integration for strategize/execute phases
Library OperatingSystem
Library Process
Library Collections
*** Variables ***
${PYTHON} python3
*** Test Cases ***
Strategize Stub Produces Decisions
[Documentation] Run strategize stub and verify decisions are produced.
${result}= Run Process ${PYTHON} -c
... ${STRATEGIZE_SCRIPT}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} PASS:decisions=2
Should Contain ${result.stdout} PASS:root_id_set=True
Execute Stub Captures ChangeSet
[Documentation] Run execute stub and verify changeset is captured.
${result}= Run Process ${PYTHON} -c
... ${EXECUTE_SCRIPT}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} PASS:changeset_id_set=True
Should Contain ${result.stdout} PASS:execute_complete=True
Full Lifecycle Strategize Then Execute
[Documentation] Run full strategize-then-execute lifecycle.
${result}= Run Process ${PYTHON} -c
... ${LIFECYCLE_SCRIPT}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} PASS:strategize_ok
Should Contain ${result.stdout} PASS:execute_ok
Should Contain ${result.stdout} PASS:decision_root_set
Strategize Streaming Events Emitted
[Documentation] Verify strategize emits streaming events.
${result}= Run Process ${PYTHON} -c
... ${STREAM_SCRIPT}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} PASS:strategize_started
Should Contain ${result.stdout} PASS:strategize_complete
Phase Guard Prevents Invalid Execute
[Documentation] Verify execute is blocked when plan is not ready.
${result}= Run Process ${PYTHON} -c
... ${GUARD_SCRIPT}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} PASS:guard_blocked
*** Variables ***
${STRATEGIZE_SCRIPT} SEPARATOR=\n
... import os, sys
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
... from cleveragents.application.services.plan_executor import StrategizeStubActor, StrategyDecision
... from cleveragents.domain.models.core.plan import PlanInvariant, InvariantSource
... from ulid import ULID
... actor = StrategizeStubActor()
... result = actor.execute(
... ${SPACE}${SPACE}${SPACE}${SPACE}plan_id=str(ULID()),
... ${SPACE}${SPACE}${SPACE}${SPACE}definition_of_done="Tests pass" + chr(10) + "Coverage met",
... ${SPACE}${SPACE}${SPACE}${SPACE}invariants=[PlanInvariant(text="No regressions", source=InvariantSource.ACTION)],
... )
... print(f"PASS:decisions={len(result.decisions)}")
... print(f"PASS:root_id_set={result.decision_root_id is not None}")
${EXECUTE_SCRIPT} SEPARATOR=\n
... import os, sys
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
... from cleveragents.application.services.plan_executor import ExecuteStubActor, StrategyDecision
... from ulid import ULID
... actor = ExecuteStubActor()
... decisions = [
... ${SPACE}${SPACE}${SPACE}${SPACE}StrategyDecision(decision_id=str(ULID()), step_text="Step 1", sequence=0),
... ${SPACE}${SPACE}${SPACE}${SPACE}StrategyDecision(decision_id=str(ULID()), step_text="Step 2", sequence=1),
... ]
... result = actor.execute(plan_id=str(ULID()), decisions=decisions)
... print(f"PASS:changeset_id_set={result.changeset_id is not None}")
... print(f"PASS:execute_complete=True")
${LIFECYCLE_SCRIPT} SEPARATOR=\n
... import os, sys
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
... from cleveragents.config.settings import Settings
... from cleveragents.application.services.plan_lifecycle_service import PlanLifecycleService
... from cleveragents.application.services.plan_executor import PlanExecutor
... from cleveragents.tool.registry import ToolRegistry
... from cleveragents.tool.runner import ToolRunner
... settings = Settings()
... lifecycle = PlanLifecycleService(settings=settings)
... runner = ToolRunner(registry=ToolRegistry())
... executor = PlanExecutor(lifecycle_service=lifecycle, tool_runner=runner)
... action = lifecycle.create_action(
... ${SPACE}${SPACE}${SPACE}${SPACE}name="local/robot-test",
... ${SPACE}${SPACE}${SPACE}${SPACE}description="Robot test action",
... ${SPACE}${SPACE}${SPACE}${SPACE}definition_of_done="Write tests" + chr(10) + "Run linter",
... ${SPACE}${SPACE}${SPACE}${SPACE}strategy_actor="local/stub",
... ${SPACE}${SPACE}${SPACE}${SPACE}execution_actor="local/stub",
... )
... plan = lifecycle.use_action(action_name=str(action.namespaced_name))
... plan_id = plan.identity.plan_id
... s_result = executor.run_strategize(plan_id)
... print("PASS:strategize_ok")
... lifecycle.execute_plan(plan_id)
... e_result = executor.run_execute(plan_id)
... print("PASS:execute_ok")
... plan = lifecycle.get_plan(plan_id)
... if plan.decision_root_id:
... ${SPACE}${SPACE}${SPACE}${SPACE}print("PASS:decision_root_set")
${STREAM_SCRIPT} SEPARATOR=\n
... import os, sys
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
... from cleveragents.application.services.plan_executor import StrategizeStubActor
... from ulid import ULID
... events = []
... def callback(event_type, data):
... ${SPACE}${SPACE}${SPACE}${SPACE}events.append(event_type)
... actor = StrategizeStubActor()
... actor.execute(plan_id=str(ULID()), definition_of_done="Test", stream_callback=callback)
... for e in events:
... ${SPACE}${SPACE}${SPACE}${SPACE}print(f"PASS:{e}")
${GUARD_SCRIPT} SEPARATOR=\n
... import os, sys
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
... from cleveragents.config.settings import Settings
... from cleveragents.application.services.plan_lifecycle_service import PlanLifecycleService
... from cleveragents.application.services.plan_executor import PlanExecutor
... from cleveragents.tool.registry import ToolRegistry
... from cleveragents.tool.runner import ToolRunner
... from cleveragents.core.exceptions import PlanError
... settings = Settings()
... lifecycle = PlanLifecycleService(settings=settings)
... runner = ToolRunner(registry=ToolRegistry())
... executor = PlanExecutor(lifecycle_service=lifecycle, tool_runner=runner)
... action = lifecycle.create_action(
... ${SPACE}${SPACE}${SPACE}${SPACE}name="local/guard-test",
... ${SPACE}${SPACE}${SPACE}${SPACE}description="Guard test",
... ${SPACE}${SPACE}${SPACE}${SPACE}definition_of_done="Test",
... ${SPACE}${SPACE}${SPACE}${SPACE}strategy_actor="local/stub",
... ${SPACE}${SPACE}${SPACE}${SPACE}execution_actor="local/stub",
... )
... plan = lifecycle.use_action(action_name=str(action.namespaced_name))
... try:
... ${SPACE}${SPACE}${SPACE}${SPACE}executor.run_execute(plan.identity.plan_id)
... ${SPACE}${SPACE}${SPACE}${SPACE}print("FAIL:guard_not_triggered")
... except PlanError:
... ${SPACE}${SPACE}${SPACE}${SPACE}print("PASS:guard_blocked")
@@ -0,0 +1,561 @@
"""Plan executor service: stub actors for Strategize and Execute phases.
Provides local-only (no LLM) stub actors for M1 that integrate with the
``PlanLifecycleService`` to drive plans through the Strategize and Execute
phases.
## Strategize Stub Actor
Accepts plan context and produces a minimal decision tree derived from
the action's ``definition_of_done``. The actor is **read-only**: it
records decisions in plan metadata without modifying any resources.
## Execute Stub Actor
Accepts plan context and the decisions produced by the strategize phase.
Routes tool calls through ``ToolRunner`` with ``ChangeSetCapture`` to
record all mutations. Persists ``changeset_id`` and execution metadata
(tool call count, sandbox refs) into plan metadata.
## Streaming Hooks
Both actors accept an optional *stream_callback* that is invoked with
interim status messages during processing. This enables the ``--stream``
CLI flag to emit real-time progress updates.
"""
from __future__ import annotations
import traceback
from collections.abc import Callable
from datetime import datetime
from typing import Any
import structlog
from pydantic import BaseModel, ConfigDict, Field
from ulid import ULID
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.plan import (
PlanInvariant,
PlanPhase,
ProcessingState,
)
from cleveragents.tool.builtins.changeset import ChangeSet, ChangeSetCapture
from cleveragents.tool.runner import ToolRunner
logger = structlog.get_logger(__name__)
# Type alias for streaming callbacks
StreamCallback = Callable[[str, dict[str, Any]], None]
# ---------------------------------------------------------------------------
# Result models
# ---------------------------------------------------------------------------
class StrategyDecision(BaseModel):
"""A single decision node in the strategy decision tree."""
decision_id: str = Field(..., description="ULID for this decision node")
step_text: str = Field(..., description="The step description text")
sequence: int = Field(..., ge=0, description="Order in the decision tree")
parent_id: str | None = Field(
default=None, description="Parent decision node ULID"
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
class StrategizeResult(BaseModel):
"""Output from the Strategize stub actor."""
decision_root_id: str = Field(..., description="ULID of the root decision node")
decisions: list[StrategyDecision] = Field(
default_factory=list, description="Ordered list of strategy decisions"
)
invariant_records: list[dict[str, Any]] = Field(
default_factory=list,
description="Records of invariant enforcement (stubbed for D2)",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
class ExecuteResult(BaseModel):
"""Output from the Execute stub actor."""
changeset_id: str = Field(..., description="ID of the produced ChangeSet")
changeset: ChangeSet = Field(..., description="The captured ChangeSet")
tool_calls_count: int = Field(
default=0, ge=0, description="Number of tool calls made"
)
sandbox_refs: list[str] = Field(
default_factory=list, description="Sandbox reference IDs used"
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# Stub actors
# ---------------------------------------------------------------------------
class StrategizeStubActor:
"""Local-only strategize actor for M1.
Produces a minimal decision tree from the plan's definition of done.
This is a read-only operation: no resources are modified.
"""
def execute(
self,
plan_id: str,
definition_of_done: str | None,
invariants: list[PlanInvariant] | None = None,
stream_callback: StreamCallback | None = None,
) -> StrategizeResult:
"""Run the strategize stub.
Args:
plan_id: The plan ULID.
definition_of_done: The action's completion criteria text.
invariants: Plan invariants for enforcement recording.
stream_callback: Optional callback for streaming status updates.
Returns:
A ``StrategizeResult`` containing the decision tree.
Raises:
ValidationError: If plan_id is empty.
"""
if not plan_id:
raise ValidationError("plan_id must not be empty")
if stream_callback is not None:
stream_callback(
"strategize_started",
{"plan_id": plan_id, "phase": "strategize"},
)
# Parse definition_of_done into steps
steps = self._parse_steps(definition_of_done or "")
# Build decision tree
root_id = str(ULID())
decisions: list[StrategyDecision] = []
for idx, step_text in enumerate(steps):
decision = StrategyDecision(
decision_id=str(ULID()) if idx > 0 else root_id,
step_text=step_text,
sequence=idx,
parent_id=root_id if idx > 0 else None,
)
decisions.append(decision)
# Record invariant enforcement (stubbed for D2 reconciliation)
invariant_records: list[dict[str, Any]] = []
for inv in invariants or []:
invariant_records.append(
{
"text": inv.text,
"source": inv.source.value,
"enforced": True,
"enforcement_note": "stub: accepted without reconciliation",
}
)
if stream_callback is not None:
stream_callback(
"strategize_decisions",
{
"plan_id": plan_id,
"decision_count": len(decisions),
"root_id": root_id,
},
)
result = StrategizeResult(
decision_root_id=root_id,
decisions=decisions,
invariant_records=invariant_records,
)
if stream_callback is not None:
stream_callback(
"strategize_complete",
{"plan_id": plan_id, "decision_count": len(decisions)},
)
return result
@staticmethod
def _parse_steps(definition_of_done: str) -> list[str]:
"""Parse definition of done into discrete steps.
Splits on newlines, strips whitespace, removes empty lines
and common list prefixes (``-``, ``*``, numbered).
Args:
definition_of_done: Raw definition of done text.
Returns:
List of step strings.
"""
if not definition_of_done.strip():
return ["Complete the plan objectives"]
lines = definition_of_done.strip().splitlines()
steps: list[str] = []
for line in lines:
cleaned = line.strip()
if not cleaned:
continue
# Remove common list prefixes
for prefix in ("-", "*", ""):
if cleaned.startswith(prefix):
cleaned = cleaned[len(prefix) :].strip()
break
# Remove numbered prefixes like "1.", "2)"
if len(cleaned) > 2 and cleaned[0].isdigit():
rest = cleaned.lstrip("0123456789")
if rest and rest[0] in (".", ")"):
cleaned = rest[1:].strip()
if cleaned:
steps.append(cleaned)
return steps if steps else ["Complete the plan objectives"]
class ExecuteStubActor:
"""Local-only execute actor for M1.
Accepts decisions from the strategize phase and executes them using
sandbox resources with tool calls routed through ``ToolRunner`` and
``ChangeSetCapture``.
"""
def execute(
self,
plan_id: str,
decisions: list[StrategyDecision],
tool_runner: ToolRunner | None = None,
sandbox_root: str | None = None,
stream_callback: StreamCallback | None = None,
) -> ExecuteResult:
"""Run the execute stub.
Args:
plan_id: The plan ULID.
decisions: Strategy decisions from the strategize phase.
tool_runner: Optional ToolRunner for executing tool calls.
sandbox_root: Optional sandbox root directory path.
stream_callback: Optional callback for streaming status updates.
Returns:
An ``ExecuteResult`` with changeset and execution metadata.
Raises:
ValidationError: If plan_id is empty.
"""
if not plan_id:
raise ValidationError("plan_id must not be empty")
if stream_callback is not None:
stream_callback(
"execute_started",
{"plan_id": plan_id, "phase": "execute"},
)
# Set up ChangeSet capture
changeset_id = str(ULID())
capture = ChangeSetCapture(
plan_id=plan_id,
resource_id=changeset_id,
sandbox_root=sandbox_root,
)
tool_calls_count = 0
sandbox_refs: list[str] = []
if sandbox_root is not None:
sandbox_refs.append(sandbox_root)
# In stub mode, we iterate decisions and record them as executed.
# Real LLM-driven execution will be added in later milestones.
for decision in decisions:
if stream_callback is not None:
stream_callback(
"execute_step",
{
"plan_id": plan_id,
"decision_id": decision.decision_id,
"step": decision.step_text,
"sequence": decision.sequence,
},
)
# If a ToolRunner is provided, discover available tools
# (stub: no actual tool calls in M1, just counting)
if tool_runner is not None:
available_tools = tool_runner.discover()
tool_calls_count += len(available_tools)
changeset = capture.get_changeset()
if stream_callback is not None:
stream_callback(
"execute_complete",
{
"plan_id": plan_id,
"changeset_id": changeset_id,
"tool_calls_count": tool_calls_count,
},
)
return ExecuteResult(
changeset_id=changeset_id,
changeset=changeset,
tool_calls_count=tool_calls_count,
sandbox_refs=sandbox_refs,
)
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
class PlanExecutor:
"""Orchestrates strategize and execute phases for a plan.
Bridges the ``PlanLifecycleService`` with the stub actors, handling
phase transitions, error capture, metadata persistence, and streaming.
"""
def __init__(
self,
lifecycle_service: Any,
tool_runner: ToolRunner | None = None,
sandbox_root: str | None = None,
) -> None:
"""Initialize the plan executor.
Args:
lifecycle_service: The ``PlanLifecycleService`` instance.
tool_runner: Optional ``ToolRunner`` for execute phase.
sandbox_root: Optional sandbox root directory.
"""
if lifecycle_service is None:
raise ValidationError("lifecycle_service must not be None")
self._lifecycle = lifecycle_service
self._tool_runner = tool_runner
self._sandbox_root = sandbox_root
self._strategize_actor = StrategizeStubActor()
self._execute_actor = ExecuteStubActor()
self._logger = logger.bind(service="plan_executor")
def run_strategize(
self,
plan_id: str,
stream_callback: StreamCallback | None = None,
) -> StrategizeResult:
"""Run the strategize phase for a plan.
Transitions the plan through QUEUED -> PROCESSING -> COMPLETE,
invoking the strategize stub actor and persisting results.
Args:
plan_id: The plan ULID.
stream_callback: Optional streaming callback.
Returns:
The ``StrategizeResult`` from the stub actor.
Raises:
PlanError: If the plan is not in the correct state.
ValidationError: If plan_id is empty.
"""
if not plan_id:
raise ValidationError("plan_id must not be empty")
plan = self._lifecycle.get_plan(plan_id)
# Guard: must be in Strategize phase
if plan.phase != PlanPhase.STRATEGIZE:
raise PlanError(
f"Plan {plan_id} is not in Strategize phase "
f"(current: {plan.phase.value})"
)
# Start strategize
self._lifecycle.start_strategize(plan_id)
try:
# Run the strategize stub actor
result = self._strategize_actor.execute(
plan_id=plan_id,
definition_of_done=plan.definition_of_done,
invariants=plan.invariants,
stream_callback=stream_callback,
)
# Persist decision tree into plan metadata
plan = self._lifecycle.get_plan(plan_id)
plan.decision_root_id = result.decision_root_id
plan.timestamps.updated_at = datetime.now()
# Store decisions and invariant records in error_details as
# structured metadata (Plan model uses error_details for
# arbitrary metadata storage until a dedicated field lands)
plan.error_details = {
"strategy_decisions": str(len(result.decisions)),
"invariant_records": str(len(result.invariant_records)),
}
self._lifecycle._commit_plan(plan)
# Complete strategize
self._lifecycle.complete_strategize(plan_id)
self._logger.info(
"Strategize completed via executor",
plan_id=plan_id,
decision_count=len(result.decisions),
root_id=result.decision_root_id,
)
return result
except Exception as exc:
# Capture error details
error_msg = f"{type(exc).__name__}: {exc}"
error_details = {
"exception_type": type(exc).__name__,
"traceback": traceback.format_exc(),
}
plan = self._lifecycle.get_plan(plan_id)
plan.error_details = error_details
self._lifecycle._commit_plan(plan)
self._lifecycle.fail_strategize(plan_id, error_msg)
raise
def run_execute(
self,
plan_id: str,
stream_callback: StreamCallback | None = None,
) -> ExecuteResult:
"""Run the execute phase for a plan.
Guards that the plan is in Execute/QUEUED state, then transitions
through PROCESSING -> COMPLETE with full ChangeSet capture.
Args:
plan_id: The plan ULID.
stream_callback: Optional streaming callback.
Returns:
The ``ExecuteResult`` from the stub actor.
Raises:
PlanError: If the plan is not in the correct state.
ValidationError: If plan_id is empty.
"""
if not plan_id:
raise ValidationError("plan_id must not be empty")
plan = self._lifecycle.get_plan(plan_id)
# Guard: must be in Execute phase
if plan.phase != PlanPhase.EXECUTE:
raise PlanError(
f"Plan {plan_id} is not in Execute phase "
f"(current: {plan.phase.value})"
)
# Guard: must be in QUEUED state
if plan.state != ProcessingState.QUEUED:
raise PlanError(
f"Plan {plan_id} is not queued for execution "
f"(current: {plan.state})"
)
# Extract decisions from plan metadata (decision_root_id must exist)
if plan.decision_root_id is None:
raise PlanError(
f"Plan {plan_id} has no decision tree. "
"Strategize must complete before Execute."
)
# Build stub decisions from definition_of_done for execute
# (In full implementation, these come from the persisted decision tree)
steps = StrategizeStubActor._parse_steps(plan.definition_of_done or "")
decisions = [
StrategyDecision(
decision_id=plan.decision_root_id if idx == 0 else str(ULID()),
step_text=step,
sequence=idx,
parent_id=plan.decision_root_id if idx > 0 else None,
)
for idx, step in enumerate(steps)
]
# Start execute
self._lifecycle.start_execute(plan_id)
try:
# Run the execute stub actor
result = self._execute_actor.execute(
plan_id=plan_id,
decisions=decisions,
tool_runner=self._tool_runner,
sandbox_root=self._sandbox_root,
stream_callback=stream_callback,
)
# Persist execution metadata into plan
plan = self._lifecycle.get_plan(plan_id)
plan.changeset_id = result.changeset_id
plan.sandbox_refs = result.sandbox_refs
plan.error_details = {
"tool_calls_count": str(result.tool_calls_count),
"sandbox_refs_count": str(len(result.sandbox_refs)),
}
plan.timestamps.updated_at = datetime.now()
self._lifecycle._commit_plan(plan)
# Complete execute
self._lifecycle.complete_execute(plan_id)
self._logger.info(
"Execute completed via executor",
plan_id=plan_id,
changeset_id=result.changeset_id,
tool_calls=result.tool_calls_count,
)
return result
except Exception as exc:
error_msg = f"{type(exc).__name__}: {exc}"
error_details = {
"exception_type": type(exc).__name__,
"traceback": traceback.format_exc(),
}
plan = self._lifecycle.get_plan(plan_id)
plan.error_details = error_details
self._lifecycle._commit_plan(plan)
self._lifecycle.fail_execute(plan_id, error_msg)
raise
+13
View File
@@ -136,3 +136,16 @@ method_name # noqa: B018, F821
InlineToolExecutor # noqa: B018, F821
InlineToolResult # noqa: B018, F821
_SUPPORTED_SOURCES # noqa: B018, F821
# Plan executor — public API
StrategizeStubActor # noqa: B018, F821
ExecuteStubActor # noqa: B018, F821
PlanExecutor # noqa: B018, F821
StrategyDecision # noqa: B018, F821
StrategizeResult # noqa: B018, F821
ExecuteResult # noqa: B018, F821
StreamCallback # noqa: B018, F821
run_strategize # noqa: B018, F821
run_execute # noqa: B018, F821
_parse_steps # noqa: B018, F821
invariant_records # noqa: B018, F821