9ec578f229
CI / lint (pull_request) Successful in 44s
CI / typecheck (pull_request) Successful in 49s
CI / security (pull_request) Successful in 47s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m4s
CI / integration_tests (pull_request) Successful in 1m2s
CI / build (pull_request) Successful in 33s
CI / coverage (pull_request) Successful in 3m5s
CI / status-check (pull_request) Successful in 2s
CI / lint (push) Successful in 31s
CI / typecheck (push) Successful in 46s
CI / security (push) Successful in 46s
CI / quality (push) Successful in 31s
CI / unit_tests (push) Successful in 3m2s
CI / integration_tests (push) Successful in 1m2s
CI / build (push) Successful in 31s
CI / coverage (push) Successful in 3m4s
CI / status-check (push) Successful in 6s
Extends AgentFactory.__init__ and create_executor() with a keyword-only
tool_agent_class: type[ToolAgent] = ToolAgent parameter so callers can
inject a custom ToolAgent subclass at runtime without monkey-patching
module globals (resolves the five-module setattr loop in cleveragents-webapp).
**AgentFactory** (agents/factory.py):
- Accepts tool_agent_class keyword argument; validates it is a ToolAgent
subclass at construction time (fail-fast, CONTRIBUTING.md §Argument
Validation).
- Stores as self._tool_agent_class and uses it to populate
self.agent_types['tool'], so every create_agent('tool') call
instantiates the supplied subclass.
- Forwards tool_agent_class to LLMAgent when creating 'llm'-typed agents
so the LLM tool-calling loop honours the injected subclass.
- Default ToolAgent is preserved, so all existing callers are unaffected.
**LLMAgent** (agents/llm.py):
- Accepts a keyword-only tool_agent_class: type[ToolAgent] = ToolAgent
argument, validated as a ToolAgent subclass at construction time.
- The multi-turn tool-call loop (_execute_tool_loop) constructs its
ephemeral tool executors via self._tool_agent_class(...) across all
three dispatch sites (regular loop, budget-exhaustion synthesis round,
and stuck-model synthesis round), so the LLM tool-calling path no
longer falls back to the module-level ToolAgent. This closes the gap
that previously prevented removing the webapp monkey-patch for the LLM
tool-calling path.
**Executor / create_executor** (runtime.py):
- Executor.__init__ accepts and validates tool_agent_class; stores as
self.tool_agent_class.
- create_executor() accepts tool_agent_class and forwards it to Executor.
**Dispatch paths** (runtime_dispatch.py):
- _execute_tool: uses executor.tool_agent_class(...) instead of the
module-level ToolAgent(...) so the direct-construction path (bypassing
AgentFactory) also honours the injected subclass.
- _execute_graph and _execute_graph_stream: pass
tool_agent_class=executor.tool_agent_class to AgentFactory so graph
nodes using tool-typed agents pick up the subclass.
- _execute_llm and _execute_llm_stream: pass
tool_agent_class=executor.tool_agent_class to AgentFactory so the
single-LLM actor's internally-created LLMAgent (and its tool-calling
loop) honours the injected subclass. Without this, a single LLM actor
that makes tool calls silently fell back to the base ToolAgent.
- _execute_multi_actor: passes tool_agent_class=executor.tool_agent_class
to the sub-Executor so every actor inside a multi-actor bundle (tool,
graph, or LLM) honours the injected subclass. Without this, all
dispatch paths reachable through a multi-actor bundle fell back to the
base ToolAgent.
- Removed the now-unused module-level ToolAgent import; the _execute_tool
docstring cross-reference is fully qualified so it still resolves.
All six dispatch paths that construct or dispatch tool agents now
forward the injected subclass.
**ReactiveCleverAgentsApp** (core/application.py):
- Removed the redundant register_agent_type('tool', ToolAgent) call from
load_configuration and the dead type_map re-registration block in
_create_agents; AgentFactory.__init__ already owns the 'tool'
registration. This drops the last hardcoded module-level ToolAgent
references flagged in the issue #73 audit.
**Tests** (features/tool_agent_class_injection.feature + steps):
- 17 BDD scenarios covering: default class when parameter omitted;
custom subclass stored on factory and executor; invalid class rejected
with ConfigurationError (factory, executor, and LLMAgent); factory
create_agent returns custom instance; _execute_tool dispatch
instantiates custom subclass; the LLM tool loop instantiates the
injected subclass; isinstance LSP compatibility through the factory;
the _execute_llm dispatch path forwarding tool_agent_class to
AgentFactory (single-LLM actor making a tool call); and the
_execute_multi_actor dispatch path forwarding tool_agent_class to the
sub-Executor. The two new dispatch-path scenarios were verified to
fail without the source fixes (instantiation_count=0), confirming
they guard the injection contract for the single-LLM and multi-actor
paths.
**Updated tests** (features/steps/runtime_coverage_steps.py,
features/steps/runtime_extended_coverage_steps.py):
- Removed the now-dead patch('cleveractors.runtime_dispatch.ToolAgent')
mocks (and their mock instances) that no longer intercept _execute_tool
after it switched to executor.tool_agent_class. The tool-actor scenario
continues to pass via the real ToolAgent; the normal-coverage branch
uses patch.object(ToolAgent, 'process_message', ...) to stub tool
execution.
**Updated test** (features/steps/credential_executor_paths_steps.py):
- The _execute_multi_actor credentials-forwarding scenario intercepts
Executor.__init__ with a capturing_init to assert the credentials
dict reaches the sub-Executor. Now that _execute_multi_actor forwards
tool_agent_class to the sub-Executor, capturing_init accepts and
forwards that keyword so the assertion continues to pass.
**Removed obsolete test** (features/application_coverage_gaps.feature):
- Dropped the '_create_agents registers built-in agent types' scenario
and its step definitions; the behaviour it asserted (redundant
re-registration) was removed from _create_agents.
ISSUES CLOSED: #73
232 lines
8.1 KiB
Python
232 lines
8.1 KiB
Python
"""Step definitions for Runtime Executor API BDD tests.
|
|
|
|
Given steps (environment, config, credentials, limits, pricing) are defined
|
|
in ``runtime_config_steps.py`` (extracted to stay under the 500-line file
|
|
limit, CONTRIBUTING.md General Principles).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.api.async_step import async_run_until_complete
|
|
|
|
from cleveractors.core.exceptions import ConfigurationError, ExecutionError
|
|
from cleveractors.result import ActorResult, NodeUsage
|
|
from cleveractors.runtime import (
|
|
Executor,
|
|
create_executor,
|
|
)
|
|
|
|
|
|
@given("the LLM agent is configured to fail")
|
|
def step_llm_fail(context: Any) -> None:
|
|
context._llm_should_fail = True
|
|
|
|
|
|
@when("I call create_executor")
|
|
def step_call_create_executor(context: Any) -> None:
|
|
context.test_executor = create_executor(
|
|
config_dict=context.config_dict,
|
|
credentials=context.credentials,
|
|
limits=context.limits if hasattr(context, "limits") else None,
|
|
pricing=context.pricing if hasattr(context, "pricing") else None,
|
|
)
|
|
|
|
|
|
@when("I call create_executor with None limits and pricing")
|
|
def step_call_create_executor_none(context: Any) -> None:
|
|
context.test_executor = create_executor(
|
|
config_dict=context.config_dict,
|
|
credentials=context.credentials,
|
|
limits=None,
|
|
pricing=None,
|
|
)
|
|
|
|
|
|
@when("I execute the actor with message {msg}")
|
|
@async_run_until_complete
|
|
async def step_execute_actor(context: Any, msg: str) -> None:
|
|
msg = msg.strip('"')
|
|
executor = create_executor(
|
|
config_dict=context.config_dict,
|
|
credentials=context.credentials,
|
|
limits=context.limits if hasattr(context, "limits") and context.limits else {},
|
|
pricing=context.pricing
|
|
if hasattr(context, "pricing") and context.pricing
|
|
else {},
|
|
)
|
|
context.test_executor = executor
|
|
|
|
with (
|
|
patch("cleveractors.runtime_dispatch.TemplateRenderer") as mock_renderer,
|
|
patch("cleveractors.agents.llm.LLMAgent") as mock_llm,
|
|
patch("cleveractors.runtime_dispatch.AgentFactory") as mock_factory,
|
|
patch("cleveractors.runtime_dispatch.PureLangGraph") as mock_pure_graph,
|
|
):
|
|
mock_renderer_instance = MagicMock()
|
|
mock_renderer.return_value = mock_renderer_instance
|
|
|
|
if getattr(context, "_llm_should_fail", False):
|
|
mock_llm_instance = MagicMock()
|
|
mock_llm_instance.process_message = AsyncMock(
|
|
side_effect=Exception("LLM API error")
|
|
)
|
|
mock_llm_instance.cleanup = AsyncMock()
|
|
mock_llm_instance._last_token_usage = (0, 0)
|
|
else:
|
|
mock_llm_instance = MagicMock()
|
|
mock_llm_instance.process_message = AsyncMock(
|
|
return_value="Mock LLM response"
|
|
)
|
|
mock_llm_instance.cleanup = AsyncMock()
|
|
# Simulate real token usage from LLMAgent._last_token_usage (AC2)
|
|
mock_llm_instance._last_token_usage = (100, 50)
|
|
|
|
mock_llm.return_value = mock_llm_instance
|
|
|
|
mock_graph_instance = MagicMock()
|
|
# PureLangGraph.execute() returns a 3-tuple (AC4): (response, state, node_usages)
|
|
mock_graph_instance.execute = AsyncMock(
|
|
return_value=(
|
|
"Mock graph response",
|
|
{},
|
|
[("entry", "openai", "gpt-3.5-turbo", 100, 50)],
|
|
)
|
|
)
|
|
mock_pure_graph.return_value = mock_graph_instance
|
|
|
|
mock_factory_instance = MagicMock()
|
|
mock_factory_instance.create_agent = MagicMock(return_value=mock_llm_instance)
|
|
mock_factory.return_value = mock_factory_instance
|
|
|
|
try:
|
|
context.test_result = await executor.execute(msg)
|
|
context.test_error = None
|
|
except Exception as e:
|
|
context.test_error = e
|
|
context.test_result = None
|
|
|
|
|
|
@then("an Executor instance should be returned")
|
|
def step_assert_executor(context: Any) -> None:
|
|
assert context.test_executor is not None
|
|
assert isinstance(context.test_executor, Executor)
|
|
|
|
|
|
@then("the executor config should match the config dict")
|
|
def step_assert_config(context: Any) -> None:
|
|
assert context.test_executor.config == context.config_dict
|
|
|
|
|
|
@then("the executor credentials should match the credentials dict")
|
|
def step_assert_credentials(context: Any) -> None:
|
|
assert context.test_executor.credentials == context.credentials
|
|
|
|
|
|
@then("the executor limits should match the limits dict")
|
|
def step_assert_limits(context: Any) -> None:
|
|
assert context.test_executor.limits == context.limits
|
|
|
|
|
|
@then("the executor pricing should match the pricing dict")
|
|
def step_assert_pricing(context: Any) -> None:
|
|
assert context.test_executor.pricing == context.pricing
|
|
|
|
|
|
@then("the executor limits should be an empty dictionary")
|
|
def step_assert_limits_empty(context: Any) -> None:
|
|
assert context.test_executor.limits == {}
|
|
|
|
|
|
@then("the executor pricing should be an empty dictionary")
|
|
def step_assert_pricing_empty(context: Any) -> None:
|
|
assert context.test_executor.pricing == {}
|
|
|
|
|
|
@then("the execution should return an ActorResult")
|
|
def step_assert_actor_result(context: Any) -> None:
|
|
assert context.test_result is not None
|
|
assert isinstance(context.test_result, ActorResult)
|
|
|
|
|
|
@then("the ActorResult response should be non-empty")
|
|
def step_assert_response(context: Any) -> None:
|
|
assert context.test_result.response is not None
|
|
assert len(context.test_result.response) > 0
|
|
|
|
|
|
@then("the ActorResult should have token usage tracked")
|
|
def step_assert_tokens(context: Any) -> None:
|
|
assert context.test_result.prompt_tokens == 100, (
|
|
f"Expected prompt_tokens == 100, got {context.test_result.prompt_tokens}"
|
|
)
|
|
assert context.test_result.completion_tokens == 50, (
|
|
f"Expected completion_tokens == 50, got {context.test_result.completion_tokens}"
|
|
)
|
|
|
|
|
|
@then("the ActorResult should have at least one node usage entry")
|
|
def step_assert_nodes(context: Any) -> None:
|
|
assert len(context.test_result.nodes) >= 1
|
|
|
|
|
|
@then("the ActorResult should have zero prompt tokens for tool agents")
|
|
def step_assert_zero_tokens(context: Any) -> None:
|
|
assert context.test_result.prompt_tokens == 0
|
|
assert context.test_result.completion_tokens == 0
|
|
|
|
|
|
@then("the node usage IDs should be prefixed with the default actor name")
|
|
def step_assert_node_prefix(context: Any) -> None:
|
|
for node in context.test_result.nodes:
|
|
assert node.node_id.startswith("default.")
|
|
|
|
|
|
@then("a ConfigurationError should be raised for runtime")
|
|
def step_assert_config_error(context: Any) -> None:
|
|
assert context.test_error is not None
|
|
assert isinstance(context.test_error, ConfigurationError)
|
|
|
|
|
|
@then("the error message should mention the unknown actor type")
|
|
def step_assert_error_unknown_type(context: Any) -> None:
|
|
assert "unknown_type" in str(context.test_error).lower()
|
|
|
|
|
|
@then(
|
|
"an ExecutionError should be raised for runtime with the original cause suppressed"
|
|
)
|
|
def step_assert_execution_error_cause_suppressed(context: Any) -> None:
|
|
assert context.test_error is not None
|
|
assert isinstance(context.test_error, ExecutionError)
|
|
assert context.test_error.__cause__ is None, (
|
|
f"Expected ExecutionError with suppressed cause, "
|
|
f"but got __cause__={context.test_error.__cause__!r}"
|
|
)
|
|
|
|
|
|
@then("a ConfigurationError should be raised for runtime about no actors")
|
|
def step_assert_no_actors_error(context: Any) -> None:
|
|
assert context.test_error is not None
|
|
assert isinstance(context.test_error, ConfigurationError)
|
|
assert "no actors" in str(context.test_error).lower(), (
|
|
f"Expected error to mention 'no actors', got: {context.test_error}"
|
|
)
|
|
|
|
|
|
@when("I import from cleaveractors.runtime_types")
|
|
def step_import_runtime_types(context):
|
|
from cleveractors.runtime_types import ActorResult, NodeUsage
|
|
|
|
context.rt_actor_result = ActorResult
|
|
context.rt_node_usage = NodeUsage
|
|
|
|
|
|
@then("ActorResult and NodeUsage should be importable")
|
|
def step_assert_rt_imports(context):
|
|
assert context.rt_actor_result is not None
|
|
assert context.rt_node_usage is not None
|