strix/tests/test_tool_call_ids.py

382 lines
14 KiB
Python

"""Tests for tool-call id presence and uniqueness.
Providers that number tool calls per turn (``exec_command:0``, ``:1``, ...)
restart the counter on every turn, so the same id eventually appears twice in
one conversation. Others hand back a tool call with no id at all, leaving the
paired tool message with a blank ``tool_call_id``. Strict providers reject the
whole request either way, and because the history is replayed on every retry
the agent can never recover. Gateways that validate ids the way those
providers do prove both failures and their fixes.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, Any
import pytest
from agents import Agent, Runner, function_tool
from agents.models.interface import Model, ModelProvider
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.run import RunConfig
from openai import AsyncOpenAI
from openai.types.responses import ResponseFunctionToolCall
from strix.config.models import _NonStreamingModel, _TurnGuardModel
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_history_call_ids
if TYPE_CHECKING:
from collections.abc import Iterator
def _tool_call_completion(call_id: str, n: int = 1) -> dict[str, Any]:
return {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {"name": "do_thing", "arguments": json.dumps({"n": n})},
}
],
},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
}
def _text_completion(text: str) -> dict[str, Any]:
return {
"id": "chatcmpl-2",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
_REQUESTS: list[list[dict[str, Any]]] = []
def _assistant_call_ids(messages: list[dict[str, Any]]) -> list[str]:
return [str(call.get("id")) for message in messages for call in message.get("tool_calls") or []]
def _tool_results(messages: list[dict[str, Any]]) -> list[str]:
return [str(m.get("content")) for m in messages if m.get("role") == "tool"]
class _StrictHandler(BaseHTTPRequestHandler):
"""Gateway that rejects a history reusing a tool-call id, like strict providers do."""
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
messages = body.get("messages", [])
_REQUESTS.append(messages)
call_ids = _assistant_call_ids(messages)
if len(call_ids) != len(set(call_ids)):
self._respond(
400,
{
"error": {
"message": (
"tool messages need a resolvable tool name: carry `tool`/`name`, "
"or match a preceding assistant tool_call by order"
)
}
},
)
return
turn = len(_REQUESTS)
if turn <= 2:
# The provider restarts its per-turn counter, so both turns say ":0".
self._respond(200, _tool_call_completion("exec_command:0", n=turn))
else:
self._respond(200, _text_completion("all done"))
def _respond(self, status: int, payload: dict[str, Any]) -> None:
encoded = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
class _BlankIdHandler(BaseHTTPRequestHandler):
"""Gateway that hands out an id-less tool call and rejects blank ids, like GLM does."""
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
messages = body.get("messages", [])
_REQUESTS.append(messages)
for index, message in enumerate(messages):
if message.get("role") == "tool" and not message.get("tool_call_id"):
self._respond(
400,
{
"error": {
"message": (
f"messages[{index}]: tool messages must include "
"a non-empty string tool_call_id"
),
"code": 400,
}
},
)
return
if len(_REQUESTS) == 1:
self._respond(200, _tool_call_completion(""))
else:
self._respond(200, _text_completion("all done"))
def _respond(self, status: int, payload: dict[str, Any]) -> None:
encoded = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _serve(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
_REQUESTS.clear()
server = HTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
finally:
server.shutdown()
server.server_close()
@pytest.fixture
def strict_gateway() -> Iterator[str]:
yield from _serve(_StrictHandler)
@pytest.fixture
def blank_id_gateway() -> Iterator[str]:
yield from _serve(_BlankIdHandler)
def _model(base_url: str) -> Model:
# The gateway answers plain JSON, so the run loop's streamed turns are
# served non-streamed; the ids on the wire are the same either way.
client = AsyncOpenAI(api_key="tok", base_url=base_url, max_retries=0)
return _NonStreamingModel(OpenAIChatCompletionsModel(model="gw-model", openai_client=client))
async def _run_agent(base_url: str, *, wrap: bool) -> Any:
@function_tool
def do_thing(n: int) -> str:
return f"did {n}"
class _Provider(ModelProvider):
def get_model(self, model_name: str | None) -> Model: # noqa: ARG002
model = _model(base_url)
return _TurnGuardModel(model) if wrap else model
agent = Agent(name="t", instructions="use the tool", tools=[do_thing], model="gw-model")
result = Runner.run_streamed(
agent, input="please", run_config=RunConfig(model_provider=_Provider())
)
async for _ in result.stream_events():
pass
return result
@pytest.mark.asyncio
async def test_recycled_call_id_erases_a_turn_without_the_wrapper(strict_gateway: str) -> None:
# Repro: two turns run a tool and both are labelled ``exec_command:0``, so
# the colliding call and its result are dropped as duplicates. The agent
# ends the run having silently lost a turn of its own work — and a provider
# that does not drop them instead rejects the malformed history outright.
result = await _run_agent(strict_gateway, wrap=False)
assert result.final_output == "all done"
assert _assistant_call_ids(_REQUESTS[-1]) == ["exec_command:0"]
assert _tool_results(_REQUESTS[-1]) == ["did 2"]
@pytest.mark.asyncio
async def test_recycled_call_id_is_rewritten_so_no_turn_is_lost(strict_gateway: str) -> None:
result = await _run_agent(strict_gateway, wrap=True)
assert result.final_output == "all done"
call_ids = _assistant_call_ids(_REQUESTS[-1])
assert len(call_ids) == len(set(call_ids)) == 2
assert call_ids[0] == "exec_command:0"
assert call_ids[1].startswith("call_")
assert _tool_results(_REQUESTS[-1]) == ["did 1", "did 2"]
def test_history_dedupe_keeps_outputs_paired_with_their_call() -> None:
items = [
{"type": "function_call", "call_id": "exec_command:0", "name": "a", "arguments": "{}"},
{"type": "function_call_output", "call_id": "exec_command:0", "output": "first"},
{"type": "function_call", "call_id": "exec_command:0", "name": "b", "arguments": "{}"},
{"type": "function_call_output", "call_id": "exec_command:0", "output": "second"},
]
rebuilt, changed = dedupe_history_call_ids(items)
assert changed
ids = [item["call_id"] for item in rebuilt]
assert ids[0] == ids[1] == "exec_command:0"
assert ids[2] == ids[3] != "exec_command:0"
assert rebuilt[3]["output"] == "second"
def test_history_dedupe_pairs_parallel_calls_by_order() -> None:
items = [
{"type": "function_call", "call_id": "dup", "name": "a", "arguments": "{}"},
{"type": "function_call", "call_id": "dup", "name": "b", "arguments": "{}"},
{"type": "function_call_output", "call_id": "dup", "output": "for-a"},
{"type": "function_call_output", "call_id": "dup", "output": "for-b"},
]
rebuilt, changed = dedupe_history_call_ids(items)
assert changed
assert rebuilt[0]["call_id"] == rebuilt[2]["call_id"] == "dup"
assert rebuilt[1]["call_id"] == rebuilt[3]["call_id"]
assert rebuilt[1]["call_id"] != "dup"
@pytest.mark.asyncio
async def test_blank_call_id_is_rejected_by_the_provider_without_the_wrapper(
blank_id_gateway: str,
) -> None:
# Repro: the model answers with a tool call carrying no id, so the paired
# tool result goes back as a ``tool`` message with a blank ``tool_call_id``
# and the provider rejects the whole replayed history with a 400.
with pytest.raises(Exception, match="non-empty string tool_call_id"):
await _run_agent(blank_id_gateway, wrap=False)
@pytest.mark.asyncio
async def test_blank_call_id_is_filled_in_so_the_history_stays_valid(
blank_id_gateway: str,
) -> None:
result = await _run_agent(blank_id_gateway, wrap=True)
assert result.final_output == "all done"
call_ids = _assistant_call_ids(_REQUESTS[-1])
assert len(call_ids) == 1
assert call_ids[0].startswith("call_")
assert _tool_results(_REQUESTS[-1]) == ["did 1"]
def test_history_dedupe_fills_in_blank_ids_and_keeps_outputs_paired() -> None:
items = [
{"type": "function_call", "call_id": "", "name": "a", "arguments": "{}"},
{"type": "function_call", "call_id": "", "name": "b", "arguments": "{}"},
{"type": "function_call_output", "call_id": "", "output": "for-a"},
{"type": "function_call_output", "call_id": "", "output": "for-b"},
]
rebuilt, changed = dedupe_history_call_ids(items)
assert changed
ids = [item["call_id"] for item in rebuilt]
assert all(call_id.startswith("call_") for call_id in ids)
assert ids[0] == ids[2]
assert ids[1] == ids[3]
assert ids[0] != ids[1]
def test_history_dedupe_fills_in_a_missing_call_id_key() -> None:
items = [
{"type": "function_call", "name": "a", "arguments": "{}"},
{"type": "function_call_output", "output": "x"},
]
rebuilt, changed = dedupe_history_call_ids(items)
assert changed
assert rebuilt[0]["call_id"] == rebuilt[1]["call_id"]
assert rebuilt[0]["call_id"].startswith("call_")
def test_history_dedupe_leaves_unique_ids_alone() -> None:
items = [
{"type": "function_call", "call_id": "call_a", "name": "a", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_a", "output": "x"},
{"type": "function_call", "call_id": "call_b", "name": "b", "arguments": "{}"},
]
rebuilt, changed = dedupe_history_call_ids(items)
assert not changed
assert rebuilt == items
def test_turn_rewriter_is_stable_across_repeated_sightings() -> None:
history = [{"type": "function_call", "call_id": "exec_command:0", "name": "a"}]
rewriter = TurnCallIdRewriter(history)
call = ResponseFunctionToolCall(
call_id="exec_command:0", name="a", arguments="{}", type="function_call"
)
first = rewriter.rewrite_item(call)
second = rewriter.rewrite_item(first)
assert first.call_id != "exec_command:0"
assert second.call_id == first.call_id
def test_turn_rewriter_fills_in_a_blank_id_stably() -> None:
rewriter = TurnCallIdRewriter([])
call = ResponseFunctionToolCall(
id="fc_1", call_id="", name="a", arguments="{}", type="function_call"
)
first = rewriter.rewrite_item(call)
second = rewriter.rewrite_item(call)
assert first.call_id.startswith("call_")
assert second.call_id == first.call_id
assert rewriter.rewrite_item(first).call_id == first.call_id
def test_turn_rewriter_gives_parallel_blank_calls_distinct_ids() -> None:
rewriter = TurnCallIdRewriter([])
a = ResponseFunctionToolCall(
id="fc_1", call_id="", name="a", arguments="{}", type="function_call"
)
b = ResponseFunctionToolCall(
id="fc_2", call_id="", name="b", arguments="{}", type="function_call"
)
assert rewriter.rewrite_item(a).call_id != rewriter.rewrite_item(b).call_id