mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +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.
251 lines
8.2 KiB
Python
251 lines
8.2 KiB
Python
"""Plan mode tests — read-only enforcement + the propose_plan approval round-trip."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import aisuite as ai
|
|
from coworker.engine import TurnEngine
|
|
from coworker.events import EventType
|
|
from coworker.permissions import Mode, PermissionEngine
|
|
from coworker.providers import (
|
|
AssistantTurn,
|
|
ModelCapabilities,
|
|
ProviderClient,
|
|
ToolCall,
|
|
)
|
|
from coworker.tools import ToolRegistry
|
|
from coworker.tools.plan import propose_plan_tool
|
|
|
|
|
|
def _text_turn(text):
|
|
return AssistantTurn(text=text, finish_reason="stop")
|
|
|
|
|
|
def _tool_turn(name, args, call_id="call_1"):
|
|
return AssistantTurn(
|
|
tool_calls=[ToolCall(id=call_id, name=name, arguments=args)],
|
|
finish_reason="tool_calls",
|
|
)
|
|
|
|
|
|
class ScriptedProvider(ProviderClient):
|
|
def __init__(self, turns):
|
|
self._turns = list(turns)
|
|
|
|
def complete(self, *, model, messages, tools=None, **settings):
|
|
return self._turns.pop(0)
|
|
|
|
def capabilities(self, model):
|
|
return ModelCapabilities()
|
|
|
|
|
|
def _plan_engine(tmp_path, turns, *, plan_approver=None):
|
|
registry = ToolRegistry()
|
|
registry.register_all(ai.toolkits.files(root=str(tmp_path), allow_write=True))
|
|
registry.register(propose_plan_tool())
|
|
permissions = PermissionEngine(workspace_root=tmp_path, mode=Mode.PLAN)
|
|
engine = TurnEngine(
|
|
provider=ScriptedProvider(turns),
|
|
registry=registry,
|
|
permissions=permissions,
|
|
model="gpt-5.5",
|
|
plan_approver=plan_approver,
|
|
)
|
|
return engine, permissions
|
|
|
|
|
|
def _collect(engine, user_input):
|
|
async def _run():
|
|
return [ev async for ev in engine.run(user_input)]
|
|
|
|
return asyncio.run(_run())
|
|
|
|
|
|
def test_plan_mode_blocks_writes_without_asking(tmp_path):
|
|
engine, _ = _plan_engine(
|
|
tmp_path,
|
|
[
|
|
_tool_turn("write_file", {"path": "x.py", "content": "x"}),
|
|
_text_turn("understood, planning instead"),
|
|
],
|
|
)
|
|
events = _collect(engine, "fix the bug")
|
|
types = [e.type for e in events]
|
|
assert EventType.PERMISSION_REQUIRED not in types # blocked, not escalated
|
|
assert not (tmp_path / "x.py").exists()
|
|
finished = next(e for e in events if e.type == EventType.TOOL_FINISHED)
|
|
assert finished.data["status"] == "denied"
|
|
assert any(
|
|
m.get("role") == "tool" and "plan mode is read-only" in m["content"]
|
|
for m in engine.messages
|
|
)
|
|
|
|
|
|
def test_plan_approval_flips_mode_and_executes(tmp_path):
|
|
seen_plans = []
|
|
|
|
async def approve(args, tool_call_id=None):
|
|
seen_plans.append(args.get("plan"))
|
|
return {"approved": True, "mode": "auto"}
|
|
|
|
engine, permissions = _plan_engine(
|
|
tmp_path,
|
|
[
|
|
_tool_turn("propose_plan", {"plan": "1. write x.py 2. verify"}),
|
|
_tool_turn("write_file", {"path": "x.py", "content": "done\n"}, "call_2"),
|
|
_text_turn("implemented"),
|
|
],
|
|
plan_approver=approve,
|
|
)
|
|
events = _collect(engine, "fix the bug")
|
|
types = [e.type for e in events]
|
|
assert EventType.PLAN_PROPOSED in types
|
|
assert seen_plans == ["1. write x.py 2. verify"]
|
|
# same session flipped to auto and executed the write with no approval prompt
|
|
assert permissions.mode is Mode.BYPASS_APPROVALS
|
|
assert EventType.PERMISSION_REQUIRED not in types
|
|
assert (tmp_path / "x.py").read_text() == "done\n"
|
|
|
|
|
|
def test_plan_rejection_keeps_plan_mode_and_returns_feedback(tmp_path):
|
|
async def reject(args, tool_call_id=None):
|
|
return {"approved": False, "feedback": "don't touch x.py, fix y.py instead"}
|
|
|
|
engine, permissions = _plan_engine(
|
|
tmp_path,
|
|
[
|
|
_tool_turn("propose_plan", {"plan": "edit x.py"}),
|
|
_text_turn("revising the plan"),
|
|
],
|
|
plan_approver=reject,
|
|
)
|
|
events = _collect(engine, "fix the bug")
|
|
assert permissions.mode is Mode.PLAN # still read-only
|
|
finished = next(e for e in events if e.type == EventType.TOOL_FINISHED)
|
|
assert finished.data["status"] == "denied"
|
|
assert any(
|
|
m.get("role") == "tool" and "fix y.py instead" in m["content"]
|
|
for m in engine.messages
|
|
)
|
|
|
|
|
|
def test_propose_plan_without_approver_noops(tmp_path):
|
|
engine, permissions = _plan_engine(
|
|
tmp_path,
|
|
[_tool_turn("propose_plan", {"plan": "p"}), _text_turn("ok")],
|
|
)
|
|
events = _collect(engine, "go")
|
|
assert EventType.PLAN_PROPOSED not in [e.type for e in events]
|
|
assert permissions.mode is Mode.PLAN
|
|
assert any(
|
|
m.get("role") == "tool" and "isn't available" in m["content"]
|
|
for m in engine.messages
|
|
)
|
|
|
|
|
|
# -- build_engine wiring ----------------------------------------------------------
|
|
|
|
|
|
class _Stub:
|
|
def complete(self, **kwargs): # pragma: no cover
|
|
raise NotImplementedError
|
|
|
|
def capabilities(self, model):
|
|
return ModelCapabilities()
|
|
|
|
|
|
def test_build_engine_plan_mode_wiring(tmp_path):
|
|
from coworker.agent import build_engine
|
|
from coworker.agents import code_agent
|
|
|
|
engine = build_engine(
|
|
agent=code_agent(), workspace=tmp_path, provider=_Stub(), mode=Mode.PLAN
|
|
)
|
|
try:
|
|
assert "propose_plan" in engine.registry.names()
|
|
# the per-turn reminder is live while planning, gone after the flip
|
|
assert "Plan mode is active" in engine.context_provider()
|
|
engine.permissions.mode = Mode.INTERACTIVE
|
|
assert "Plan mode is active" not in engine.context_provider()
|
|
finally:
|
|
engine.executor.close()
|
|
|
|
|
|
def test_build_engine_interactive_registers_tool_without_reminder(tmp_path):
|
|
from coworker.agent import build_engine
|
|
from coworker.agents import code_agent
|
|
|
|
# The tool is always registered (the GUI can flip a live session into plan mode via
|
|
# set_mode), but the per-turn reminder only appears while actually planning.
|
|
engine = build_engine(agent=code_agent(), workspace=tmp_path, provider=_Stub())
|
|
try:
|
|
assert "propose_plan" in engine.registry.names()
|
|
assert "Plan mode is active" not in engine.context_provider()
|
|
finally:
|
|
engine.executor.close()
|
|
|
|
|
|
def test_discuss_mode_blocks_writes_without_plan_pressure(tmp_path):
|
|
engine, permissions = _plan_engine(
|
|
tmp_path,
|
|
[
|
|
_tool_turn("write_file", {"path": "x.py", "content": "x"}),
|
|
_text_turn("here's what I'd change instead"),
|
|
],
|
|
)
|
|
permissions.mode = Mode.DISCUSS
|
|
events = _collect(engine, "tweak x.py")
|
|
assert not (tmp_path / "x.py").exists()
|
|
assert any(
|
|
m.get("role") == "tool" and "discuss mode is read-only" in m["content"]
|
|
for m in engine.messages
|
|
)
|
|
|
|
|
|
def test_propose_plan_in_discuss_mode_says_describe_instead(tmp_path):
|
|
engine, permissions = _plan_engine(
|
|
tmp_path,
|
|
[_tool_turn("propose_plan", {"plan": "p"}), _text_turn("ok, describing")],
|
|
)
|
|
permissions.mode = Mode.DISCUSS
|
|
events = _collect(engine, "go")
|
|
assert EventType.PLAN_PROPOSED not in [e.type for e in events]
|
|
assert any(
|
|
m.get("role") == "tool" and "describe the proposed changes" in m["content"]
|
|
for m in engine.messages
|
|
)
|
|
|
|
|
|
def test_build_engine_discuss_reminder_not_plan_contract(tmp_path):
|
|
from coworker.agent import build_engine
|
|
from coworker.agents import code_agent
|
|
|
|
engine = build_engine(
|
|
agent=code_agent(), workspace=tmp_path, provider=_Stub(), mode=Mode.DISCUSS
|
|
)
|
|
try:
|
|
ctx = engine.context_provider()
|
|
assert "Discuss mode is active" in ctx
|
|
assert "propose_plan" not in ctx # no planning pressure in discuss mode
|
|
finally:
|
|
engine.executor.close()
|
|
|
|
|
|
def test_propose_plan_outside_plan_mode_is_rejected(tmp_path):
|
|
async def approve(args, tool_call_id=None): # pragma: no cover - must not be called
|
|
raise AssertionError("approver should not run outside plan mode")
|
|
|
|
engine, permissions = _plan_engine(
|
|
tmp_path,
|
|
[_tool_turn("propose_plan", {"plan": "p"}), _text_turn("ok, proceeding")],
|
|
plan_approver=approve,
|
|
)
|
|
permissions.mode = Mode.INTERACTIVE # session was flipped out of plan mode
|
|
events = _collect(engine, "go")
|
|
assert EventType.PLAN_PROPOSED not in [e.type for e in events]
|
|
assert any(
|
|
m.get("role") == "tool" and "not in plan mode" in m["content"]
|
|
for m in engine.messages
|
|
)
|