Files
openworker/coworker/tui/app.py
T
Devika Verma c958d6f262 Step 2: Auto-Approve mode - the reviewer, the hook, and the renames
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.
2026-08-12 12:42:17 -07:00

255 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()