FastAPI app, upstream Confluence/model adapters, authoritative history, rootless container lifecycle, artifact storage and downloads, fake peers under backend/dev, tests under tests/backend. Root pytest.ini deselects the live marker by default; requirements gain the backend dependencies.
209 lines
9.0 KiB
Python
209 lines
9.0 KiB
Python
"""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())
|