feat(mode): ask_user_question joins the plan allowlist; the section steers to the exit tool

Live-session feedback (a real Zed elicitation round-trip): the model
presented its finished plan as a plain reply and asked the USER to
switch modes — the exact reversal the roadmap warns about — because the
shipped section's 'present it with the exit_plan_mode tool' read as a
suggestion. The section now says a finished plan is delivered by
calling exit_plan_mode, preferred over pasting it as a plain reply or
asking the user to switch modes — firmer, without imperatives.

ask_user_question enters the shipped plan allowlist (asking is
read-only-safe), and the section points a blocked decision at it. The
plan-acp-agent example composes the bash family (default mode only —
plan's allowlist keeps excluding it, so the two modes now demo a real
difference) plus tool-ask-user; both recorded scenarios re-recorded:
the pin now shows plan = [ask_user_question, exit_plan_mode, read,
todo_write] and post-exit default = the full eight-tool surface.
This commit is contained in:
kingwl
2026-07-10 15:05:26 +08:00
parent 14f7976e3d
commit 02e0756b78
12 changed files with 1127 additions and 1029 deletions

View File

@@ -34,9 +34,9 @@ The model-facing exit tool. Its single required argument is the plan text — a
plan:
section: |
You are in plan mode: ...
tools: [read, todo_write, web_search, web_fetch, exit_plan_mode]
tools: [read, todo_write, web_search, web_fetch, ask_user_question, exit_plan_mode]
```
Definitions are validated at load (`resolveConfig`): the built-in `plan` (read-only allowlist, `bash`/`subagent` excluded) merges unless overridden, `default` is rejected as a key, and allowlists may name not-yet-registered tools (registration is dynamic). An unknown name fails loudly at `set()` time.
Definitions are validated at load (`resolveConfig`): the built-in `plan` (read-only allowlist plus `ask_user_question`, `bash`/`subagent` excluded) merges unless overridden, `default` is rejected as a key, and allowlists may name not-yet-registered tools (registration is dynamic). An unknown name fails loudly at `set()` time.
RFC: [plan mode](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -107,12 +107,15 @@ export interface ResolvedModes {
const PLAN_SECTION
= 'You are in plan mode: a read-only planning state. Explore, analyze, and design; '
+ 'do not attempt to modify anything — mutating tools are not available and calls '
+ 'to them are denied. When your plan is ready, present it with the exit_plan_mode '
+ 'tool and wait for the user\'s review. If exit_plan_mode is unavailable or its '
+ 'review fails, ask the user to switch the session out of plan mode instead of '
+ 'retrying denied tools.'
+ 'to them are denied. When a decision or a missing detail blocks the plan, ask the '
+ 'user through the ask_user_question tool where it is available. A finished plan '
+ 'is delivered by calling exit_plan_mode — that call is what puts it in front of '
+ 'the user for review, so prefer it over pasting the plan as a plain reply or '
+ 'asking the user to switch modes themselves. If exit_plan_mode is unavailable or '
+ 'its review fails, ask the user to switch the session out of plan mode instead '
+ 'of retrying denied tools.'
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE]
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', EXIT_PLAN_MODE]
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'

View File

@@ -75,7 +75,7 @@ describe('resolveConfig', () => {
it('merges the built-in plan definition with the read-only allowlist', () => {
const resolved = resolveConfig({})
const plan = resolved.definitions.get(PLAN_MODE)
expect(plan?.tools).toEqual(['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE])
expect(plan?.tools).toEqual(['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', EXIT_PLAN_MODE])
expect(plan?.section).toContain('plan mode')
})