Files
cleveragents-core/features/steps/plan_subplan_tool_steps.py
T
aditya b888afab71
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 19s
CI / quality (pull_request) Successful in 19s
CI / security (pull_request) Successful in 32s
CI / typecheck (pull_request) Successful in 44s
CI / integration_tests (pull_request) Successful in 2m46s
CI / unit_tests (pull_request) Failing after 19m21s
CI / docker (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 21m3s
CI / coverage (pull_request) Has been cancelled
feat(actor): add plan_subplan tool and decision emission
Add builtin/plan-subplan tool for strategy actors to emit SUBPLAN_SPAWN
or SUBPLAN_PARALLEL_SPAWN decisions when decomposing a plan into child
plans. Implements all acceptance criteria from issue #198:

- SubplanPayload (Pydantic) validates goal, resource_scopes/project_ref
  (at least one required), merge_strategy, max_parallel (1-50), parallel
  flag, dependencies, and context_view override.
- Defaults: merge_strategy=git_three_way, max_parallel=5, parallel=False,
  dependencies=[]. Omitted fields inherit sensible values automatically.
- make_plan_subplan_spec(decision_service=None) factory supports optional
  DecisionService injection for persistent decision recording.
- _build_rationale() generates human-readable rationale text (goal, scope,
  execution mode, dependencies, context_view) surfaced in plan explain.
- register_subplan_tool() added to tool/builtins/__init__.py for bulk
  registration. PLAN_SUBPLAN_SPEC exported as the default ready-to-use spec.
- Actor YAML example (examples/actors/strategy_with_subplan.yaml) with
  annotated serial and parallel spawn payload examples.
- Behave BDD: 20 scenarios, 70 steps covering validation, defaults,
  decision type, rationale, service injection, registry, and ToolRunner.
- Robot Framework: 9 smoke tests via robot/plan_subplan_tool.robot.
- ASV benchmarks: benchmarks/subplan_actor_tool_bench.py (5 suites).
- Coverage: 100% on subplan_tool.py. Lint, typecheck, and security clean.

ISSUES CLOSED: #198
2026-02-27 08:02:48 +00:00

321 lines
11 KiB
Python

"""Step definitions for plan_subplan_tool.feature.
Tests the builtin/plan-subplan tool: payload validation, defaults,
decision emission, rationale generation, and ToolRunner integration.
"""
from __future__ import annotations
import json
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from ulid import ULID
from cleveragents.domain.models.core.decision import Decision
from cleveragents.tool.builtins.subplan_tool import (
PLAN_SUBPLAN_SPEC,
make_plan_subplan_spec,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_PLAN_ID = str(ULID())
_SEQ = 0
# ---------------------------------------------------------------------------
# Stub decision service
# ---------------------------------------------------------------------------
class _StubDecisionService:
"""In-memory stub that records decisions without a database."""
def __init__(self) -> None:
self.recorded: list[Decision] = []
def record_decision(self, decision: Decision) -> Decision:
self.recorded.append(decision)
return decision
# ---------------------------------------------------------------------------
# Helper: build minimal valid inputs
# ---------------------------------------------------------------------------
def _inputs(
goal: str = "Implement feature X",
resource_scopes: list[str] | None = None,
project_ref: str | None = None,
parallel: bool = False,
merge_strategy: str | None = None,
max_parallel: int | None = None,
dependencies: list[str] | None = None,
context_view: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"goal": goal,
"plan_id": _PLAN_ID,
"sequence_number": _SEQ,
"parallel": parallel,
}
if resource_scopes is not None:
payload["resource_scopes"] = resource_scopes
if project_ref is not None:
payload["project_ref"] = project_ref
if merge_strategy is not None:
payload["merge_strategy"] = merge_strategy
if max_parallel is not None:
payload["max_parallel"] = max_parallel
if dependencies is not None:
payload["dependencies"] = dependencies
if context_view is not None:
payload["context_view"] = context_view
return payload
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given("a plan_subplan tool handler")
def step_given_handler(ctx: Context) -> None:
ctx.subplan_handler = PLAN_SUBPLAN_SPEC.handler
@given("a plan_subplan tool handler with a stub decision service")
def step_given_handler_with_service(ctx: Context) -> None:
ctx.stub_service = _StubDecisionService()
spec = make_plan_subplan_spec(decision_service=ctx.stub_service)
ctx.subplan_handler = spec.handler
@given("a ToolRunner with the plan_subplan tool registered")
def step_given_runner(ctx: Context) -> None:
registry = ToolRegistry()
registry.register(PLAN_SUBPLAN_SPEC)
ctx.runner = ToolRunner(registry)
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when('I invoke plan_subplan with goal "{goal}" and resource_scopes {scopes}')
def step_when_with_scopes(ctx: Context, goal: str, scopes: str) -> None:
ctx.result = ctx.subplan_handler(
_inputs(goal=goal, resource_scopes=json.loads(scopes))
)
@when('I invoke plan_subplan with goal "{goal}" and project_ref "{ref}"')
def step_when_with_project_ref(ctx: Context, goal: str, ref: str) -> None:
ctx.result = ctx.subplan_handler(_inputs(goal=goal, project_ref=ref))
@when("I invoke parallel plan_subplan with dependencies {deps}")
def step_when_parallel_with_deps(ctx: Context, deps: str) -> None:
ctx.result = ctx.subplan_handler(
_inputs(
goal="Parallel work",
resource_scopes=["src/"],
parallel=True,
dependencies=json.loads(deps),
)
)
@when('I invoke plan_subplan with goal "{goal}" and no resource scope or project ref')
def step_when_no_scope(ctx: Context, goal: str) -> None:
ctx.result = ctx.subplan_handler(
{"goal": goal, "plan_id": _PLAN_ID, "sequence_number": 0}
)
@when("I invoke plan_subplan with empty goal and resource_scopes {scopes}")
def step_when_empty_goal(ctx: Context, scopes: str) -> None:
ctx.result = ctx.subplan_handler(
_inputs(goal="", resource_scopes=json.loads(scopes))
)
@when("I invoke parallel plan_subplan with max_parallel {n:d}")
def step_when_bad_max_parallel(ctx: Context, n: int) -> None:
ctx.result = ctx.subplan_handler(
_inputs(resource_scopes=["src/"], parallel=True, max_parallel=n)
)
@when('I invoke plan_subplan with invalid merge_strategy "{strategy}"')
def step_when_bad_strategy(ctx: Context, strategy: str) -> None:
ctx.result = ctx.subplan_handler(
_inputs(resource_scopes=["src/"], merge_strategy=strategy)
)
@when('I invoke plan_subplan with invalid context_view "{view}"')
def step_when_bad_context_view(ctx: Context, view: str) -> None:
ctx.result = ctx.subplan_handler(
_inputs(resource_scopes=["src/"], context_view=view)
)
@when("I invoke plan_subplan with a parent_decision_id and resource_scopes {scopes}")
def step_when_with_parent_decision(ctx: Context, scopes: str) -> None:
parent_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
inp = _inputs(resource_scopes=json.loads(scopes))
inp["parent_decision_id"] = parent_id
ctx.result = ctx.subplan_handler(inp)
@when("I invoke parallel plan_subplan with no max_parallel override")
def step_when_default_max_parallel(ctx: Context) -> None:
ctx.result = ctx.subplan_handler(_inputs(resource_scopes=["src/"], parallel=True))
@when('I invoke plan_subplan with context_view "{view}" and resource_scopes {scopes}')
def step_when_context_view(ctx: Context, view: str, scopes: str) -> None:
ctx.result = ctx.subplan_handler(
_inputs(resource_scopes=json.loads(scopes), context_view=view)
)
@when("I register the plan_subplan tool in a ToolRegistry")
def step_when_register(ctx: Context) -> None:
ctx.registry = ToolRegistry()
ctx.registry.register(PLAN_SUBPLAN_SPEC)
@when("I run plan_subplan via the ToolRunner with valid inputs")
def step_when_run_via_runner(ctx: Context) -> None:
ctx.runner_result = ctx.runner.execute(
"builtin/plan-subplan",
_inputs(goal="Runner test", resource_scopes=["src/"]),
)
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then("the plan_subplan result should succeed")
def step_then_success(ctx: Context) -> None:
r = ctx.result
assert isinstance(r, dict), f"Expected dict, got {type(r)}"
assert r.get("success") is True, f"Expected success=True, got: {r}"
@then("the plan_subplan result should fail")
def step_then_fail(ctx: Context) -> None:
r = ctx.result
assert isinstance(r, dict), f"Expected dict, got {type(r)}"
assert r.get("success") is False, f"Expected success=False, got: {r}"
@then('the plan_subplan result decision_type should be "{dtype}"')
def step_then_dtype(ctx: Context, dtype: str) -> None:
r = ctx.result
assert r.get("decision_type") == dtype, (
f"Expected decision_type={dtype!r}, got: {r.get('decision_type')!r}"
)
@then('the plan_subplan error should mention "{keyword}"')
def step_then_error_keyword(ctx: Context, keyword: str) -> None:
err = (r := ctx.result).get("error") or ""
assert keyword.lower() in err.lower(), (
f"Expected error to mention {keyword!r} in {r}"
)
@then('the plan_subplan result output merge_strategy should be "{value}"')
def step_then_merge_strategy(ctx: Context, value: str) -> None:
r = ctx.result
assert r.get("merge_strategy") == value, (
f"Expected merge_strategy={value!r}, got: {r.get('merge_strategy')!r}"
)
@then("the plan_subplan result output max_parallel should be {n:d}")
def step_then_max_parallel(ctx: Context, n: int) -> None:
r = ctx.result
assert r.get("max_parallel") == n, (
f"Expected max_parallel={n}, got: {r.get('max_parallel')!r}"
)
@then("the plan_subplan result output parallel should be false")
def step_then_not_parallel(ctx: Context) -> None:
r = ctx.result
assert r.get("parallel") is False, (
f"Expected parallel=False, got: {r.get('parallel')!r}"
)
@then("the plan_subplan result output dependencies should be empty")
def step_then_empty_deps(ctx: Context) -> None:
r = ctx.result
assert r.get("dependencies") == [], f"Expected [], got: {r.get('dependencies')!r}"
@then("the plan_subplan result rationale should mention the goal")
def step_then_rationale_goal(ctx: Context) -> None:
r = ctx.result
rationale = r.get("rationale") or ""
assert rationale, "Expected non-empty rationale"
@then('the plan_subplan result rationale should mention "{keyword}"')
def step_then_rationale_keyword(ctx: Context, keyword: str) -> None:
rationale = (ctx.result.get("rationale") or "").lower()
assert keyword.lower() in rationale, (
f"Expected rationale to mention {keyword!r}, got: {rationale!r}"
)
@then('the plan_subplan result output context_view should be "{view}"')
def step_then_context_view(ctx: Context, view: str) -> None:
r = ctx.result
assert r.get("context_view") == view, (
f"Expected context_view={view!r}, got: {r.get('context_view')!r}"
)
@then("the stub decision service should have recorded {n:d} decision")
def step_then_service_recorded(ctx: Context, n: int) -> None:
stub: _StubDecisionService = ctx.stub_service
assert len(stub.recorded) == n, (
f"Expected {n} recorded decisions, got {len(stub.recorded)}"
)
@then('the recorded decision type should be "{dtype}"')
def step_then_recorded_dtype(ctx: Context, dtype: str) -> None:
stub: _StubDecisionService = ctx.stub_service
assert stub.recorded, "No decisions were recorded"
assert str(stub.recorded[0].decision_type) == dtype, (
f"Expected {dtype!r}, got {stub.recorded[0].decision_type!r}"
)
@then('the registry should contain "{name}"')
def step_then_registry_contains(ctx: Context, name: str) -> None:
spec = ctx.registry.get(name)
assert spec is not None, f"Expected '{name}' in registry"
@then("the ToolRunner result should succeed")
def step_then_runner_success(ctx: Context) -> None:
result = ctx.runner_result
assert result.success, f"Expected success=True, error={result.error}"