Files
openworker/coworker/inbox.py
T
Rohit C PrasadandDevika 2b45018ffa OpenWorker: initial import
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>
2026-07-21 11:09:41 -07:00

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