"""Tool verification hook. Invoked after every completed tool call inside the tool-call loops (llm/stream.py and llm/chat.py) and the manual routes (routers/chat.py). For the create/update/delete tools in VERIFY_SCOPE, performs real backend verification: the dispatcher carries a structured ``(result_string, entity_id)`` tuple (UID / rowid returned by the create call, or the resolved id used by the update/delete), and here the entity is re-read from the backend to confirm the write actually landed (creates/updates) or the entity actually disappeared (deletes — verification is inverted: absence proves the deletion). ``verification_status`` values written to the audit log: "verified" ID-based re-read confirmed the expected state (entity present for creates/updates, absent for deletes) — the authoritative signal. "verified_by_fallback" ID unavailable -> content/summary match only (low confidence; NOT a trustworthy positive). "unverified" No ID and fallback match failed. "verification_failed" ID present but backend re-read contradicts the expected state (missing entity after create, or still-present entity after delete). None Tool not in the verification scope, or the tool call itself failed (nothing to verify). Never raises: any failure inside verification is logged and swallowed so it can never break or stall the tool-call loop. """ import asyncio import logging from db import log_tool_call logger = logging.getLogger("piSynapse") # Tools whose create/update/delete outcome is verified against the real # backend. A tool only lands here when the dispatcher can hand out a # structured entity id (or a reliable fallback exists). VERIFY_SCOPE = { "create_task", "create_calendar_event", "save_memory", # D-2: notes trio (create_note/update_note/delete_note) "create_note", "update_note", "delete_note", # D-2: task mutations beyond create "complete_task", "delete_task", # D-2: calendar mutations beyond create (dispatcher already returned uid) "update_calendar_event", "delete_calendar_event", } # Delete verification is INVERTED: the desired post-state is the entity being # absent from the backend, not present. _DELETE_TOOLS = {"delete_note", "delete_task", "delete_calendar_event"} async def run_verification( tool_name: str, params: dict, result: str, success: bool, *, entity_id: str | int | None = None, duration_ms: float | None = None, error: str | None = None, user_id: str | None = None, ) -> tuple[int | None, str | None]: """Verify a completed tool call and record it in the audit log. Deliberately async: the loops are async, and verification may need non-blocking I/O (the SQLite audit write and the backend re-read). Awaited at the call site so verification runs in order, right after the tool completes. Never raises: any failure inside verification (including DB errors) is logged and swallowed so it can never break or stall the tool-call loop. Returns a ``(audit_id, verification_status)`` tuple: ``audit_id`` is the audit_log id the UI attaches its correction to, and ``verification_status`` is the computed status the frontend needs to distinguish a claimed success (the ``is_tool_success`` heuristic) from a backend-verified one. Both are None when the audit write failed or verification was not applicable. Args: tool_name: Name of the tool that ran (e.g. "create_task"). params: The parsed arguments the tool received. result: The result string returned by the tool. success: True if the tool completed without an error, False otherwise. entity_id: Structured ID returned by the dispatcher (UID or rowid). duration_ms: Wall-clock duration of the tool call, in milliseconds. error: Error text when the tool failed (None when successful). """ try: verification_status = await _verify(tool_name, params, result, success, entity_id, user_id) audit_id = await log_tool_call( tool_name, params, success, duration_ms=duration_ms, error=error, verification_status=verification_status, user_id=user_id, ) return audit_id, verification_status except Exception as e: logger.warning(f"Verification hook failed for tool '{tool_name}': {e}") return None, "verification_error" async def _verify( tool_name: str, params: dict, result: str, success: bool, entity_id: str | int | None, user_id: str | None = None, ) -> str | None: """Compute the verification status for a finished tool call. Only tools in VERIFY_SCOPE are verified (other calls return None). A failed tool call has nothing to verify. When an ID is available it is authoritative; the content/summary fallback is used only when the ID is missing and is always marked ``verified_by_fallback``. For delete tools both paths check ABSENCE (the entity must no longer exist anywhere). ``user_id`` scopes the content fallback: an unscoped match could verify a write against ANOTHER user's identical content (multi-user invisibility) — the fallback re-read must be the caller's own data. """ if tool_name not in VERIFY_SCOPE: return None if not success: return None try: if entity_id is not None and entity_id != "": confirmed = await _confirm_by_id(tool_name, entity_id, params) return "verified" if confirmed else "verification_failed" matched = await _fallback_match(tool_name, params, user_id) return "verified_by_fallback" if matched else "unverified" except Exception as e: logger.warning(f"Verification for tool '{tool_name}' failed: {e}") # Visible signal (not silent None/"unverified"): the backend could not # be re-read, so nothing about this write is proven. return "verification_error" async def _confirm_by_id(tool_name: str, entity_id: str | int, params: dict) -> bool: """Re-read the mutated entity and match on ID. Returns True when the backend holds the state the tool claimed to produce: creates/updates → entity present; deletes → entity absent (inverted). """ if tool_name == "create_task": from nextcloud_tasks import list_tasks _, items = await list_tasks(show_completed=True) return any(str(item.get("uid")) == str(entity_id) for item in items) if tool_name == "complete_task": from nextcloud_tasks import list_tasks _, items = await list_tasks(show_completed=True) # The completion must have landed: the task is still present AND # reported as completed. return any( str(item.get("uid")) == str(entity_id) and bool(item.get("completed")) for item in items ) if tool_name == "delete_task": from nextcloud_tasks import list_tasks _, items = await list_tasks(show_completed=True) return not any(str(item.get("uid")) == str(entity_id) for item in items) if tool_name in ("create_calendar_event", "update_calendar_event"): from calendar_ops import list_events _, items = await asyncio.to_thread(list_events, 30) return any(str(item.get("uid")) == str(entity_id) for item in items) if tool_name == "delete_calendar_event": from calendar_ops import list_events _, items = await asyncio.to_thread(list_events, 30) return not any(str(item.get("uid")) == str(entity_id) for item in items) if tool_name == "save_memory": from db import get_db db = await get_db() async with db.execute("SELECT id FROM memories WHERE id = ?", (entity_id,)) as cur: return await cur.fetchone() is not None if tool_name in ("create_note", "update_note"): from nextcloud_notes import list_notes _, items = await list_notes() return any(str(item.get("id")) == str(entity_id) for item in items) if tool_name == "delete_note": from nextcloud_notes import list_notes _, items = await list_notes() return not any(str(item.get("id")) == str(entity_id) for item in items) return False async def _fallback_match(tool_name: str, params: dict, user_id: str | None = None) -> bool: """Content/summary match used only when the ID is unavailable.""" if tool_name == "create_task": from nextcloud_tasks import search_tasks summary = (params.get("summary") or "").strip() if not summary: return False _, items = await search_tasks(summary) return bool(items) if tool_name == "complete_task": from nextcloud_tasks import list_tasks _, items = await list_tasks(show_completed=True) uid = str(params.get("uid") or "").strip() if not uid: return False return any(str(item.get("uid")) == uid and bool(item.get("completed")) for item in items) if tool_name == "delete_task": from nextcloud_tasks import list_tasks _, items = await list_tasks(show_completed=True) uid = str(params.get("uid") or "").strip() if not uid: return False return not any(str(item.get("uid")) == uid for item in items) if tool_name in ("create_calendar_event", "update_calendar_event"): from calendar_ops import list_events summary = (params.get("new_summary") or params.get("summary") or "").strip().lower() if not summary: return False _, items = await asyncio.to_thread(list_events, 30) return any(item.get("summary", "").strip().lower() == summary for item in items) if tool_name == "delete_calendar_event": from calendar_ops import list_events summary = (params.get("summary") or "").strip().lower() if not summary: return False _, items = await asyncio.to_thread(list_events, 30) return not any(item.get("summary", "").strip().lower() == summary for item in items) if tool_name == "save_memory": from config import DEFAULT_USER from db import get_db content = (params.get("content") or "").strip() if not content: return False uid = user_id or DEFAULT_USER db = await get_db() async with db.execute( "SELECT COUNT(*) FROM memories WHERE content = ? AND user_id = ?", (content, uid) ) as cur: row = await cur.fetchone() return bool(row and row[0] > 0) if tool_name == "create_note": from nextcloud_notes import list_notes title = (params.get("title") or "").strip() content = (params.get("content") or "").strip() if not title: return False _, items = await list_notes() for item in items: if (item.get("title") or "").strip().lower() == title.lower(): return True return bool(content) and any( (item.get("content") or "").strip() == content for item in items ) if tool_name == "update_note": from nextcloud_notes import list_notes title = (params.get("title") or "").strip() content = (params.get("content") or "").strip() if not content and not title: return False _, items = await list_notes() for item in items: if content and (item.get("content") or "").strip() == content: return True if not content and title and (item.get("title") or "").strip().lower() == title.lower(): return True return False if tool_name == "delete_note": from nextcloud_notes import list_notes title = (params.get("title") or "").strip() content = (params.get("content") or "").strip() _, items = await list_notes() for item in items: if content and (item.get("content") or "").strip() == content: return False if title and (item.get("title") or "").strip().lower() == title.lower(): return False return True # nothing matches the deleted note → gone return False