diff --git a/src/ai_client.py b/src/ai_client.py index 06a324f9..9e8e983f 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -671,10 +671,33 @@ async def _execute_tool_calls_concurrently( patch_callback: Optional[Callable[[str, str], Optional[str]]] = None ) -> list[tuple[str, str, str, str]]: # tool_name, call_id, output, original_name """ - - - Executes multiple tool calls concurrently using asyncio.gather. - Returns a list of (tool_name, call_id, output, original_name). + Executes tool calls concurrently using asyncio.gather. + + Functional Purpose: + Concurrently dispatches tool calls to _execute_single_tool_call_async. + + Parameters & Inputs: + calls (list[Any]): List of tool calls. + base_dir (str): Workspace path. + pre_tool_callback (Optional[Callable]): HITL/approval callback. + qa_callback (Optional[Callable]): QA verification callback. + r_idx (int): Round index. + provider (str): LLM provider. + patch_callback (Optional[Callable]): Patch verification callback. + + Returns: + list[tuple[str, str, str, str]]: List of (tool_name, call_id, output, original_name). + + Immediate-Mode DAG / Thread Context: + Called by: run_with_tool_loop + Calls: _execute_single_tool_call_async + + SSDL: + `[I:gather] => o-> [I:_execute_single_tool_call_async] -> [M] -> [T:tool_results]` + + Thread Boundaries: + Runs in the active asyncio event loop thread. + [C: tests/test_async_tools.py:test_execute_tool_calls_concurrently_exception_handling, tests/test_async_tools.py:test_execute_tool_calls_concurrently_timing] """ monitor = performance_monitor.get_monitor() @@ -729,6 +752,44 @@ def run_with_tool_loop( send_func: Optional[Callable[[int], NormalizedResponse]] = None, on_pre_dispatch: Optional[Callable[[int, list[dict[str, Any]]], list[dict[str, Any]]]] = None, ) -> str: + """ + Orchestrates the LLM conversation loop, executing tool calls and updating history. + + Functional Purpose: + Runs a multi-round tool loop (up to MAX_TOOL_ROUNDS + 2). It dispatches client requests, + executes any generated tool calls concurrently, updates history, and repeats until completion. + + Parameters & Inputs: + client (Any): Active client instance. + request (Union[OpenAICompatibleRequest, Callable]): Initial request or builder callback. + capabilities (Optional[VendorCapabilities]): Capabilities config. + pre_tool_callback (Optional[Callable]): Human-in-the-loop validation callback. + qa_callback (Optional[Callable]): QA verification callback. + stream_callback (Optional[Callable]): Streaming callback. + patch_callback (Optional[Callable]): Verification callback for code patches. + base_dir (str): Base workspace directory. + vendor_name (str): The vendor name. + history_lock (Optional[threading.Lock]): Lock for thread safety on history. + history (Optional[list[dict[str, Any]]]): Conversation history. + trim_func (Optional[Callable]): Trimming callback for history. + reasoning_extractor (Optional[Callable]): Callback to extract reasoning content. + send_func (Optional[Callable]): Dispatch sender callback. + on_pre_dispatch (Optional[Callable]): Callback to adjust tools. + + Returns: + str: The final text response returned by the LLM. + + Immediate-Mode DAG / Thread Context: + Called by: _send_anthropic, _send_deepseek, _send_minimax, _send_qwen, _send_llama, + _send_grok, _send_llama_native + Calls: dispatch_send, _execute_tool_calls_concurrently + + SSDL: + `o-> [I:dispatch_send] -> [B:tool_calls?] => [I:_execute_tool_calls_concurrently] -> [T:response_text]` + + Thread Boundaries: + Runs synchronously in caller thread; synchronizes history modifications using history_lock. + """ def _default_send(_round_idx: int) -> NormalizedResponse: from src.openai_compatible import send_openai_compatible as _send_oc assert capabilities is not None, "capabilities required when send_func is not provided" @@ -794,6 +855,39 @@ async def _execute_single_tool_call_async( tier: str | None = None, patch_callback: Optional[Callable[[str, str], Optional[str]]] = None ) -> tuple[str, str, str, str]: + """ + Executes a single tool call asynchronously, checking the approval clutch. + + Functional Purpose: + Executes a tool call (either PowerShell script or MCP tool) based on tool approval clutch settings. + Uses pre_tool_callback for human approval when required. + + Parameters & Inputs: + name (str): The name of the tool to execute. + args (dict[str, Any]): Arguments passed to the tool. + call_id (str): Unique call identifier. + base_dir (str): Workspace root directory. + pre_tool_callback (Optional[Callable]): Hook for HITL validation. + qa_callback (Optional[Callable]): QA verification callback. + r_idx (int): Current tool loop round index. + tier (str | None): Active MMA orchestration tier. + patch_callback (Optional[Callable]): Verification callback for code patches. + + Returns: + tuple[str, str, str, str]: A tuple containing (tool_name, call_id, output, original_name). + + Immediate-Mode DAG / Thread Context: + Called by: _execute_tool_calls_concurrently + Calls: set_current_tier, events.emit, _append_comms, _run_script, + pre_tool_callback, mcp_client.async_dispatch + + SSDL: + `[I:CheckClutch] -> [B:Approved?] -> [I:run_powershell] -> [T:output]` + + Thread Boundaries: + Runs in the active asyncio event loop thread; offloads blocking synchronous calls + (like pre_tool_callback and _run_script) to separate worker threads using asyncio.to_thread. + """ set_current_tier(tier) out = "" tool_executed = False diff --git a/tests/test_ai_client_tool_loop.py b/tests/test_ai_client_tool_loop.py index c777862f..eb576dc6 100644 --- a/tests/test_ai_client_tool_loop.py +++ b/tests/test_ai_client_tool_loop.py @@ -16,6 +16,7 @@ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch import pytest +from src.result_types import Result from src.openai_compatible import NormalizedResponse, OpenAICompatibleRequest from src.ai_client import run_with_tool_loop from src.vendor_capabilities import VendorCapabilities @@ -24,13 +25,13 @@ from src.vendor_capabilities import VendorCapabilities def caps() -> VendorCapabilities: return VendorCapabilities(vendor="test", model="test-model", tool_calling=True, context_window=8192) -def _make_normalized_response(text: str = "ok", tool_calls: list[dict[str, Any]] | None = None) -> NormalizedResponse: - return NormalizedResponse( +def _make_normalized_response(text: str = "ok", tool_calls: list[dict[str, Any]] | None = None) -> Result[NormalizedResponse]: + return Result(data=NormalizedResponse( text=text, tool_calls=tool_calls or [], usage_input_tokens=10, usage_output_tokens=5, usage_cache_read_tokens=0, usage_cache_creation_tokens=0, raw_response=None, - ) + )) def test_run_with_tool_loop_no_tool_calls_returns_immediately(caps: VendorCapabilities) -> None: client = MagicMock()