mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 08:30:45 +08:00
Soften short-intent gates and add tool-result refs plus MCP wire invalidate.
Prefer report tools without hard-hiding CLI, keep soft CLI budgets, store compact+result_ref for the model, and clear frozen wire after MCP sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3785e78a72
commit
605751b6a6
13 changed files with 636 additions and 65 deletions
167
runtime/chat/tool_result_store.py
Normal file
167
runtime/chat/tool_result_store.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Persist full tool results for later fetch_tool_result while LLM sees compact payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from svc.config.paths import attachments_dir
|
||||
|
||||
_REF_RE = re.compile(r"^tr:[0-9a-f]{32}$")
|
||||
_SAVE_MIN_CHARS = 4_000
|
||||
_FETCH_DEFAULT_MAX_CHARS = 120_000
|
||||
|
||||
|
||||
def _root() -> Path:
|
||||
p = (attachments_dir() / "tool_results").resolve()
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _session_dir(session_id: str) -> Path:
|
||||
sid = str(session_id or "").strip() or "_anon"
|
||||
digest = hashlib.sha256(sid.encode("utf-8", errors="ignore")).hexdigest()[:24]
|
||||
d = _root() / digest
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _json_dumps(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _json_size(obj: Any) -> int:
|
||||
try:
|
||||
return len(_json_dumps(obj))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def make_result_ref(*, session_id: str, tool_call_id: str, payload: Any) -> str:
|
||||
raw = f"{session_id}|{tool_call_id}|{_json_size(payload)}|{time.time_ns()}"
|
||||
return "tr:" + hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()[:32]
|
||||
|
||||
|
||||
def is_tool_result_ref(value: str) -> bool:
|
||||
return bool(_REF_RE.fullmatch(str(value or "").strip().lower()))
|
||||
|
||||
|
||||
def save_tool_result_blob(
|
||||
*,
|
||||
session_id: str,
|
||||
tool_call_id: str,
|
||||
result: Any,
|
||||
force: bool = False,
|
||||
) -> str | None:
|
||||
"""Store full tool JSON when large enough (or force=True). Returns result_ref or None."""
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
size = _json_size(result)
|
||||
if (not force) and size < int(_SAVE_MIN_CHARS):
|
||||
return None
|
||||
ref = make_result_ref(session_id=sid, tool_call_id=str(tool_call_id or ""), payload=result)
|
||||
path = _session_dir(sid) / f"{ref[3:]}.json"
|
||||
meta = {
|
||||
"result_ref": ref,
|
||||
"session_id": sid,
|
||||
"tool_call_id": str(tool_call_id or ""),
|
||||
"chars": int(size),
|
||||
"saved_at_ms": int(time.time() * 1000),
|
||||
}
|
||||
path.write_text(_json_dumps({"meta": meta, "result": result}), encoding="utf-8")
|
||||
return ref
|
||||
|
||||
|
||||
def load_tool_result_blob(
|
||||
result_ref: str,
|
||||
*,
|
||||
session_id: str,
|
||||
max_chars: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ref = str(result_ref or "").strip().lower()
|
||||
sid = str(session_id or "").strip()
|
||||
if not is_tool_result_ref(ref):
|
||||
return {"ok": False, "error_code": "invalid_result_ref", "error": "invalid_result_ref"}
|
||||
if not sid:
|
||||
return {"ok": False, "error_code": "session_required", "error": "session_required"}
|
||||
path = _session_dir(sid) / f"{ref[3:]}.json"
|
||||
if not path.is_file():
|
||||
# Fallback: scan root for orphaned refs (session hash mismatch / migrate).
|
||||
found = None
|
||||
for child in _root().glob(f"*/{ref[3:]}.json"):
|
||||
found = child
|
||||
break
|
||||
if found is None:
|
||||
return {"ok": False, "error_code": "result_ref_not_found", "error": "result_ref_not_found", "result_ref": ref}
|
||||
path = found
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error_code": "result_ref_read_failed", "error": f"{type(exc).__name__}: {exc}"}
|
||||
meta = data.get("meta") if isinstance(data, dict) else None
|
||||
if isinstance(meta, dict):
|
||||
owner = str(meta.get("session_id") or "").strip()
|
||||
if owner and owner != sid:
|
||||
return {"ok": False, "error_code": "result_ref_session_mismatch", "error": "result_ref_session_mismatch"}
|
||||
result = data.get("result") if isinstance(data, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
return {"ok": False, "error_code": "result_ref_invalid_payload", "error": "result_ref_invalid_payload"}
|
||||
cap = int(_FETCH_DEFAULT_MAX_CHARS if max_chars is None else max_chars)
|
||||
cap = max(4_000, min(cap, 500_000))
|
||||
body = _json_dumps(result)
|
||||
if len(body) <= cap:
|
||||
out = dict(result)
|
||||
out["result_ref"] = ref
|
||||
out["_fetched_full"] = True
|
||||
return {"ok": True, "result_ref": ref, "result": out, "chars": len(body), "truncated": False}
|
||||
from runtime.chat.tool_runtime import truncate_tool_result_for_llm_messages
|
||||
|
||||
slim = truncate_tool_result_for_llm_messages(result, max_chars=cap)
|
||||
if isinstance(slim, dict):
|
||||
slim = dict(slim)
|
||||
slim["result_ref"] = ref
|
||||
slim["_fetched_truncated"] = True
|
||||
slim["hint"] = (
|
||||
str(slim.get("hint") or "")
|
||||
+ " Full blob still on disk; narrow the tool query or raise max_chars on fetch_tool_result."
|
||||
).strip()
|
||||
return {
|
||||
"ok": True,
|
||||
"result_ref": ref,
|
||||
"result": slim,
|
||||
"chars": len(body),
|
||||
"truncated": True,
|
||||
"fetch_cap_chars": cap,
|
||||
}
|
||||
|
||||
|
||||
def attach_result_ref(payload: dict[str, Any], *, result_ref: str | None) -> dict[str, Any]:
|
||||
if not result_ref or not isinstance(payload, dict):
|
||||
return payload
|
||||
out = dict(payload)
|
||||
out["result_ref"] = str(result_ref)
|
||||
if out.get("_truncated_for_llm") or out.get("_tool_result_guarded") or out.get("_history_compacted"):
|
||||
out["fetch_tool"] = "fetch_tool_result"
|
||||
hint = str(out.get("hint") or "").strip()
|
||||
extra = (
|
||||
f"Full tool output stored as result_ref={result_ref}. "
|
||||
f"Call fetch_tool_result(result_ref=\"{result_ref}\") when you need details."
|
||||
)
|
||||
out["hint"] = f"{hint} {extra}".strip() if hint else extra
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"attach_result_ref",
|
||||
"is_tool_result_ref",
|
||||
"load_tool_result_blob",
|
||||
"make_result_ref",
|
||||
"save_tool_result_blob",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue