feat(mode): exit_plan_mode + the ACP session-mode picker + scriptable review answers

Plan mode's stage 2 (RFC 2026-07-07-plan-mode). The exit tool: one
required plan argument (the durable log artifact), execute re-checks the
folded mode, then conducts the review over the user-interaction seam —
one single-select question (Approve / Keep planning) with free text open
— so an approval appends mode/set back to default in-turn and every
other outcome (keep-planning feedback verbatim, aborted, no provider)
returns the corrective isError with the mode unchanged. presentCall is a
generic card titled by the plan's first heading carrying the plan
markdown; over ACP the review rides the ask_user elicitation flow, in
the terminal the stdio prompt queue — no approval-seam dependency.

The ACP bridge maps the picker 1:1 onto ctx.modes (opportunistic, a
type-only peer edge): session/new + session/load advertise
availableModes/currentModeId, session/set_mode validates through set()
and echoes an optimistic current_mode_update (the pending mode IS the
selection; the logged mode/set lands at the boundary and, matching, is
not re-sent), and a session/event listener re-notifies on each logged
flip that differs from the last sent — the tool-driven exit updates the
picker. The feature matrix rows move from 'not modeled' to the
picker-to-modes / knobs-to-config-options division, with the ACP v2
removal direction recorded as a mechanical-migration risk.

The snapshot harness gains the setMode/setModeExpectError ops and a
scripted elicitationAnswers FIFO (cancel on exhaustion; a stray choice
string reaches the agent verbatim as a non-consenting custom answer, so
a scenario bug fails safe). The suite factory's header-pin requirement
now applies only to model-turn scenarios — a protocol-only suite has no
header content to anchor. examples/plan-acp-agent is the live
composition; its keyless modes-advertise scenario pins the wire surface
(advertisement, both set_mode round-trips, unknown-id rejection). The
recorded plan-mode approve/reject arc awaits a with-key recording
session; its texts are pinned at the unit tier meanwhile.

examples/AGENTS.md ceiling 653 → 680: the new example's required smoke
row does not fit the old budget.
This commit is contained in:
kingwl
2026-07-10 02:57:40 +08:00
parent 63ced3e0e2
commit edc065666f
43 changed files with 947 additions and 50 deletions

View File

@@ -4,6 +4,6 @@ Session modes: named, logged, per-agent policy states, with **plan mode** as the
| Package | Role | ctx key |
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section) and the hard layer (`tools/pre-execute` deny-by-default gate) | `ctx.modes` |
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section), the hard layer (`tools/pre-execute` deny-by-default gate), and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery; the default mode is the absence of policy, keeping the plugin invisible until a mode is set. UIs read flips off `session/event`: the [stdio app](../ui/stdio-agent) exposes `/mode`, the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. RFC: [plan mode](../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).

View File

@@ -10,7 +10,7 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
## Two layers of enforcement
**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header-delta` on the next step. The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible; adding `exit_plan_mode` resorts the canonical tool list, which the delta encoding cannot express, so entering plan mode logs the full fallback snapshot). The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent.
@@ -20,6 +20,10 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
`AgentOptions.mode` (declaration-merged) seeds a child's initial mode through the same pending-intent flush; explicit options beat the logged baseline on create AND resume. A fork child needs no mechanism — the parent's `mode/set` is inside the seeded prefix.
## `exit_plan_mode`
The model-facing exit tool. Its single required argument is the plan text — a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` re-checks the folded mode, then conducts the review over the user-interaction seam (`ctx.get('userInteraction')`, opportunistic): one single-select question — Approve, or Keep planning — with the free-text channel open. Approve appends `mode/set { mode: 'default' }` in-turn and the next step's assembly restores the full toolset; every other outcome (keep-planning with the user's feedback verbatim, an aborted question, no provider) returns the corrective `isError` and the mode stays `plan`. `presentCall` renders a `generic` card titled by the plan's first heading with the plan markdown as content; over ACP the review rides the same elicitation flow as `ask_user_question`, in the terminal the stdio provider's prompt queue.
## Config
```yaml

View File

@@ -26,6 +26,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
@@ -35,6 +36,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -26,8 +26,10 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -112,6 +114,27 @@ const PLAN_SECTION
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE]
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Present your plan for the user\'s review and, on approval, leave plan mode. '
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ 'The user may approve (the full toolset returns on your next step) or keep '
+ 'planning — their feedback comes back in the tool result; revise and present again.'
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate the config and merge the built-in `plan` definition (explicit
* resolve step — the `dsh-bash` request/spec template). Fail-loud: a
@@ -240,6 +263,61 @@ export class ModesService extends Service {
: `tool "${exec.name}" is not available in "${active.name}" mode`
return Promise.resolve({ kind: 'deny', reason })
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
execute: async (_args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (this.activeDefinition(agent.session)?.name !== PLAN_MODE) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
const interaction = ctx.get('userInteraction')
if (interaction === undefined) {
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the full toolset returns on the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
...exec.signal ? { signal: exec.signal } : {},
})
const item = answer.answers.find(entry => entry.id === 'plan-review')
if (!item?.selected.includes(APPROVE_LABEL)) {
// A custom-text-only answer is feedback, not consent — approval is
// exactly the approve option (an unknown selection never exits).
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
agent.session.append('mode/set', { mode: DEFAULT_MODE })
const note = item.custom === undefined || item.custom === '' ? '' : ` User note: ${item.custom}`
return [{ type: 'text', text: `Plan approved — plan mode exited; the full toolset returns on your next step.${note}` }]
},
presentCall: args => ({
card: 'generic',
title: firstHeading(args.plan) ?? 'Plan',
kind: 'other',
content: [{ type: 'text', text: args.plan }],
}),
presentResult: (_args, result) => ({
card: 'generic',
title: 'Plan review',
content: result.content,
}),
}))
}
/**

View File

@@ -78,7 +78,7 @@ describe('plan mode through the agent loop', () => {
const header = findEvent(log, 'request/header')
expect(modeSet.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['read'])
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(header.data.header.system).toContain('plan mode')
const result = findEvent(log, 'tool/result')
@@ -110,8 +110,12 @@ describe('plan mode through the agent loop', () => {
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
const delta = findEvent(log, 'request/header-delta')
expect(delta.data.tools).toBeDefined()
expect(delta.data.system).toBeDefined()
// The narrowing header change is logged as a FULL fallback snapshot, not a
// delta: adding exit_plan_mode reorders the canonical tool list (it sorts
// first), and a pure reordering is inexpressible in the delta encoding.
const second = findEvent(log, 'request/header', 'last')
expect(second.data.reason).toBe('fallback')
expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(second.data.header.system).toContain('plan mode')
})
})

View File

@@ -6,6 +6,7 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
@@ -283,7 +284,7 @@ describe('the boundary flush', () => {
describe('the soft layer', () => {
it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
@@ -292,7 +293,7 @@ describe('the soft layer', () => {
it('leaves an agent-less assembly untouched', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read'])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
@@ -300,7 +301,7 @@ describe('the soft layer', () => {
it('filters plan-mode tools to the allowlist and renders the mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -310,7 +311,7 @@ describe('the soft layer', () => {
it('drops exit_plan_mode outside plan mode even when a custom allowlist names it', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing', tools: ['read', EXIT_PLAN_MODE] } } })
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -320,7 +321,7 @@ describe('the soft layer', () => {
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -382,3 +383,172 @@ describe('the hard layer', () => {
expect(result.isError).toBe(false)
})
})
describe('exit_plan_mode', () => {
async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
const ctx = await setup()
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
if (answer !== undefined) {
ctx.userInteraction.registerProvider({
ask: (request) => {
asked.push(request)
return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
},
})
}
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
return { ctx, agent, asked }
}
function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
return ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: EXIT_PLAN_MODE,
arguments: { plan },
...agent ? { agent } : {},
})
}
it('registers the tool with one required plan argument', async () => {
const ctx = await setup()
const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE)
const parameters = schema?.parameters as { required?: string[]; properties?: Record<string, unknown> }
expect(Object.keys(parameters.properties ?? {})).toEqual(['plan'])
expect(parameters.required).toEqual(['plan'])
})
it('rejects an agent-less call', async () => {
const ctx = await setup()
const result = await callExit(ctx, undefined)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
})
it('rejects a call outside plan mode (defense in depth behind the gate)', async () => {
const ctx = await setup()
const agent = agentWithSession()
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
})
it('degrades to the manual exit when no user-interaction seam is composed', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
const { ctx, agent } = await setupWithReview()
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('approve: appends mode/set default in-turn and confirms', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; the full toolset returns on your next step.' }])
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
})
it('approve with a note carries the note into the confirmation', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'ship it small' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; the full toolset returns on your next step. User note: ship it small' }])
})
it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('keep planning without feedback returns the generic corrective error', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'] })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
})
it('a custom-text-only answer is feedback, never consent', async () => {
const { ctx, agent } = await setupWithReview({ selected: [], custom: 'add tests first' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('a missing answer item reads as keep-planning', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
})
it('forwards the execution abort signal to the review question', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const controller = new AbortController()
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: EXIT_PLAN_MODE,
arguments: { plan: '# P' },
agent,
signal: controller.signal,
})
expect(result.isError).toBe(false)
expect(asked[0]?.signal).toBe(controller.signal)
})
it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('presents the call as a generic card titled by the plan first heading', async () => {
const ctx = await setup()
const def = ctx.tools.get(EXIT_PLAN_MODE)!
expect(def.presentCall?.({ plan: '## Fix the flake\n\nsteps' })).toEqual({
card: 'generic',
title: 'Fix the flake',
kind: 'other',
content: [{ type: 'text', text: '## Fix the flake\n\nsteps' }],
})
expect(def.presentCall?.({ plan: 'no heading here' })).toEqual({
card: 'generic',
title: 'Plan',
kind: 'other',
content: [{ type: 'text', text: 'no heading here' }],
})
})
it('presents the result as a generic review card', async () => {
const ctx = await setup()
const def = ctx.tools.get(EXIT_PLAN_MODE)!
const content = [{ type: 'text' as const, text: 'ok' }]
expect(def.presentResult?.({ plan: '# P' }, { content, isError: false })).toEqual({
card: 'generic',
title: 'Plan review',
content,
})
})
})

View File

@@ -25,6 +25,9 @@
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../ui/user-interaction"
}
]
}