Files
temp/features/steps/checkpoint_auto_triggers_tool_steps.py
freemo 78e146d169 fix(executor): implement automatic per-tool-write and event-based checkpoint triggers
Implements all four automatic checkpoint triggers defined in the specification
for the Execute phase of the plan lifecycle:

- before_tool_execute: Checkpoint created before any tool with writes=True runs
- after_tool_execute: Checkpoint created after a write tool completes successfully
- on_subplan_spawn: Checkpoint created immediately after a child plan is spawned
- on_error: Checkpoint created after any unrecoverable error in the Execute phase

Changes:
- tool/runner.py: Added optional CheckpointService and auto_checkpoint_triggers
  parameters to ToolRunner. Checkpoint hooks fire around write-tool execution
  when a CheckpointService is wired. Exported DEFAULT_AUTO_TRIGGERS as a public
  constant (single source of truth). Made is_trigger_active() public so callers
  can query the active trigger set without accessing private attributes.
- application/services/subplan_execution_service.py: Added optional
  CheckpointService, auto_checkpoint_triggers, and parent_plan_id parameters.
  on_subplan_spawn checkpoint fires in _execute_one_with_retry before the
  first execution attempt. Now imports DEFAULT_AUTO_TRIGGERS from runner.py
  (DRY fix).
- application/services/plan_executor.py: Added _is_auto_trigger_active() helper
  and on_error checkpoint hooks in both _run_execute_with_stub() and
  _run_execute_with_runtime() error paths. Delegates to
  ToolRunner.is_trigger_active() instead of accessing private attributes
  (module boundary fix).
- application/services/config_service.py: Registered new config key
  core.checkpoints.auto_create_on (default: all four triggers enabled) with
  env var CLEVERAGENTS_CHECKPOINT_AUTO_CREATE_ON.
- application/services/llm_actors.py: Replaced Any type for lifecycle_service
  with PlanLifecycleProtocol (typed Protocol) and tool_runner with ToolRunner
  type annotation. Eliminates Any usage for injected dependencies.

Tests:
- features/checkpoint_auto_triggers.feature: 15 Behave scenarios covering all
  four triggers, disable-trigger behavior, no-checkpoint-service fallback, and
  config key registration.
- features/steps/checkpoint_auto_triggers_tool_steps.py: Step definitions for
  ToolRunner and config service scenarios (split from original 519-line file).
- features/steps/checkpoint_auto_triggers_executor_steps.py: Step definitions
  for SubplanExecutionService and PlanExecutor scenarios (split from original).

Closes #3439

ISSUES CLOSED: #3439
2026-04-05 21:13:07 +00:00

199 lines
7.0 KiB
Python

"""Step definitions for checkpoint_auto_triggers.feature — ToolRunner steps.
Covers automatic checkpoint creation for the ToolRunner triggers:
- before_tool_execute
- after_tool_execute
Also covers the config service registration step.
"""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.checkpoint_service import CheckpointService
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import DEFAULT_AUTO_TRIGGERS, ToolRunner
from cleveragents.tool.runtime import ToolSpec
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_write_tool_spec(name: str) -> ToolSpec:
"""Create a ToolSpec with writes=True."""
from cleveragents.domain.models.core.tool import ToolCapability
return ToolSpec(
name=name,
description="A write tool for testing",
capabilities=ToolCapability(writes=True, read_only=False),
handler=lambda inputs: {"written": True},
)
def _make_read_tool_spec(name: str) -> ToolSpec:
"""Create a ToolSpec with writes=False (read-only)."""
from cleveragents.domain.models.core.tool import ToolCapability
return ToolSpec(
name=name,
description="A read-only tool for testing",
capabilities=ToolCapability(writes=False, read_only=True),
handler=lambda inputs: {"data": "read"},
)
def _build_tool_runner(context: Context) -> ToolRunner:
"""Build the ToolRunner, applying any disabled triggers."""
active_triggers = DEFAULT_AUTO_TRIGGERS - getattr(
context, "disabled_triggers", set()
)
return ToolRunner(
registry=context.registry,
checkpoint_service=context.checkpoint_service,
auto_checkpoint_triggers=frozenset(active_triggers),
)
# ---------------------------------------------------------------------------
# Given steps — ToolRunner
# ---------------------------------------------------------------------------
@given('a tool runner with a checkpoint service and plan_id "{plan_id}"')
def step_tool_runner_with_checkpoint(context: Context, plan_id: str) -> None:
context.checkpoint_service = CheckpointService()
context.registry = ToolRegistry()
context.disabled_triggers: set[str] = set()
context.plan_id = plan_id
context.tool_runner = None # built lazily after trigger config
@given("a tool runner WITHOUT a checkpoint service")
def step_tool_runner_without_checkpoint(context: Context) -> None:
context.checkpoint_service = None
context.registry = ToolRegistry()
context.plan_id = ""
context.disabled_triggers = set()
context.tool_runner = None
@given('a write tool "{tool_name}" is registered')
def step_register_write_tool(context: Context, tool_name: str) -> None:
spec = _make_write_tool_spec(tool_name)
context.registry.register(spec)
context.tool_name = tool_name
@given('a read-only tool "{tool_name}" is registered')
def step_register_read_tool(context: Context, tool_name: str) -> None:
spec = _make_read_tool_spec(tool_name)
context.registry.register(spec)
context.tool_name = tool_name
@given('the "{trigger}" trigger is disabled')
def step_disable_trigger(context: Context, trigger: str) -> None:
if not hasattr(context, "disabled_triggers"):
context.disabled_triggers = set()
context.disabled_triggers.add(trigger)
# ---------------------------------------------------------------------------
# When steps — ToolRunner
# ---------------------------------------------------------------------------
@when('I execute tool "{tool_name}" with plan_id "{plan_id}"')
def step_execute_tool_with_plan_id(
context: Context, tool_name: str, plan_id: str
) -> None:
runner = _build_tool_runner(context)
context.tool_result = runner.execute(
tool_name,
{},
plan_id=plan_id,
)
context.exception = None
@when('I execute tool "{tool_name}" without a plan_id')
def step_execute_tool_without_plan_id(context: Context, tool_name: str) -> None:
runner = _build_tool_runner(context)
try:
context.tool_result = runner.execute(tool_name, {})
context.exception = None
except Exception as exc:
context.exception = exc
# ---------------------------------------------------------------------------
# Then steps — ToolRunner checkpoints
# ---------------------------------------------------------------------------
@then('a checkpoint with phase "{phase}" should exist for plan "{plan_id}"')
def step_checkpoint_phase_exists(context: Context, phase: str, plan_id: str) -> None:
checkpoints = context.checkpoint_service.list_checkpoints(plan_id)
reasons = [cp.metadata.reason for cp in checkpoints]
# Phase is stored in metadata.phase; trigger name is in metadata.reason
assert any(phase in r for r in reasons), (
f"Expected a checkpoint with '{phase}' in reason for plan {plan_id}. "
f"Got reasons: {reasons}"
)
@then('no checkpoint with phase "{phase}" should exist for plan "{plan_id}"')
def step_no_checkpoint_phase(context: Context, phase: str, plan_id: str) -> None:
checkpoints = context.checkpoint_service.list_checkpoints(plan_id)
reasons = [cp.metadata.reason for cp in checkpoints]
assert not any(phase in r for r in reasons), (
f"Expected NO checkpoint with '{phase}' in reason for plan {plan_id}. "
f"Got reasons: {reasons}"
)
@then('{count:d} checkpoints should exist for plan "{plan_id}"')
def step_checkpoint_count(context: Context, count: int, plan_id: str) -> None:
checkpoints = context.checkpoint_service.list_checkpoints(plan_id)
assert len(checkpoints) == count, (
f"Expected {count} checkpoints for plan {plan_id}, got {len(checkpoints)}"
)
@then("no auto-trigger exception should be raised")
def step_no_exception(context: Context) -> None:
assert getattr(context, "exception", None) is None, (
f"Unexpected exception: {context.exception}"
)
# ---------------------------------------------------------------------------
# Config service steps
# ---------------------------------------------------------------------------
@given("the config service is initialized")
def step_config_service_initialized(context: Context) -> None:
from cleveragents.application.services.config_service import ConfigService
context.config_service = ConfigService()
@when('I get the config key "{key}"')
def step_get_config_key(context: Context, key: str) -> None:
from cleveragents.application.services.config_service import ConfigService
entry = ConfigService.get_entry(key)
context.config_value = entry.default if entry is not None else None
@then('the value should contain "{substring}"')
def step_value_contains(context: Context, substring: str) -> None:
value = context.config_value
assert value is not None, "Config value for key is None"
assert substring in str(value), f"Expected '{substring}' in config value '{value}'"