diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index f20079b6d6..f482cd5b24 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -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`` 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. diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index b44b4be0d3..f4ff99bab0 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -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)."""