mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
WhatsApp/channel binary_ref uploads now expand into text_ref/tabular summaries for the model, and outbound replies only auto-attach generated image/video media instead of lookup tool text_ref results. Co-authored-by: Cursor <cursoragent@cursor.com>
742 lines
34 KiB
Python
742 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
"""Agent 消息构建模块。
|
|
|
|
把 `Agent._build_llm_messages` 的职责下沉到此处,便于:
|
|
- Manager 决策/Final merge 复用同一套“消息规范化与附件注入”规则
|
|
- 后续 Workspace/RAG/Trace 插入上下文时有单一入口
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Any
|
|
|
|
from svc.llm.chat_models import _normalize_image_b64_payload, gemini_openai_compat_client, ChatModel
|
|
from runtime.chat.media_redact import redact_embedded_image_blobs
|
|
from runtime.chat.tool_runtime import tool_llm_message_max_chars, truncate_tool_result_for_llm_messages
|
|
from runtime.prompt_templates import render_prompt
|
|
from svc.files.attachment_assets import attachment_id_to_data_url, AttachmentAssetStore
|
|
from svc.files.file_attachments import expand_attachment_ref
|
|
from runtime.relay_pointer import parse_pointer_uri
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_THINK_BLOCK_RE = re.compile(r"<think>\s*(.*?)\s*</think>\s*", flags=re.IGNORECASE | re.DOTALL)
|
|
_TOOL_CONTEXT_RESULT_MAX_CHARS = 50
|
|
_LAST_BUILD_LLM_MESSAGES_STATS: dict[str, int] = {
|
|
"dropped_unpaired_tool_rows": 0,
|
|
"dropped_no_id_tool_rows": 0,
|
|
}
|
|
|
|
|
|
def _replay_recent_tool_rounds() -> int:
|
|
raw = str(os.getenv("AIA_REPLAY_TOOL_FULL_ROUNDS") or "").strip()
|
|
if raw.isdigit():
|
|
return max(0, min(int(raw), 12))
|
|
return 3
|
|
|
|
|
|
def _allow_reasoning_signature_replay(model: ChatModel) -> bool:
|
|
# - auto (default): only providers that require signature continuity (Gemini paths).
|
|
# - on: always include signature metadata on assistant tool_calls.
|
|
# - off: never include signature metadata.
|
|
policy = str(os.getenv("AIA_REPLAY_REASONING_SIGNATURE_POLICY") or "auto").strip().lower()
|
|
if policy in ("0", "off", "false", "no"):
|
|
return False
|
|
if policy in ("1", "on", "true", "yes"):
|
|
return True
|
|
if gemini_openai_compat_client(model):
|
|
return True
|
|
return model.__class__.__name__ == "GoogleGeminiChatModel"
|
|
|
|
|
|
def _strip_reasoning_blocks(text: str) -> str:
|
|
return _THINK_BLOCK_RE.sub("", str(text or "")).strip()
|
|
|
|
|
|
def _parse_tool_calls(raw_tc: Any) -> list[dict[str, Any]]:
|
|
if not raw_tc:
|
|
return []
|
|
try:
|
|
data = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
|
except Exception:
|
|
return []
|
|
if not isinstance(data, list):
|
|
return []
|
|
return [x for x in data if isinstance(x, dict)]
|
|
|
|
|
|
def _tool_call_id_from_tool_row(raw_tc: Any) -> str:
|
|
if not raw_tc:
|
|
return ""
|
|
try:
|
|
meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
|
except Exception:
|
|
return ""
|
|
if not isinstance(meta, dict):
|
|
return ""
|
|
return str(meta.get("tool_call_id") or "").strip()
|
|
|
|
|
|
def _collect_historical_tool_call_ids(store_messages: list[Any], *, full_rounds: int) -> set[str]:
|
|
if full_rounds < 0:
|
|
full_rounds = 0
|
|
full_ids: set[str] = set()
|
|
rounds = 0
|
|
for m in reversed(store_messages or []):
|
|
role = str(getattr(m, "role", "") or "")
|
|
if role != "assistant":
|
|
continue
|
|
tcs = _parse_tool_calls(getattr(m, "tool_calls", None))
|
|
tc_ids = [str(tc.get("id") or "").strip() for tc in tcs if str(tc.get("id") or "").strip()]
|
|
if not tc_ids:
|
|
continue
|
|
rounds += 1
|
|
if rounds <= full_rounds:
|
|
full_ids.update(tc_ids)
|
|
historical_ids: set[str] = set()
|
|
for m in store_messages or []:
|
|
if str(getattr(m, "role", "") or "") != "tool":
|
|
continue
|
|
tcid = _tool_call_id_from_tool_row(getattr(m, "tool_calls", None))
|
|
if tcid and tcid not in full_ids:
|
|
historical_ids.add(tcid)
|
|
return historical_ids
|
|
|
|
|
|
def _summarize_historical_tool_content(raw: str, *, cap: int) -> str:
|
|
s = str(raw or "").strip()
|
|
if not s:
|
|
return json.dumps({"ok": None, "summary": "", "_history_summarized": True}, ensure_ascii=False)
|
|
out: dict[str, Any] = {"_history_summarized": True}
|
|
try:
|
|
obj = json.loads(s)
|
|
except Exception:
|
|
preview = s[: max(1, cap - 120)] + ("\n...<truncated>" if len(s) > cap else "")
|
|
out["summary"] = preview
|
|
return json.dumps(out, ensure_ascii=False)
|
|
if not isinstance(obj, dict):
|
|
out["summary"] = s[: max(1, cap - 120)] + ("\n...<truncated>" if len(s) > cap else "")
|
|
return json.dumps(out, ensure_ascii=False)
|
|
out["ok"] = obj.get("ok")
|
|
for key in ("error_code", "error", "hint"):
|
|
v = str(obj.get(key) or "").strip()
|
|
if v:
|
|
out[key] = v
|
|
if "result" in obj:
|
|
r = obj.get("result")
|
|
if isinstance(r, dict):
|
|
out["result_keys"] = sorted(list(r.keys()))[:20]
|
|
preview = s[: max(1, cap - 260)] + ("\n...<truncated>" if len(s) > cap else "")
|
|
out["preview"] = preview
|
|
return json.dumps(out, ensure_ascii=False)
|
|
|
|
|
|
def _summarize_unpaired_tool_content(raw: str, *, cap: int) -> str:
|
|
"""Best-effort summarize tool JSON for model-friendly context."""
|
|
s = str(raw or "").strip()
|
|
if not s:
|
|
return ""
|
|
if cap > 0 and len(s) > cap:
|
|
s = s[: max(1, cap - 80)] + "\n...<truncated>"
|
|
try:
|
|
obj = json.loads(s)
|
|
except Exception:
|
|
return s
|
|
if not isinstance(obj, dict):
|
|
return s
|
|
lines: list[str] = []
|
|
ok = obj.get("ok")
|
|
if ok is not None:
|
|
lines.append(f"ok={bool(ok)}")
|
|
ec = str(obj.get("error_code") or "").strip()
|
|
if ec:
|
|
lines.append(f"error_code={ec}")
|
|
err = str(obj.get("error") or "").strip()
|
|
if err:
|
|
lines.append(f"error={err}")
|
|
hint = str(obj.get("hint") or "").strip()
|
|
if hint:
|
|
lines.append(f"hint={hint}")
|
|
# Extract MCP-style text blocks when present.
|
|
try:
|
|
nested = obj.get("result")
|
|
content = None
|
|
if isinstance(nested, dict):
|
|
content = nested.get("content")
|
|
if isinstance(content, list):
|
|
texts = []
|
|
for b in content:
|
|
if isinstance(b, dict) and str(b.get("type") or "").strip().lower() == "text":
|
|
t = str(b.get("text") or "").strip()
|
|
if t:
|
|
texts.append(t)
|
|
if texts:
|
|
lines.append("content_text=" + " | ".join(texts)[: min(800, cap)])
|
|
except Exception:
|
|
pass
|
|
head = " ".join(lines).strip()
|
|
if head:
|
|
return head + "\n" + s
|
|
return s
|
|
|
|
|
|
def _truncate_tool_context(text: str, *, lang: str) -> str:
|
|
# NOTE: this is a history-replay safety clamp only.
|
|
# Never apply this to current-turn tool rows; otherwise the model may
|
|
# incorrectly infer runtime truncation from UI-facing copy.
|
|
s = str(text or "").strip()
|
|
if not s:
|
|
return s
|
|
if len(s) <= _TOOL_CONTEXT_RESULT_MAX_CHARS:
|
|
return s
|
|
tip = "…(详情请重新阅读)" if not str(lang or "").startswith("en") else "... (details truncated, please re-read)"
|
|
return s[:_TOOL_CONTEXT_RESULT_MAX_CHARS] + tip
|
|
|
|
|
|
def build_llm_messages(
|
|
*,
|
|
store_messages: list[Any],
|
|
system_prompt: str,
|
|
model: ChatModel,
|
|
lang: str,
|
|
tool_context_truncate_enabled: bool = True,
|
|
active_turn_uuid: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""把 DB 中的消息序列转换为 LLM messages。
|
|
|
|
When ``active_turn_uuid`` matches a tool/user row ``turn_uuid``, that turn is treated as the
|
|
in-flight MCP turn: tool JSON is not stripped of nested image payloads here (see also
|
|
:func:`~runtime.direct_loop._guard_tool_results_for_llm_context`). Omit or leave empty
|
|
to apply image-blob stripping for every tool row (safe default for callers without turn context).
|
|
|
|
Only the **last** user message may expand attachments into native multimodal ``input_image``
|
|
blocks shaped like ``{"type":"input_image","image_base64","mime"}``; ``OpenAIChatModel`` and
|
|
``OpenAIResponsesModel`` both accept this shape (see transport normalization code). Older user
|
|
attachments are replayed as text metadata only.
|
|
"""
|
|
out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}]
|
|
dropped_unpaired_tool_rows = 0
|
|
dropped_no_id_tool_rows = 0
|
|
thinking_mode_enabled = bool(getattr(model, "thinking_mode_enabled", False))
|
|
allow_signature_replay = _allow_reasoning_signature_replay(model)
|
|
reasoning_by_turn: dict[str, list[tuple[int, str]]] = {}
|
|
if thinking_mode_enabled:
|
|
for m in store_messages or []:
|
|
if str(getattr(m, "role", "") or "") != "assistant":
|
|
continue
|
|
if str(getattr(m, "event_type", "") or "").strip().lower() != "reasoning":
|
|
continue
|
|
tid = str(getattr(m, "turn_uuid", "") or "").strip()
|
|
if not tid:
|
|
continue
|
|
try:
|
|
idx = 0
|
|
ep = getattr(m, "event_payload", None)
|
|
if isinstance(ep, str) and ep.strip():
|
|
payload = json.loads(ep)
|
|
if isinstance(payload, dict):
|
|
idx = int(payload.get("chunk_index") or 0)
|
|
except Exception:
|
|
idx = 0
|
|
reasoning_by_turn.setdefault(tid, []).append((idx, str(getattr(m, "content", "") or "")))
|
|
historical_tool_ids = _collect_historical_tool_call_ids(
|
|
store_messages=store_messages, full_rounds=_replay_recent_tool_rounds()
|
|
)
|
|
# Some OpenAI-compatible gateways error if a tool message references a tool_call_id
|
|
# that is not present in the assistant tool_calls within the same request context.
|
|
# This can happen when context windows are trimmed and the assistant tool_calls row is dropped.
|
|
valid_tool_call_ids: set[str] = set()
|
|
# Precompute tool_call_id suffix sets to detect broken tool_calls -> tool pairing.
|
|
tool_ids_after: list[set[str]] = []
|
|
seen_tool_ids: set[str] = set()
|
|
for m in reversed(store_messages or []):
|
|
role = str(getattr(m, "role", "") or "")
|
|
if role == "tool":
|
|
tcid = _tool_call_id_from_tool_row(getattr(m, "tool_calls", None))
|
|
if tcid:
|
|
seen_tool_ids.add(tcid)
|
|
tool_ids_after.append(set(seen_tool_ids))
|
|
tool_ids_after.reverse()
|
|
pending_tool_ids_for_next_tool_rows: set[str] = set()
|
|
|
|
last_user_msg_idx = -1
|
|
for _ui, _um in enumerate(store_messages or []):
|
|
if str(getattr(_um, "role", "") or "") != "user":
|
|
continue
|
|
if str(getattr(_um, "event_type", "") or "").strip().lower() == "reasoning":
|
|
continue
|
|
last_user_msg_idx = _ui
|
|
|
|
def _attach_reasoning_content(row: dict[str, Any], m: Any) -> dict[str, Any]:
|
|
if not thinking_mode_enabled:
|
|
return row
|
|
rc = ""
|
|
ep = getattr(m, "event_payload", None)
|
|
if isinstance(ep, str) and ep.strip():
|
|
try:
|
|
payload = json.loads(ep)
|
|
if isinstance(payload, dict):
|
|
rc = str(payload.get("reasoning_content") or "").strip()
|
|
except Exception:
|
|
rc = ""
|
|
if not rc:
|
|
tid = str(getattr(m, "turn_uuid", "") or "").strip()
|
|
chunks = reasoning_by_turn.get(tid) or []
|
|
if chunks:
|
|
rc = "\n".join(
|
|
[
|
|
str(x[1] or "").strip()
|
|
for x in sorted(chunks, key=lambda t: int(t[0] or 0))
|
|
if str(x[1] or "").strip()
|
|
]
|
|
).strip()
|
|
# Provider contract: in thinking mode, the field must be present for every assistant message,
|
|
# even if empty (some gateways error on missing key).
|
|
row["reasoning_content"] = rc
|
|
return row
|
|
|
|
for i, m in enumerate(store_messages):
|
|
role = str(getattr(m, "role", "") or "")
|
|
event_type = str(getattr(m, "event_type", "") or "").strip().lower()
|
|
if event_type == "reasoning":
|
|
continue
|
|
|
|
if role == "user":
|
|
content_list: list[dict[str, Any]] = []
|
|
text = getattr(m, "content", None)
|
|
if text:
|
|
content_list.append({"type": "text", "text": str(text)})
|
|
|
|
attachments = []
|
|
raw_att = getattr(m, "attachments", None)
|
|
if raw_att:
|
|
try:
|
|
attachments = json.loads(raw_att) if isinstance(raw_att, str) else raw_att
|
|
except Exception:
|
|
attachments = []
|
|
|
|
expand_user_image_for_model = bool(i == last_user_msg_idx)
|
|
if expand_user_image_for_model:
|
|
expanded_atts: list[dict[str, Any]] = []
|
|
for att in attachments or []:
|
|
if not isinstance(att, dict):
|
|
continue
|
|
if str(att.get("type") or "").strip().lower() == "binary_ref":
|
|
subs = expand_attachment_ref(att)
|
|
if subs and any(str(s.get("type") or "") != "binary_ref" for s in subs):
|
|
expanded_atts.extend(subs)
|
|
continue
|
|
expanded_atts.append(att)
|
|
attachments = expanded_atts
|
|
|
|
for att in attachments or []:
|
|
if not isinstance(att, dict):
|
|
continue
|
|
att_type = att.get("type")
|
|
if att_type in ("image", "input_image"):
|
|
if expand_user_image_for_model:
|
|
b64 = _normalize_image_b64_payload(att.get("image_base64") or att.get("data"))
|
|
if not b64:
|
|
continue
|
|
content_list.append(
|
|
{
|
|
"type": "input_image",
|
|
"image_base64": b64,
|
|
"mime": att.get("mime") or "image/jpeg",
|
|
}
|
|
)
|
|
else:
|
|
name = str(att.get("name") or "image")
|
|
mime = str(att.get("mime") or "image/jpeg")
|
|
hs = "(historical attachment; pixels not replayed into model)"
|
|
hs_zh = "(历史附件;不向模型回放像素)"
|
|
hint = hs_zh if not str(lang or "").startswith("en") else hs
|
|
meta_line = f"- name={name} mime={mime} {hint}"
|
|
content_list.append(
|
|
{
|
|
"type": "text",
|
|
"text": render_prompt(
|
|
"tools/image_attachment_meta.md",
|
|
variables={"meta_line": meta_line},
|
|
strict=True,
|
|
),
|
|
}
|
|
)
|
|
elif att_type == "image_ref":
|
|
name = str(att.get("name") or "image")
|
|
mime = str(att.get("mime") or "image/jpeg")
|
|
aid = str(att.get("attachment_id") or "")
|
|
if expand_user_image_for_model:
|
|
data_url = attachment_id_to_data_url(aid, mime=mime) if aid else ""
|
|
if data_url:
|
|
if ";base64," in data_url:
|
|
b64 = data_url.split(";base64,", 1)[1]
|
|
content_list.append(
|
|
{
|
|
"type": "input_image",
|
|
"image_base64": b64,
|
|
"mime": mime,
|
|
}
|
|
)
|
|
continue
|
|
w = att.get("width")
|
|
h = att.get("height")
|
|
sz = att.get("bytes")
|
|
meta_line = f"- name={name} mime={mime} id={aid}"
|
|
if w and h:
|
|
meta_line += f" size={w}x{h}"
|
|
if sz:
|
|
meta_line += f" bytes={sz}"
|
|
content_list.append(
|
|
{
|
|
"type": "text",
|
|
"text": render_prompt(
|
|
"tools/image_attachment_meta.md",
|
|
variables={"meta_line": meta_line},
|
|
strict=True,
|
|
),
|
|
}
|
|
)
|
|
elif att_type == "text":
|
|
name = att.get("name", "file")
|
|
text_content = att.get("content", "")
|
|
content_list.append(
|
|
{
|
|
"type": "text",
|
|
"text": render_prompt(
|
|
"tools/text_attachment_wrap.md",
|
|
variables={"name": str(name), "content": str(text_content)},
|
|
strict=True,
|
|
),
|
|
}
|
|
)
|
|
elif att_type == "tabular_ref":
|
|
name = str(att.get("name") or "table")
|
|
table_id = str(att.get("table_id") or "")
|
|
rows = int(att.get("rows") or 0)
|
|
cols = int(att.get("cols") or 0)
|
|
aid = str(att.get("attachment_id") or "")
|
|
sheets = att.get("sheets") if isinstance(att.get("sheets"), list) else []
|
|
sheet_hint = ""
|
|
if sheets:
|
|
names = [str((x or {}).get("sheet_name") or "") for x in sheets if isinstance(x, dict)]
|
|
names = [x for x in names if x]
|
|
if names:
|
|
sheet_hint = f"\n- sheets: {', '.join(names[:8])}"
|
|
content_list.append(
|
|
{
|
|
"type": "text",
|
|
"text": (
|
|
f"[LargeTableAttachment]\n"
|
|
f"- name: {name}\n"
|
|
f"- table_id: {table_id}\n"
|
|
f"- attachment_id: {aid}\n"
|
|
f"- rows: {rows}\n"
|
|
f"- cols: {cols}\n"
|
|
f"{sheet_hint}\n"
|
|
f"- tools: query_tabular_attachment, run_tabular_sql, analyze_tabular_attachment_full_scan"
|
|
),
|
|
}
|
|
)
|
|
elif att_type == "text_ref":
|
|
name = str(att.get("name") or "document")
|
|
text_id = str(att.get("text_id") or "")
|
|
chars = int(att.get("chars") or 0)
|
|
chunks = int(att.get("chunks") or 0)
|
|
source_kind = str(att.get("source_kind") or "text")
|
|
aid = str(att.get("attachment_id") or "")
|
|
content_list.append(
|
|
{
|
|
"type": "text",
|
|
"text": (
|
|
f"[LongTextAttachment]\n"
|
|
f"- name: {name}\n"
|
|
f"- text_id: {text_id}\n"
|
|
f"- attachment_id: {aid}\n"
|
|
f"- source_kind: {source_kind}\n"
|
|
f"- chars: {chars}\n"
|
|
f"- chunks: {chunks}\n"
|
|
f"- tools: query_text_attachment\n"
|
|
"- note: for detailed evidence, call `query_text_attachment` with text_id."
|
|
),
|
|
}
|
|
)
|
|
elif att_type == "video_ref":
|
|
name = str(att.get("name") or "video")
|
|
mime = str(att.get("mime") or "video/*")
|
|
aid = str(att.get("attachment_id") or "")
|
|
sz = att.get("bytes")
|
|
meta_line = f"[VideoAttachment]\n- name: {name}\n- mime: {mime}\n- attachment_id: {aid}"
|
|
if sz:
|
|
meta_line += f"\n- bytes: {sz}"
|
|
meta_line += "\n- tools: query_video_attachment"
|
|
content_list.append({"type": "text", "text": meta_line})
|
|
elif att_type == "binary_ref":
|
|
aid = str(att.get("attachment_id") or "").strip()
|
|
name = str(att.get("name") or "file")
|
|
mime = str(att.get("mime") or "application/octet-stream")
|
|
sz = att.get("bytes")
|
|
if aid and (name == "file" or not mime or mime == "application/octet-stream"):
|
|
try:
|
|
meta = AttachmentAssetStore().get_meta(aid)
|
|
if meta:
|
|
if name == "file":
|
|
name = str(meta.name or name)
|
|
if not mime or mime == "application/octet-stream":
|
|
mime = str(meta.mime or mime)
|
|
if sz is None:
|
|
sz = meta.bytes
|
|
except Exception:
|
|
pass
|
|
meta_line = f"[BinaryAttachment]\n- name: {name}\n- mime: {mime}\n- attachment_id: {aid}"
|
|
if sz:
|
|
meta_line += f"\n- bytes: {sz}"
|
|
meta_line += "\n- tools: attachment_local_url"
|
|
meta_line += "\n- note: user uploaded a file; resolve or analyze it via attachment_id."
|
|
content_list.append({"type": "text", "text": meta_line})
|
|
elif att_type == "relay_pointer":
|
|
p_uri = str(att.get("pointer_uri") or "").strip()
|
|
if not p_uri:
|
|
continue
|
|
mime = str(att.get("mime") or att.get("mime_type") or "").strip()
|
|
aid = str(att.get("attachment_id") or "").strip()
|
|
if (not aid) and p_uri:
|
|
try:
|
|
_scope, _fid = parse_pointer_uri(p_uri)
|
|
aid = str(_fid or "").strip()
|
|
except Exception:
|
|
aid = ""
|
|
if expand_user_image_for_model and aid and mime.startswith("image/"):
|
|
data_url = attachment_id_to_data_url(aid, mime=mime)
|
|
if data_url and ";base64," in data_url:
|
|
b64 = data_url.split(";base64,", 1)[1]
|
|
content_list.append(
|
|
{
|
|
"type": "input_image",
|
|
"image_base64": b64,
|
|
"mime": mime or "image/jpeg",
|
|
}
|
|
)
|
|
rel_path = str(att.get("rel_path") or "").strip()
|
|
sz = att.get("bytes")
|
|
sha = str(att.get("sha256") or "").strip()
|
|
pointer_line = f"- pointer_uri={p_uri}"
|
|
if rel_path:
|
|
pointer_line += f" rel_path={rel_path}"
|
|
if mime:
|
|
pointer_line += f" mime={mime}"
|
|
if sz:
|
|
pointer_line += f" bytes={sz}"
|
|
if sha:
|
|
pointer_line += f" sha256={sha}"
|
|
content_list.append({"type": "text", "text": pointer_line})
|
|
|
|
if not content_list:
|
|
placeholder = "(No text content)" if str(lang or "").startswith("en") else "(无文本内容)"
|
|
content_list.append({"type": "text", "text": placeholder})
|
|
|
|
if len(content_list) == 1 and content_list[0].get("type") == "text":
|
|
out.append({"role": "user", "content": content_list[0]["text"]})
|
|
else:
|
|
out.append({"role": "user", "content": content_list})
|
|
continue
|
|
|
|
if role == "assistant":
|
|
tool_calls = None
|
|
raw_tc = getattr(m, "tool_calls", None)
|
|
if raw_tc:
|
|
try:
|
|
tool_calls = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
|
except Exception:
|
|
tool_calls = None
|
|
|
|
if tool_calls and isinstance(tool_calls, list):
|
|
# Guard: only include assistant tool_calls when paired tool rows are present.
|
|
# OpenAI-compatible providers require strict adjacency:
|
|
# assistant(tool_calls) must be followed immediately by matching tool rows.
|
|
want_ids = [str(tc.get("id") or "").strip() for tc in tool_calls if isinstance(tc, dict) and str(tc.get("id") or "").strip()]
|
|
suffix = tool_ids_after[i] if (i >= 0 and i < len(tool_ids_after)) else set()
|
|
if want_ids and any(tid not in suffix for tid in want_ids):
|
|
tool_calls = None
|
|
# Stronger guard than suffix-presence: verify immediate following block.
|
|
if tool_calls is not None and want_ids:
|
|
immediate_ids: set[str] = set()
|
|
for j in range(i + 1, len(store_messages)):
|
|
nm = store_messages[j]
|
|
n_event_type = str(getattr(nm, "event_type", "") or "").strip().lower()
|
|
# Ignore reasoning-only rows when checking adjacency.
|
|
if n_event_type == "reasoning":
|
|
continue
|
|
n_role = str(getattr(nm, "role", "") or "")
|
|
if n_role != "tool":
|
|
break
|
|
tcid = _tool_call_id_from_tool_row(getattr(nm, "tool_calls", None))
|
|
if tcid:
|
|
immediate_ids.add(str(tcid))
|
|
if any(tid not in immediate_ids for tid in want_ids):
|
|
tool_calls = None
|
|
if tool_calls is None:
|
|
out.append(
|
|
_attach_reasoning_content(
|
|
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
|
m,
|
|
)
|
|
)
|
|
continue
|
|
api_tool_calls = []
|
|
gemini_fc = gemini_openai_compat_client(model)
|
|
for idx, tc in enumerate(tool_calls):
|
|
if not isinstance(tc, dict) or not tc.get("id") or not tc.get("name"):
|
|
continue
|
|
try:
|
|
valid_tool_call_ids.add(str(tc.get("id") or ""))
|
|
except Exception:
|
|
pass
|
|
entry: dict[str, Any] = {
|
|
"id": tc.get("id"),
|
|
"type": "function",
|
|
"function": {
|
|
"name": tc.get("name"),
|
|
"arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False),
|
|
},
|
|
}
|
|
raw_sig = tc.get("thought_signature")
|
|
if allow_signature_replay and gemini_fc:
|
|
if isinstance(raw_sig, str):
|
|
sig = raw_sig
|
|
elif idx == 0:
|
|
sig = "skip_thought_signature_validator"
|
|
else:
|
|
sig = ""
|
|
entry["extra_content"] = {"google": {"thought_signature": sig}}
|
|
elif allow_signature_replay and isinstance(raw_sig, str):
|
|
entry["extra_content"] = {"google": {"thought_signature": raw_sig}}
|
|
api_tool_calls.append(entry)
|
|
if api_tool_calls:
|
|
pending_tool_ids_for_next_tool_rows = {
|
|
str(tc.get("id") or "").strip()
|
|
for tc in api_tool_calls
|
|
if str(tc.get("id") or "").strip()
|
|
}
|
|
out.append(
|
|
_attach_reasoning_content(
|
|
{
|
|
"role": "assistant",
|
|
"content": _strip_reasoning_blocks(getattr(m, "content", "") or ""),
|
|
"tool_calls": api_tool_calls,
|
|
},
|
|
m,
|
|
)
|
|
)
|
|
else:
|
|
pending_tool_ids_for_next_tool_rows = set()
|
|
out.append(
|
|
_attach_reasoning_content(
|
|
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
|
m,
|
|
)
|
|
)
|
|
else:
|
|
pending_tool_ids_for_next_tool_rows = set()
|
|
out.append(
|
|
_attach_reasoning_content(
|
|
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
|
m,
|
|
)
|
|
)
|
|
continue
|
|
|
|
if role == "tool":
|
|
tool_call_id = None
|
|
raw_tc = getattr(m, "tool_calls", None)
|
|
if raw_tc:
|
|
try:
|
|
meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
|
if isinstance(meta, dict):
|
|
tool_call_id = meta.get("tool_call_id")
|
|
except Exception:
|
|
tool_call_id = None
|
|
if tool_call_id is not None:
|
|
try:
|
|
tool_call_id = str(tool_call_id).strip()
|
|
except Exception:
|
|
tool_call_id = ""
|
|
if tool_call_id:
|
|
# Guard against dangling tool_call_id (assistant tool_calls missing from this trimmed context window).
|
|
# Also require strict immediate-turn pairing: a tool row must follow the assistant
|
|
# tool_calls message that introduced this id (no unrelated message in-between).
|
|
# Some OpenAI-compatible gateways enforce this strictly.
|
|
if str(tool_call_id) not in valid_tool_call_ids or str(tool_call_id) not in pending_tool_ids_for_next_tool_rows:
|
|
# Strict pairing mode: drop unpaired tool rows entirely.
|
|
# This avoids provider-side 400 errors caused by orphan tool_result blocks.
|
|
dropped_unpaired_tool_rows += 1
|
|
continue
|
|
pending_tool_ids_for_next_tool_rows.discard(str(tool_call_id))
|
|
raw_tc_content = getattr(m, "content", "") or ""
|
|
_tun = str(getattr(m, "turn_uuid", "") or "").strip()
|
|
_aus = str(active_turn_uuid or "").strip()
|
|
if (not _aus) or (_tun != _aus):
|
|
try:
|
|
_p = json.loads(raw_tc_content)
|
|
_p2 = redact_embedded_image_blobs(_p)
|
|
raw_tc_content = json.dumps(_p2, ensure_ascii=False, default=str)
|
|
except Exception:
|
|
pass
|
|
tool_content_out = raw_tc_content
|
|
cap = tool_llm_message_max_chars()
|
|
if str(tool_call_id) in historical_tool_ids:
|
|
summary_cap = 1800
|
|
if cap > 0:
|
|
summary_cap = max(600, min(2400, cap // 3))
|
|
tool_content_out = _summarize_historical_tool_content(raw_tc_content, cap=summary_cap)
|
|
elif cap > 0 and len(raw_tc_content) > cap:
|
|
try:
|
|
parsed = json.loads(raw_tc_content)
|
|
if isinstance(parsed, dict):
|
|
tool_content_out = json.dumps(
|
|
truncate_tool_result_for_llm_messages(parsed), ensure_ascii=False, default=str
|
|
)
|
|
else:
|
|
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
|
|
except Exception:
|
|
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
|
|
if tool_context_truncate_enabled and str(tool_call_id) in historical_tool_ids:
|
|
# Preserve explicit guard markers from upstream context guards.
|
|
if "_tool_result_guarded" not in str(tool_content_out or ""):
|
|
tool_content_out = _truncate_tool_context(tool_content_out, lang=lang)
|
|
tool_row: dict[str, Any] = {
|
|
"role": "tool",
|
|
"tool_call_id": tool_call_id,
|
|
"content": tool_content_out,
|
|
}
|
|
# Some OpenAI-compatible gateways expect `call_id` instead of `tool_call_id`.
|
|
# Sending both (non-empty) keeps compatibility; servers should ignore unknown fields.
|
|
tool_row["call_id"] = tool_call_id
|
|
try:
|
|
meta2 = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
|
except Exception:
|
|
meta2 = None
|
|
if isinstance(meta2, dict) and meta2.get("name"):
|
|
tool_row["name"] = str(meta2["name"])
|
|
out.append(tool_row)
|
|
else:
|
|
pending_tool_ids_for_next_tool_rows = set()
|
|
# Strict pairing mode: drop no-id tool rows entirely.
|
|
dropped_no_id_tool_rows += 1
|
|
continue
|
|
|
|
global _LAST_BUILD_LLM_MESSAGES_STATS
|
|
_LAST_BUILD_LLM_MESSAGES_STATS = {
|
|
"dropped_unpaired_tool_rows": int(dropped_unpaired_tool_rows),
|
|
"dropped_no_id_tool_rows": int(dropped_no_id_tool_rows),
|
|
}
|
|
return out
|
|
|
|
|
|
def get_last_build_llm_messages_stats() -> dict[str, int]:
|
|
return dict(_LAST_BUILD_LLM_MESSAGES_STATS)
|
|
|
|
|
|
__all__ = ["build_llm_messages", "get_last_build_llm_messages_stats"]
|