"""Authoritative in-memory access history, page auditing, and warnings.""" from __future__ import annotations import datetime import json import urllib.parse from backend.errors import sanitize_message from typing import Any, Dict, List, Optional MAX_HISTORY_ENTRIES = 100 MAX_HISTORY_BYTES = 128 * 1024 * 1024 # 128 MiB RESERVED_METADATA_BYTES_PER_ENTRY = 64 * 1024 # 64 KiB MAX_PAGE_SUMMARIES_BYTES = 16 * 1024 * 1024 # 16 MiB MAX_WARNINGS = 100 def rfc3339_utc(dt: Optional[datetime.datetime] = None) -> str: """Format a datetime as RFC 3339 UTC string with Z suffix.""" if dt is None: dt = datetime.datetime.now(datetime.timezone.utc) elif dt.tzinfo is None: dt = dt.replace(tzinfo=datetime.timezone.utc) else: dt = dt.astimezone(datetime.timezone.utc) return dt.isoformat(timespec="microseconds").replace("+00:00", "Z") class WarningsManager: """Bounded, aggregated warning collector.""" def __init__(self, max_warnings: int = MAX_WARNINGS): self.max_warnings = max_warnings self._warnings: List[Dict[str, Any]] = [] self._seen_keys: Dict[str, int] = {} def add_warning( self, code: str, message: str, tool_call_id: Optional[str] = None, name: Optional[str] = None, ) -> None: clean_code = sanitize_message(str(code), 64) message = sanitize_message(str(message)) name = sanitize_message(str(name), 256) if name else None tool_call_id = str(tool_call_id)[:128] if tool_call_id else None key = json.dumps([clean_code, message, tool_call_id, name]) if key in self._seen_keys: # Aggregate repeat counts for duplicate warnings idx = self._seen_keys[key] w = self._warnings[idx] w["count"] = w.get("count", 1) + 1 return if len(self._warnings) >= self.max_warnings: return # bound to max_warnings self._seen_keys[key] = len(self._warnings) item: Dict[str, Any] = { "code": clean_code, "message": message[:1024], } if tool_call_id: item["tool_call_id"] = tool_call_id if name: item["name"] = name[:256] self._warnings.append(item) def get_warnings(self) -> List[Dict[str, Any]]: return list(self._warnings) class HistoryManager: """Authoritative in-memory tool execution history and page auditing.""" def __init__(self, warnings_manager: Optional[WarningsManager] = None): self.warnings = warnings_manager or WarningsManager() self.entries: List[Dict[str, Any]] = [] self._current_size_bytes = 2 self._pages: Dict[str, Dict[str, Any]] = {} def record_call( self, tool_call_id: str, tool: str, parameters: Dict[str, Any], started_at: str, completed_at: str, status: str, # "success" | "error" cache_hit: bool, result: Optional[Dict[str, Any]], error: Optional[Dict[str, Any]], ) -> None: """Record an authoritative tool dispatch entry.""" if len(self.entries) >= MAX_HISTORY_ENTRIES: self.warnings.add_warning("history_overflow", "Maximum tool history entries (100) reached") return if status == "success" and tool == "confluence_view" and isinstance(result, dict): page_id = str(result.get("page_id", ""))[:128] if page_id and page_id not in self._pages: url = str(result.get("url", "")) try: parsed = urllib.parse.urlsplit(url) usable = parsed.scheme in ("http", "https") and parsed.hostname and not parsed.username _ = parsed.port except ValueError: usable = False # Never shorten a URL into a different clickable destination. if not usable or len(json.dumps(url).encode()) > 32 * 1024: url = "" self.warnings.add_warning("unusable_page_url", "Page URL is malformed or too large", tool_call_id=tool_call_id) self._pages[page_id] = { "page_id": page_id, "title": str(result.get("title", ""))[:8192], "space": str(result.get("space", ""))[:256], "url": url, "accessed_at": completed_at, } if len(str(result.get("title", ""))) > 8192: self.warnings.add_warning("page_summaries_truncated", "Page summary display text shortened") def size(value): return len(json.dumps(value).encode("utf-8")) def snapshot(value, budget): if value is None: return None, False if size(value) <= budget: return json.loads(json.dumps(value)), False if isinstance(value, dict): reduced = {} for key, val in value.items(): candidate = {**reduced, key: val} if size(candidate) <= budget: reduced = candidate elif isinstance(val, str): low, high = 0, len(val) while low < high: mid = (low + high + 1) // 2 if size({**reduced, key: val[:mid] + "...[truncated]"}) <= budget: low = mid else: high = mid - 1 if low: reduced[key] = val[:low] + "...[truncated]" return json.loads(json.dumps(reduced)), True return None, True def result_snapshot(value, budget): if size(value) <= budget: return json.loads(json.dumps(value)), False # Preserve the expected tool result fields and types when shortening a snapshot. if tool == "confluence_view": reduced = {k: str(value.get(k, ""))[:256] for k in ("page_id", "title", "space", "url")} reduced.update(markdown="", truncated=True) low, high = 0, len(value.get("markdown", "")) while low < high: mid = (low + high + 1) // 2 if size({**reduced, "markdown": value["markdown"][:mid]}) <= budget: low = mid else: high = mid - 1 reduced["markdown"] = value.get("markdown", "")[:low] if reduced["url"] != value.get("url", ""): reduced["url"] = "" return reduced, True array_key = "pages" if tool == "confluence_search" else "spaces" if tool == "confluence_list_spaces" else None if array_key: reduced = {array_key: [], "pagination": dict(value.get("pagination", {}))} for item in value.get(array_key, []): shortened = {k: str(v)[:256] for k, v in item.items()} if "url" in shortened and shortened["url"] != item["url"]: shortened["url"] = "" if size({**reduced, array_key: reduced[array_key] + [shortened]}) > budget: break reduced[array_key].append(shortened) return reduced, True return snapshot(value, budget) stored_parameters, parameters_truncated = snapshot(parameters, 24 * 1024) stored_error, _ = snapshot(error, 8 * 1024) entry = { "tool_call_id": str(tool_call_id)[:128], "tool": str(tool)[:128], "parameters": stored_parameters, "parameters_truncated": parameters_truncated, "started_at": str(started_at)[:64], "completed_at": str(completed_at)[:64], "status": status, "cache_hit": bool(cache_hit), "result": None, "error": stored_error if status == "error" else None, "result_truncated": False, } remaining_slots = MAX_HISTORY_ENTRIES - len(self.entries) - 1 available = max(0, MAX_HISTORY_BYTES - self._current_size_bytes - remaining_slots * RESERVED_METADATA_BYTES_PER_ENTRY - size(entry) - 2) if status == "success" and result is not None: stored_result, truncated = result_snapshot(result, available) entry["result"] = stored_result entry["result_truncated"] = truncated or bool(result.get("truncated", False)) if truncated: self.warnings.add_warning("result_truncated", "Tool result snapshot shortened to fit history budget", tool_call_id=tool_call_id) self._current_size_bytes += size(entry) + 2 self.entries.append(entry) def get_tool_history(self) -> List[Dict[str, Any]]: return list(self.entries) def get_pages_accessed(self) -> List[Dict[str, Any]]: """Page audit is captured before result snapshots are truncated.""" return list(self._pages.values())