Feature: LLMAgent._execute_tool_loop() — shared tool-call orchestration helper # ── Basic single tool round ─────────────────────────────────────────────── Scenario: _execute_tool_loop returns result for single tool round Given I have an LLMAgent with tool loop test config for tool "echo" And I set a mock that makes one tool call then returns a final answer "Final answer from loop" When I run _execute_tool_loop with an initial messages list Then the loop result final_response content should be "Final answer from loop" And the loop result accumulated_prompt should be greater than 0 And the loop result accumulated_completion should be greater than 0 And the loop result budget_exhausted should be false And the loop result synthesis_was_run should be false # ── Multi-round (two consecutive tool-call rounds) ──────────────────────── Scenario: _execute_tool_loop accumulates tokens across two tool-call rounds Given I have an LLMAgent with tool loop test config for tool "echo" And I set a mock that makes two tool calls then returns a final answer "Final answer two rounds" When I run _execute_tool_loop with an initial messages list Then the loop result final_response content should be "Final answer two rounds" And the loop result accumulated_prompt should be at least 30 And the loop result accumulated_completion should be at least 15 # ── tool_max_rounds exhaustion ──────────────────────────────────────────── Scenario: _execute_tool_loop handles tool_max_rounds exhaustion Given I have an LLMAgent with tool loop test config for tool "echo" and tool_max_rounds 1 And I set a mock that always returns tool_calls never a final answer When I run _execute_tool_loop with an initial messages list Then the loop result should contain some content from the synthesis prompt And the loop result budget_exhausted should be false # ── Token accumulation spanning all rounds ──────────────────────────────── Scenario: _execute_tool_loop token counts span all ainvoke rounds Given I have an LLMAgent with tool loop test config for tool "echo" And I set a mock that makes two tool calls then returns a final answer "Accumulated answer" When I run _execute_tool_loop with an initial messages list Then the loop result accumulated_prompt should equal the sum of all rounds prompt tokens And the loop result accumulated_completion should equal the sum of all rounds completion tokens # ── Token-budget exhaustion ─────────────────────────────────────────────── Scenario: _execute_tool_loop triggers synthesis when token budget exhausted Given I have an LLMAgent with tool loop test config for tool "echo" and tiny token_budget_percent And I set a mock that triggers budget exhaustion then returns "Budget exhausted answer" When I run _execute_tool_loop with an initial messages list Then the loop result budget_exhausted should be true And the loop result final_response content should be "Budget exhausted answer" # ── Stuck-model synthesis ───────────────────────────────────────────────── Scenario: _execute_tool_loop runs synthesis when model is stuck in tool-only mode Given I have an LLMAgent with tool loop test config for tool "echo" and tool_max_rounds 2 And I set a mock that returns empty content tool calls then "Synthesis answer" When I run _execute_tool_loop with an initial messages list Then the loop result synthesis_was_run should be true And the loop result final_response content should be "Synthesis answer" # ── Tool-output pruning ─────────────────────────────────────────────────── Scenario: _execute_tool_loop runs pruning pass and includes its tokens in accumulated counts Given I have an LLMAgent with tool loop test config for tool "file_read" and pruning enabled And I set a mock that makes one file_read tool call then returns "Pruned answer" And I set a mock pruning model that adds extra tokens When I run _execute_tool_loop with an initial messages list Then the loop result final_response content should be "Pruned answer" And the loop result accumulated_prompt should include pruning tokens # ── Tool dispatch error ─────────────────────────────────────────────────── Scenario: _execute_tool_loop captures tool dispatch ExecutionError as ToolMessage Given I have an LLMAgent with tool loop test config for tool "echo" And I set a mock that calls a nonexistent tool then returns "After error answer" When I run _execute_tool_loop with an initial messages list Then the loop result final_response content should be "After error answer" And the messages list should contain a ToolMessage with not available content # ── Exception path: partial token counts preserved ──────────────────────── Scenario: _execute_tool_loop wraps exception in _ToolLoopError with partial counts Given I have an LLMAgent with tool loop test config for tool "echo" And I set a mock that returns one tool round then raises a RuntimeError When I attempt to run _execute_tool_loop with an initial messages list Then a _ToolLoopError should be raised And the _ToolLoopError any_invocation_made should be true And the _ToolLoopError accumulated_prompt should be greater than 0 # ── Exception before any invocation ────────────────────────────────────── Scenario: _ToolLoopError has any_invocation_made false when error before first ainvoke Given I have an LLMAgent with tool loop test config for tool "echo" and bad tool_max_rounds When I attempt to run _execute_tool_loop with an initial messages list Then a _ToolLoopError should be raised And the _ToolLoopError any_invocation_made should be false And the _ToolLoopError cause should be a ConfigurationError # ── USD cost budget exhaustion (in-node gating, issue #76) ──────────────── Scenario: _execute_tool_loop raises budget_exhausted when pricing context is set and cost exceeds limit Given I have an LLMAgent with tool loop test config for tool "echo" and tool_max_rounds 5 And I set a mock that makes one tool call then returns a final answer "cost triggered" And the USD budget ContextVars are set for in-node gating When I run _execute_tool_loop with an initial messages list Then an ExecutionError with kind "cost" and reason "budget_exhausted" should be raised Scenario: _execute_tool_loop skips pruning pass when budget tripped mid-loop (issue #76) Given I have an LLMAgent with tool loop test config for tool "file_read" and pruning enabled And I set a mock that makes one file_read tool call then returns "Final text" And I set a mock pruning model that adds extra tokens And the USD budget ContextVars are set so first round exhausts limit When I run _execute_tool_loop with an initial messages list Then an ExecutionError with kind "cost" and reason "budget_exhausted" should be raised And the accumulated prompt should NOT include pruning tokens And the accumulated completion should NOT include pruning tokens