mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 02:03:25 +08:00
refactor: root-package imports (svc/runtime/interfaces) and fix PYTHONPATH
- 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>
This commit is contained in:
parent
7941b27f58
commit
420abac9f1
405 changed files with 1316 additions and 1345 deletions
160
svc/llm/tool_call_id.py
Normal file
160
svc/llm/tool_call_id.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue