mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 07:20:10 +00:00
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
369 lines
13 KiB
Python
369 lines
13 KiB
Python
"""The Inbox — the canonical, cross-session human-attention queue.
|
|
|
|
While a user works in one session (or is away with a session running Unattended), the Inbox
|
|
holds what other agents need from them: an **approval**, a **question**, or a **notification**.
|
|
It is the store of record; messaging connectors / mobile (Phase 3) are transports of the same
|
|
items.
|
|
|
|
Item state machine (the anti-race contract): each item is ``pending → resolved``, resolved
|
|
**once**, idempotent + first-responder-wins — so answering from any surface (in-app, Slack, the
|
|
composer after resuming) is safe. ``inbox_approver`` turns a permission request into an item and
|
|
suspends the agent until that item is resolved.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
KIND_APPROVAL = "approval"
|
|
KIND_QUESTION = "question"
|
|
KIND_NOTIFICATION = "notification"
|
|
KIND_DIRECTORY = "directory" # agent asks to be granted a folder
|
|
KIND_PLAN = "plan" # agent presents a plan for approval
|
|
|
|
STATE_PENDING = "pending"
|
|
STATE_RESOLVED = "resolved"
|
|
|
|
# Where a pending prompt surfaces. INLINE = an attended session answers it in the composer (parked
|
|
# server-side, redelivered on reconnect, never in the cross-session list). INBOX = the user set the
|
|
# session Unattended, so it joins the cross-session Inbox queue. Either way it's the same parked,
|
|
# awaitable, resolve-from-anywhere record — only the visibility differs.
|
|
VIS_INLINE = "inline"
|
|
VIS_INBOX = "inbox"
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def args_preview(arguments: Optional[dict], *, limit: int = 240) -> str:
|
|
"""A compact one-line summary of a tool call's arguments, for an approval card body (so a
|
|
mirrored 'Run `write_file`?' shows *what* — path/content — not just the tool name).
|
|
"""
|
|
parts: list[str] = []
|
|
for k, v in (arguments or {}).items():
|
|
s = v if isinstance(v, str) else json.dumps(v, default=str, ensure_ascii=False)
|
|
s = " ".join(str(s).split()) # collapse whitespace/newlines
|
|
if len(s) > 80:
|
|
s = s[:79] + "…"
|
|
parts.append(f"{k}: {s}")
|
|
out = " · ".join(parts)
|
|
return out[: limit - 1] + "…" if len(out) > limit else out
|
|
|
|
|
|
@dataclass
|
|
class InboxItem:
|
|
id: str
|
|
session_id: str
|
|
kind: str
|
|
title: str
|
|
body: str = ""
|
|
state: str = STATE_PENDING
|
|
resolution: Optional[str] = (
|
|
None # approval: "allow"/"deny"/"always"; question: answer text
|
|
)
|
|
inbox: str = "default" # named inbox / delivery binding (Phase 3 routing)
|
|
created_at: str = field(default_factory=_now)
|
|
resolved_at: Optional[str] = None
|
|
visibility: str = VIS_INBOX # inline (attended) vs inbox (unattended)
|
|
# The tool call this prompt is blocking (durable resume: persisted so a restart can rebuild the
|
|
# suspension and continue the turn). Makes an item idempotent by (session_id, tool_call_id).
|
|
tool_call_id: Optional[str] = None
|
|
# Question metadata (ask_user): optional quick-reply choices + a free-text escape, mirroring
|
|
# the structured-but-always-answerable shape of Claude Code's AskUserQuestion.
|
|
options: list[str] = field(default_factory=list)
|
|
allow_text: bool = (
|
|
True # accept a typed answer even when options exist (the "Other" escape)
|
|
)
|
|
multi: bool = False # allow choosing more than one option
|
|
# Kind-specific payload (directory: suggested path/writable; plan: the plan text; …).
|
|
data: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
class InboxStore:
|
|
def __init__(self, path: Optional[str | Path] = None) -> None:
|
|
self.path = Path(path) if path else None
|
|
self._lock = threading.Lock()
|
|
self._items: dict[str, InboxItem] = {}
|
|
self._waiters: dict[str, asyncio.Event] = {}
|
|
self._load()
|
|
|
|
# -- persistence ------------------------------------------------------------
|
|
def _load(self) -> None:
|
|
if self.path and self.path.is_file():
|
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
for raw in data.get("items", []):
|
|
item = InboxItem(**raw)
|
|
self._items[item.id] = item
|
|
|
|
def _save(self) -> None:
|
|
if not self.path:
|
|
return
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.path.write_text(
|
|
json.dumps({"items": [asdict(i) for i in self._items.values()]}, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# -- adding -----------------------------------------------------------------
|
|
def add(
|
|
self,
|
|
session_id: str,
|
|
kind: str,
|
|
title: str,
|
|
*,
|
|
body: str = "",
|
|
inbox: str = "default",
|
|
visibility: str = VIS_INBOX,
|
|
data: Optional[dict[str, Any]] = None,
|
|
options=None,
|
|
allow_text: bool = True,
|
|
multi: bool = False,
|
|
tool_call_id: Optional[str] = None,
|
|
) -> InboxItem:
|
|
# Idempotent by (session_id, tool_call_id): a durable resume re-raises the same prompt, and
|
|
# must reuse the existing (possibly already-resolved) item rather than re-prompt.
|
|
if tool_call_id:
|
|
existing = self.for_tool_call(session_id, tool_call_id)
|
|
if existing is not None:
|
|
return existing
|
|
item = InboxItem(
|
|
id=uuid.uuid4().hex,
|
|
session_id=session_id,
|
|
kind=kind,
|
|
title=title,
|
|
body=body,
|
|
inbox=inbox,
|
|
visibility=visibility,
|
|
data=dict(data or {}),
|
|
options=list(options or []),
|
|
allow_text=bool(allow_text),
|
|
multi=bool(multi),
|
|
tool_call_id=tool_call_id,
|
|
)
|
|
with self._lock:
|
|
self._items[item.id] = item
|
|
self._save()
|
|
return item
|
|
|
|
def for_tool_call(self, session_id: str, tool_call_id: str) -> Optional[InboxItem]:
|
|
for i in self._items.values():
|
|
if i.session_id == session_id and i.tool_call_id == tool_call_id:
|
|
return i
|
|
return None
|
|
|
|
def add_approval(
|
|
self,
|
|
session_id,
|
|
title,
|
|
*,
|
|
body="",
|
|
inbox="default",
|
|
visibility=VIS_INBOX,
|
|
data=None,
|
|
tool_call_id=None,
|
|
) -> InboxItem:
|
|
# `data` carries the automation-run context for standing scoped approvals (§25):
|
|
# {task_id, task_title, standing_target?} — the in-app card's "Allow every time" gate.
|
|
return self.add(
|
|
session_id,
|
|
KIND_APPROVAL,
|
|
title,
|
|
body=body,
|
|
inbox=inbox,
|
|
visibility=visibility,
|
|
data=data,
|
|
tool_call_id=tool_call_id,
|
|
)
|
|
|
|
def add_question(
|
|
self,
|
|
session_id,
|
|
title,
|
|
*,
|
|
body="",
|
|
inbox="default",
|
|
visibility=VIS_INBOX,
|
|
options=None,
|
|
allow_text=True,
|
|
multi=False,
|
|
tool_call_id=None,
|
|
) -> InboxItem:
|
|
return self.add(
|
|
session_id,
|
|
KIND_QUESTION,
|
|
title,
|
|
body=body,
|
|
inbox=inbox,
|
|
visibility=visibility,
|
|
options=options,
|
|
allow_text=allow_text,
|
|
multi=multi,
|
|
tool_call_id=tool_call_id,
|
|
)
|
|
|
|
def add_directory(
|
|
self,
|
|
session_id,
|
|
title,
|
|
*,
|
|
body="",
|
|
inbox="default",
|
|
visibility=VIS_INBOX,
|
|
data=None,
|
|
tool_call_id=None,
|
|
) -> InboxItem:
|
|
return self.add(
|
|
session_id,
|
|
KIND_DIRECTORY,
|
|
title,
|
|
body=body,
|
|
inbox=inbox,
|
|
visibility=visibility,
|
|
data=data,
|
|
tool_call_id=tool_call_id,
|
|
)
|
|
|
|
def add_plan(
|
|
self,
|
|
session_id,
|
|
title,
|
|
*,
|
|
body="",
|
|
inbox="default",
|
|
visibility=VIS_INBOX,
|
|
data=None,
|
|
tool_call_id=None,
|
|
) -> InboxItem:
|
|
return self.add(
|
|
session_id,
|
|
KIND_PLAN,
|
|
title,
|
|
body=body,
|
|
inbox=inbox,
|
|
visibility=visibility,
|
|
data=data,
|
|
tool_call_id=tool_call_id,
|
|
)
|
|
|
|
def add_notification(
|
|
self, session_id, title, *, body="", inbox="default", visibility=VIS_INBOX
|
|
) -> InboxItem:
|
|
return self.add(
|
|
session_id,
|
|
KIND_NOTIFICATION,
|
|
title,
|
|
body=body,
|
|
inbox=inbox,
|
|
visibility=visibility,
|
|
)
|
|
|
|
# -- queries ----------------------------------------------------------------
|
|
def get(self, item_id: str) -> Optional[InboxItem]:
|
|
return self._items.get(item_id)
|
|
|
|
def list(
|
|
self,
|
|
*,
|
|
session_id: Optional[str] = None,
|
|
state: Optional[str] = None,
|
|
inbox: Optional[str] = None,
|
|
visibility: Optional[str] = None,
|
|
) -> list[InboxItem]:
|
|
out = list(self._items.values())
|
|
if session_id is not None:
|
|
out = [i for i in out if i.session_id == session_id]
|
|
if state is not None:
|
|
out = [i for i in out if i.state == state]
|
|
if inbox is not None:
|
|
out = [i for i in out if i.inbox == inbox]
|
|
if visibility is not None:
|
|
out = [i for i in out if i.visibility == visibility]
|
|
return sorted(out, key=lambda i: i.created_at)
|
|
|
|
def pending(self, session_id: Optional[str] = None) -> list[InboxItem]:
|
|
return self.list(session_id=session_id, state=STATE_PENDING)
|
|
|
|
# -- the state machine ------------------------------------------------------
|
|
def resolve(self, item_id: str, resolution: str) -> bool:
|
|
"""Resolve an item exactly once. First responder wins; later attempts are no-ops
|
|
(return False). Fires any awaiting agent (the suspended inbox_approver)."""
|
|
with self._lock:
|
|
item = self._items.get(item_id)
|
|
if item is None or item.state == STATE_RESOLVED:
|
|
return False
|
|
item.state = STATE_RESOLVED
|
|
item.resolution = resolution
|
|
item.resolved_at = _now()
|
|
self._save()
|
|
waiter = self._waiters.get(item_id)
|
|
if waiter is not None:
|
|
waiter.set()
|
|
return True
|
|
|
|
def resolve_session(
|
|
self, session_id: str, resolution: str = "session deleted"
|
|
) -> int:
|
|
"""Resolve every still-pending item of a session (called when the session is deleted —
|
|
an orphaned approval/question can never be meaningfully answered). Releases any waiter
|
|
the usual way; returns how many items were closed."""
|
|
closed = 0
|
|
for item in self.pending(session_id):
|
|
if self.resolve(item.id, resolution):
|
|
closed += 1
|
|
return closed
|
|
|
|
async def wait(self, item_id: str) -> str:
|
|
"""Await an item's resolution; returns the resolution string. Used by the approver to
|
|
suspend the agent until a human answers (from any surface)."""
|
|
item = self._items.get(item_id)
|
|
if item is not None and item.state == STATE_RESOLVED:
|
|
return item.resolution or ""
|
|
ev = self._waiters.setdefault(item_id, asyncio.Event())
|
|
await ev.wait()
|
|
resolved = self._items.get(item_id)
|
|
return (resolved.resolution if resolved else "") or ""
|
|
|
|
# -- resume reconciliation --------------------------------------------------
|
|
def reconcile_on_resume(self, session_id: str) -> dict:
|
|
"""When a user resumes attended control, surface this session's still-pending items
|
|
inline (one place to answer from now on) plus a recap of what was answered while away.
|
|
Single source of truth: every item already has one authoritative resolution."""
|
|
pending = self.pending(session_id)
|
|
recap = [i for i in self.list(session_id=session_id, state=STATE_RESOLVED)]
|
|
return {
|
|
"pending": [asdict(i) for i in pending],
|
|
"recap": [asdict(i) for i in recap],
|
|
}
|
|
|
|
|
|
# -- approver routing -----------------------------------------------------------
|
|
def inbox_approver(store: InboxStore, session_id: str, *, inbox: str = "default"):
|
|
"""An Approver that routes a permission request to the Inbox and suspends until resolved.
|
|
Maps the resolution to an ApprovalOutcome (allow → ONCE, always → ALWAYS_TOOL, else DENY).
|
|
"""
|
|
from .engine import ApprovalOutcome, PermissionRequest
|
|
|
|
async def approve(request: "PermissionRequest") -> "ApprovalOutcome":
|
|
item = store.add_approval(
|
|
session_id,
|
|
title=f"Run `{request.tool_name}`?",
|
|
body=request.reason or "",
|
|
inbox=inbox,
|
|
)
|
|
resolution = await store.wait(item.id)
|
|
if resolution == "always":
|
|
return ApprovalOutcome.ALWAYS_TOOL
|
|
if resolution == "allow":
|
|
return ApprovalOutcome.ONCE
|
|
return ApprovalOutcome.DENY
|
|
|
|
return approve
|