fix(codex): uniquify replayed call_ids that recur across turns
Stored tool call ids are minted per turn (terminal:0, terminal:1…), so the same id recurs on later turns of one session. The Responses adapter replayed each pair verbatim, putting the same call_id on the wire N times; strict validators (opencode Console, OpenAI) reject the request with 400 "Duplicate function_call_output for call_id" and every retry fails the same way (#102629, #111231 bug 1). Every occurrence past the first now gets a `_dup<n>` wire id and the matching tool output pops the id its function_call was given, in call order, so pairs stay intact and stored history is untouched. Re-applied onto the facade split (helpers moved into _replay_tool_call_items / _tool_output_items) from PR #102634.
This commit is contained in:
@@ -418,7 +418,38 @@ def _replay_message_items(
|
||||
return replayed
|
||||
|
||||
|
||||
def _replay_tool_call_items(msg: Dict[str, Any], *, start_index: int) -> List[Dict[str, Any]]:
|
||||
class _WireCallIds:
|
||||
"""Per-request wire ids for replayed tool pairs.
|
||||
|
||||
Stored call ids are minted per turn (``terminal:0``, ``terminal:1``…), so the same id recurs on
|
||||
later turns of one session. Replayed verbatim, strict Responses validators reject the whole
|
||||
request with 400 "Duplicate function_call_output for call_id" and every retry of the turn fails
|
||||
identically (#102629, #111231). Every occurrence past the first gets a ``_dup<n>`` wire id; the
|
||||
matching tool output pops the id its ``function_call`` was given, in call order, so pairs stay
|
||||
intact and the stored history is untouched.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._seen: Dict[str, int] = {}
|
||||
self._queue: Dict[str, List[str]] = {}
|
||||
|
||||
def for_call(self, call_id: str) -> str:
|
||||
base = _clamp_responses_call_id(call_id)
|
||||
n = self._seen.get(base, 0)
|
||||
self._seen[base] = n + 1
|
||||
wire = base if n == 0 else _clamp_responses_call_id(f"{base}_dup{n}")
|
||||
self._queue.setdefault(base, []).append(wire)
|
||||
return wire
|
||||
|
||||
def for_output(self, call_id: str) -> str:
|
||||
base = _clamp_responses_call_id(call_id)
|
||||
queue = self._queue.get(base)
|
||||
return queue.pop(0) if queue else base
|
||||
|
||||
|
||||
def _replay_tool_call_items(
|
||||
msg: Dict[str, Any], *, start_index: int, wire_ids: Optional[_WireCallIds] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Convert an assistant message's ``tool_calls`` into ``function_call`` items."""
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
for tc in _as_list(msg.get("tool_calls")):
|
||||
@@ -431,13 +462,14 @@ def _replay_tool_call_items(msg: Dict[str, Any], *, start_index: int) -> List[Di
|
||||
index = start_index + len(replayed)
|
||||
call_id = _resolve_call_id(tc.get("call_id"), tc.get("id"), fn_name, str(arguments), index, canonicalize_fc=True)
|
||||
replayed.append({
|
||||
"type": "function_call", "call_id": _clamp_responses_call_id(call_id),
|
||||
"type": "function_call",
|
||||
"call_id": wire_ids.for_call(call_id) if wire_ids else _clamp_responses_call_id(call_id),
|
||||
"name": _sanitize_replayed_fn_name(fn_name), "arguments": _coerce_arguments(arguments),
|
||||
})
|
||||
return replayed
|
||||
|
||||
|
||||
def _tool_output_items(msg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
def _tool_output_items(msg: Dict[str, Any], *, wire_ids: Optional[_WireCallIds] = None) -> List[Dict[str, Any]]:
|
||||
"""Convert a tool-role message to ``[function_call_output]`` (``[]`` if unpairable)."""
|
||||
raw_tool_call_id = msg.get("tool_call_id")
|
||||
call_id, tool_response_item_id = _split_responses_tool_id(raw_tool_call_id)
|
||||
@@ -453,7 +485,8 @@ def _tool_output_items(msg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
tool_content = msg.get("content")
|
||||
is_parts = isinstance(tool_content, list)
|
||||
output_value: Any = (_chat_content_to_responses_parts(tool_content) or "") if is_parts else str(tool_content or "")
|
||||
return [{"type": "function_call_output", "call_id": _clamp_responses_call_id(call_id), "output": output_value}]
|
||||
wire_call_id = wire_ids.for_output(call_id) if wire_ids else _clamp_responses_call_id(call_id)
|
||||
return [{"type": "function_call_output", "call_id": wire_call_id, "output": output_value}]
|
||||
|
||||
|
||||
def _chat_messages_to_responses_input(
|
||||
@@ -506,6 +539,7 @@ def _chat_messages_to_responses_input(
|
||||
# `function_call_output` wrapper) that no longer carries it (#90976).
|
||||
item_sources: List[Optional[Dict[str, Any]]] = []
|
||||
seen_item_ids: set = set()
|
||||
wire_ids = _WireCallIds()
|
||||
def emit(new_items: List[Dict[str, Any]], msg: Dict[str, Any]) -> None:
|
||||
items.extend(new_items)
|
||||
item_sources.extend([msg] * len(new_items))
|
||||
@@ -514,7 +548,7 @@ def _chat_messages_to_responses_input(
|
||||
continue
|
||||
role = msg.get("role")
|
||||
if role == "tool":
|
||||
emit(_tool_output_items(msg), msg)
|
||||
emit(_tool_output_items(msg, wire_ids=wire_ids), msg)
|
||||
continue
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
@@ -542,7 +576,7 @@ def _chat_messages_to_responses_input(
|
||||
fallback = content_parts or (content_text if content_text.strip() else "" if reasoning_items else None)
|
||||
if fallback is not None:
|
||||
emit([{"role": "assistant", "content": fallback}], msg)
|
||||
emit(_replay_tool_call_items(msg, start_index=len(items)), msg)
|
||||
emit(_replay_tool_call_items(msg, start_index=len(items), wire_ids=wire_ids), msg)
|
||||
# The server renders nothing placed before a compaction item, so pre-checkpoint history is
|
||||
# dead weight and plaintext asks / merged summaries silently vanish. Keep the newest checkpoint
|
||||
# first, retain pre-checkpoint USER and SUMMARY messages within a token budget, leave the tail.
|
||||
|
||||
@@ -464,6 +464,62 @@ def test_chat_messages_to_responses_input_canonicalizes_fc_only_pair():
|
||||
assert len(call["call_id"]) <= 64
|
||||
|
||||
|
||||
def test_chat_messages_to_responses_input_uniquifies_call_id_reused_across_turns():
|
||||
"""A stored call_id (e.g. a short-lived id like "terminal:0") can recur
|
||||
on a later, unrelated turn. Replayed verbatim, both function_call items
|
||||
and both function_call_output items would carry the same call_id, and
|
||||
the Responses API rejects the whole request with 400 "Duplicate
|
||||
function_call_output" (#102629). Each occurrence must get a unique
|
||||
call_id, still correctly paired with its own output."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"call_id": "terminal:0",
|
||||
"function": {"name": "terminal", "arguments": '{"command":"first"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "terminal:0",
|
||||
"content": "first result",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"call_id": "terminal:0",
|
||||
"function": {"name": "terminal", "arguments": '{"command":"second"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "terminal:0",
|
||||
"content": "second result",
|
||||
},
|
||||
]
|
||||
|
||||
items = _chat_messages_to_responses_input(messages)
|
||||
|
||||
calls = [i for i in items if i.get("type") == "function_call"]
|
||||
outputs = [i for i in items if i.get("type") == "function_call_output"]
|
||||
assert len(calls) == 2
|
||||
assert len(outputs) == 2
|
||||
|
||||
call_ids = [c["call_id"] for c in calls]
|
||||
assert len(set(call_ids)) == 2, "duplicate call_ids would 400 the whole request"
|
||||
|
||||
assert calls[0]["call_id"] == outputs[0]["call_id"]
|
||||
assert calls[1]["call_id"] == outputs[1]["call_id"]
|
||||
assert outputs[0]["output"] == "first result"
|
||||
assert outputs[1]["output"] == "second result"
|
||||
|
||||
|
||||
def test_preflight_codex_input_items_sanitizes_replayed_fn_name():
|
||||
"""The preflight choke-point also coerces invalid replayed names
|
||||
(covers callers that build input items without the chat converter)."""
|
||||
|
||||
Reference in New Issue
Block a user