3a316551be
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>
168 lines
5.9 KiB
Python
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"}
|