完善 MCP/会话上下文治理并补齐搜索与诊断能力。

补充历史压缩与会话诊断链路,强化 tool pairing 与空响应兜底观测;完善 Admin MCP 导入/绑定与相关前端展示;新增 web_search_fast/web_fetch_clean 及多项测试与文档更新,并同步技能安装与角色绑定策略改进。

Made-with: Cursor
This commit is contained in:
oliver 2026-05-01 17:46:50 +08:00
parent df91434936
commit ae44cbcad5
43 changed files with 3549 additions and 57 deletions

View file

@ -23,6 +23,10 @@ from oclaw.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:
@ -210,6 +214,8 @@ def build_llm_messages(
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]]] = {}
@ -250,6 +256,7 @@ def build_llm_messages(
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 []):
@ -550,6 +557,11 @@ def build_llm_messages(
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(
{
@ -561,6 +573,7 @@ def build_llm_messages(
)
)
else:
pending_tool_ids_for_next_tool_rows = set()
out.append(
_attach_reasoning_content(
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
@ -568,6 +581,7 @@ def build_llm_messages(
)
)
else:
pending_tool_ids_for_next_tool_rows = set()
out.append(
_attach_reasoning_content(
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
@ -593,26 +607,15 @@ def build_llm_messages(
tool_call_id = ""
if tool_call_id:
# Guard against dangling tool_call_id (assistant tool_calls missing from this trimmed context window).
if str(tool_call_id) not in valid_tool_call_ids:
# Preserve tool evidence, but downgrade to plain assistant text when pairing is broken.
# Some OpenAI-compatible gateways reject a role=tool message if tool_call_id cannot be paired
# to an assistant.tool_calls.id within the same request context.
r0 = getattr(m, "content", "") or ""
cap0 = tool_llm_message_max_chars()
pretty = _summarize_unpaired_tool_content(r0, cap=cap0)
if tool_context_truncate_enabled:
pretty = _truncate_tool_context(pretty, lang=lang)
out.append(
{
"role": "assistant",
"content": render_prompt(
"tools/tool_result_unpaired.md",
variables={"tag": "tool_use_result:unpaired", "payload": pretty},
strict=True,
),
}
)
# 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()
@ -661,23 +664,21 @@ def build_llm_messages(
tool_row["name"] = str(meta2["name"])
out.append(tool_row)
else:
r = getattr(m, "content", "") or ""
cap2 = tool_llm_message_max_chars()
pretty2 = _summarize_unpaired_tool_content(r, cap=cap2)
# Unpaired tool rows are already summarized; avoid extra 50-char clipping.
out.append(
{
"role": "assistant",
"content": render_prompt(
"tools/tool_result_unpaired.md",
variables={"tag": "tool_use_result:no_id", "payload": pretty2},
strict=True,
),
}
)
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
__all__ = ["build_llm_messages"]
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"]

View file

@ -0,0 +1,244 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from oclaw.runtime.chat.media_redact import redact_embedded_image_blobs
from oclaw.runtime.direct_loop import (
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS,
_image_tool_result_replay_cap_chars,
_video_tool_result_replay_cap_chars,
)
@dataclass(frozen=True)
class HistoryCompactionResult:
ok: bool
session_id: str
scanned_tool_messages: int = 0
compacted_tool_messages: int = 0
rewritten_all_tool_messages: int = 0
skipped_already_guarded: int = 0
max_original_chars_seen: int = 0
cap_chars: int = 0
detail: str = ""
def _json_dumps_safe(obj: Any) -> str:
try:
return json.dumps(obj, ensure_ascii=False, default=str)
except Exception:
return json.dumps({"ok": False, "error": "not_json_serializable"}, ensure_ascii=False)
def _guard_tool_result_text_for_history(
*,
store: Any,
raw: str,
cap_chars: int,
image_cap_chars: int,
video_cap_chars: int,
) -> tuple[str, bool]:
"""Return (new_raw, changed) following the same strategy as context replay guard."""
text = str(raw or "")
if not text:
return text, False
# Redact embedded blobs first (same as context guard).
try:
p0 = json.loads(text)
p1 = redact_embedded_image_blobs(p0)
text = _json_dumps_safe(p1)
except Exception:
pass
# If already guarded, do not rewrite again.
try:
obj0 = json.loads(text)
if isinstance(obj0, dict) and (obj0.get("_tool_result_guarded") or obj0.get("_image_tool_result_guarded") or obj0.get("_video_tool_result_guarded")):
return text, False
except Exception:
obj0 = None
ok = None
error_code = ""
error = ""
obj: dict[str, Any] | None = None
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
obj = parsed
ok = obj.get("ok")
error_code = str(obj.get("error_code") or "").strip()
error = str(obj.get("error") or "").strip()
except Exception:
obj = None
if isinstance(obj, dict):
task = str(obj.get("task") or "").strip().lower()
t = str(obj.get("text") or "")
has_attachment_id = bool(str(obj.get("attachment_id") or "").strip())
if task in {"describe", "ocr"} and has_attachment_id and len(t) > int(image_cap_chars):
preview = t[: int(image_cap_chars)] + "\n...<image_tool_result_truncated_for_context_replay>"
guarded_obj = dict(obj)
guarded_obj["text"] = preview
guarded_obj["_image_tool_result_guarded"] = True
guarded_obj["image_result_original_chars"] = len(t)
guarded_obj["image_result_replay_cap_chars"] = int(image_cap_chars)
guarded_obj["image_result_hint"] = (
"Image analysis result was truncated for context replay. "
"Refine query_image_attachment(question=...) for narrower evidence. / "
"图片分析结果在上下文回放中已截断,请缩小 query_image_attachment 的问题范围。"
)
return _json_dumps_safe(guarded_obj), True
if task == "transcript" and has_attachment_id and len(t) > int(video_cap_chars):
preview = t[: int(video_cap_chars)] + "\n...<video_tool_result_truncated_for_context_replay>"
guarded_obj = dict(obj)
guarded_obj["text"] = preview
guarded_obj["_video_tool_result_guarded"] = True
guarded_obj["video_result_original_chars"] = len(t)
guarded_obj["video_result_replay_cap_chars"] = int(video_cap_chars)
return _json_dumps_safe(guarded_obj), True
if len(text) <= int(cap_chars):
return text, False
preview = text[: max(1, min(4000, int(cap_chars) - 400))] + "\n...<tool_result_guard_truncated>"
guarded_obj = {
"ok": bool(ok) if ok is not None else None,
"error_code": error_code,
"error": error,
"_tool_result_guarded": True,
"original_chars": len(text),
"guard_cap_chars": int(cap_chars),
"preview": preview,
"hint": (
"Tool output was too large for safe context replay; it was truncated for history storage. "
"Use narrower queries (e.g., smaller glob/max_results) or adjust AIA_TOOL_LLM_MESSAGE_MAX_CHARS. / "
"工具输出过大,已压缩写回历史;请缩小范围或配置 AIA_TOOL_LLM_MESSAGE_MAX_CHARS。"
),
}
return _json_dumps_safe(guarded_obj), True
def compact_tool_results_in_session_history(
*,
store: Any,
session_id: str,
cap_chars: int | None = None,
limit_messages: int = 5000,
rewrite_all: bool = True,
) -> HistoryCompactionResult:
"""Rewrite overlarge `role=tool` chat_message.content in DB using the replay-guard strategy."""
sid = str(session_id or "").strip()
if not sid:
return HistoryCompactionResult(ok=False, session_id="", detail="session_id_required")
cap = max(4096, min(int(cap_chars or _OCLAW_TOOL_RESULT_HARD_CAP_CHARS), 500_000))
image_cap = int(_image_tool_result_replay_cap_chars(store))
video_cap = int(_video_tool_result_replay_cap_chars(store))
scanned = 0
compacted = 0
rewritten_all = 0
skipped = 0
max_seen = 0
# We only need to scan tool messages; fetching ids+content is enough.
with store._connect() as conn: # noqa: SLF001
cur = conn.execute(
"select id, content from chat_message where session_id=? and role='tool' "
"order by id asc limit ?",
(sid, max(1, min(int(limit_messages or 5000), 200_000))),
)
rows = cur.fetchall() or []
for mid, raw in rows:
scanned += 1
txt = str(raw or "")
max_seen = max(max_seen, len(txt))
new_txt, changed = _guard_tool_result_text_for_history(
store=store,
raw=txt,
cap_chars=cap,
image_cap_chars=image_cap,
video_cap_chars=video_cap,
)
# Defensive fallback: if content is still over cap but guard didn't report change,
# force a minimal guard so polluted history can always be compacted.
if (not changed) and len(txt) > int(cap):
preview = txt[: max(1, min(4000, int(cap) - 400))] + "\n...<tool_result_guard_truncated>"
new_txt = _json_dumps_safe(
{
"ok": None,
"error_code": "",
"error": "",
"_tool_result_guarded": True,
"original_chars": len(txt),
"guard_cap_chars": int(cap),
"preview": preview,
"hint": (
"Tool output was too large for safe context replay; it was truncated for history storage. / "
"工具输出过大,已压缩写回历史。"
),
}
)
changed = True
# Full rewrite mode: compact every tool_result row into guarded envelope,
# even when current content is under cap. This keeps history bounded and
# prevents heterogeneous huge payload persistence.
if (not changed) and bool(rewrite_all):
preview_cap = max(200, min(1200, int(cap) - 200))
preview = txt[:preview_cap]
if len(txt) > preview_cap:
preview += "\n...<tool_result_guard_truncated>"
ok_val = None
try:
_obj = json.loads(txt)
if isinstance(_obj, dict):
_ok = _obj.get("ok")
ok_val = bool(_ok) if _ok is not None else None
except Exception:
ok_val = None
new_txt = _json_dumps_safe(
{
"ok": ok_val,
"error_code": "",
"error": "",
"_tool_result_guarded": True,
"_history_full_rewrite": True,
"original_chars": len(txt),
"guard_cap_chars": int(cap),
"preview": preview,
"hint": (
"Tool result history was compacted by operator action. / "
"该工具结果历史已按运维操作统一压缩。"
),
}
)
changed = (new_txt != txt)
if changed:
rewritten_all += 1
if not changed:
# could be already guarded, or under cap
if txt and ("_tool_result_guarded" in txt or "_image_tool_result_guarded" in txt or "_video_tool_result_guarded" in txt):
skipped += 1
continue
conn.execute("update chat_message set content=? where id=? and session_id=?", (new_txt, int(mid), sid))
compacted += 1
return HistoryCompactionResult(
ok=True,
session_id=sid,
scanned_tool_messages=int(scanned),
compacted_tool_messages=int(compacted),
rewritten_all_tool_messages=int(rewritten_all),
skipped_already_guarded=int(skipped),
max_original_chars_seen=int(max_seen),
cap_chars=int(cap),
detail="ok",
)
__all__ = [
"HistoryCompactionResult",
"compact_tool_results_in_session_history",
]

View file

@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class SessionContextStats:
session_id: str
total_messages: int
sampled_messages: int
last_n: int
last_n_total_chars: int
last_n_tool_total_chars: int
last_n_max_msg_chars: int
last_n_max_tool_chars: int
last_n_max_user_chars: int
empty_assistant_text_in_sampled: int
empty_assistant_text_ids: tuple[int, ...]
max_content_chars_in_sampled: int
max_tool_chars_in_sampled: int
def compute_session_context_stats(
*,
store: Any,
session_id: str,
sample_n: int = 120,
last_n: int = 80,
) -> SessionContextStats:
"""Compute lightweight DB-backed stats for diagnosing context overflow vs empty responses.
This intentionally only inspects DB text lengths (not token counts).
"""
sid = str(session_id or "").strip()
if not sid:
raise ValueError("session_id required")
sample_n = max(1, min(int(sample_n or 120), 2000))
last_n = max(1, min(int(last_n or 80), 2000))
total = 0
sampled_rows: list[tuple[int, str, str, int]] = []
last_rows: list[tuple[int, str, str, int]] = []
try:
# Prefer raw SQL to avoid store-level object hydration overhead.
with store._connect() as conn: # noqa: SLF001
cur = conn.execute("select count(1) from chat_message where session_id=?", (sid,))
total = int((cur.fetchone() or [0])[0] or 0)
cur = conn.execute(
"select id, role, event_type, coalesce(length(content),0) as n "
"from chat_message where session_id=? order by id desc limit ?",
(sid, int(sample_n)),
)
sampled_rows = [(int(r[0]), str(r[1] or ""), str(r[2] or ""), int(r[3] or 0)) for r in (cur.fetchall() or [])]
cur = conn.execute(
"select id, role, event_type, coalesce(length(content),0) as n "
"from chat_message where session_id=? order by id desc limit ?",
(sid, int(last_n)),
)
last_rows = [(int(r[0]), str(r[1] or ""), str(r[2] or ""), int(r[3] or 0)) for r in (cur.fetchall() or [])]
except Exception:
# Fallback path via store API if direct SQL fails for any reason.
msgs = list(store.get_messages(session_id=sid, limit=int(sample_n)))
total = len(msgs)
sampled_rows = [
(int(getattr(m, "id", 0) or 0), str(getattr(m, "role", "") or ""), str(getattr(m, "event_type", "") or ""), len(str(getattr(m, "content", "") or "")))
for m in msgs
]
last_rows = sampled_rows[: int(last_n)]
empty_ids = tuple(sorted([mid for mid, role, ev, n in sampled_rows if role == "assistant" and ev == "assistant_text" and int(n) == 0]))
max_content = max([n for *_rest, n in sampled_rows] or [0])
max_tool = max([n for _mid, role, _ev, n in sampled_rows if role == "tool"] or [0])
last_total = sum(int(n) for *_rest, n in last_rows)
last_tool_total = sum(int(n) for _mid, role, _ev, n in last_rows if role == "tool")
last_max = max([int(n) for *_rest, n in last_rows] or [0])
last_tool_max = max([int(n) for _mid, role, _ev, n in last_rows if role == "tool"] or [0])
last_user_max = max([int(n) for _mid, role, _ev, n in last_rows if role == "user"] or [0])
return SessionContextStats(
session_id=sid,
total_messages=int(total),
sampled_messages=len(sampled_rows),
last_n=int(last_n),
last_n_total_chars=int(last_total),
last_n_tool_total_chars=int(last_tool_total),
last_n_max_msg_chars=int(last_max),
last_n_max_tool_chars=int(last_tool_max),
last_n_max_user_chars=int(last_user_max),
empty_assistant_text_in_sampled=len(empty_ids),
empty_assistant_text_ids=empty_ids,
max_content_chars_in_sampled=int(max_content),
max_tool_chars_in_sampled=int(max_tool),
)
__all__ = [
"SessionContextStats",
"compute_session_context_stats",
]

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Iterator
_tool_lane_owner: ContextVar[str | None] = ContextVar("tool_lane_owner", default=None)
_tool_lane_session: ContextVar[str | None] = ContextVar("tool_lane_session", default=None)
_tool_workspace_lane_role: ContextVar[str | None] = ContextVar("tool_workspace_lane_role", default=None)
@contextmanager
def tool_workspace_lane_scope(
*,
workspace_owner_session_id: str | None,
session_id: str | None,
workspace_lane_role: str | None = None,
) -> Iterator[None]:
o = str(workspace_owner_session_id or "").strip() or None
s = str(session_id or "").strip() or None
r = str(workspace_lane_role or "").strip().lower() or None
t_o = _tool_lane_owner.set(o)
t_s = _tool_lane_session.set(s)
t_r = _tool_workspace_lane_role.set(r)
try:
yield
finally:
_tool_lane_owner.reset(t_o)
_tool_lane_session.reset(t_s)
_tool_workspace_lane_role.reset(t_r)
def current_tool_lane_sessions() -> tuple[str | None, str | None]:
return _tool_lane_owner.get(), _tool_lane_session.get()
def current_tool_workspace_lane_role() -> str | None:
return _tool_workspace_lane_role.get()
__all__ = [
"current_tool_lane_sessions",
"current_tool_workspace_lane_role",
"tool_workspace_lane_scope",
]

View file

@ -26,6 +26,7 @@ from oclaw.runtime.tools.path_guard import (
workspace_path_access_scope,
workspace_write_namespace_scope,
)
from oclaw.runtime.chat.tool_invocation_context import tool_workspace_lane_scope
logger = logging.getLogger(__name__)
_tool_exec_log = logging.getLogger("oclaw.tool_exec")
@ -474,6 +475,8 @@ class ToolExecutionContext:
path_policy_user_id: str | None = None
workspace_dir: str | None = None
turn_uuid: str | None = None
#: Binding role for private ``skill_auto_install`` paths (``_workspace/<role>/``, sibling of ``public/``).
workspace_lane_role: str | None = None
class ToolExecutor:
@ -516,7 +519,11 @@ class ToolExecutor:
owner_fallback_session_id=ctx.workspace_owner_session_id,
allowlist_tenant_id=ctx.path_policy_tenant_id,
allowlist_user_id=ctx.path_policy_user_id,
), workspace_write_namespace_scope(ws_ns):
), workspace_write_namespace_scope(ws_ns), tool_workspace_lane_scope(
workspace_owner_session_id=ctx.workspace_owner_session_id,
session_id=ctx.session_id,
workspace_lane_role=ctx.workspace_lane_role,
):
return tool.handler(tc.arguments)
if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0: