Files
cleveractors-core/robot/llm_tool_calling.robot
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

65 lines
3.2 KiB
Plaintext

*** Settings ***
Documentation LLM tool-calling integration tests.
... Exercises the full pipeline (Executor → LLMAgent → ToolAgent)
... with a mock chat model that returns tool_calls, so no real
... LLM API key is required.
Library ToolCallingTestLib.py
*** Test Cases ***
LLM Agent Receives Tools And Executes Tool Call
[Documentation] Full lifecycle: config with echo tool → mock returns
... tool_calls → ToolAgent executes echo → final response produced.
Create Executor With Tool Calling Agent tool_name=echo message=hello_from_tool
Execute With Message Tell me something using the echo tool
Result Is Valid Actor Result
Result Has Nodes
Chat Model Was Invoked With Tools
Multiple Tool Rounds Occurred
Result Response Contains Final answer after tool use
LLM Agent Tool Calling Works With MATH Tool
[Documentation] MATH tool recognises the expression and computes result.
Create Executor With Tool Calling Agent tool_name=math message=2+2
Execute With Message What is 2+2? Use the math tool.
Result Is Valid Actor Result
Chat Model Was Invoked With Tools
Multiple Tool Rounds Occurred
Result Response Contains Final answer after tool use
LLM Agent With FILE_READ Tool Receives Tool Calls
[Documentation] file_read tool is normalised and passed to the model.
Create Executor With Tool Calling Agent tool_name=file_read message=some_file.txt
Execute With Message Read the file some_file.txt for me
Result Is Valid Actor Result
Chat Model Was Invoked With Tools
Tool Calling Produces Non-Empty Final Response
[Documentation] After tool execution, the final response is populated.
Create Executor With Tool Calling Agent tool_name=echo message=tool_data
Execute With Message Use the echo tool and report back
Result Is Valid Actor Result
Tool Call Result Is In Response
Result Prompt Tokens Greater Than Zero
Streaming Path Exercises Tool Loop Via Execute Stream
[Documentation] execute_stream on a tool-declaring single-LLM actor exercises
... the _execute_tool_loop() path (issue #67): the tool-call loop runs via
... ainvoke(), the final response is yielded as a single content chunk, and
... executor.last_result reports accumulated token counts covering all rounds.
Create Executor With Tool Calling Agent tool_name=echo message=from_stream
Execute Stream With Message Tell me about yourself
Result Is Valid Actor Result
Result Has Nodes
Stream Result Response Is Non Empty
Stream Result Prompt Tokens Greater Than Zero
Streaming Path Accumulates Tokens Across Two Tool Rounds
[Documentation] When the mock LLM returns tool_calls for two rounds before the
... final answer, execute_stream yields the final answer and the accumulated
... prompt_tokens equals the sum across all three invocations.
Create Executor With Multi Round Tool Calling Agent tool_name=echo rounds=2
Execute Stream With Message Multi round test
Result Is Valid Actor Result
Stream Result Response Contains Multi round final answer
Stream Result Prompt Tokens Equals Multi Round Sum