Files
FabledScribe/src/fabledassistant/services/pending_actions.py
T
bvandeusen 3a316551be feat(curator): authority routing — mutating tools queue for review (C3/5)
The interceptor that closes the loop on the curator review queue.
With this commit, the curator can call update_note / update_milestone
/ update_project / update_profile / delete_note — those calls are
caught by execute_tool's authority='curator' path, snapshotted, and
written to pending_curator_actions for the user to approve or reject
later. Additive tools still run immediately.

services/tools/_registry.py:
- New _CURATOR_MUTATING_TOOLS frozenset: {update_note, update_milestone,
  update_project, update_profile, delete_note}. update_event /
  delete_event intentionally excluded — calendar events should always
  be explicit user intent.
- execute_tool gains a keyword-only  parameter, defaulting
  to 'user'. Default behaviour is unchanged; existing callers keep
  working without changes.
- When authority='curator' AND tool is in _CURATOR_MUTATING_TOOLS,
  _queue_for_review captures a snapshot of the target via a per-tool
  helper and writes a pending action. Returns {success:true,
  pending:true, action_id:N, message:...} so the curator sees the
  call as 'completed' for its bookkeeping.
- Per-tool snapshot helpers: _snapshot_note (covers update_note +
  delete_note — uses the same fuzzy match update_note_tool uses, so
  the snapshot reflects what'd actually be mutated), _snapshot_milestone,
  _snapshot_project, _snapshot_profile. Snapshot capture is best-effort
  — failure logs but still queues with empty snapshot so a curator
  proposal never silently drops.

services/curator.py:
- Allowlist now includes the five mutating tools. They're safe to expose
  because execute_tool intercepts them; the curator can propose without
  being able to actually mutate.
- The execute_tool call now passes authority='curator'.
- System prompt explicitly authorizes the proposal pattern:
  'update_note', 'update_milestone', 'update_project', 'update_profile',
  'delete_note' are described as proposing tools that wait for user
  approval. 'Don't try to update or delete anything' line removed.

services/pending_actions.py:
- approve() now passes authority='user' on the replay so the curator
  interceptor doesn't re-route the replay back into pending and create
  an infinite loop.

What's left in the queue:
- C4: API routes (list/approve/reject endpoints).
- C5: Frontend Needs Review panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:37:55 -04:00

168 lines
5.9 KiB
Python

"""Pending-action service — curator-proposed mutations awaiting user approval.
See models/pending_curator_action.py for the schema rationale. This module
is the API everything else uses:
- `create_pending(...)` is called from execute_tool's curator-authority
interceptor when the curator tries to call a mutating tool. The caller
has already captured a snapshot of the target's current state.
- `list_pending(user_id)` is what the Needs Review panel reads.
- `approve(action_id, user_id)` replays the original tool call via
`execute_tool` with `authority="user"`, which bypasses the curator
interceptor and just runs. On success, the row moves to status='approved'.
- `reject(action_id, user_id)` marks the row 'rejected' without executing.
History row stays around for audit; nothing runs.
"""
from __future__ import annotations
import datetime
import logging
from datetime import timezone
from typing import Any
from sqlalchemy import select, update
from fabledassistant.models import async_session
from fabledassistant.models.pending_curator_action import PendingCuratorAction
logger = logging.getLogger(__name__)
async def create_pending(
*,
user_id: int,
conv_id: int | None,
action_type: str,
target_type: str | None,
target_id: int | None,
target_label: str | None,
payload: dict,
current_snapshot: dict,
) -> PendingCuratorAction:
"""Persist a curator-proposed mutation for later review.
Returns the created row so the interceptor can include its id in the
tool-result envelope (handy for tests, logs, and future UI deep-links).
"""
async with async_session() as session:
row = PendingCuratorAction(
user_id=user_id,
conv_id=conv_id,
action_type=action_type,
target_type=target_type,
target_id=target_id,
target_label=target_label,
payload=payload or {},
current_snapshot=current_snapshot or {},
status="pending",
)
session.add(row)
await session.commit()
await session.refresh(row)
logger.info(
"Curator proposed %s on %s/%s (label=%r) — action_id=%d, conv=%s",
action_type, target_type, target_id, target_label, row.id, conv_id,
)
return row
async def list_pending(user_id: int, limit: int = 50) -> list[dict]:
"""Return the user's pending actions, newest first.
Only `status='pending'` — once a row is approved or rejected, it
drops off this list. Use the index `ix_pending_curator_actions_user_pending`.
"""
async with async_session() as session:
result = await session.execute(
select(PendingCuratorAction)
.where(
PendingCuratorAction.user_id == user_id,
PendingCuratorAction.status == "pending",
)
.order_by(PendingCuratorAction.created_at.desc())
.limit(limit)
)
return [row.to_dict() for row in result.scalars().all()]
async def _load_for_user(action_id: int, user_id: int) -> PendingCuratorAction | None:
async with async_session() as session:
result = await session.execute(
select(PendingCuratorAction).where(
PendingCuratorAction.id == action_id,
PendingCuratorAction.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def _mark_reviewed(action_id: int, status: str) -> None:
async with async_session() as session:
await session.execute(
update(PendingCuratorAction)
.where(PendingCuratorAction.id == action_id)
.values(status=status, reviewed_at=datetime.datetime.now(timezone.utc))
)
await session.commit()
async def approve(action_id: int, user_id: int) -> dict[str, Any]:
"""Replay the curator's proposed action with user authority and mark approved.
The replay path goes through `execute_tool` with `authority="user"` so
the curator interceptor doesn't intercept its own replay (which would
create another pending row and loop forever).
Returns the tool-result dict from execute_tool. Action stays in
`pending` if replay returns an error so the user can retry; only
transitions to `approved` on success.
"""
row = await _load_for_user(action_id, user_id)
if row is None:
return {"success": False, "error": "Pending action not found"}
if row.status != "pending":
return {
"success": False,
"error": f"Action already {row.status}",
}
from fabledassistant.services.tools import execute_tool
try:
# authority="user" bypasses the curator interceptor — required so
# the replay actually executes the mutating tool instead of
# creating another pending row (which would infinite-loop).
result = await execute_tool(
user_id,
row.action_type,
row.payload,
conv_id=row.conv_id,
authority="user",
)
except Exception as e:
logger.exception(
"Replay of curator action %d (%s) failed",
action_id, row.action_type,
)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
if result.get("success", True) and not result.get("error"):
await _mark_reviewed(action_id, "approved")
return result
async def reject(action_id: int, user_id: int) -> dict[str, Any]:
"""Mark the pending action as rejected without executing anything."""
row = await _load_for_user(action_id, user_id)
if row is None:
return {"success": False, "error": "Pending action not found"}
if row.status != "pending":
return {
"success": False,
"error": f"Action already {row.status}",
}
await _mark_reviewed(action_id, "rejected")
return {"success": True, "id": action_id, "status": "rejected"}