Files
2026-07-01 17:56:31 +08:00

189 lines
6.1 KiB
Python

"""Shared helpers for recoverable tool-output previews."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import datetime
from hashlib import sha256
from pathlib import Path
from typing import Any
from opc.core.config import get_opc_home
from opc.layer4_tools.execution_context import resolve_task_execution_context
DEFAULT_TRUNCATION_MARKER = "truncated"
@dataclass(frozen=True)
class TextClip:
text: str
truncated: bool
omitted_chars: int
original_chars: int
kept_chars: int
def clip_text(
value: Any,
*,
limit: int,
marker: str = DEFAULT_TRUNCATION_MARKER,
prefer_newline: bool = True,
) -> TextClip:
"""Return a marked preview of ``value`` within a character budget."""
text = str(value or "")
original_chars = len(text)
limit = max(0, int(limit or 0))
if original_chars <= limit:
return TextClip(
text=text,
truncated=False,
omitted_chars=0,
original_chars=original_chars,
kept_chars=original_chars,
)
if limit <= 0:
kept = ""
else:
kept = text[:limit]
if prefer_newline and "\n" in kept:
floor = max(1, int(limit * 0.80))
boundary = kept.rfind("\n", floor)
if boundary > 0:
kept = kept[:boundary]
kept = kept.rstrip()
omitted = original_chars - len(kept)
marker_text = marker.strip("[]") or DEFAULT_TRUNCATION_MARKER
suffix = f"[{marker_text}: {omitted} chars omitted]"
rendered = f"{kept}\n{suffix}" if kept else suffix
return TextClip(
text=rendered,
truncated=True,
omitted_chars=omitted,
original_chars=original_chars,
kept_chars=len(kept),
)
def truncation_metadata(clip: TextClip, *, prefix: str = "") -> dict[str, Any]:
"""Render stable metadata keys for a text clip."""
return {
f"{prefix}truncated": clip.truncated,
f"{prefix}omitted_chars": clip.omitted_chars,
f"{prefix}original_chars": clip.original_chars,
}
def _safe_segment(value: str, fallback: str) -> str:
segment = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").strip()).strip(".-")
return segment[:80] or fallback
def _tool_results_root(task: Any | None) -> Path:
context = resolve_task_execution_context(task)
comms_root = str(context.get("comms_root", "") or "").strip()
if comms_root:
return Path(comms_root).expanduser().resolve() / "tool-results"
return get_opc_home() / "artifacts" / "tool-results"
def persist_tool_result(
content: Any,
*,
tool_name: str,
task: Any | None = None,
extension: str = "json",
) -> dict[str, Any]:
"""Persist full tool output and return path/size metadata."""
if isinstance(content, str):
text = content
extension = extension or "txt"
else:
text = json.dumps(content, ensure_ascii=False, indent=2, default=str)
extension = extension or "json"
task_id = str(getattr(task, "id", "") or "").strip()
session_id = str(getattr(task, "session_id", "") or getattr(task, "parent_session_id", "") or "").strip()
bucket = _safe_segment(task_id or session_id or "global", "global")
root = _tool_results_root(task) / bucket
root.mkdir(parents=True, exist_ok=True)
stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
digest = sha256(text.encode("utf-8", errors="replace")).hexdigest()[:12]
suffix = extension.lstrip(".") or "txt"
path = root / f"{_safe_segment(tool_name, 'tool')}-{stamp}-{digest}.{suffix}"
path.write_text(text, encoding="utf-8")
return {
"full_output_path": str(path),
"original_size_chars": len(text),
}
def _json_size(value: Any) -> int:
return len(json.dumps(value, ensure_ascii=False, default=str))
def budget_tool_output(
output: dict[str, Any],
*,
tool_name: str,
task: Any | None = None,
max_chars: int,
preview_chars: int | None = None,
persist_large_results: bool = True,
self_bounded_output: bool = False,
) -> dict[str, Any]:
"""Apply a recoverable registry-level budget to a tool output."""
max_chars = max(1, int(max_chars or 1))
try:
serialized = json.dumps(output, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return output
if len(serialized) <= max_chars:
return output
preview_limit = max(400, int(preview_chars or max_chars // 2))
persisted: dict[str, Any] = {}
if persist_large_results and not self_bounded_output:
persisted = persist_tool_result(output, tool_name=tool_name, task=task, extension="json")
meta = {
"truncated": True,
"omitted_chars": max(0, len(serialized) - max_chars),
"original_size_chars": len(serialized),
**persisted,
}
result_val = output.get("result")
if isinstance(result_val, dict):
preview_result = json.loads(json.dumps(result_val, ensure_ascii=False, default=str))
for key in ("stdout", "stderr", "content", "rendered", "summary", "diff_preview"):
value = preview_result.get(key)
if isinstance(value, str):
clip = clip_text(
value,
limit=preview_limit,
marker=f"{tool_name} {key} truncated",
)
preview_result[key] = clip.text
if clip.truncated:
preview_result[f"{key}_truncated"] = True
preview_result[f"{key}_omitted_chars"] = clip.omitted_chars
preview_result.update({key: value for key, value in meta.items() if value not in ("", None)})
preview_output = {**output, "result": preview_result, **meta}
if _json_size(preview_output) <= max_chars:
return preview_output
clip = clip_text(serialized, limit=preview_limit, marker=f"{tool_name} result truncated")
return {
"result": {
"preview": clip.text,
**{key: value for key, value in meta.items() if value not in ("", None)},
},
"success": output.get("success", True),
**meta,
}