Files
cleveractors-core/robot/ToolCallingTestLib.py
hurui200320 306f114b8a
CI / lint (pull_request) Successful in 47s
CI / typecheck (pull_request) Successful in 51s
CI / security (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m7s
CI / integration_tests (pull_request) Successful in 1m10s
CI / build (pull_request) Successful in 35s
CI / coverage (pull_request) Successful in 3m9s
CI / status-check (pull_request) Successful in 3s
CI / lint (push) Successful in 34s
CI / typecheck (push) Successful in 51s
CI / security (push) Successful in 50s
CI / quality (push) Successful in 33s
CI / unit_tests (push) Successful in 3m6s
CI / integration_tests (push) Successful in 1m7s
CI / build (push) Successful in 33s
CI / coverage (push) Successful in 3m7s
CI / status-check (push) Successful in 3s
feat(agents): add tool-call support to LLMAgent.stream_message
Extract the entire multi-turn tool-call orchestration from the inline
loop inside process_message() into a new shared private method
_execute_tool_loop().  Both process_message() and stream_message() now
delegate to this single implementation, eliminating the previous
capability gap where stream_message() had no tool-calling support.

Design:

- _ToolLoopResult dataclass: carries final_response, accumulated_prompt,
  accumulated_completion, budget_exhausted, and synthesis_was_run.  Both
  callers read token counts from this object instead of local sentinels.

- _ToolLoopError exception: wraps any exception raised inside the helper
  and carries partial accumulated token counts + any_invocation_made flag.
  Callers catch this, set their _captured_prompt/_captured_completion
  sentinels (billing integrity), then re-raise the original cause.

- _execute_tool_loop(): extracted verbatim from process_message().
  Contains the full ainvoke() loop, per-tool ToolAgent dispatch, token
  accumulation across rounds, token-budget exhaustion + synthesis path
  (§4.4.7 D-4), stuck-model synthesis prompt, and tool-output pruning
  pass (§4.4.8).  On exception, wraps in _ToolLoopError for the caller.

process_message() refactor:

- Replaces ~490 lines of inline tool loop with a 15-line delegation.
  For tool-configured actors: awaits _execute_tool_loop(), extracts
  response text and token counts from the result.
  For no-tool actors: single plain ainvoke() (unchanged behaviour).
  Billing-integrity sentinels (_captured_prompt/_captured_completion)
  are set from _ToolLoopError on exception and from the result on
  success.  All existing Behave scenarios continue to pass unmodified.

stream_message() integration:

- When tools are configured: awaits _execute_tool_loop() and yields the
  final response as a single content chunk.  Token counts come from the
  result's accumulated_prompt/accumulated_completion fields (spanning
  all tool rounds + pruning passes).  Memory update is performed before
  the generator returns.  Billing sentinels are updated from
  _ToolLoopError on exception.
- When no tools are configured: the existing astream() token-by-token
  path runs completely unchanged.

Single-chunk trade-off: when tools are involved, the user already waits
for tool execution before the final answer begins, so yielding that
answer as one chunk vs. token-by-token has minimal UX impact.  The
alternative (re-calling astream() after ainvoke() produced the final
response) would double LLM cost for every tool-using request.

Webapp workaround: the actor_config_has_tools fallback in
cleveragents/cleveragents-webapp#328 (stream_fallback_to_non_stream in
_execute_via_cleveractors_stream / _generate_actor_sse) can now be
removed.

Tests added:
- features/llm_agent_tool_loop.feature + steps: 10 Behave scenarios
  covering _execute_tool_loop() internals independently (single round,
  multi-round, max_rounds exhaustion, token accumulation, budget
  exhaustion, stuck-model synthesis, pruning pass, tool dispatch error,
  exception path with partial counts, pre-invocation ConfigurationError).
- features/llm_agent_stream_tool_calls.feature + steps: 7 Behave
  scenarios for stream_message() with tool-call support (single chunk
  output, two-round token sum, final text, no-tools astream unchanged,
  no-tools token counts, exception billing integrity, memory update).
- robot/llm_tool_calling.robot: two new Robot integration tests:
  'Streaming Path Exercises Tool Loop Via Execute Stream' and
  'Streaming Path Accumulates Tokens Across Two Tool Rounds'.
- robot/ToolCallingTestLib.py: new keywords and
  create_executor_with_multi_round_tool_calling_agent factory.

Quality gates: format ✓  lint ✓  typecheck ✓  security_scan ✓
unit_tests ✓  integration_tests ✓  coverage_report ✓ (97.0%)

ISSUES CLOSED: #67
2026-07-02 07:47:39 +00:00

318 lines
12 KiB
Python

"""Library for LLM tool-calling integration tests.
Provides keywords that create an Executor with a mock chat model that
returns tool_calls, then execute messages and verify that tools were
invoked correctly — without requiring a real LLM API key.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock, patch
from langchain_core.messages import AIMessage
from cleveractors.result import ActorResult
from cleveractors.runtime import Executor, create_executor
class ToolCallingTestLib:
"""Keywords for integration-style tests of LLM tool-calling."""
def __init__(self) -> None:
self._executor: Executor | None = None
self._last_result: ActorResult | None = None
self._mock_ainvoke_calls: list[dict[str, Any]] = []
self._patches: list[Any] = []
self._multi_round_expected_prompt: int = 0
def _teardown_patches(self) -> None:
for p in self._patches:
p.stop()
self._patches.clear()
def create_executor_with_tool_calling_agent(
self, tool_name: str = "echo", message: str = ""
) -> None:
"""Create an Executor whose LLM agent is configured with a tool.
The chat model is mocked so that ``ainvoke`` returns an AIMessage
with tool_calls. The model first returns a tool-call message,
then a final plain-text answer.
"""
self._teardown_patches()
self._mock_ainvoke_calls = []
final_response_text = "Final answer after tool use"
def _build_mock_model(*args, **kwargs):
mock_model = MagicMock()
mock_model.temperature = 0.7
async def _mock_ainvoke(messages, **invoke_kwargs):
self._mock_ainvoke_calls.append({"kwargs": invoke_kwargs})
call_count = len(self._mock_ainvoke_calls)
if call_count == 1 and invoke_kwargs.get("tools"):
tool_content = message or "hello from tool"
return AIMessage(
content="",
usage_metadata={
"input_tokens": 50,
"output_tokens": 10,
"total_tokens": 60,
},
tool_calls=[
{
"id": "call_001",
"name": tool_name,
"args": {"message": tool_content},
},
],
)
return AIMessage(
content=final_response_text,
usage_metadata={
"input_tokens": 30,
"output_tokens": 15,
"total_tokens": 45,
},
)
async def _mock_astream(messages, **invoke_kwargs):
self._mock_ainvoke_calls.append({"kwargs": invoke_kwargs})
call_count = len(self._mock_ainvoke_calls)
if call_count == 1 and invoke_kwargs.get("tools"):
tool_content = message or "hello from stream"
yield AIMessage(
content="tool_call_response",
usage_metadata={
"input_tokens": 40,
"output_tokens": 10,
"total_tokens": 50,
},
tool_calls=[
{
"id": "call_001",
"name": tool_name,
"args": {"message": tool_content},
},
],
)
else:
yield AIMessage(
content=final_response_text,
usage_metadata={
"input_tokens": 25,
"output_tokens": 12,
"total_tokens": 37,
},
)
mock_model.ainvoke = _mock_ainvoke
mock_model.astream = _mock_astream
return mock_model
patcher = patch(
"cleveractors.agents.llm.build_chat_model",
side_effect=_build_mock_model,
)
patcher.start()
self._patches.append(patcher)
config = {
"type": "llm",
"name": "tool_test_agent",
"provider": "openai",
"model": "gpt-3.5-turbo",
"config": {
"tools": [{"name": tool_name}],
},
}
self._executor = create_executor(
config_dict=config,
credentials={"openai": {"api_key": "mock-key"}},
limits={},
pricing={},
)
async def _execute_async(self, message: str) -> ActorResult:
return await self._executor.execute(message)
def execute_with_message(self, message: str) -> None:
"""Execute the executor with *message* and store the result."""
try:
self._last_result = asyncio.run(self._execute_async(message))
finally:
self._teardown_patches()
def execute_stream_with_message(self, message: str) -> None:
"""Execute via execute_stream and store the last result."""
async def _stream():
chunks = []
async for token in self._executor.execute_stream(message):
chunks.append(token)
return "".join(chunks)
try:
asyncio.run(_stream())
self._last_result = self._executor.last_result
finally:
self._teardown_patches()
def result_is_valid_actor_result(self) -> None:
assert isinstance(self._last_result, ActorResult), (
f"Expected ActorResult, got {type(self._last_result)}"
)
def result_response_contains(self, text: str) -> None:
assert self._last_result is not None
assert text in self._last_result.response, (
f"Expected '{text}' in response, got: {self._last_result.response[:200]}"
)
def result_has_nodes(self) -> None:
assert self._last_result is not None
assert len(self._last_result.nodes) > 0, "Expected at least one node in result"
def result_prompt_tokens_greater_than_zero(self) -> None:
assert self._last_result is not None
assert self._last_result.prompt_tokens > 0, (
f"Expected prompt_tokens > 0, got {self._last_result.prompt_tokens}"
)
def chat_model_was_invoked_with_tools(self) -> None:
for call in self._mock_ainvoke_calls:
if call.get("kwargs", {}).get("tools"):
return
raise AssertionError("chat_model.ainvoke was never called with tools keyword")
def multiple_tool_rounds_occurred(self) -> None:
assert len(self._mock_ainvoke_calls) >= 2, (
f"Expected >= 2 ainvoke calls, got {len(self._mock_ainvoke_calls)}"
)
def tool_call_result_is_in_response(self) -> None:
assert self._last_result is not None
assert len(self._last_result.response.strip()) > 0, (
"Expected non-empty response after tool calls"
)
# ------------------------------------------------------------------
# New keywords for issue #67 (stream_message tool-loop integration)
# ------------------------------------------------------------------
def stream_result_response_is_non_empty(self) -> None: # noqa: D401
"""Verify that the stream response is non empty."""
assert self._last_result is not None
assert len(self._last_result.response.strip()) > 0, (
f"Expected non-empty stream response, got: {self._last_result.response!r}"
)
def stream_result_prompt_tokens_greater_than_zero(self) -> None:
"""Verify stream result has non-zero accumulated prompt tokens."""
assert self._last_result is not None
assert self._last_result.prompt_tokens > 0, (
f"Expected stream prompt_tokens > 0, got {self._last_result.prompt_tokens}"
)
def stream_result_response_contains(self, text: str) -> None:
"""Verify the stream response contains *text*."""
assert self._last_result is not None
assert text in self._last_result.response, (
f"Expected {text!r} in stream response, got: {self._last_result.response[:200]!r}"
)
def stream_result_prompt_tokens_equals_multi_round_sum(self) -> None:
"""Verify accumulated prompt tokens equal the sum across all mock rounds."""
assert self._last_result is not None
expected = self._multi_round_expected_prompt
actual = self._last_result.prompt_tokens
assert actual == expected, (
f"Expected prompt_tokens={expected} (sum of all rounds), got {actual}"
)
def create_executor_with_multi_round_tool_calling_agent(
self, tool_name: str = "echo", rounds: int = 2
) -> None:
"""Create an Executor whose mock model returns tool_calls for *rounds* rounds
then a final answer. Tracks per-round token counts for assertion.
"""
self._teardown_patches()
self._mock_ainvoke_calls = []
# Per-round prompt tokens: 50 per tool round + 30 for final answer
tool_round_prompt = 50
final_prompt = 30
tool_round_completion = 10
final_completion = 15
total_rounds = int(rounds)
self._multi_round_expected_prompt = (
tool_round_prompt * total_rounds + final_prompt
)
final_response_text = "Multi round final answer"
def _build_mock_model(*args, **kwargs): # type: ignore[no-untyped-def]
mock_model = MagicMock()
mock_model.temperature = 0.7
async def _mock_ainvoke(messages, **invoke_kwargs): # type: ignore[no-untyped-def]
self._mock_ainvoke_calls.append({"kwargs": invoke_kwargs})
call_count = len(self._mock_ainvoke_calls)
if call_count <= total_rounds and invoke_kwargs.get("tools"):
return AIMessage(
content="",
usage_metadata={
"input_tokens": tool_round_prompt,
"output_tokens": tool_round_completion,
"total_tokens": tool_round_prompt + tool_round_completion,
},
tool_calls=[
{
"id": f"call_{call_count:03d}",
"name": tool_name,
"args": {"message": f"round_{call_count}"},
}
],
)
return AIMessage(
content=final_response_text,
usage_metadata={
"input_tokens": final_prompt,
"output_tokens": final_completion,
"total_tokens": final_prompt + final_completion,
},
)
mock_model.ainvoke = _mock_ainvoke
mock_model.astream = MagicMock() # not called in tool path
return mock_model
patcher = patch(
"cleveractors.agents.llm.build_chat_model",
side_effect=_build_mock_model,
)
patcher.start()
self._patches.append(patcher)
config = {
"type": "llm",
"name": "multi_round_tool_agent",
"provider": "openai",
"model": "gpt-3.5-turbo",
"config": {
"tools": [{"name": tool_name}],
},
}
self._executor = create_executor(
config_dict=config,
credentials={"openai": {"api_key": "mock-key"}},
limits={},
pricing={},
)