mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
The mode from ocw-context/docs/reviewed-auto-mode.md (rev. 4), v1 scope.
coworker/reviewer.py (new)
- The 8.3 prompt verbatim, cache-shaped: instructions + known world (folders
and remotes only) + user-message history in the stable prefix; this turn's
request and ONE action in the suffix.
- parse_verdict: any defect (empty, non-JSON, unknown verdict) -> unsure.
There is no parse path that results in execution (8.5).
- Reviewer.review never raises: provider errors and timeouts -> unsure.
Metering counters (checks / verdicts / tokens) for 1.7.
- AGENT_DENY_MESSAGE: the terse, non-diagnostic refusal the agent gets on a
deny; the full reason goes to the user only (8.4 asymmetry).
coworker/engine.py
- Reviewer consulted ONLY when: attached, mode is AUTO_APPROVE, session
explicitly attended (unset is_attended counts as NOT attended, so
automations can never be reviewed), fewer than two denials this turn.
- Consulted ONLY on decisions the gate marked needs_user - hard denies
never reach it, so it can only turn "ask" into "allow" (1.2).
- One action per request, fired concurrently for all of a turn's escalating
calls before the sequential authorize loop (8.6): a verdict cannot land
on the wrong action, and approval cards still reach the human one at a
time in call order.
- allow -> runs, audited with the reason. deny -> blocked; user event
carries the full reviewer reason + allow_anyway; agent message carries
only AGENT_DENY_MESSAGE. unsure -> today's card.
- Reviewer sees the user's words only, extracted mechanically from
role=user messages - never agent output, never tool results (4.4).
coworker/permissions.py
- Mode.AUTO renamed Mode.BYPASS_APPROVALS ("bypass-approvals"); legacy
"auto" still parses via _missing_ so configs, saved sessions, and the
golden decision table are untouched.
- Mode.AUTO_APPROVE ("auto-approve"): gate-identical to INTERACTIVE except
session grants ("always allow this ...") no longer auto-allow - they
route to the reviewer instead (1.5: out-of-band standing policy may skip
the judge; an in-flow click may not). Config allowlists still skip.
- _domain_allowed(include_session=False) checks the user-settings list only.
coworker/config.py: auto_approve flag, off by default, _GLOBAL_ONLY (a
cloned repo cannot hand itself a looser reviewer). agent.py attaches the
Reviewer only when the flag is on; without it AUTO_APPROVE behaves exactly
like INTERACTIVE.
server/manager.py: autonomy audit ranks auto-approve above interactive
(turning the reviewer on IS raising autonomy) and below bypass.
GUI: mode picker label "Full access" -> "Bypass approvals" (wire value
"auto" kept). Verified live against the real sidecar; e2e spec updated;
tsc and all 111 GUI unit tests pass.
Tests: tests/test_auto_approve.py (33) - gate behaviour per mode, fail-
closed parsing, prompt shape, deny asymmetry, retry guard, attended
gating, hard-deny isolation, per-action verdict landing, and that the
reviewer never sees agent prose. Permission suites + golden table: 146
passing unchanged.
255 lines
9.8 KiB
Python
255 lines
9.8 KiB
Python
"""Textual TUI — the first surface. Renders the engine's event stream, routes approvals
|
||
to a modal, and supports a few slash commands. Talks to the engine in-process for now
|
||
(the OpenAI-compatible server is a later phase)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from textual import work
|
||
from textual.app import App, ComposeResult
|
||
from textual.binding import Binding
|
||
from textual.containers import Horizontal, Vertical
|
||
from textual.screen import ModalScreen
|
||
from textual.widgets import Button, Footer, Header, Input, Label, RichLog, Static
|
||
|
||
from ..agent import build_code_engine
|
||
from ..engine import ApprovalOutcome, PermissionRequest
|
||
from ..events import Event, EventType
|
||
from ..conversations import ConversationStore
|
||
from ..memory import MemoryStore
|
||
from ..permissions import Mode
|
||
from ..providers import ProviderClient
|
||
from ..sessions import SessionRecord
|
||
|
||
|
||
def _short(value: Any, limit: int = 80) -> str:
|
||
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
||
text = text.replace("\n", "\\n")
|
||
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||
|
||
|
||
class ApprovalScreen(ModalScreen[ApprovalOutcome]):
|
||
BINDINGS = [
|
||
Binding("y", "decide('once')", "Approve"),
|
||
Binding("n", "decide('deny')", "Deny"),
|
||
Binding("a", "decide('always_tool')", "Always tool"),
|
||
Binding("c", "decide('always_command')", "Always cmd"),
|
||
]
|
||
|
||
def __init__(self, request: PermissionRequest) -> None:
|
||
super().__init__()
|
||
self.request = request
|
||
|
||
def compose(self) -> ComposeResult:
|
||
r = self.request
|
||
args = ", ".join(f"{k}={_short(v)}" for k, v in (r.arguments or {}).items())
|
||
with Vertical(id="approval"):
|
||
yield Label("Permission required", id="approval-title")
|
||
yield Static(f"tool: {r.tool_name}")
|
||
yield Static(f"args: {args or '(none)'}")
|
||
yield Static(f"reason: {r.reason}")
|
||
with Horizontal(id="approval-buttons"):
|
||
yield Button("Approve (y)", id="once", variant="success")
|
||
yield Button("Deny (n)", id="deny", variant="error")
|
||
yield Button("Always tool (a)", id="always_tool")
|
||
yield Button("Always cmd (c)", id="always_command")
|
||
|
||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||
self.dismiss(ApprovalOutcome(event.button.id))
|
||
|
||
def action_decide(self, outcome: str) -> None:
|
||
self.dismiss(ApprovalOutcome(outcome))
|
||
|
||
|
||
class CoworkerApp(App):
|
||
CSS = """
|
||
#log { border: round $primary 30%; padding: 0 1; }
|
||
#prompt { dock: bottom; }
|
||
#approval { padding: 1 2; border: thick $warning; background: $panel; width: 80%; }
|
||
#approval-title { text-style: bold; color: $warning; }
|
||
#approval-buttons { height: auto; padding-top: 1; }
|
||
#approval-buttons Button { margin-right: 1; }
|
||
"""
|
||
BINDINGS = [
|
||
Binding("ctrl+c", "quit", "Quit"),
|
||
Binding("escape", "interrupt", "Interrupt"),
|
||
]
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
workspace: str | Path,
|
||
model: str = "gpt-5.6-sol",
|
||
mode: Mode = Mode.INTERACTIVE,
|
||
provider: Optional[ProviderClient] = None,
|
||
memory_store: Optional[MemoryStore] = None,
|
||
memory_off: bool = False,
|
||
user_rules: str = "",
|
||
session_store: Optional[ConversationStore] = None,
|
||
session_id: Optional[str] = None,
|
||
resume_messages: Optional[list[dict]] = None,
|
||
) -> None:
|
||
super().__init__()
|
||
self.workspace = Path(workspace).expanduser().resolve()
|
||
self.model = model
|
||
self.mode = mode
|
||
self._provider = provider
|
||
self._memory_store = memory_store
|
||
self._memory_off = memory_off
|
||
self._user_rules = user_rules
|
||
self._session_store = session_store
|
||
self._session_id = session_id
|
||
self._resume_messages = resume_messages
|
||
self.engine = None
|
||
self.rendered: list[str] = [] # plain-text mirror for tests
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header(show_clock=False)
|
||
yield RichLog(id="log", wrap=True, markup=True, highlight=False)
|
||
yield Input(placeholder="Ask the coder… (/help for commands)", id="prompt")
|
||
yield Footer()
|
||
|
||
def on_mount(self) -> None:
|
||
self.engine = build_code_engine(
|
||
workspace=self.workspace,
|
||
model=self.model,
|
||
mode=self.mode,
|
||
approver=self._approve,
|
||
provider=self._provider,
|
||
memory_store=self._memory_store,
|
||
memory_off=self._memory_off,
|
||
user_rules=self._user_rules,
|
||
messages=self._resume_messages,
|
||
)
|
||
self._write(
|
||
f"[b]coworker · code[/b] · model {self.model} · mode {self.mode.value}"
|
||
)
|
||
self._write(f"workspace: {self.workspace}")
|
||
if self._resume_messages:
|
||
self._write(
|
||
f"[dim]resumed session {self._session_id} · "
|
||
f"{len(self._resume_messages)} messages[/dim]"
|
||
)
|
||
self._write("Type a request, or /help for commands.\n")
|
||
self.query_one("#prompt", Input).focus()
|
||
|
||
# -- approvals --------------------------------------------------------------
|
||
async def _approve(self, request: PermissionRequest) -> ApprovalOutcome:
|
||
return await self.push_screen_wait(ApprovalScreen(request))
|
||
|
||
# -- input ------------------------------------------------------------------
|
||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||
text = event.value.strip()
|
||
self.query_one("#prompt", Input).value = ""
|
||
if not text:
|
||
return
|
||
if text.startswith("/"):
|
||
self._handle_command(text)
|
||
return
|
||
self._write(f"[b cyan]you[/b cyan] › {text}")
|
||
self.run_turn(text)
|
||
|
||
@work(exclusive=True)
|
||
async def run_turn(self, text: str) -> None:
|
||
assert self.engine is not None
|
||
try:
|
||
async for event in self.engine.run(text):
|
||
self._render_event(event)
|
||
except Exception as exc: # pragma: no cover - surfaced to the user
|
||
self._write(f"[red]error:[/red] {exc}")
|
||
self._persist_session()
|
||
|
||
def _persist_session(self) -> None:
|
||
if self._session_store is None or self.engine is None or not self._session_id:
|
||
return
|
||
self._session_store.save(
|
||
SessionRecord(
|
||
session_id=self._session_id,
|
||
workspace=str(self.workspace),
|
||
model=self.model,
|
||
mode=self.mode.value,
|
||
messages=self.engine.messages,
|
||
)
|
||
)
|
||
|
||
# -- rendering --------------------------------------------------------------
|
||
def _render_event(self, event: Event) -> None:
|
||
data = event.data
|
||
if event.type is EventType.ASSISTANT_MESSAGE:
|
||
if data.get("text"):
|
||
self._write(f"[b green]assistant[/b green]\n{data['text']}")
|
||
elif event.type is EventType.TOOL_PROPOSED:
|
||
self._write(
|
||
f"[yellow]→ {data['name']}[/yellow] {_short(data.get('arguments'), 100)}"
|
||
)
|
||
elif event.type is EventType.TOOL_FINISHED:
|
||
status = data.get("status")
|
||
tag = "green" if status == "ok" else "red"
|
||
extra = data.get("result_preview") or data.get("reason") or ""
|
||
self._write(
|
||
f" [{tag}]✓ {data['name']} · {status}[/{tag}] {_short(extra, 100)}"
|
||
)
|
||
elif event.type is EventType.INTERRUPTED:
|
||
self._write("[red]⏹ interrupted[/red]")
|
||
elif event.type is EventType.ERROR:
|
||
self._write(f"[red]error: {data.get('error')}[/red]")
|
||
elif event.type is EventType.TURN_END:
|
||
if data.get("status") == "max_iterations_exceeded":
|
||
self._write("[red]⚠ stopped: max iterations reached[/red]")
|
||
|
||
def _write(self, text: str) -> None:
|
||
self.rendered.append(text)
|
||
if self.is_running:
|
||
self.query_one("#log", RichLog).write(text)
|
||
|
||
# -- commands ---------------------------------------------------------------
|
||
def _handle_command(self, command: str) -> None:
|
||
parts = command.split()
|
||
name = parts[0]
|
||
arg = parts[1] if len(parts) > 1 else None
|
||
if name in {"/quit", "/exit"}:
|
||
self.exit()
|
||
elif name == "/help":
|
||
self._write(
|
||
"commands: /mode plan|interactive|auto · /model <id> · /clear · /quit"
|
||
)
|
||
elif name == "/mode" and arg in {"plan", "interactive", "auto", "bypass-approvals", "auto-approve"}:
|
||
self.mode = Mode(arg)
|
||
if self.engine:
|
||
self.engine.permissions.mode = self.mode
|
||
self._write(f"mode → {arg}")
|
||
elif name == "/model" and arg:
|
||
self.model = arg
|
||
if self.engine:
|
||
self.engine.model = arg
|
||
self._write(f"model → {arg}")
|
||
elif name == "/clear":
|
||
if self.engine:
|
||
self.engine.messages = []
|
||
self.engine = build_code_engine(
|
||
workspace=self.workspace,
|
||
model=self.model,
|
||
mode=self.mode,
|
||
approver=self._approve,
|
||
provider=self._provider,
|
||
)
|
||
self.query_one("#log", RichLog).clear()
|
||
self.rendered.clear()
|
||
self._write("conversation cleared")
|
||
else:
|
||
self._write(f"[red]unknown command:[/red] {command}")
|
||
|
||
def action_interrupt(self) -> None:
|
||
if self.engine:
|
||
self.engine.request_interrupt()
|
||
|
||
def action_quit(self) -> None: # type: ignore[override]
|
||
engine = self.engine
|
||
executor = getattr(engine, "executor", None) if engine else None
|
||
if executor:
|
||
executor.close()
|
||
self.exit()
|