c901800062
Approval friction (harmless commands kept prompting): - Persist "Allow for this session" grants to approval_allowlist.yaml under a new sessions scope (capped LRU), hydrated lazily, so they survive `opc ui` restarts and re-entering the session instead of living only in memory. - Safe-prefix matching now accepts compound read-only commands: every segment must match a safe prefix, and fd-duplication / /dev/null redirections (2>&1, 2>/dev/null) no longer disqualify a command; real write redirections (>, >>, <) still do. Default safe prefixes gain common read-only commands (cd, cat, head, grep, git log, ...). - First-use approval now gates only MEDIUM+ risk; heuristically LOW actions proceed without a card. - Shell-substitution detection flags eval/source only at command position of a segment (no more false positives on `grep source file`); $(...) and backticks still flag anywhere. Approval card timeout redesign (deferred decisions): - The card's structured approval context (action, allowlist patterns, scopes) now travels through the escalation event into the persisted card metadata. - Timeout without a default action no longer marks the card timed out, and the session-detail reconciler no longer stales deferred-capable cards: the card stays pending and clickable indefinitely, including across restarts. - Clicking after the inline wait expired applies the allowlist grant (approve-once grants the exact command at session scope), resolves the card, and rewrites the reply to target the parked AWAITING_HUMAN checkpoint so the task resumes through the normal message pipeline and the retried command auto-approves. With no parked checkpoint the grant still lands and a helper reply explains the state. Verified: approval engine suite (40) incl. new deferred-decision and compound-command tests, ws_handler + runtime suites green, real escalated commands from project 999 replayed against the user's config now auto-approve while pip install / $(...) / rm -rf / write redirects still prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
5.2 KiB
Python
138 lines
5.2 KiB
Python
"""Escalation engine — handles human-in-the-loop decision points."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from typing import Any, Callable, Coroutine, Optional
|
|
|
|
from loguru import logger
|
|
|
|
from opc.core.models import EscalationType, OPCEvent, Task
|
|
from opc.core.events import EventBus
|
|
|
|
|
|
UserReplyCallback = Callable[[str, list[dict]], Coroutine[Any, Any, Optional[str]]]
|
|
|
|
|
|
class EscalationEngine:
|
|
"""Manages escalation to the human owner for decisions, info, and risk warnings."""
|
|
|
|
def __init__(
|
|
self,
|
|
event_bus: EventBus,
|
|
timeout_seconds: int = 300,
|
|
user_reply_callback: UserReplyCallback | None = None,
|
|
) -> None:
|
|
self.event_bus = event_bus
|
|
self.timeout_seconds = timeout_seconds
|
|
self.user_reply_callback = user_reply_callback
|
|
self._pending: dict[str, asyncio.Event] = {}
|
|
self._replies: dict[str, str] = {}
|
|
|
|
async def escalate(
|
|
self,
|
|
task: Task,
|
|
escalation_type: EscalationType,
|
|
message: str,
|
|
options: list[dict[str, str]] | None = None,
|
|
default_action: str | None = None,
|
|
context: dict[str, Any] | None = None,
|
|
) -> str | None:
|
|
"""Escalate to the user and wait for a reply.
|
|
|
|
Returns the user's reply or the default action on timeout.
|
|
``context`` carries structured approval data (action, allowlist
|
|
patterns, scopes) into the UI card so a decision can still be applied
|
|
after this inline wait has expired.
|
|
"""
|
|
# Use a unique escalation id per prompt so repeated approvals for the
|
|
# same task do not alias to older UI cards or stale pending state.
|
|
escalation_id = f"esc_{task.id}_{uuid.uuid4().hex}"
|
|
|
|
await self.event_bus.publish(OPCEvent(
|
|
event_type="escalation_created",
|
|
payload={
|
|
"escalation_id": escalation_id,
|
|
"task_id": task.id,
|
|
"type": escalation_type.value,
|
|
"message": message,
|
|
"options": options or [],
|
|
"default_action": default_action,
|
|
"approval_context": dict(context or {}),
|
|
},
|
|
))
|
|
|
|
logger.info(f"Escalation [{escalation_type.value}] for task {task.id}: {message}")
|
|
|
|
if self.user_reply_callback:
|
|
try:
|
|
reply = await asyncio.wait_for(
|
|
self.user_reply_callback(message, options or []),
|
|
timeout=self.timeout_seconds,
|
|
)
|
|
if reply is not None:
|
|
await self.event_bus.publish(OPCEvent(
|
|
event_type="escalation_resolved",
|
|
payload={"escalation_id": escalation_id, "reply": reply},
|
|
))
|
|
return reply
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"Escalation {escalation_id} timed out, using default: {default_action}")
|
|
await self.event_bus.publish(OPCEvent(
|
|
event_type="escalation_timeout",
|
|
payload={"escalation_id": escalation_id, "default_action": default_action},
|
|
))
|
|
return default_action
|
|
except Exception as e:
|
|
logger.error(f"Escalation callback error: {e}")
|
|
|
|
return default_action
|
|
|
|
async def escalate_info_needed(self, task: Task, info_description: str) -> str | None:
|
|
return await self.escalate(
|
|
task=task,
|
|
escalation_type=EscalationType.INFO_NEEDED,
|
|
message=f"[INFO NEEDED] Task: {task.title}\nMissing: {info_description}\nPlease provide to continue.",
|
|
)
|
|
|
|
async def escalate_decision(
|
|
self,
|
|
task: Task,
|
|
question: str,
|
|
options: list[dict[str, str]],
|
|
default_action: str | None = None,
|
|
context: dict[str, Any] | None = None,
|
|
) -> str | None:
|
|
metadata = dict(getattr(task, "metadata", {}) or {})
|
|
execution_mode = str(metadata.get("execution_mode", "") or "").strip()
|
|
mode = str(metadata.get("mode", "") or "").strip()
|
|
runtime_kind = str(metadata.get("runtime_kind", "") or "").strip()
|
|
is_task_mode = (
|
|
execution_mode == "task_mode"
|
|
or mode == "task"
|
|
or runtime_kind == "task_mode_agent_turn"
|
|
)
|
|
task_label = (
|
|
str(metadata.get("original_message") or getattr(task, "description", "") or task.title).strip()
|
|
if is_task_mode
|
|
else task.title
|
|
)
|
|
return await self.escalate(
|
|
task=task,
|
|
escalation_type=EscalationType.DECISION_NEEDED,
|
|
message=f"[DECISION NEEDED] Task: {task_label}\n{question}",
|
|
options=options,
|
|
default_action=default_action,
|
|
context=context,
|
|
)
|
|
|
|
async def escalate_risk(self, task: Task, risk_description: str) -> str | None:
|
|
return await self.escalate(
|
|
task=task,
|
|
escalation_type=EscalationType.RISK_WARNING,
|
|
message=f"[RISK WARNING] {risk_description}",
|
|
options=[{"id": "proceed", "label": "Proceed"}, {"id": "abort", "label": "Abort"}],
|
|
default_action="abort",
|
|
)
|