fix(domain): add missing execute hook to ToolLifecycle model to satisfy spec four-stage lifecycle #3052
@@ -0,0 +1,259 @@
|
||||
"""Step definitions for ToolLifecycle execute hook tests (issue #2820)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.core.tool import (
|
||||
Tool,
|
||||
ToolLifecycle,
|
||||
ToolSource,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_builtin_tool(**overrides: Any) -> Tool:
|
||||
"""Create a minimal builtin Tool with optional overrides."""
|
||||
defaults: dict[str, Any] = {
|
||||
"name": "local/test-tool",
|
||||
"description": "A test tool",
|
||||
"source": ToolSource.BUILTIN,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Tool(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — ToolLifecycle construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I create a tool lifecycle with execute hook "{execute}"')
|
||||
def step_create_lifecycle_with_execute(context: Context, execute: str) -> None:
|
||||
"""Create a ToolLifecycle with only the execute hook set."""
|
||||
context.tool_lifecycle = ToolLifecycle(execute=execute)
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
@when("I create a tool lifecycle with all four hooks set")
|
||||
def step_create_lifecycle_all_four(context: Context) -> None:
|
||||
"""Create a ToolLifecycle with all four lifecycle hooks set."""
|
||||
context.tool_lifecycle = ToolLifecycle(
|
||||
discover="hooks.discover",
|
||||
activate="hooks.activate",
|
||||
execute="hooks.execute",
|
||||
deactivate="hooks.deactivate",
|
||||
)
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
@when("I create a tool lifecycle with no hooks")
|
||||
def step_create_lifecycle_no_hooks(context: Context) -> None:
|
||||
"""Create a ToolLifecycle with no hooks (all default to None)."""
|
||||
context.tool_lifecycle = ToolLifecycle()
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
@when('I create a tool lifecycle with only execute hook "{execute}"')
|
||||
def step_create_lifecycle_only_execute(context: Context, execute: str) -> None:
|
||||
"""Create a ToolLifecycle with only the execute hook set (all others None)."""
|
||||
context.tool_lifecycle = ToolLifecycle(execute=execute)
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — Tool.from_config() with lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I load a tool from config with lifecycle execute "{execute}"')
|
||||
def step_load_tool_from_config_with_lifecycle_execute(
|
||||
context: Context, execute: str
|
||||
) -> None:
|
||||
"""Load a Tool from config dict that includes a lifecycle.execute hook."""
|
||||
config: dict[str, Any] = {
|
||||
"name": "local/lifecycle-tool",
|
||||
"description": "Tool with lifecycle execute hook",
|
||||
"source": "builtin",
|
||||
"lifecycle": {
|
||||
"execute": execute,
|
||||
},
|
||||
}
|
||||
context.tool_model = Tool.from_config(config)
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
@when("I load a tool from config with lifecycle execute None")
|
||||
def step_load_tool_from_config_with_lifecycle_execute_none(context: Context) -> None:
|
||||
"""Load a Tool from config dict that includes a lifecycle with execute=None."""
|
||||
config: dict[str, Any] = {
|
||||
"name": "local/lifecycle-tool-none",
|
||||
"description": "Tool with lifecycle execute=None",
|
||||
"source": "builtin",
|
||||
"lifecycle": {
|
||||
"discover": "hooks.discover",
|
||||
},
|
||||
}
|
||||
context.tool_model = Tool.from_config(config)
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — Tool.as_cli_dict() with lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I create a tool with lifecycle execute "{execute}" and call as_cli_dict')
|
||||
def step_create_tool_with_lifecycle_execute_and_cli_dict(
|
||||
context: Context, execute: str
|
||||
) -> None:
|
||||
"""Create a Tool with a lifecycle.execute hook and call as_cli_dict."""
|
||||
tool = _make_builtin_tool(
|
||||
lifecycle=ToolLifecycle(execute=execute),
|
||||
)
|
||||
context.tool_cli_dict = tool.as_cli_dict()
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
@when("I create a tool with no lifecycle and call as_cli_dict")
|
||||
def step_create_tool_no_lifecycle_and_cli_dict(context: Context) -> None:
|
||||
"""Create a Tool with no lifecycle and call as_cli_dict."""
|
||||
tool = _make_builtin_tool()
|
||||
context.tool_cli_dict = tool.as_cli_dict()
|
||||
context.tool_model_error = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — ToolLifecycle field assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the tool lifecycle execute should be "{expected}"')
|
||||
def step_check_lifecycle_execute(context: Context, expected: str) -> None:
|
||||
"""Assert the lifecycle execute hook equals the expected value."""
|
||||
# Support both context.tool_lifecycle and context.tool_model.lifecycle
|
||||
if hasattr(context, "tool_lifecycle"):
|
||||
actual = context.tool_lifecycle.execute
|
||||
else:
|
||||
assert context.tool_model.lifecycle is not None, (
|
||||
"Expected tool_model.lifecycle to be set, but it is None"
|
||||
)
|
||||
actual = context.tool_model.lifecycle.execute
|
||||
assert actual == expected, (
|
||||
f"Expected lifecycle.execute == {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tool lifecycle execute should be None")
|
||||
def step_check_lifecycle_execute_none(context: Context) -> None:
|
||||
"""Assert the lifecycle execute hook is None."""
|
||||
if hasattr(context, "tool_lifecycle"):
|
||||
actual = context.tool_lifecycle.execute
|
||||
elif context.tool_model.lifecycle is not None:
|
||||
actual = context.tool_model.lifecycle.execute
|
||||
else:
|
||||
actual = None
|
||||
assert actual is None, f"Expected lifecycle.execute to be None, got {actual!r}"
|
||||
|
||||
|
||||
@then("the tool lifecycle discover should be None")
|
||||
def step_check_lifecycle_discover_none(context: Context) -> None:
|
||||
"""Assert the lifecycle discover hook is None."""
|
||||
if hasattr(context, "tool_lifecycle"):
|
||||
actual = context.tool_lifecycle.discover
|
||||
elif context.tool_model.lifecycle is not None:
|
||||
actual = context.tool_model.lifecycle.discover
|
||||
else:
|
||||
actual = None
|
||||
assert actual is None, f"Expected lifecycle.discover to be None, got {actual!r}"
|
||||
|
||||
|
||||
@then('the tool lifecycle activate should be "{expected}"')
|
||||
def step_check_lifecycle_activate(context: Context, expected: str) -> None:
|
||||
"""Assert the lifecycle activate hook equals the expected value."""
|
||||
if hasattr(context, "tool_lifecycle"):
|
||||
actual = context.tool_lifecycle.activate
|
||||
else:
|
||||
assert context.tool_model.lifecycle is not None
|
||||
actual = context.tool_model.lifecycle.activate
|
||||
assert actual == expected, (
|
||||
f"Expected lifecycle.activate == {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tool lifecycle activate should be None")
|
||||
def step_check_lifecycle_activate_none(context: Context) -> None:
|
||||
"""Assert the lifecycle activate hook is None."""
|
||||
if hasattr(context, "tool_lifecycle"):
|
||||
actual = context.tool_lifecycle.activate
|
||||
elif context.tool_model.lifecycle is not None:
|
||||
actual = context.tool_model.lifecycle.activate
|
||||
else:
|
||||
actual = None
|
||||
assert actual is None, f"Expected lifecycle.activate to be None, got {actual!r}"
|
||||
|
||||
|
||||
@then('the tool lifecycle deactivate should be "{expected}"')
|
||||
def step_check_lifecycle_deactivate(context: Context, expected: str) -> None:
|
||||
"""Assert the lifecycle deactivate hook equals the expected value."""
|
||||
if hasattr(context, "tool_lifecycle"):
|
||||
actual = context.tool_lifecycle.deactivate
|
||||
else:
|
||||
assert context.tool_model.lifecycle is not None
|
||||
actual = context.tool_model.lifecycle.deactivate
|
||||
assert actual == expected, (
|
||||
f"Expected lifecycle.deactivate == {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tool lifecycle deactivate should be None")
|
||||
def step_check_lifecycle_deactivate_none(context: Context) -> None:
|
||||
"""Assert the lifecycle deactivate hook is None."""
|
||||
if hasattr(context, "tool_lifecycle"):
|
||||
actual = context.tool_lifecycle.deactivate
|
||||
elif context.tool_model.lifecycle is not None:
|
||||
actual = context.tool_model.lifecycle.deactivate
|
||||
else:
|
||||
actual = None
|
||||
assert actual is None, f"Expected lifecycle.deactivate to be None, got {actual!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — as_cli_dict lifecycle assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the tool cli dict lifecycle should have key "{key}"')
|
||||
def step_check_cli_dict_lifecycle_has_key(context: Context, key: str) -> None:
|
||||
"""Assert the lifecycle dict in as_cli_dict contains the given key."""
|
||||
assert "lifecycle" in context.tool_cli_dict, (
|
||||
"Expected 'lifecycle' key in cli dict, but it was absent"
|
||||
)
|
||||
lc = context.tool_cli_dict["lifecycle"]
|
||||
assert key in lc, (
|
||||
f"Expected lifecycle dict to have key {key!r}, got keys: {list(lc.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tool cli dict lifecycle execute should be "{expected}"')
|
||||
def step_check_cli_dict_lifecycle_execute(context: Context, expected: str) -> None:
|
||||
"""Assert the lifecycle.execute value in as_cli_dict equals expected."""
|
||||
assert "lifecycle" in context.tool_cli_dict, (
|
||||
"Expected 'lifecycle' key in cli dict, but it was absent"
|
||||
)
|
||||
actual = context.tool_cli_dict["lifecycle"].get("execute")
|
||||
assert actual == expected, (
|
||||
f"Expected lifecycle.execute == {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tool cli dict should not have key "{key}"')
|
||||
def step_check_cli_dict_missing_key(context: Context, key: str) -> None:
|
||||
"""Assert the cli dict does NOT contain the given key."""
|
||||
assert key not in context.tool_cli_dict, (
|
||||
f"Expected cli dict NOT to have key {key!r}, but it was present"
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
Feature: ToolLifecycle domain model execute hook
|
||||
Verify that ToolLifecycle exposes the execute hook required by the
|
||||
spec four-stage lifecycle (discover / activate / execute / deactivate).
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# TDD issue-capture test for #2820
|
||||
# The @tdd_expected_fail tag is removed once the fix is in place.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@tdd_issue @tdd_issue_2820
|
||||
Scenario: ToolLifecycle has an execute field (issue capture)
|
||||
When I create a tool lifecycle with execute hook "my.module.execute"
|
||||
Then the tool lifecycle execute should be "my.module.execute"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Positive scenarios — all four lifecycle hooks present
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Scenario: ToolLifecycle with all four hooks set
|
||||
When I create a tool lifecycle with all four hooks set
|
||||
Then the tool lifecycle discover should be "hooks.discover"
|
||||
And the tool lifecycle activate should be "hooks.activate"
|
||||
And the tool lifecycle execute should be "hooks.execute"
|
||||
And the tool lifecycle deactivate should be "hooks.deactivate"
|
||||
|
||||
Scenario: ToolLifecycle execute defaults to None
|
||||
When I create a tool lifecycle with no hooks
|
||||
Then the tool lifecycle execute should be None
|
||||
|
||||
Scenario: ToolLifecycle execute can be set independently
|
||||
When I create a tool lifecycle with only execute hook "run.tool"
|
||||
Then the tool lifecycle execute should be "run.tool"
|
||||
And the tool lifecycle discover should be None
|
||||
And the tool lifecycle activate should be None
|
||||
And the tool lifecycle deactivate should be None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Round-trip YAML serialisation/deserialisation
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Scenario: Tool from_config parses execute hook from YAML lifecycle block
|
||||
When I load a tool from config with lifecycle execute "my.execute.hook"
|
||||
Then the tool model should be created
|
||||
And the tool lifecycle execute should be "my.execute.hook"
|
||||
|
||||
Scenario: Tool from_config with execute=None preserves None
|
||||
When I load a tool from config with lifecycle execute None
|
||||
Then the tool model should be created
|
||||
And the tool lifecycle execute should be None
|
||||
|
||||
Scenario: Tool as_cli_dict renders execute hook when set
|
||||
When I create a tool with lifecycle execute "cli.execute.hook" and call as_cli_dict
|
||||
Then the tool cli dict lifecycle should have key "execute"
|
||||
And the tool cli dict lifecycle execute should be "cli.execute.hook"
|
||||
|
||||
Scenario: Tool as_cli_dict omits execute when None
|
||||
When I create a tool with no lifecycle and call as_cli_dict
|
||||
Then the tool cli dict should not have key "lifecycle"
|
||||
@@ -277,10 +277,14 @@ class ToolLifecycle(BaseModel):
|
||||
|
||||
Each hook is a dotted Python path or shell command string that the
|
||||
runtime invokes at the corresponding lifecycle stage.
|
||||
|
||||
The four-stage lifecycle matches the spec contract:
|
||||
``discover`` → ``activate`` → ``execute`` → ``deactivate``.
|
||||
"""
|
||||
|
||||
discover: str | None = Field(default=None, description="Hook for tool discovery")
|
||||
activate: str | None = Field(default=None, description="Hook for tool activation")
|
||||
execute: str | None = Field(default=None, description="Hook for tool execution")
|
||||
deactivate: str | None = Field(
|
||||
default=None, description="Hook for tool deactivation"
|
||||
)
|
||||
@@ -530,6 +534,20 @@ class Tool(BaseModel):
|
||||
|
||||
result["timeout"] = self.timeout
|
||||
|
||||
if self.lifecycle is not None:
|
||||
lc = self.lifecycle
|
||||
lc_dict: dict[str, str | None] = {}
|
||||
if lc.discover is not None:
|
||||
lc_dict["discover"] = lc.discover
|
||||
if lc.activate is not None:
|
||||
lc_dict["activate"] = lc.activate
|
||||
if lc.execute is not None:
|
||||
lc_dict["execute"] = lc.execute
|
||||
if lc.deactivate is not None:
|
||||
lc_dict["deactivate"] = lc.deactivate
|
||||
if lc_dict:
|
||||
result["lifecycle"] = lc_dict
|
||||
|
||||
ee = self.execution_environment
|
||||
if ee.mode != EnvironmentPreferenceMode.NONE:
|
||||
ee_dict: dict[str, str] = {"mode": ee.mode.value}
|
||||
|
||||
Reference in New Issue
Block a user