feat(service): add subplan service and spawn workflow #506

Merged
freemo merged 1 commits from feature/m5-subplan-service into master 2026-03-02 14:46:33 +00:00
10 changed files with 1796 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
"""Airspeed Velocity benchmarks for subplan spawn service throughput.
Measures spawn creation, validation, and entry building overhead for
SubplanService across varying numbers of spawn entries.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from cleveragents.application.services.subplan_service import (
SpawnEntry,
SubplanService,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
from cleveragents.domain.models.core.plan import (
ExecutionMode,
NamespacedName,
Plan,
PlanIdentity,
SubplanConfig,
)
_PLAN_ID = "01HGZ6FE0AQDYTR4BXVQZ6PN00"
def _mock_decision_service() -> MagicMock:
svc: MagicMock = MagicMock()
svc.list_by_type = MagicMock(return_value=[])
return svc
def _make_decision(
decision_id: str,
sequence: int = 0,
resource_ids: list[str] | None = None,
) -> Decision:
resources: list[ResourceRef] = []
if resource_ids:
resources = [ResourceRef(resource_id=rid) for rid in resource_ids]
return Decision(
decision_id=decision_id,
plan_id=_PLAN_ID,
decision_type=DecisionType.SUBPLAN_SPAWN,
sequence_number=sequence,
question="Spawn child plan?",
chosen_option="local/sub-action",
context_snapshot=ContextSnapshot(relevant_resources=resources),
)
def _make_plan() -> Plan:
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ID),
namespaced_name=NamespacedName(namespace="local", name="bench-plan"),
description="Benchmark plan",
action_name="local/bench-action",
)
class SubplanSpawnThroughputSuite:
"""Benchmark SubplanService spawn throughput."""
def setup(self) -> None:
"""Prepare fixtures for spawn benchmarks."""
self.service = SubplanService(
decision_service=_mock_decision_service(),
)
self.plan = _make_plan()
self.seq_config = SubplanConfig(
execution_mode=ExecutionMode.SEQUENTIAL,
)
self.par_config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
max_parallel=50,
)
# Build entries for varying sizes
self.entries_1 = self._build_entries(1)
self.entries_5 = self._build_entries(5)
self.entries_20 = self._build_entries(20)
def _build_entries(self, count: int) -> list[SpawnEntry]:
entries: list[SpawnEntry] = []
for i in range(count):
did = f"01HGZ6FE0AQDYTR4BXVQZ6D{i:02d}0"
dec = _make_decision(did, sequence=i)
entries.append(SpawnEntry(decision=dec, action_name=f"local/sub-{i}"))
return entries
def time_spawn_single_sequential(self) -> None:
"""Time spawning a single child plan in sequential mode."""
self.service.spawn(
parent_plan=self.plan,
config=self.seq_config,
spawn_entries=self.entries_1,
)
def time_spawn_5_sequential(self) -> None:
"""Time spawning 5 child plans in sequential mode."""
self.service.spawn(
parent_plan=self.plan,
config=self.seq_config,
spawn_entries=self.entries_5,
)
def time_spawn_20_parallel(self) -> None:
"""Time spawning 20 child plans in parallel mode."""
self.service.spawn(
parent_plan=self.plan,
config=self.par_config,
spawn_entries=self.entries_20,
)
def time_validate_5_entries(self) -> None:
"""Time validating 5 spawn entries."""
self.service.validate_spawn(
config=self.seq_config,
spawn_entries=self.entries_5,
)
def time_validate_20_entries_with_resources(self) -> None:
"""Time validating 20 entries with resource scope check."""
self.service.validate_spawn(
config=self.par_config,
spawn_entries=self.entries_20,
available_resources=frozenset(f"res-{i}" for i in range(100)),
)
def time_build_entries_from_decisions(self) -> None:
"""Time building spawn entries from 5 decisions."""
decisions = [
_make_decision(
f"01HGZ6FE0AQDYTR4BXVQZ6D{i:02d}0",
sequence=i,
resource_ids=[f"01HGZ6FE0AQDYTR4BXVQZ6R{i:02d}0"],
)
for i in range(5)
]
self.service.build_spawn_entries(decisions)
def time_service_construction(self) -> None:
"""Time constructing SubplanService."""
SubplanService(decision_service=_mock_decision_service())
+140
View File
@@ -0,0 +1,140 @@
# Subplan Service: Spawn Workflow and Lifecycle
The `SubplanService` coordinates the spawning of child plans from
`DecisionService` spawn entries and `SubplanConfig`. It validates
resource scopes, merge strategies, and parallelism bounds before
creating child plan statuses.
## Overview
When a parent plan decides to decompose work into child plans (via
`subplan_spawn` or `subplan_parallel_spawn` decisions during Strategize),
the `SubplanService` handles the spawn workflow:
1. Extract spawn decisions from the `DecisionService`
2. Build `SpawnEntry` objects from those decisions
3. Validate the spawn request
4. Create `SubplanStatus` and `SpawnMetadata` for each entry
5. Return the result for the caller to persist on the parent plan
## Spawn Workflow
```
DecisionService SubplanService Parent Plan
| | |
| get_spawn_decisions | |
|<-----------------------| |
| [Decision, ...] | |
|----------------------->| |
| | build_spawn_entries |
| | validate_spawn |
| | spawn |
| |------------------------->|
| | SpawnResult |
| | (statuses + metadata) |
```
## Key Types
### SpawnMetadata
Persisted alongside each child plan for status output and provenance:
| Field | Type | Description |
|---------------------|--------|------------------------------------------|
| `spawn_decision_id` | `str` | ULID of the decision that triggered spawn|
| `parent_plan_id` | `str` | ULID of the parent plan |
| `root_plan_id` | `str` | ULID of the root plan in the hierarchy |
| `execution_mode` | `str` | How the subplan should be executed |
### SpawnEntry
A single spawn request derived from a decision:
| Field | Type | Description |
|--------------------|--------------|--------------------------------------|
| `decision` | `Decision` | The decision that triggered spawn |
| `action_name` | `str` | Namespaced action name for child plan|
| `target_resources` | `list[str]` | Resource IDs the child plan uses |
| `description` | `str` | Description/prompt for the child plan|
### SpawnResult
Result of spawning child plans:
| Field | Type | Description |
|--------------------|-------------------------------|--------------------------------|
| `spawned_statuses` | `list[SubplanStatus]` | Status for each child plan |
| `metadata` | `dict[str, SpawnMetadata]` | Metadata keyed by subplan_id |
| `total_spawned` | `int` | Number of child plans created |
| `execution_mode` | `str` | Execution mode from config |
## Spawn Validation
Before any child plan is created, `validate_spawn` checks:
1. **Resource scopes resolved**: All `target_resources` in each entry
exist in the `available_resources` set. Unresolved resources produce
a validation error.
2. **Merge strategy defined**: The `SubplanConfig.merge_strategy` must
not be `None`. Without a merge strategy, child plan results cannot
be combined.
3. **max_parallel bounds**: In `PARALLEL` execution mode, the number of
spawn entries must not exceed `config.max_parallel`.
4. **Valid action names**: Each entry must have a non-empty `action_name`.
5. **Correct decision types**: Each entry's decision must be either
`subplan_spawn` or `subplan_parallel_spawn`.
If any check fails, `SpawnValidationError` is raised with all errors.
## Integration with Other Services
- **DecisionService**: Provides spawn-type decisions via
`list_by_type(plan_id, "subplan_spawn")`.
- **SubplanExecutionService**: Executes the spawned child plans using
the `SubplanStatus` objects from `SpawnResult`.
- **SubplanMergeService**: Merges child plan outputs using the strategy
from `SubplanConfig`.
- **PlanLifecycleService**: Manages the parent plan's lifecycle,
persisting the updated `subplan_statuses` and `subplan_config`.
## Example Usage
```python
from cleveragents.application.services.subplan_service import (
SubplanService,
)
# Get spawn decisions for the parent plan
decisions = subplan_service.get_spawn_decisions(parent_plan.identity.plan_id)
# Build spawn entries from decisions
entries = subplan_service.build_spawn_entries(decisions)
# Spawn child plans (validates first)
result = subplan_service.spawn(
parent_plan=parent_plan,
config=parent_plan.subplan_config,
spawn_entries=entries,
available_resources=frozenset(known_resource_ids),
)
# Update the parent plan with spawned statuses
parent_plan.subplan_statuses = result.spawned_statuses
```
## Error Handling
| Error | When |
|--------------------------|---------------------------------------------|
| `ValueError` | `None` or empty required arguments |
| `SpawnValidationError` | Validation checks fail (resource scope, |
| | merge strategy, max_parallel, action name, |
| | or decision type) |
`SpawnValidationError` inherits from `ValidationError` and includes a
`validation_errors` list with detailed messages for each failure.
@@ -0,0 +1,587 @@
"""Step definitions for subplan spawn service scenarios."""
from __future__ import annotations
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.subplan_service import (
SpawnEntry,
SpawnValidationError,
SubplanService,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
from cleveragents.domain.models.core.plan import (
ExecutionMode,
NamespacedName,
Plan,
PlanIdentity,
SubplanConfig,
)
_PLAN_ID = "01HGZ6FE0AQDYTR4BXVQZ6PN00"
_ROOT_ID = "01HGZ6FE0AQDYTR4BXVQZ6RF00"
_DEC_ID1 = "01HGZ6FE0AQDYTR4BXVQZ6DA00"
_DEC_ID2 = "01HGZ6FE0AQDYTR4BXVQZ6DB00"
_DEC_ID3 = "01HGZ6FE0AQDYTR4BXVQZ6DC00"
def _mock_decision_service() -> MagicMock:
"""Create a mock DecisionService."""
svc: MagicMock = MagicMock()
svc.list_by_type = MagicMock(return_value=[])
return svc
def _make_decision(
decision_id: str,
plan_id: str = _PLAN_ID,
decision_type: DecisionType = DecisionType.SUBPLAN_SPAWN,
sequence: int = 0,
chosen_option: str = "local/sub-action",
resource_ids: list[str] | None = None,
) -> Decision:
"""Create a Decision with sensible defaults."""
resources: list[ResourceRef] = []
if resource_ids:
resources = [ResourceRef(resource_id=rid) for rid in resource_ids]
return Decision(
decision_id=decision_id,
plan_id=plan_id,
decision_type=decision_type,
sequence_number=sequence,
question="Should we spawn a child plan?",
chosen_option=chosen_option,
context_snapshot=ContextSnapshot(relevant_resources=resources),
)
def _make_plan(
plan_id: str = _PLAN_ID,
parent_plan_id: str | None = None,
root_plan_id: str | None = None,
) -> Plan:
"""Create a minimal Plan for testing."""
return Plan(
identity=PlanIdentity(
plan_id=plan_id,
parent_plan_id=parent_plan_id,
root_plan_id=root_plan_id,
),
namespaced_name=NamespacedName(namespace="local", name="test-plan"),
description="A test plan for subplan spawn",
action_name="local/test-action",
)
# ---- Successful spawn ----
@given("a parent plan with a subplan spawn decision")
def step_given_parent_with_spawn_decision(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(_DEC_ID1)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
description="Test subplan",
)
]
@given("a subplan config with sequential execution mode")
def step_given_config_sequential(context: Context) -> None:
context.subplan_config = SubplanConfig(
execution_mode=ExecutionMode.SEQUENTIAL,
)
@when("the subplan service spawns child plans")
def step_when_spawn(context: Context) -> None:
context.spawn_error = None
try:
context.spawn_result = context.subplan_service.spawn(
parent_plan=context.parent_plan,
config=context.subplan_config,
spawn_entries=context.spawn_entries,
available_resources=getattr(context, "available_resources", None),
)
except (SpawnValidationError, ValueError) as exc:
context.spawn_error = exc
context.spawn_result = None
@then("{count:d} child plan status should be created")
def step_then_count_statuses_singular(context: Context, count: int) -> None:
assert context.spawn_result is not None, f"Spawn failed: {context.spawn_error}"
actual: int = len(context.spawn_result.spawned_statuses)
assert actual == count, f"Expected {count} statuses, got {actual}"
@then("{count:d} child plan statuses should be created")
def step_then_count_statuses_plural(context: Context, count: int) -> None:
assert context.spawn_result is not None, f"Spawn failed: {context.spawn_error}"
actual: int = len(context.spawn_result.spawned_statuses)
assert actual == count, f"Expected {count} statuses, got {actual}"
@then("the spawn metadata should contain the parent plan id")
def step_then_metadata_parent_id(context: Context) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.parent_plan_id == context.parent_plan.identity.plan_id
@then("the spawn metadata should contain the root plan id")
def step_then_metadata_root_id(context: Context) -> None:
assert context.spawn_result is not None
plan: Plan = context.parent_plan
expected_root: str = plan.identity.root_plan_id or plan.identity.plan_id
for meta in context.spawn_result.metadata.values():
assert meta.root_plan_id == expected_root
@then("the spawn metadata should contain the execution mode")
def step_then_metadata_exec_mode(context: Context) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.execution_mode in {m.value for m in ExecutionMode}
# ---- Multiple entries ----
@given("a parent plan with {count:d} subplan spawn decisions")
def step_given_parent_with_n_decisions(context: Context, count: int) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
context.spawn_entries = []
for i in range(count):
# Build unique ULID-like IDs
suffix: str = f"{i:02d}"
did: str = f"01HGZ6FE0AQDYTR4BXVQZ6D{suffix}0"
dec: Decision = _make_decision(did, sequence=i)
context.spawn_entries.append(
SpawnEntry(
decision=dec,
action_name=f"local/sub-action-{i}",
description=f"Subplan {i}",
)
)
@given("a subplan config with parallel execution mode and max_parallel {n:d}")
def step_given_config_parallel(context: Context, n: int) -> None:
context.subplan_config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
max_parallel=n,
)
@then("each status should have a unique subplan id")
def step_then_unique_ids(context: Context) -> None:
assert context.spawn_result is not None
ids: list[str] = [s.subplan_id for s in context.spawn_result.spawned_statuses]
assert len(ids) == len(set(ids)), f"Duplicate IDs found: {ids}"
@then("the spawn result total_spawned should be {count:d}")
def step_then_total_spawned(context: Context, count: int) -> None:
assert context.spawn_result is not None
assert context.spawn_result.total_spawned == count
# ---- Validation: resource scopes ----
@given('a parent plan with a spawn entry targeting resource "{res_id}"')
def step_given_entry_with_resource(context: Context, res_id: str) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(
_DEC_ID1,
resource_ids=[res_id],
)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
target_resources=[res_id],
)
]
@given('available resources are "{res_a}" and "{res_b}"')
def step_given_available_resources(
context: Context,
res_a: str,
res_b: str,
) -> None:
context.available_resources = frozenset({res_a, res_b})
@when("the subplan service validates the spawn request")
def step_when_validate(context: Context) -> None:
context.validation_result = context.subplan_service.validate_spawn(
config=context.subplan_config,
spawn_entries=context.spawn_entries,
available_resources=getattr(context, "available_resources", None),
)
@then("the validation result should be invalid")
def step_then_invalid(context: Context) -> None:
assert not context.validation_result.valid, (
"Expected invalid, got valid with no errors"
)
@then("the validation errors should mention unresolved resource")
def step_then_error_mentions_resource(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "resource" in errors_str.lower(), (
f"Expected 'resource' in errors: {context.validation_result.errors}"
)
# ---- Validation: merge strategy ----
@given("a parent plan with a valid spawn entry")
def step_given_valid_entry(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(_DEC_ID1)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
)
]
@given("a subplan config with no merge strategy")
def step_given_config_no_merge(context: Context) -> None:
# Create a config and then forcibly set merge_strategy to None
config: SubplanConfig = SubplanConfig(
execution_mode=ExecutionMode.SEQUENTIAL,
)
# Pydantic models allow assignment when validate_assignment is True
# We use model_copy to create a version with None merge_strategy
# Since SubplanMergeStrategy is required, we use object.__setattr__
object.__setattr__(config, "merge_strategy", None)
context.subplan_config = config
@then("the validation errors should mention merge strategy")
def step_then_error_mentions_merge(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "merge strategy" in errors_str.lower(), (
f"Expected 'merge strategy' in errors: {context.validation_result.errors}"
)
# ---- Validation: max_parallel ----
@then("the validation errors should mention max_parallel")
def step_then_error_mentions_max_parallel(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "max_parallel" in errors_str.lower(), (
f"Expected 'max_parallel' in errors: {context.validation_result.errors}"
)
# ---- Metadata persistence ----
@given('a parent plan that is a subplan with root plan id "{root_id}"')
def step_given_subplan_parent(context: Context, root_id: str) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan(
parent_plan_id="01HGZ6FE0AQDYTR4BXVQZ6PP00",
root_plan_id=root_id,
)
@given("a valid spawn decision")
def step_given_valid_spawn_decision(context: Context) -> None:
dec: Decision = _make_decision(_DEC_ID1)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
)
]
@then('the spawn metadata root_plan_id should be "{expected}"')
def step_then_metadata_root_equals(context: Context, expected: str) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.root_plan_id == expected, (
f"Expected root '{expected}', got '{meta.root_plan_id}'"
)
@then('the spawn metadata execution_mode should be "{expected}"')
def step_then_metadata_mode_equals(context: Context, expected: str) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.execution_mode == expected, (
f"Expected mode '{expected}', got '{meta.execution_mode}'"
)
# ---- Decision type validation ----
@given("a parent plan with a non-spawn decision type")
def step_given_non_spawn_decision(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(
_DEC_ID1,
decision_type=DecisionType.STRATEGY_CHOICE,
)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
)
]
@then("the validation errors should mention decision type")
def step_then_error_mentions_decision_type(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "type" in errors_str.lower(), (
f"Expected 'type' in errors: {context.validation_result.errors}"
)
# ---- Get spawn decisions ----
@given('a decision service with spawn decisions for plan "{plan_id}"')
def step_given_decision_service_with_spawns(
context: Context,
plan_id: str,
) -> None:
spawn_dec: Decision = _make_decision(
_DEC_ID1,
plan_id=plan_id,
sequence=0,
)
parallel_dec: Decision = _make_decision(
_DEC_ID2,
plan_id=plan_id,
decision_type=DecisionType.SUBPLAN_PARALLEL_SPAWN,
sequence=1,
)
mock_svc: MagicMock = _mock_decision_service()
mock_svc.list_by_type = MagicMock(
side_effect=lambda pid, dtype: (
[spawn_dec] if dtype == DecisionType.SUBPLAN_SPAWN.value else [parallel_dec]
)
)
context.decision_service = mock_svc
context.subplan_service = SubplanService(
decision_service=mock_svc,
)
context.plan_id = plan_id
@when("the subplan service retrieves spawn decisions")
def step_when_get_spawn_decisions(context: Context) -> None:
context.spawn_decisions = context.subplan_service.get_spawn_decisions(
context.plan_id,
)
@then("the returned decisions should only contain spawn types")
def step_then_only_spawn_types(context: Context) -> None:
for dec in context.spawn_decisions:
assert dec.decision_type in (
DecisionType.SUBPLAN_SPAWN,
DecisionType.SUBPLAN_PARALLEL_SPAWN,
), f"Unexpected decision type: {dec.decision_type}"
# ---- Build spawn entries ----
@given("a list of spawn decisions with resource refs")
def step_given_decisions_with_resources(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.input_decisions = [
_make_decision(
_DEC_ID1,
resource_ids=["01HGZ6FE0AQDYTR4BXVQZ6RA00"],
chosen_option="local/test-sub",
),
_make_decision(
_DEC_ID2,
sequence=1,
resource_ids=[
"01HGZ6FE0AQDYTR4BXVQZ6RB00",
"01HGZ6FE0AQDYTR4BXVQZ6RC00",
],
chosen_option="local/test-sub-2",
),
]
@when("the subplan service builds spawn entries")
def step_when_build_entries(context: Context) -> None:
context.built_entries = context.subplan_service.build_spawn_entries(
context.input_decisions,
)
@then("each entry should have action_name and target_resources")
def step_then_entries_have_fields(context: Context) -> None:
for entry in context.built_entries:
assert entry.action_name, "Entry missing action_name"
# First entry should have 1 resource, second should have 2
assert len(context.built_entries) == 2
assert len(context.built_entries[0].target_resources) == 1
assert len(context.built_entries[1].target_resources) == 2
# ---- Service construction ----
@when("the subplan service is constructed with None decision_service")
def step_when_construct_none(context: Context) -> None:
context.construct_error = None
try:
SubplanService(decision_service=None) # type: ignore[arg-type]
except ValueError as exc:
context.construct_error = exc
@then("a ValueError should be raised for subplan service")
def step_then_value_error(context: Context) -> None:
error: Exception | None = getattr(
context,
"construct_error",
None,
) or getattr(context, "spawn_error", None)
assert error is not None, "Expected ValueError but none was raised"
assert isinstance(error, ValueError), (
f"Expected ValueError, got {type(error).__name__}"
)
# ---- Null guard scenarios ----
@given("a valid subplan service")
def step_given_valid_service(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.subplan_config = SubplanConfig()
@given("a parent plan with identity")
def step_given_plan_with_identity(context: Context) -> None:
context.parent_plan = _make_plan()
@when("spawn is called with None parent_plan")
def step_when_spawn_none_plan(context: Context) -> None:
context.spawn_error = None
try:
context.subplan_service.spawn(
parent_plan=None, # type: ignore[arg-type]
config=context.subplan_config,
spawn_entries=[
SpawnEntry(
decision=_make_decision(_DEC_ID1),
action_name="local/test",
)
],
)
except ValueError as exc:
context.spawn_error = exc
@when("spawn is called with empty spawn_entries")
def step_when_spawn_empty_entries(context: Context) -> None:
context.spawn_error = None
try:
context.subplan_service.spawn(
parent_plan=context.parent_plan,
config=context.subplan_config,
spawn_entries=[],
)
except ValueError as exc:
context.spawn_error = exc
@then('a spawn ValueError should be raised with message "{msg}"')
def step_then_value_error_msg(context: Context, msg: str) -> None:
error: Exception | None = getattr(
context,
"construct_error",
None,
) or getattr(context, "spawn_error", None)
assert error is not None, f"Expected ValueError with '{msg}'"
assert isinstance(error, ValueError)
assert msg in str(error), f"Expected '{msg}' in '{error}'"
# ---- Parallel spawn decision ----
@given("a parent plan with a subplan_parallel_spawn decision")
def step_given_parallel_spawn_decision(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(
_DEC_ID1,
decision_type=DecisionType.SUBPLAN_PARALLEL_SPAWN,
)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/parallel-sub",
)
]
+129
View File
@@ -0,0 +1,129 @@
@phase1 @subplan @spawn
Feature: Subplan Spawn Service
As a system orchestrating hierarchical plans
I want to spawn child plans from decision entries
And validate spawn requests before execution
So that parent plans can safely decompose work into coordinated child plans
# --- Successful spawn ---
@spawn_success
Scenario: Successfully spawn child plan from decision entry
Given a parent plan with a subplan spawn decision
And a subplan config with sequential execution mode
When the subplan service spawns child plans
Then 1 child plan status should be created
And the spawn metadata should contain the parent plan id
And the spawn metadata should contain the root plan id
And the spawn metadata should contain the execution mode
# --- Multiple entries ---
@spawn_multiple
Scenario: Spawn from multiple decision entries
Given a parent plan with 3 subplan spawn decisions
And a subplan config with parallel execution mode and max_parallel 5
When the subplan service spawns child plans
Then 3 child plan statuses should be created
And each status should have a unique subplan id
And the spawn result total_spawned should be 3
# --- Validation: resource scopes ---
@validation @resource_scope
Scenario: Validate spawn with missing resource scope
Given a parent plan with a spawn entry targeting resource "res-missing"
And available resources are "res-a" and "res-b"
And a subplan config with sequential execution mode
When the subplan service validates the spawn request
Then the validation result should be invalid
And the validation errors should mention unresolved resource
# --- Validation: merge strategy ---
@validation @merge_strategy
Scenario: Validate spawn with undefined merge strategy
Given a parent plan with a valid spawn entry
And a subplan config with no merge strategy
When the subplan service validates the spawn request
Then the validation result should be invalid
And the validation errors should mention merge strategy
# --- Validation: max_parallel ---
@validation @max_parallel
Scenario: Validate spawn exceeding max_parallel bound
Given a parent plan with 5 subplan spawn decisions
And a subplan config with parallel execution mode and max_parallel 3
When the subplan service validates the spawn request
Then the validation result should be invalid
And the validation errors should mention max_parallel
# --- Metadata persistence ---
@metadata
Scenario: Spawn metadata persisted for status output
Given a parent plan that is a subplan with root plan id "01HGZ6FE0AQDYTR4BXVQZ6RF00"
And a subplan config with sequential execution mode
And a valid spawn decision
When the subplan service spawns child plans
Then the spawn metadata root_plan_id should be "01HGZ6FE0AQDYTR4BXVQZ6RF00"
And the spawn metadata execution_mode should be "sequential"
# --- Decision type validation ---
@validation @decision_type
Scenario: Validate spawn with wrong decision type
Given a parent plan with a non-spawn decision type
And a subplan config with sequential execution mode
When the subplan service validates the spawn request
Then the validation result should be invalid
And the validation errors should mention decision type
# --- Get spawn decisions ---
@spawn_decisions
Scenario: Get spawn decisions from decision service
Given a decision service with spawn decisions for plan "01HGZ6FE0AQDYTR4BXVQZ6PN00"
When the subplan service retrieves spawn decisions
Then the returned decisions should only contain spawn types
# --- Build spawn entries ---
@build_entries
Scenario: Build spawn entries from decisions
Given a list of spawn decisions with resource refs
When the subplan service builds spawn entries
Then each entry should have action_name and target_resources
# --- Service construction ---
@construction
Scenario: SubplanService rejects None decision_service
When the subplan service is constructed with None decision_service
Then a ValueError should be raised for subplan service
# --- Spawn rejects None inputs ---
@validation @null_guard
Scenario: Spawn rejects None parent_plan
Given a valid subplan service
When spawn is called with None parent_plan
Then a spawn ValueError should be raised with message "parent_plan must not be None"
@validation @null_guard
Scenario: Spawn rejects empty spawn_entries
Given a valid subplan service
And a parent plan with identity
When spawn is called with empty spawn_entries
Then a spawn ValueError should be raised with message "spawn_entries must not be empty"
# --- Parallel spawn decision ---
@spawn_parallel
Scenario: Spawn from parallel spawn decision type
Given a parent plan with a subplan_parallel_spawn decision
And a subplan config with parallel execution mode and max_parallel 5
When the subplan service spawns child plans
Then 1 child plan status should be created
And the spawn metadata execution_mode should be "parallel"
+251
View File
@@ -0,0 +1,251 @@
"""Helper script for subplan spawn Robot Framework smoke tests.
Exercises SubplanService spawn workflow, validation, and metadata
persistence without requiring the full service layer.
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
from cleveragents.application.services.subplan_service import (
SpawnEntry,
SubplanService,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
from cleveragents.domain.models.core.plan import (
ExecutionMode,
NamespacedName,
Plan,
PlanIdentity,
SubplanConfig,
)
_PLAN_ID = "01HGZ6FE0AQDYTR4BXVQZ6PN00"
_ROOT_ID = "01HGZ6FE0AQDYTR4BXVQZ6RF00"
_DEC_ID1 = "01HGZ6FE0AQDYTR4BXVQZ6DA00"
_DEC_ID2 = "01HGZ6FE0AQDYTR4BXVQZ6DB00"
_DEC_ID3 = "01HGZ6FE0AQDYTR4BXVQZ6DC00"
def _mock_decision_service() -> MagicMock:
svc: MagicMock = MagicMock()
svc.list_by_type = MagicMock(return_value=[])
return svc
def _make_decision(
decision_id: str,
plan_id: str = _PLAN_ID,
decision_type: DecisionType = DecisionType.SUBPLAN_SPAWN,
sequence: int = 0,
chosen_option: str = "local/sub-action",
resource_ids: list[str] | None = None,
) -> Decision:
resources: list[ResourceRef] = []
if resource_ids:
resources = [ResourceRef(resource_id=rid) for rid in resource_ids]
return Decision(
decision_id=decision_id,
plan_id=plan_id,
decision_type=decision_type,
sequence_number=sequence,
question="Spawn child plan?",
chosen_option=chosen_option,
context_snapshot=ContextSnapshot(relevant_resources=resources),
)
def _make_plan(
plan_id: str = _PLAN_ID,
parent_plan_id: str | None = None,
root_plan_id: str | None = None,
) -> Plan:
return Plan(
identity=PlanIdentity(
plan_id=plan_id,
parent_plan_id=parent_plan_id,
root_plan_id=root_plan_id,
),
namespaced_name=NamespacedName(namespace="local", name="test-plan"),
description="Robot test plan",
action_name="local/test-action",
)
def _spawn_single() -> None:
"""Spawn a single child plan from a decision entry."""
svc = SubplanService(decision_service=_mock_decision_service())
plan = _make_plan()
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
dec = _make_decision(_DEC_ID1)
entries = [SpawnEntry(decision=dec, action_name="local/sub-action")]
result = svc.spawn(parent_plan=plan, config=config, spawn_entries=entries)
assert result.total_spawned == 1
assert len(result.spawned_statuses) == 1
meta = next(iter(result.metadata.values()))
assert meta.parent_plan_id == _PLAN_ID
print("spawn-single-ok")
def _spawn_multiple() -> None:
"""Spawn multiple child plans from decision entries."""
svc = SubplanService(decision_service=_mock_decision_service())
plan = _make_plan()
config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
max_parallel=5,
)
entries = []
for i, did in enumerate([_DEC_ID1, _DEC_ID2, _DEC_ID3]):
dec = _make_decision(did, sequence=i)
entries.append(SpawnEntry(decision=dec, action_name=f"local/sub-{i}"))
result = svc.spawn(parent_plan=plan, config=config, spawn_entries=entries)
assert result.total_spawned == 3
ids = [s.subplan_id for s in result.spawned_statuses]
assert len(ids) == len(set(ids)), "Duplicate IDs"
print("spawn-multiple-ok")
def _validate_resource_scope() -> None:
"""Validate spawn with missing resource scope."""
svc = SubplanService(decision_service=_mock_decision_service())
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
dec = _make_decision(_DEC_ID1, resource_ids=["res-missing"])
entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
target_resources=["res-missing"],
)
]
result = svc.validate_spawn(
config=config,
spawn_entries=entries,
available_resources=frozenset({"res-a", "res-b"}),
)
assert not result.valid
assert any("resource" in e.lower() for e in result.errors)
print("validate-resource-ok")
def _validate_max_parallel() -> None:
"""Validate spawn exceeding max_parallel bound."""
svc = SubplanService(decision_service=_mock_decision_service())
config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
max_parallel=2,
)
entries = []
for i in range(5):
did = f"01HGZ6FE0AQDYTR4BXVQZ6D{i:02d}0"
dec = _make_decision(did, sequence=i)
entries.append(SpawnEntry(decision=dec, action_name=f"local/sub-{i}"))
result = svc.validate_spawn(config=config, spawn_entries=entries)
assert not result.valid
assert any("max_parallel" in e.lower() for e in result.errors)
print("validate-max-parallel-ok")
def _metadata_persistence() -> None:
"""Verify spawn metadata contains correct plan IDs."""
svc = SubplanService(decision_service=_mock_decision_service())
plan = _make_plan(
parent_plan_id="01HGZ6FE0AQDYTR4BXVQZ6PP00",
root_plan_id=_ROOT_ID,
)
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
dec = _make_decision(_DEC_ID1)
entries = [SpawnEntry(decision=dec, action_name="local/sub-action")]
result = svc.spawn(parent_plan=plan, config=config, spawn_entries=entries)
meta = next(iter(result.metadata.values()))
assert meta.root_plan_id == _ROOT_ID
assert meta.execution_mode == "sequential"
print("metadata-persistence-ok")
def _validation_guards() -> None:
"""Verify service rejects invalid construction and inputs."""
# None decision_service
try:
SubplanService(decision_service=None) # type: ignore[arg-type]
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
# None parent_plan
svc = SubplanService(decision_service=_mock_decision_service())
try:
svc.spawn(
parent_plan=None, # type: ignore[arg-type]
config=SubplanConfig(),
spawn_entries=[
SpawnEntry(
decision=_make_decision(_DEC_ID1),
action_name="local/test",
)
],
)
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
# Empty spawn_entries
try:
svc.spawn(
parent_plan=_make_plan(),
config=SubplanConfig(),
spawn_entries=[],
)
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
print("validation-guards-ok")
def _build_entries() -> None:
"""Build spawn entries from decisions."""
svc = SubplanService(decision_service=_mock_decision_service())
decisions = [
_make_decision(
_DEC_ID1,
resource_ids=["01HGZ6FE0AQDYTR4BXVQZ6RA00"],
chosen_option="local/test-sub",
),
_make_decision(
_DEC_ID2,
sequence=1,
chosen_option="local/test-sub-2",
),
]
entries = svc.build_spawn_entries(decisions)
assert len(entries) == 2
assert entries[0].action_name == "local/test-sub"
assert len(entries[0].target_resources) == 1
print("build-entries-ok")
_COMMANDS: dict[str, object] = {
"spawn-single": _spawn_single,
"spawn-multiple": _spawn_multiple,
"validate-resource": _validate_resource_scope,
"validate-max-parallel": _validate_max_parallel,
"metadata-persistence": _metadata_persistence,
"validation-guards": _validation_guards,
"build-entries": _build_entries,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(1)
fn = _COMMANDS[sys.argv[1]]
assert callable(fn)
fn()
+59
View File
@@ -0,0 +1,59 @@
*** Settings ***
Documentation Smoke tests for subplan spawn service, validation,
... and metadata persistence.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} robot/helper_subplan_spawn.py
*** Test Cases ***
Spawn Single Child Plan From Decision Entry
[Documentation] Verify spawning a single child plan creates status and metadata
[Tags] subplan spawn
${result}= Run Process ${PYTHON} ${HELPER} spawn-single cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} spawn-single-ok
Spawn Multiple Child Plans From Decisions
[Documentation] Verify spawning multiple child plans from decision entries
[Tags] subplan spawn multiple
${result}= Run Process ${PYTHON} ${HELPER} spawn-multiple cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} spawn-multiple-ok
Validate Resource Scope Missing
[Documentation] Verify validation rejects missing resource scopes
[Tags] subplan spawn validation
${result}= Run Process ${PYTHON} ${HELPER} validate-resource cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} validate-resource-ok
Validate Max Parallel Exceeded
[Documentation] Verify validation rejects exceeding max_parallel bounds
[Tags] subplan spawn validation parallel
${result}= Run Process ${PYTHON} ${HELPER} validate-max-parallel cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} validate-max-parallel-ok
Spawn Metadata Persistence
[Documentation] Verify spawn metadata contains correct plan IDs and mode
[Tags] subplan spawn metadata
${result}= Run Process ${PYTHON} ${HELPER} metadata-persistence cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} metadata-persistence-ok
Service Validation Guards
[Documentation] Verify service rejects invalid inputs
[Tags] subplan spawn validation
${result}= Run Process ${PYTHON} ${HELPER} validation-guards cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} validation-guards-ok
Build Spawn Entries From Decisions
[Documentation] Verify building spawn entries from decision objects
[Tags] subplan spawn entries
${result}= Run Process ${PYTHON} ${HELPER} build-entries cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} build-entries-ok
@@ -25,6 +25,7 @@ from cleveragents.application.services.project_service import ProjectService
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from cleveragents.application.services.subplan_service import SubplanService
from cleveragents.application.services.vector_store_service import VectorStoreService
from cleveragents.config.settings import Settings, get_settings
from cleveragents.domain.providers.ai_provider import AIProviderInterface
@@ -266,6 +267,12 @@ class Container(containers.DeclarativeContainer):
plan_lifecycle_service=plan_lifecycle_service,
)
# Subplan Service - Factory (spawns child plans from decisions)
subplan_service = providers.Factory(
SubplanService,
decision_service=decision_service,
)
# Resource Registry Service - uses database session factory from UoW
resource_registry_service = providers.Factory(
_build_resource_registry_service,
@@ -71,6 +71,14 @@ from cleveragents.application.services.subplan_merge_service import (
SubplanMergeResult,
SubplanMergeService,
)
from cleveragents.application.services.subplan_service import (
SpawnEntry,
SpawnMetadata,
SpawnResult,
SpawnValidationError,
SpawnValidationResult,
SubplanService,
)
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
@@ -126,11 +134,17 @@ __all__ = [
"SemanticValidationService",
"SemanticValidationSeverity",
"SkillRegistryService",
"SpawnEntry",
"SpawnMetadata",
"SpawnResult",
"SpawnValidationError",
"SpawnValidationResult",
"SubplanExecutionOutput",
"SubplanExecutionResult",
"SubplanExecutionService",
"SubplanMergeResult",
"SubplanMergeService",
"SubplanService",
"SyntaxCheckRule",
"ToolRegistryService",
"ValidationAttachment",
@@ -0,0 +1,447 @@
"""Subplan service for building and spawning child plans.
Coordinates the spawning of child plans from ``DecisionService`` spawn entries
and ``SubplanConfig``. The service validates resource scopes, merge strategy
presence, and ``max_parallel`` bounds before creating child plans.
Spawn metadata (spawn decision ID, parent/root plan IDs, execution mode) is
persisted alongside each child plan for status output and provenance tracking.
Design decisions:
- Dependency injection: ``DecisionService`` and ``UnitOfWork`` are
injected via the constructor (consistent with existing service patterns).
- Stateless: All spawn state is derived from ``Decision`` entries and
``SubplanConfig`` on the parent ``Plan``.
- Fail-fast: Validation errors are raised immediately before any plan
creation occurs.
Based on:
- docs/specification.md L18170-L18295 (subplan spawning)
- ADR-006 (Plan Lifecycle)
- Forgejo issue #197
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.decision import Decision, DecisionType
from cleveragents.domain.models.core.plan import (
ExecutionMode,
Plan,
SubplanConfig,
SubplanStatus,
)
if TYPE_CHECKING:
from cleveragents.application.services.decision_service import DecisionService
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SpawnMetadata:
"""Metadata for a spawned subplan.
Persisted alongside each child plan for status output and provenance.
Attributes:
spawn_decision_id: ULID of the decision that triggered the spawn.
parent_plan_id: ULID of the parent plan.
root_plan_id: ULID of the root plan in the hierarchy.
execution_mode: How the subplan should be executed.
"""
spawn_decision_id: str
parent_plan_id: str
root_plan_id: str
execution_mode: str
@dataclass(frozen=True)
class SpawnEntry:
"""A single spawn request derived from a decision.
Attributes:
decision: The decision that triggered this spawn.
action_name: Namespaced action name for the child plan.
target_resources: Resource IDs the child plan operates on.
description: Description/prompt for the child plan.
"""
decision: Decision
action_name: str
target_resources: list[str] = field(default_factory=list)
description: str = ""
@dataclass(frozen=True)
class SpawnValidationResult:
"""Result of validating a spawn request.
Attributes:
valid: Whether the spawn request passed all validation checks.
errors: List of validation error messages.
"""
valid: bool
errors: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class SpawnResult:
"""Result of spawning child plans.
Attributes:
spawned_statuses: Status objects for each spawned child plan.
metadata: Spawn metadata for each child plan (keyed by subplan_id).
total_spawned: Number of child plans spawned.
execution_mode: The execution mode from the SubplanConfig.
"""
spawned_statuses: list[SubplanStatus]
metadata: dict[str, SpawnMetadata] = field(default_factory=dict)
total_spawned: int = 0
execution_mode: str = ExecutionMode.SEQUENTIAL
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class SpawnValidationError(ValidationError):
"""Raised when spawn validation fails.
Attributes:
validation_errors: List of individual validation failure messages.
"""
def __init__(self, validation_errors: list[str]) -> None:
self.validation_errors: list[str] = validation_errors
errors_str: str = "; ".join(validation_errors)
super().__init__(f"Spawn validation failed: {errors_str}")
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
class SubplanService:
"""Service for spawning child plans from decision spawn entries.
Coordinates the creation of child plans by:
1. Extracting spawn entries from ``DecisionService`` for a parent plan.
2. Validating each entry (resource scopes, merge strategy, parallelism).
3. Building ``SubplanStatus`` objects for the parent plan.
4. Persisting ``SpawnMetadata`` for status queries.
Args:
decision_service: Service for querying decision records.
Raises:
ValueError: If *decision_service* is ``None``.
"""
def __init__(
self,
decision_service: DecisionService,
) -> None:
if decision_service is None:
raise ValueError("decision_service must not be None")
self._decision_service: DecisionService = decision_service
@property
def decision_service(self) -> DecisionService:
"""The injected decision service."""
return self._decision_service
# ------------------------------------------------------------------
# spawn
# ------------------------------------------------------------------
def spawn(
self,
parent_plan: Plan,
config: SubplanConfig,
spawn_entries: list[SpawnEntry],
*,
available_resources: frozenset[str] | None = None,
) -> SpawnResult:
"""Build child plan statuses from spawn entries.
Validates the spawn request, then creates ``SubplanStatus`` objects
and ``SpawnMetadata`` for each entry. The caller is responsible for
persisting the updated parent plan.
Args:
parent_plan: The parent plan that will own the child plans.
config: Subplan execution configuration.
spawn_entries: Entries describing which child plans to create.
available_resources: Optional set of known resource IDs for
scope validation. When ``None``, resource scope checks
are skipped.
Returns:
A :class:`SpawnResult` with statuses and metadata.
Raises:
ValueError: If *parent_plan*, *config*, or *spawn_entries* is
``None``.
ValueError: If *spawn_entries* is empty.
SpawnValidationError: If validation fails.
"""
if parent_plan is None:
raise ValueError("parent_plan must not be None")
if config is None:
raise ValueError("config must not be None")
if spawn_entries is None:
raise ValueError("spawn_entries must not be None")
if not spawn_entries:
raise ValueError("spawn_entries must not be empty")
# Validate before spawning
validation: SpawnValidationResult = self.validate_spawn(
config=config,
spawn_entries=spawn_entries,
available_resources=available_resources,
)
if not validation.valid:
raise SpawnValidationError(validation.errors)
# Build statuses and metadata
parent_id: str = parent_plan.identity.plan_id
root_id: str = parent_plan.identity.root_plan_id or parent_id
mode: str = config.execution_mode.value
statuses: list[SubplanStatus] = []
metadata: dict[str, SpawnMetadata] = {}
for entry in spawn_entries:
# Use the decision's downstream_plan_ids if available,
# otherwise generate a subplan_id from the decision_id
subplan_id: str = entry.decision.decision_id
status: SubplanStatus = SubplanStatus(
subplan_id=subplan_id,
action_name=entry.action_name,
target_resources=list(entry.target_resources),
)
statuses.append(status)
meta: SpawnMetadata = SpawnMetadata(
spawn_decision_id=entry.decision.decision_id,
parent_plan_id=parent_id,
root_plan_id=root_id,
execution_mode=mode,
)
metadata[subplan_id] = meta
logger.info(
"spawned_subplans",
extra={
"parent_plan_id": parent_id,
"root_plan_id": root_id,
"count": len(statuses),
"execution_mode": mode,
},
)
return SpawnResult(
spawned_statuses=statuses,
metadata=metadata,
total_spawned=len(statuses),
execution_mode=mode,
)
# ------------------------------------------------------------------
# validate_spawn
# ------------------------------------------------------------------
def validate_spawn(
self,
config: SubplanConfig,
spawn_entries: list[SpawnEntry],
*,
available_resources: frozenset[str] | None = None,
) -> SpawnValidationResult:
"""Validate a spawn request before execution.
Checks:
1. Resource scopes are resolved (all referenced resources exist).
2. Merge strategy is defined (not None/missing).
3. ``max_parallel`` bounds are respected (number of entries does
not exceed ``max_parallel`` when using PARALLEL mode).
Args:
config: Subplan execution configuration.
spawn_entries: Entries describing which child plans to create.
available_resources: Optional set of known resource IDs. When
provided, each entry's ``target_resources`` are checked
against this set.
Returns:
A :class:`SpawnValidationResult` indicating success or listing
errors.
Raises:
ValueError: If *config* or *spawn_entries* is ``None``.
"""
if config is None:
raise ValueError("config must not be None")
if spawn_entries is None:
raise ValueError("spawn_entries must not be None")
errors: list[str] = []
# 1. Resource scope validation
if available_resources is not None:
for entry in spawn_entries:
for resource_id in entry.target_resources:
if resource_id not in available_resources:
errors.append(
f"Unresolved resource scope: resource '{resource_id}' "
f"not found for spawn decision "
f"'{entry.decision.decision_id}'"
)
# 2. Merge strategy validation
if config.merge_strategy is None:
errors.append("Merge strategy must be defined in SubplanConfig")
# 3. max_parallel bounds validation
if config.execution_mode == ExecutionMode.PARALLEL:
entry_count: int = len(spawn_entries)
if entry_count > config.max_parallel:
errors.append(
f"Number of spawn entries ({entry_count}) exceeds "
f"max_parallel bound ({config.max_parallel})"
)
# 4. Each entry must have a valid action_name
for entry in spawn_entries:
if not entry.action_name or not entry.action_name.strip():
errors.append(
f"Spawn entry for decision "
f"'{entry.decision.decision_id}' has empty action_name"
)
# 5. Each entry must reference a spawn-type decision
for entry in spawn_entries:
if entry.decision.decision_type not in (
DecisionType.SUBPLAN_SPAWN,
DecisionType.SUBPLAN_PARALLEL_SPAWN,
):
errors.append(
f"Decision '{entry.decision.decision_id}' has type "
f"'{entry.decision.decision_type}' but must be "
f"'{DecisionType.SUBPLAN_SPAWN}' or "
f"'{DecisionType.SUBPLAN_PARALLEL_SPAWN}'"
)
valid: bool = len(errors) == 0
return SpawnValidationResult(valid=valid, errors=errors)
# ------------------------------------------------------------------
# get_spawn_decisions
# ------------------------------------------------------------------
def get_spawn_decisions(self, plan_id: str) -> list[Decision]:
"""Retrieve spawn-type decisions for a plan.
Queries the ``DecisionService`` for both ``subplan_spawn`` and
``subplan_parallel_spawn`` decision types.
Args:
plan_id: ULID of the plan to query.
Returns:
List of spawn decisions ordered by sequence number.
Raises:
ValueError: If *plan_id* is empty.
"""
if not plan_id or not plan_id.strip():
raise ValueError("plan_id must not be empty")
spawn_decisions: list[Decision] = self._decision_service.list_by_type(
plan_id, DecisionType.SUBPLAN_SPAWN.value
)
parallel_decisions: list[Decision] = self._decision_service.list_by_type(
plan_id, DecisionType.SUBPLAN_PARALLEL_SPAWN.value
)
all_decisions: list[Decision] = spawn_decisions + parallel_decisions
all_decisions.sort(key=lambda d: d.sequence_number)
return all_decisions
# ------------------------------------------------------------------
# build_spawn_entries
# ------------------------------------------------------------------
def build_spawn_entries(
self,
decisions: list[Decision],
default_action_name: str = "local/subplan-action",
) -> list[SpawnEntry]:
"""Build spawn entries from decisions.
Converts decision objects into ``SpawnEntry`` objects suitable
for passing to :meth:`spawn`.
Args:
decisions: Spawn-type decisions.
default_action_name: Action name to use when the decision's
``chosen_option`` does not specify one.
Returns:
List of spawn entries.
Raises:
ValueError: If *decisions* is ``None``.
"""
if decisions is None:
raise ValueError("decisions must not be None")
entries: list[SpawnEntry] = []
for decision in decisions:
# Use chosen_option as action_name if it looks like a
# namespaced action reference; otherwise use default
action_name: str = default_action_name
if "/" in decision.chosen_option:
action_name = decision.chosen_option
# Extract target resources from context snapshot
target_resources: list[str] = [
ref.resource_id for ref in decision.context_snapshot.relevant_resources
]
entry: SpawnEntry = SpawnEntry(
decision=decision,
action_name=action_name,
target_resources=target_resources,
description=decision.question,
)
entries.append(entry)
return entries
__all__: list[str] = [
"SpawnEntry",
"SpawnMetadata",
"SpawnResult",
"SpawnValidationError",
"SpawnValidationResult",
"SubplanService",
]
+14
View File
@@ -469,3 +469,17 @@ CheckpointNotFoundError # noqa: B018, F821
rollback_plan # noqa: B018, F821
_build_checkpoint_service # noqa: B018, F821
validate_checkpoint_type # noqa: B018, F821
# SubplanService public API (issue #197)
spawn_decision_id # noqa: B018, F821
SpawnMetadata # noqa: B018, F821
SpawnEntry # noqa: B018, F821
SpawnResult # noqa: B018, F821
SpawnValidationResult # noqa: B018, F821
SpawnValidationError # noqa: B018, F821
SubplanService # noqa: B018, F821
subplan_service # noqa: B018, F821
validation_errors # noqa: B018, F821
get_spawn_decisions # noqa: B018, F821
build_spawn_entries # noqa: B018, F821
validate_spawn # noqa: B018, F821