mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 07:20:44 +08:00
- Rename platform/ to svc/ to avoid shadowing stdlib platform. - Replace from oclaw.* with from svc/runtime/interfaces; update -m CLI paths. - tests/conftest: prepend repo root to sys.path (no parent-folder package name). - CI: paths and offline_eval script under repo root. - Ops scripts: PYTHONPATH must be repo root for python -m runtime.* (fixes gateway/WhatsApp sidecar startup). - Fix default oclaw.json path in tabular/file attachment limits; stabilize attachment test config. Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
"""OpenAI-compatible tool_call_id / tool result id rewriting.
|
|
|
|
Inspired by Oclaw (MIT) `src/agents/tool-call-id.ts`: sanitize ids to strict
|
|
alphanumeric form and keep assistant.tool_calls[].id paired with role=tool
|
|
tool_call_id in encounter order.
|
|
|
|
See OCLAW_MIT_LICENSE.txt in this package.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
from collections import deque
|
|
from typing import Any, Literal
|
|
|
|
ToolCallIdMode = Literal["strict", "strict9"]
|
|
|
|
# OpenAI Chat Completions: tool_calls[].id commonly capped ~40 chars on some gateways.
|
|
DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN = 40
|
|
|
|
|
|
def _short_hash(text: str, length: int = 8) -> str:
|
|
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:length]
|
|
|
|
|
|
def sanitize_tool_call_id(raw: str | None, *, mode: ToolCallIdMode = "strict") -> str:
|
|
"""Strip to [a-zA-Z0-9] only; empty input gets a deterministic placeholder."""
|
|
if not raw or not isinstance(raw, str):
|
|
return "sanitizedtoolid" if mode == "strict" else "defaultid"
|
|
s = raw.strip()
|
|
if not s:
|
|
return "sanitizedtoolid" if mode == "strict" else "defaultid"
|
|
alnum = re.sub(r"[^a-zA-Z0-9]", "", s)
|
|
if mode == "strict9":
|
|
if len(alnum) >= 9:
|
|
return alnum[:9]
|
|
if alnum:
|
|
return _short_hash(alnum, 9)
|
|
return _short_hash("sanitized", 9)
|
|
return alnum if alnum else "sanitizedtoolid"
|
|
|
|
|
|
def _make_unique(seed: str, used: set[str], *, max_len: int) -> str:
|
|
base = sanitize_tool_call_id(seed, mode="strict")[:max_len]
|
|
if base and base not in used:
|
|
used.add(base)
|
|
return base
|
|
h = _short_hash(seed, 8)
|
|
for i in range(1000):
|
|
cand = f"{base[: max(0, max_len - len(h) - 1)]}{h}"[:max_len]
|
|
if i > 0:
|
|
suf = f"x{i}"
|
|
cand = f"{base[: max(0, max_len - len(suf) - len(h))]}{suf}{h}"[:max_len]
|
|
if cand not in used:
|
|
used.add(cand)
|
|
return cand
|
|
cand = _short_hash(f"{seed}:{id(used)}", max_len)[:max_len]
|
|
used.add(cand)
|
|
return cand
|
|
|
|
|
|
def rewrite_openai_chat_messages_tool_ids(
|
|
messages: list[dict[str, Any]],
|
|
*,
|
|
max_len: int = DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN,
|
|
) -> list[dict[str, Any]]:
|
|
"""Rewrite assistant tool_calls ids and matching tool tool_call_id/call_id.
|
|
|
|
Uses a per-raw-id queue (oclaw-style) so repeated raw ids map to distinct
|
|
new ids in order, and tool results consume the queue FIFO.
|
|
"""
|
|
if not messages:
|
|
return messages
|
|
|
|
pending: dict[str, deque[str]] = {}
|
|
used: set[str] = set()
|
|
|
|
def _alloc_for_raw(raw: str) -> str:
|
|
raw = str(raw or "").strip()
|
|
seed = raw if raw else "empty"
|
|
return _make_unique(seed, used, max_len=max_len)
|
|
|
|
out: list[dict[str, Any]] = []
|
|
for m in messages:
|
|
if not isinstance(m, dict):
|
|
continue
|
|
role = str(m.get("role") or "")
|
|
if role == "assistant" and m.get("tool_calls"):
|
|
mm = dict(m)
|
|
tcs = mm.get("tool_calls")
|
|
if isinstance(tcs, list):
|
|
new_tcs: list[dict[str, Any]] = []
|
|
for tc in tcs:
|
|
if not isinstance(tc, dict):
|
|
new_tcs.append(tc)
|
|
continue
|
|
raw_id = str(tc.get("id") or "").strip()
|
|
new_id = _alloc_for_raw(raw_id)
|
|
q = pending.setdefault(raw_id, deque())
|
|
q.append(new_id)
|
|
new_tcs.append({**tc, "id": new_id})
|
|
mm["tool_calls"] = new_tcs
|
|
out.append(mm)
|
|
continue
|
|
if role == "tool":
|
|
mm = dict(m)
|
|
raw_ref = (
|
|
str(mm.get("tool_call_id") or mm.get("call_id") or "").strip()
|
|
)
|
|
new_ref: str | None = None
|
|
if raw_ref and raw_ref in pending and pending[raw_ref]:
|
|
new_ref = pending[raw_ref].popleft()
|
|
elif raw_ref:
|
|
# Orphan or ordering mismatch: allocate fresh id (best-effort pairing)
|
|
new_ref = _alloc_for_raw(raw_ref)
|
|
if new_ref:
|
|
mm["tool_call_id"] = new_ref
|
|
# Keep call_id in sync when both present (some gateways expect call_id)
|
|
if "call_id" in mm:
|
|
mm["call_id"] = new_ref
|
|
else:
|
|
mm.pop("tool_call_id", None)
|
|
mm.pop("call_id", None)
|
|
out.append(mm)
|
|
continue
|
|
out.append(dict(m) if isinstance(m, dict) else m)
|
|
return out
|
|
|
|
|
|
def repair_orphan_tool_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Drop tool_call_id/call_id from tool rows that reference no prior assistant tool_calls id."""
|
|
allowed: set[str] = set()
|
|
for m in messages:
|
|
if not isinstance(m, dict):
|
|
continue
|
|
if str(m.get("role") or "") != "assistant":
|
|
continue
|
|
tcs = m.get("tool_calls")
|
|
if not isinstance(tcs, list):
|
|
continue
|
|
for tc in tcs:
|
|
if isinstance(tc, dict) and tc.get("id"):
|
|
allowed.add(str(tc.get("id")))
|
|
|
|
out: list[dict[str, Any]] = []
|
|
for m in messages:
|
|
if not isinstance(m, dict):
|
|
out.append(m)
|
|
continue
|
|
if str(m.get("role") or "") != "tool":
|
|
out.append(m)
|
|
continue
|
|
mm = dict(m)
|
|
tid = str(mm.get("tool_call_id") or mm.get("call_id") or "").strip()
|
|
if tid and tid not in allowed:
|
|
mm.pop("tool_call_id", None)
|
|
mm.pop("call_id", None)
|
|
out.append(mm)
|
|
return out
|