Skip to content

Commit b4a94b7

Browse files
committed
Add Discuss mode, separate from Plan
Read-only was conflated with plan mode: picking it pushed the agent toward proposing a plan even when the user just wanted to talk safely. - Mode.DISCUSS: same read-only enforcement as plan mode, but no planning contract — the agent explores and describes changes in chat instead of proposing them - propose_plan is now always registered (the GUI flips a live session's mode via set_mode) and rejected outside plan mode, with a discuss-appropriate message - GUI mode menu: Discuss / Plan / Ask for approval / Full access / Custom, ordered by how much the agent may do
1 parent 71e61b3 commit b4a94b7

6 files changed

Lines changed: 115 additions & 14 deletions

File tree

platform/coworker/agent.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
from .tools.shell import LocalExecutor
3636
from .tools.todo import TodoList
3737

38+
# Appended each turn while discuss mode is active: enforcement-only read-only, with no
39+
# pressure toward a plan proposal (that's what distinguishes it from plan mode).
40+
_DISCUSS_MODE_CONTEXT = """\
41+
Discuss mode is active: write and shell tools are disabled. Explore and answer freely; if
42+
the user asks for a change, describe it in chat instead of attempting it (they can switch
43+
to plan or approval mode to have you make it)."""
44+
3845
# Appended to the latest user message every turn while plan mode is active. The mode can
3946
# flip mid-session (plan approval), so this can't live in the static instructions.
4047
_PLAN_MODE_CONTEXT = """\
@@ -219,10 +226,10 @@ def build_engine(
219226
auto_allow_tools=set(config.auto_allow),
220227
roots=root_list or None,
221228
)
222-
# Sessions that START in plan mode get the exit door: propose_plan, whose approval
223-
# flips `permissions.mode` live (the engine handles the round-trip).
224-
if mode is Mode.PLAN:
225-
registry.register(propose_plan_tool())
229+
# The plan-mode exit door. Always registered (surfaces can flip a live session into
230+
# plan mode via set_mode, and the registry is fixed at build); the engine rejects the
231+
# call whenever the session isn't actually in plan mode.
232+
registry.register(propose_plan_tool())
226233

227234
# Per-turn ephemeral context, appended to the latest user message since mid-thread system
228235
# messages aren't reliable across providers. Two producers: the plan-mode reminder (mode can
@@ -238,6 +245,8 @@ def context_provider() -> str:
238245
parts = []
239246
if permissions.mode is Mode.PLAN:
240247
parts.append(_PLAN_MODE_CONTEXT)
248+
elif permissions.mode is Mode.DISCUSS:
249+
parts.append(_DISCUSS_MODE_CONTEXT)
241250
if roots_context is not None:
242251
ctx = roots_context()
243252
if ctx:

platform/coworker/engine.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,21 @@ async def _handle_plan_proposal(self, tool_call: ToolCall) -> AsyncIterator[Even
395395
the user's feedback so the agent can revise."""
396396
args = tool_call.arguments or {}
397397
plan = str(args.get("plan", ""))
398-
if self.plan_approver is None:
399-
result: dict[str, Any] = {
398+
if self.permissions.mode is not Mode.PLAN:
399+
# The tool is always registered (mode can flip mid-session), but proposing a
400+
# plan only means something while the session is actually in plan mode. The
401+
# right next step differs by mode: discuss stays read-only, so the agent
402+
# should talk through the change; write-capable modes should just do it.
403+
if self.permissions.mode is Mode.DISCUSS:
404+
error = (
405+
"not in plan mode — this is discuss mode (read-only), so describe "
406+
"the proposed changes in chat instead"
407+
)
408+
else:
409+
error = "not in plan mode — proceed with the work directly"
410+
result: dict[str, Any] = {"approved": False, "error": error}
411+
elif self.plan_approver is None:
412+
result = {
400413
"approved": False,
401414
"error": "plan approval isn't available here",
402415
}

platform/coworker/permissions.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,20 @@
1515

1616

1717
class Mode(str, Enum):
18-
PLAN = "plan" # read-only
18+
DISCUSS = "discuss" # read-only conversation: no edits, no planning workflow
19+
PLAN = (
20+
"plan" # read-only + the planning contract (explore → propose_plan → execute)
21+
)
1922
INTERACTIVE = "interactive" # ask for approval (default)
2023
AUTO = "auto" # full access
2124
CUSTOM = "custom" # interactive + auto-allow the config's `auto_allow` tools
2225

2326

27+
# Modes whose enforcement is read-only. DISCUSS and PLAN share the same gate; they differ
28+
# only in intent — PLAN additionally drives the agent toward a propose_plan approval.
29+
READ_ONLY_MODES = frozenset({Mode.DISCUSS, Mode.PLAN})
30+
31+
2432
@dataclass
2533
class Decision:
2634
allowed: bool
@@ -74,9 +82,11 @@ def evaluate(
7482
is_shell = tool_name == SHELL_TOOL
7583
consequential = is_write or is_shell or requires_approval
7684

77-
# Plan mode: read-only.
78-
if self.mode is Mode.PLAN and consequential:
79-
return Decision(False, "plan mode is read-only", needs_user=False)
85+
# Discuss / plan modes: read-only.
86+
if self.mode in READ_ONLY_MODES and consequential:
87+
return Decision(
88+
False, f"{self.mode.value} mode is read-only", needs_user=False
89+
)
8090

8191
# Path scoping for writes that name a path (all modes): must land in a writable root.
8292
if is_write:

platform/coworker/server/run.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ def main(argv=None) -> None:
114114
parser.add_argument("--cwd", default=None, help="optional seed/default workspace")
115115
parser.add_argument("--model", default=cfg.model)
116116
parser.add_argument(
117-
"--mode", default=cfg.mode, choices=["plan", "interactive", "auto"]
117+
"--mode",
118+
default=cfg.mode,
119+
choices=["discuss", "plan", "interactive", "auto"],
118120
)
119121
parser.add_argument("--host", default=cfg.host)
120122
parser.add_argument("--port", type=int, default=cfg.port)

platform/surfaces/gui/src/components/Composer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { Dropdown, type Option } from "./Dropdown";
55
import { Icon } from "./Icon";
66

77
const PERMISSION_OPTIONS: Option[] = [
8-
{ value: "plan", label: "Read-only", description: "Suggest changes — don't edit files or run commands" },
8+
{ value: "discuss", label: "Discuss", description: "Chat and explore — no edits or commands" },
9+
{ value: "plan", label: "Plan", description: "Explore read-only, propose a plan for approval, then build" },
910
{ value: "interactive", label: "Ask for approval", description: "Ask before edits and commands" },
1011
{ value: "auto", label: "Full access", description: "Run everything without asking" },
1112
{ value: "custom", label: "Custom", description: "Use auto-allow rules from config.toml" },

platform/tests/test_plan_mode.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,79 @@ def test_build_engine_plan_mode_wiring(tmp_path):
172172
engine.executor.close()
173173

174174

175-
def test_build_engine_interactive_has_no_propose_plan(tmp_path):
175+
def test_build_engine_interactive_registers_tool_without_reminder(tmp_path):
176176
from coworker.agent import build_engine
177177
from coworker.agents import code_agent
178178

179+
# The tool is always registered (the GUI can flip a live session into plan mode via
180+
# set_mode), but the per-turn reminder only appears while actually planning.
179181
engine = build_engine(agent=code_agent(), workspace=tmp_path, provider=_Stub())
180182
try:
181-
assert "propose_plan" not in engine.registry.names()
183+
assert "propose_plan" in engine.registry.names()
182184
assert "Plan mode is active" not in engine.context_provider()
183185
finally:
184186
engine.executor.close()
187+
188+
189+
def test_discuss_mode_blocks_writes_without_plan_pressure(tmp_path):
190+
engine, permissions = _plan_engine(
191+
tmp_path,
192+
[
193+
_tool_turn("write_file", {"path": "x.py", "content": "x"}),
194+
_text_turn("here's what I'd change instead"),
195+
],
196+
)
197+
permissions.mode = Mode.DISCUSS
198+
events = _collect(engine, "tweak x.py")
199+
assert not (tmp_path / "x.py").exists()
200+
assert any(
201+
m.get("role") == "tool" and "discuss mode is read-only" in m["content"]
202+
for m in engine.messages
203+
)
204+
205+
206+
def test_propose_plan_in_discuss_mode_says_describe_instead(tmp_path):
207+
engine, permissions = _plan_engine(
208+
tmp_path,
209+
[_tool_turn("propose_plan", {"plan": "p"}), _text_turn("ok, describing")],
210+
)
211+
permissions.mode = Mode.DISCUSS
212+
events = _collect(engine, "go")
213+
assert EventType.PLAN_PROPOSED not in [e.type for e in events]
214+
assert any(
215+
m.get("role") == "tool" and "describe the proposed changes" in m["content"]
216+
for m in engine.messages
217+
)
218+
219+
220+
def test_build_engine_discuss_reminder_not_plan_contract(tmp_path):
221+
from coworker.agent import build_engine
222+
from coworker.agents import code_agent
223+
224+
engine = build_engine(
225+
agent=code_agent(), workspace=tmp_path, provider=_Stub(), mode=Mode.DISCUSS
226+
)
227+
try:
228+
ctx = engine.context_provider()
229+
assert "Discuss mode is active" in ctx
230+
assert "propose_plan" not in ctx # no planning pressure in discuss mode
231+
finally:
232+
engine.executor.close()
233+
234+
235+
def test_propose_plan_outside_plan_mode_is_rejected(tmp_path):
236+
async def approve(args): # pragma: no cover - must not be called
237+
raise AssertionError("approver should not run outside plan mode")
238+
239+
engine, permissions = _plan_engine(
240+
tmp_path,
241+
[_tool_turn("propose_plan", {"plan": "p"}), _text_turn("ok, proceeding")],
242+
plan_approver=approve,
243+
)
244+
permissions.mode = Mode.INTERACTIVE # session was flipped out of plan mode
245+
events = _collect(engine, "go")
246+
assert EventType.PLAN_PROPOSED not in [e.type for e in events]
247+
assert any(
248+
m.get("role") == "tool" and "not in plan mode" in m["content"]
249+
for m in engine.messages
250+
)

0 commit comments

Comments
 (0)