Files
cleveragents-core/docs/reference/plan_execute.md
T
freemo ddc6fb7372
CI / lint (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 49s
CI / quality (pull_request) Successful in 45s
CI / security (pull_request) Successful in 56s
CI / helm (pull_request) Successful in 26s
CI / build (pull_request) Successful in 32s
CI / unit_tests (pull_request) Failing after 7m5s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Failing after 11m14s
CI / integration_tests (pull_request) Failing after 23m7s
CI / coverage (pull_request) Successful in 11m9s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m59s
docs: update session export panels and plan executor subplan wiring
- docs/reference/session_cli.md: add Rich output panels documentation
  for agents session export (Session Export, Contents, Integrity panels
  with example output); update success message to 'Export completed';
  add stdout export note and example (#3424)
- docs/reference/plan_execute.md: add Subplan Spawning section
  documenting SubplanService and SubplanExecutionService injection into
  PlanExecutor, execution flow (_spawn_subplans, _execute_subplans,
  _apply_subplan_results_to_plan), failure handling, and updated
  architecture diagram (#3561)
2026-04-05 21:09:42 +00:00

412 lines
16 KiB
Markdown

# 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.
When a `PlanExecutionContext` is provided, the execute phase delegates to
`RuntimeExecuteActor` for full tool-calling runtime integration with
changeset capture through `ChangeSetStore`.
## Architecture
```
PlanExecutor
├── StrategizeStubActor (read-only, produces decision tree)
├── ExecuteStubActor (legacy stub: sandbox + ChangeSetCapture)
├── RuntimeExecuteActor (runtime: ToolRunner + ChangeSetStore)
├── PlanExecutionContext (plan metadata + resource bindings)
├── PlanLifecycleService (phase transitions, persistence)
├── SubplanService (optional — spawn child plans from decisions)
├── SubplanExecutionService (optional — execute spawned child plans)
└── ErrorRecoveryService (optional — error recording + retry logic)
```
## Subplan Spawning (v3.8.0+)
When a plan's Strategize phase records `subplan_spawn` or
`subplan_parallel_spawn` decisions, the Execute phase automatically
spawns and executes child plans via the injected `SubplanService` and
`SubplanExecutionService`.
### Wiring
Both services are injected as optional constructor parameters:
```python
from cleveragents.application.services.plan_executor import PlanExecutor
from cleveragents.application.services.subplan_service import SubplanService
from cleveragents.application.services.subplan_execution_service import (
SubplanExecutionService,
)
executor = PlanExecutor(
lifecycle_service=lifecycle,
tool_runner=runner,
execution_context=ctx,
subplan_service=subplan_svc, # optional
subplan_execution_service=exec_svc, # optional
)
```
When either service is `None`, all subplan spawning logic is skipped
(backward-compatible no-op).
### Execution Flow
After the actor completes the Execute phase:
1. **`_spawn_subplans()`** — queries `SubplanService.get_spawn_decisions()`
for the plan, then calls `SubplanService.spawn()` for each decision,
creating child plan records.
2. **`_execute_subplans()`** — calls `SubplanExecutionService.execute_all()`,
routing sequential `subplan_spawn` decisions through ordered execution
and `subplan_parallel_spawn` groups through concurrent execution.
3. **`_apply_subplan_results_to_plan()`** — propagates child plan failure
information back to the parent plan. When one or more child plans fail,
the parent plan's `error_details` field is annotated with a
`failed_subplan_ids` list.
### Properties
| Property | Type | Description |
|----------|------|-------------|
| `subplan_service` | `SubplanService \| None` | Injected subplan service |
| `subplan_execution_service` | `SubplanExecutionService \| None` | Injected execution service |
### Failure Handling
Child plan failures are **annotated, not raised**. The parent plan
continues to the Apply phase even if child plans fail; the
`failed_subplan_ids` list in `error_details` allows downstream
consumers to detect and handle failures.
See [`subplan_service.md`](subplan_service.md) and
[`subplans.md`](subplans.md) for full subplan API reference.
## Execution Modes
| Mode | Actor | Trigger | Output |
|---------|---------------------|-----------------------------------|------------------------|
| Stub | ExecuteStubActor | No `execution_context` | `ExecuteResult` |
| Runtime | RuntimeExecuteActor | `execution_context` is provided | `RuntimeExecuteResult` |
## PlanExecutionContext
The `PlanExecutionContext` bridges plan metadata into the tool runtime:
```python
from cleveragents.application.services.plan_execution_context import (
PlanExecutionContext,
)
from cleveragents.domain.models.core.change import InMemoryChangeSetStore
ctx = PlanExecutionContext(
plan_id="01HGZ...",
decision_root_id="01HGZ...",
sandbox_root="/tmp/sandbox",
automation_profile="trusted",
project_resources={"repo": {"path": "/code"}},
changeset_store=InMemoryChangeSetStore(),
)
# Start a changeset for tracking mutations
changeset_id = ctx.start_changeset()
# Record changes during execution
ctx.record_change(entry)
# Retrieve changeset
cs = ctx.get_changeset(changeset_id)
# Summarize context state
summary = ctx.summarize()
```
### Fields
| Field | Type | Required | Description |
|---------------------|---------------------------|----------|------------------------------------|
| `plan_id` | `str` | Yes | ULID of the plan |
| `decision_root_id` | `str \| None` | No | Root decision from strategize |
| `sandbox_root` | `str \| None` | No | Sandbox filesystem path |
| `automation_profile` | `str \| None` | No | Automation profile name |
| `project_resources` | `dict[str, Any]` | No | Project resource metadata |
| `resource_bindings` | `dict[str, BoundResource]`| No | Resolved resource bindings |
| `changeset_store` | `ChangeSetStore` | No | Defaults to InMemoryChangeSetStore |
## RuntimeExecuteActor
Wraps `ToolRunner` to execute strategy decisions with changeset capture:
```python
from cleveragents.application.services.plan_execution_context import (
RuntimeExecuteActor,
RuntimeExecuteResult,
)
actor = RuntimeExecuteActor(
tool_runner=runner,
execution_context=ctx,
)
result: RuntimeExecuteResult = actor.execute(decisions)
```
### RuntimeExecuteResult Fields
| Field | Type | Description |
|--------------------------|---------------|----------------------------------|
| `changeset_id` | `str` | ULID of the produced changeset |
| `tool_call_count` | `int` | Number of tool calls made |
| `sandbox_refs` | `list[str]` | Sandbox reference paths |
| `decision_ids_processed` | `list[str]` | Processed decision node IDs |
| `execution_duration_ms` | `float` | Wall-clock execution time (ms) |
## PlanExecutor Runtime Mode
The `PlanExecutor` auto-selects runtime vs stub mode:
```python
from cleveragents.application.services.plan_executor import PlanExecutor
# Stub mode (no execution_context)
executor = PlanExecutor(lifecycle_service=lifecycle, tool_runner=runner)
assert not executor.has_runtime
# Runtime mode (with execution_context)
executor = PlanExecutor(
lifecycle_service=lifecycle,
tool_runner=runner,
execution_context=ctx,
)
assert executor.has_runtime
assert executor.changeset_store is not None
# Execute auto-dispatches to RuntimeExecuteActor
result = executor.run_execute(plan_id)
```
## CLI Executor Wiring (`_get_plan_executor`)
The `plan execute` CLI command constructs a `PlanExecutor` via the
internal `_get_plan_executor()` helper in
`cleveragents.cli.commands.plan`. This helper resolves the
`ProviderRegistry` from the DI container and builds
`LLMStrategizeActor` / `LLMExecuteActor` for real LLM calls.
```python
def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None):
"""Build a PlanExecutor wired with real LLM actors.
Args:
lifecycle_service: Optional pre-existing PlanLifecycleService.
When provided the executor shares the same service (and its
in-memory plan cache) as the caller.
"""
```
### Shared Lifecycle Service Instance
Because `PlanLifecycleService` is registered as a **Factory** provider
in the DI container, each `container.plan_lifecycle_service()` call
returns a **new instance** with its own in-memory `_plans` cache. The
`plan execute` handler creates one service for CLI-level state reads
and passes it to `_get_plan_executor()` so the executor shares the
same instance:
```python
# In the plan execute CLI handler:
service = _get_lifecycle_service()
executor = _get_plan_executor(lifecycle_service=service) # shared instance
```
This is critical because `executor.run_strategize()` mutates plan
state through the lifecycle service (e.g. advancing from
`strategize/complete` to `execute/queued` via `auto_progress`). If
the executor used a separate service instance, the CLI handler's
subsequent `service.get_plan()` call would return stale cached state,
causing spurious "not in an executable state" errors.
> **Note:** When `lifecycle_service` is omitted (e.g. from test code
> or standalone scripts), `_get_plan_executor` falls back to creating
> a fresh instance from the container.
### Properties
| Property | Type | Description |
|---------------------|-----------------------------|--------------------------------------|
| `has_runtime` | `bool` | True if execution_context is set |
| `changeset_store` | `ChangeSetStore \| None` | Store from execution context |
| `execution_context` | `PlanExecutionContext \| None` | The execution context |
## ChangeSetStore Wiring
The `ChangeSetStore` protocol defines the interface for changeset persistence:
```python
class ChangeSetStore(Protocol):
def start(self, plan_id: str) -> str: ...
def record(self, changeset_id: str, entry: ChangeEntry) -> None: ...
def get(self, changeset_id: str) -> SpecChangeSet | None: ...
def get_for_plan(self, plan_id: str) -> list[SpecChangeSet]: ...
def summarize(self, changeset_id: str) -> dict[str, Any]: ...
```
`InMemoryChangeSetStore` is the default for M1. Database-backed
implementations will be added in D1 milestone.
## Phase Lifecycle
| Phase | Actor | Mode | Output |
|------------|------------------------|-----------|----------------------------------|
| Strategize | StrategizeStubActor | Read-only | Decision tree, invariant records |
| Execute | RuntimeExecuteActor | Runtime | ChangeSet via ChangeSetStore |
| Execute | ExecuteStubActor | Stub | ChangeSet via ChangeSetCapture |
## 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,
execution_context=ctx, # optional — enables runtime mode
error_recovery_service=er_service, # optional — enables retry logic
)
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 with `exception_type`, `traceback`, `mode`, and
structured error-recovery metadata (category, retry count, recovery
hints) when an `ErrorRecoveryService` is attached
- The plan transitions to `ERRORED` processing state
### Automatic Retry (D1b)
When an `ErrorRecoveryService` is provided to the executor, the
**Execute** phase (stub mode) includes an automatic retry loop:
1. On failure the error is classified (transient, validation, etc.)
and recorded via `ErrorRecoveryService.record_error()`.
2. If the error is retriable and retry attempts remain, the execute
actor is re-invoked automatically.
3. If retries are exhausted or the error is non-retriable, the plan
transitions to `ERRORED` with full recovery hints stored in
`error_details`.
Retry limits default to 3 and are controlled by the automation
profile's `modify_config` threshold (named per spec § Automatable
Tasks; controls transient-failure retry gating).
Use `agents plan errors <plan_id>` to inspect error decisions,
retry history, and recovery suggestions for a plan.
See [Error Recovery Reference](error_recovery.md) for full details.
### Manual Recovery
Plans in `ERRORED` state without automatic retry can be recovered by:
1. Reviewing errors via `agents plan errors <plan_id>`
2. Resetting the plan's processing state back to `QUEUED`
3. Re-running the failed phase via the executor
## Streaming Hooks
Both phases accept an optional `stream_callback`:
### Event Types
| Event | Phase | Actor | Description |
|----------------------------|----------|---------|---------------------------------|
| `strategize_started` | Strat. | Stub | Phase processing began |
| `strategize_decisions` | Strat. | Stub | Decisions produced |
| `strategize_complete` | Strat. | Stub | Phase completed |
| `execute_started` | Execute | Stub | Stub execute began |
| `execute_step` | Execute | Stub | Stub decision step |
| `execute_complete` | Execute | Stub | Stub execute completed |
| `runtime_execute_started` | Execute | Runtime | Runtime execute began |
| `runtime_execute_step` | Execute | Runtime | Runtime decision step |
| `runtime_execute_complete` | Execute | Runtime | Runtime execute completed |
## Module Reference
- **`cleveragents.application.services.plan_execution_context`**
- `PlanExecutionContext`: Execution context bridging plan to runtime
- `RuntimeExecuteActor`: Tool-calling execute actor
- `RuntimeExecuteResult`: Runtime execution output model
- **`cleveragents.application.services.plan_executor`**
- `PlanExecutor`: Orchestrator with runtime/stub mode selection
- `StrategizeStubActor`: Local-only strategize actor
- `ExecuteStubActor`: Local-only execute actor
- `StrategyDecision`, `StrategizeResult`, `ExecuteResult`: Data models
- `StreamCallback`: Type alias for streaming callbacks
- **`cleveragents.application.services.error_recovery_service`**: Error
recovery integration (optional)
- See [Error Recovery Reference](error_recovery.md)