175 lines
7.2 KiB
Python
175 lines
7.2 KiB
Python
"""Provider-minted parallel tool-call ids are normalized once, at mint time (#130363)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from agent.chat_completion_helpers import _assistant_tool_call_dict
|
|
from run_agent import AIAgent
|
|
|
|
|
|
def _tool_call(raw_id, n, **extra):
|
|
return SimpleNamespace(
|
|
id=raw_id, type="function",
|
|
function=SimpleNamespace(name="read_file", arguments=f'{{"path": "{n}"}}'), **extra,
|
|
)
|
|
|
|
|
|
def _agent():
|
|
# Real AIAgent methods (duplicate repair, assistant serialization), no init side effects.
|
|
agent = AIAgent.__new__(AIAgent)
|
|
agent.provider, agent.model, agent.session_id, agent.tools = "nous", "m", "s1", []
|
|
agent.valid_tool_names = {"read_file"}
|
|
agent.log_prefix = ""
|
|
agent._invalid_tool_retries = agent._invalid_json_retries = 0
|
|
return agent
|
|
|
|
|
|
def _mint(tool_calls):
|
|
"""Run the real mint path and return the rows that would be stored and replayed."""
|
|
from agent.turn_tool_validation import validate_tool_calls
|
|
|
|
agent = _agent()
|
|
verdict = validate_tool_calls(
|
|
agent, SimpleNamespace(content="", tool_calls=tool_calls), "tool_calls",
|
|
messages=[], conversation_history=[], api_call_count=1, effective_task_id="t1",
|
|
)
|
|
assert verdict.action == "ok"
|
|
stored = [_assistant_tool_call_dict(agent, tc, i) for i, tc in enumerate(tool_calls)]
|
|
# Tool results take their id from the same object, so call and result agree.
|
|
assert [row["id"] for row in stored] == [AIAgent._get_tool_call_id_static(tc) for tc in tool_calls]
|
|
return stored
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_metrics(monkeypatch):
|
|
import hermes_cli.observability.shared_metrics_model as metrics
|
|
|
|
monkeypatch.setattr(metrics, "record_tool_call_quality", lambda *a, **k: None)
|
|
|
|
|
|
def test_parallel_provider_ids_are_rewritten_stably():
|
|
raw = ["chatcmpl-tool-aaa", "chatcmpl-tool-bbb"]
|
|
ids = [row["id"] for row in _mint([_tool_call(i, n) for n, i in enumerate(raw)])]
|
|
|
|
assert all(i.startswith("call_") for i in ids)
|
|
assert len(set(ids)) == 2
|
|
# Byte-stable across a re-mint of the same turn.
|
|
assert [row["id"] for row in _mint([_tool_call(i, n) for n, i in enumerate(raw)])] == ids
|
|
|
|
|
|
@pytest.mark.parametrize("raw", [
|
|
["chatcmpl-tool-aaa"],
|
|
["chatcmpl-tool-aaa", "call_1"],
|
|
["call_0", "call_1"],
|
|
])
|
|
def test_single_mixed_and_ordinary_batches_are_untouched(raw):
|
|
assert [row["id"] for row in _mint([_tool_call(i, n) for n, i in enumerate(raw)])] == raw
|
|
|
|
|
|
@pytest.mark.parametrize("keys", [("a", "b"), ("a", "a")], ids=["distinct", "duplicate"])
|
|
def test_response_item_half_survives_when_only_id_is_composite(keys):
|
|
calls = [
|
|
_tool_call(f"chatcmpl-tool-{k}|fc_original_{n}", n, call_id=f"chatcmpl-tool-{k}")
|
|
for n, k in enumerate(keys)
|
|
]
|
|
stored = _mint(calls)
|
|
|
|
assert [row["response_item_id"] for row in stored] == ["fc_original_0", "fc_original_1"]
|
|
assert all(row["id"].startswith("call_") for row in stored)
|
|
assert len({row["id"] for row in stored}) == 2
|
|
|
|
|
|
def _padded(calls):
|
|
calls[0].id = " chatcmpl-tool-a"
|
|
|
|
|
|
def _blank_call_id(calls):
|
|
calls[0].call_id = " "
|
|
|
|
|
|
def _mixed_until_dedup(calls):
|
|
# Mixed at validation; dedup then drops call_c, leaving an all-provider batch.
|
|
calls.append(_tool_call("call_c", "a"))
|
|
|
|
|
|
@pytest.mark.parametrize("shape", [None, _padded, _blank_call_id, _mixed_until_dedup],
|
|
ids=["plain", "padded", "blank_call_id", "mixed_until_dedup"])
|
|
def test_final_staged_batch_never_reaches_wire_all_provider_prefixed(shape, monkeypatch):
|
|
import agent.turn_tool_round as round_module
|
|
from agent.transports.chat_completions import ChatCompletionsTransport
|
|
|
|
class _Staged(Exception):
|
|
pass
|
|
|
|
staged = []
|
|
|
|
def _capture(agent, *, assistant_message, **kwargs):
|
|
staged.extend(assistant_message.tool_calls)
|
|
raise _Staged
|
|
|
|
monkeypatch.setattr(round_module, "stage_tool_call_message", _capture)
|
|
agent = _agent()
|
|
agent.quiet_mode, agent.verbose_logging = True, False
|
|
calls = [_tool_call("chatcmpl-tool-a", "a"), _tool_call("chatcmpl-tool-b", "b")]
|
|
if shape:
|
|
shape(calls)
|
|
with pytest.raises(_Staged):
|
|
round_module.run_tool_round(
|
|
agent, assistant_message=SimpleNamespace(content="", tool_calls=calls), finish_reason="tool_calls",
|
|
messages=[], conversation_history=[], api_call_count=1, effective_task_id="t", user_message="read",
|
|
system_message="", active_system_prompt="", compression_attempts=0, max_compression_attempts=1,
|
|
final_response=None, failed=False, _turn_exit_reason=None, truncated_tool_call_retries=0,
|
|
current_turn_user_idx=0,
|
|
)
|
|
rows = [_assistant_tool_call_dict(agent, tc, i) for i, tc in enumerate(staged)]
|
|
wire = ChatCompletionsTransport().convert_messages([
|
|
{"role": "assistant", "content": "", "tool_calls": rows},
|
|
*[{"role": "tool", "tool_call_id": AIAgent._get_tool_call_id_static(tc), "content": "ok"} for tc in staged],
|
|
])
|
|
ids = [c["id"] for c in wire[0]["tool_calls"]]
|
|
|
|
assert len(ids) == 2
|
|
assert ids == [m["tool_call_id"] for m in wire[1:]]
|
|
assert not any(i.startswith("chatcmpl-tool-") for i in ids), ids
|
|
|
|
|
|
@pytest.mark.parametrize("api", ["responses", "chat"])
|
|
def test_real_transport_tool_calls_normalize_without_crashing(api):
|
|
from agent.transports.chat_completions import ChatCompletionsTransport
|
|
from agent.transports.codex import ResponsesApiTransport
|
|
from agent.turn_tool_validation import validate_tool_calls
|
|
|
|
if api == "responses":
|
|
# Production ToolCall exposes call_id as a read-only view of provider_data.
|
|
response = SimpleNamespace(status="completed", output=[
|
|
SimpleNamespace(type="function_call", id=f"fc_{n}", call_id=f"chatcmpl-tool-{n}",
|
|
name="read_file", arguments="{}") for n in range(2)])
|
|
message = ResponsesApiTransport().normalize_response(response)
|
|
else:
|
|
calls = [SimpleNamespace(id=f"chatcmpl-tool-{n}", function=SimpleNamespace(name="read_file", arguments="{}"))
|
|
for n in range(2)]
|
|
message = ChatCompletionsTransport().normalize_response(SimpleNamespace(choices=[
|
|
SimpleNamespace(message=SimpleNamespace(content="", tool_calls=calls), finish_reason="tool_calls")]))
|
|
agent = _agent()
|
|
verdict = validate_tool_calls(agent, message, "tool_calls", messages=[], conversation_history=[],
|
|
api_call_count=1, effective_task_id="t")
|
|
assert verdict.action == "ok"
|
|
rows = [_assistant_tool_call_dict(agent, tc, i) for i, tc in enumerate(message.tool_calls)]
|
|
|
|
assert [r["id"] for r in rows] == [AIAgent._get_tool_call_id_static(tc) for tc in message.tool_calls]
|
|
assert all(r["id"].startswith("call_") for r in rows) and len({r["id"] for r in rows}) == 2
|
|
if api == "responses":
|
|
assert [r["response_item_id"] for r in rows] == ["fc_0", "fc_1"]
|
|
assert [tc.call_id for tc in message.tool_calls] == [r["id"] for r in rows]
|
|
|
|
|
|
def test_unencodable_provider_ids_do_not_crash_and_stay_distinct():
|
|
raw = ["chatcmpl-tool-\ud800", "chatcmpl-tool-?"]
|
|
ids = [row["id"] for row in _mint([_tool_call(i, n) for n, i in enumerate(raw)])]
|
|
|
|
assert all(i.startswith("call_") for i in ids)
|
|
assert len(set(ids)) == 2
|