forked from HAL9000/cleveragents-core
78e146d169
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
199 lines
7.0 KiB
Python
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}'"
|