mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
"""Keep tool-call ids unique within a conversation.
|
|
|
|
Some providers return per-turn tool-call ids (``exec_command:0``,
|
|
``exec_command:1``, ...) whose counter restarts on every turn. Once the same
|
|
id appears twice in one conversation, the request payload has two assistant
|
|
tool calls sharing an id and strict providers reject the whole turn, which
|
|
permanently kills the agent because the malformed history is replayed on
|
|
every retry. Rewriting duplicates to fresh unique ids keeps the history
|
|
valid for any provider.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict, deque
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from openai.types.responses import ResponseFunctionToolCall
|
|
|
|
|
|
def new_call_id() -> str:
|
|
return f"call_{uuid4().hex}"
|
|
|
|
|
|
def collect_call_ids(items: list[Any]) -> set[str]:
|
|
used: set[str] = set()
|
|
for item in items:
|
|
if isinstance(item, dict):
|
|
call_id = item.get("call_id")
|
|
if isinstance(call_id, str):
|
|
used.add(call_id)
|
|
elif isinstance(item, ResponseFunctionToolCall):
|
|
used.add(item.call_id)
|
|
return used
|
|
|
|
|
|
def dedupe_history_call_ids(items: list[Any]) -> tuple[list[Any], bool]:
|
|
"""Rewrite duplicate call ids in a conversation history.
|
|
|
|
Outputs are paired with their call by order, so parallel calls that share
|
|
an id keep answering the right call after the rewrite.
|
|
"""
|
|
used: set[str] = set()
|
|
pending: dict[str, deque[str]] = defaultdict(deque)
|
|
rebuilt: list[Any] = []
|
|
changed = False
|
|
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
rebuilt.append(item)
|
|
continue
|
|
call_id = item.get("call_id")
|
|
if not isinstance(call_id, str):
|
|
rebuilt.append(item)
|
|
continue
|
|
|
|
kind = item.get("type")
|
|
if kind == "function_call":
|
|
effective = call_id
|
|
if call_id in used:
|
|
effective = new_call_id()
|
|
item = {**item, "call_id": effective} # noqa: PLW2901
|
|
changed = True
|
|
used.add(effective)
|
|
pending[call_id].append(effective)
|
|
elif kind == "function_call_output":
|
|
queue = pending.get(call_id)
|
|
if queue:
|
|
effective = queue.popleft()
|
|
if effective != call_id:
|
|
item = {**item, "call_id": effective} # noqa: PLW2901
|
|
changed = True
|
|
rebuilt.append(item)
|
|
|
|
return rebuilt, changed
|
|
|
|
|
|
def dedupe_input(model_input: str | list[Any]) -> str | list[Any]:
|
|
if isinstance(model_input, str):
|
|
return model_input
|
|
rebuilt, changed = dedupe_history_call_ids(model_input)
|
|
return rebuilt if changed else model_input
|
|
|
|
|
|
class TurnCallIdRewriter:
|
|
"""Rewrite a single turn's tool-call ids that collide with the history.
|
|
|
|
A turn's items surface several times (streamed item events, then the
|
|
completed response), so the same original id must always map to the same
|
|
replacement within the turn.
|
|
"""
|
|
|
|
def __init__(self, model_input: str | list[Any]) -> None:
|
|
self._used = set() if isinstance(model_input, str) else collect_call_ids(model_input)
|
|
self._remap: dict[str, str] = {}
|
|
self._settled: set[str] = set()
|
|
|
|
def rewrite_item(self, item: Any) -> Any:
|
|
if not isinstance(item, ResponseFunctionToolCall):
|
|
return item
|
|
original = item.call_id
|
|
if original in self._settled:
|
|
return item
|
|
replacement = self._remap.get(original)
|
|
if replacement is None:
|
|
if original not in self._used:
|
|
self._used.add(original)
|
|
self._settled.add(original)
|
|
return item
|
|
replacement = new_call_id()
|
|
self._remap[original] = replacement
|
|
self._used.add(replacement)
|
|
self._settled.add(replacement)
|
|
return item.model_copy(update={"call_id": replacement})
|
|
|
|
def rewrite_items(self, items: list[Any]) -> list[Any]:
|
|
return [self.rewrite_item(item) for item in items]
|