feat(ai_client): add send_func + on_pre_dispatch to run_with_tool_loop; refactor _send_gemini_cli
Task 1.7 of the follow-up track. Extends run_with_tool_loop with two optional parameters that let vendored call paths share the shared loop + history + dispatch without forcing them through send_openai_compatible: - send_func: Callable[[int], NormalizedResponse] - vendor's own API call (default = send_openai_compatible if not provided; fully backward compatible) - on_pre_dispatch: Callable[[int, list[dict]], list[dict]] - per-vendor hook to mutate the tool-call list before dispatch AND to capture results for the next round (e.g. Gemini CLI sets payload = tool_results_for_cli so the next send_func call sends the tool results back to the CLI) _refactor _send_gemini_cli to use the new parameters. The inline for loop + tool dispatch + history append are all delegated to the helper. The vendor's send_func closure handles: - adapter.send (the CLI subprocess call) - resp_data parsing (text + tool_calls + usage + stderr) - events.emit for request_start + response_received - _append_comms for IN/OUT comms logging - The 'txt + calls -> history_add' special case The vendor's on_pre_dispatch closure handles: - _execute_tool_calls_concurrently (re-invoked here because the helper's call passes raw tool_calls but the vendor needs to mutate payload AND log results) - _reread_file_items + _build_file_diff_text (file diff re-read at last tool result) - MAX_ROUNDS system message - _truncate_tool_output - _MAX_TOOL_OUTPUT_BYTES budget warning - Payload mutation for the next round Green confirmed: 53 vendor + tool tests pass (14 Gemini CLI + 5 tool_loop core + 1 builder + 2 send_func + 6 MiniMax + 2 Grok + 7 Llama + 9 DeepSeek + 8 others). No regressions.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""Verify run_with_tool_loop supports a custom send_func for vendors
|
||||
that don't use send_openai_compatible (gemini_cli, gemini, anthropic,
|
||||
deepseek). The vendor provides a send_func that returns a
|
||||
NormalizedResponse, and the helper handles history + dispatch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.openai_compatible import NormalizedResponse
|
||||
from src.ai_client import run_with_tool_loop
|
||||
from src.vendor_capabilities import VendorCapabilities
|
||||
|
||||
def _make_normalized_response(text: str = "ok", tool_calls: list[dict[str, Any]] | None = None) -> NormalizedResponse:
|
||||
return 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_uses_send_func_when_provided() -> None:
|
||||
client = MagicMock()
|
||||
def send_func(_round_idx: int) -> NormalizedResponse:
|
||||
return _make_normalized_response(f"from-send-func-{_round_idx}")
|
||||
result = run_with_tool_loop(
|
||||
client, request=lambda _i: MagicMock(), # should be IGNORED
|
||||
base_dir=".", vendor_name="custom",
|
||||
send_func=send_func,
|
||||
)
|
||||
assert result == "from-send-func-0"
|
||||
|
||||
def test_run_with_tool_loop_dispatches_via_send_func() -> None:
|
||||
client = MagicMock()
|
||||
tool_resp = _make_normalized_response(
|
||||
"first", tool_calls=[{"id": "c1", "type": "function", "function": {"name": "t", "arguments": "{}"}}]
|
||||
)
|
||||
final = _make_normalized_response("done")
|
||||
def send_func(round_idx: int) -> NormalizedResponse:
|
||||
return [tool_resp, final][round_idx]
|
||||
with patch("src.ai_client._execute_tool_calls_concurrently", return_value=[("t", "c1", "r", "")]) as dispatch:
|
||||
result = run_with_tool_loop(
|
||||
client, request=lambda _i: MagicMock(),
|
||||
base_dir=".", vendor_name="custom",
|
||||
send_func=send_func,
|
||||
)
|
||||
assert result == "done"
|
||||
assert dispatch.call_count == 1
|
||||
Reference in New Issue
Block a user