Files
deepseek-harness/packages/plan/plan-mode/src/index.ts
imccyu 37f34af15f fix(plan): gate the immediate commit on the open-turn fold, not agent status
The TUI keyless smoke exposed the wrong idle signal: /plan submitted while
the driver is between turns still saw status running (checkpointing), so
the selection queued and the immediate copy never appeared. The commit
gate is now the log's own open-turn fold — plan/mode commits at once
whenever no turn is open, and queues only inside one. Specs drive the
distinction through explicit turn/start / turn/end events instead of a
fake status; the keyless smoke pins the new idle copy on both /plan and
/plan off; regenerated cordis/service catalogs ride along.
2026-07-29 00:46:56 +08:00

445 lines
18 KiB
TypeScript

/**
* Plan mode is logged per-agent collaboration state: while active, a
* deployment-owned guidance section shapes each model request, and
* `exit_plan_mode` presents the completed plan for user review, while the
* `/plan off` command lets a user leave directly. Plan mode is independent of
* sandbox mode and approval policy; those enforcement axes do not read or
* write plan state.
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until an in-turn request boundary because every
* session event is turn-enclosed. The service flushes at `agent/step` before
* the affected request assembly, including retry turns.
*
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
*
* Agent Note:
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
*
* @module @deepseek-ai/dsh-plan-mode
*/
import { Context, Service } from 'cordis'
import { z as zod } from 'zod'
import type { ZodType } from 'zod'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
// Type-only edge: resolves `ctx.commands` for the optional command child.
import type {} from '@deepseek-ai/dsh-commands'
// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
import type { PlanProjection } from './types.ts'
// The `plan` projection-key declaration lives in src/types.ts (its one home);
// this re-export projects the type face onto the package root AND keeps the
// module edge in the emitted index.d.ts, so aggregate programs consuming the
// declarations still receive the SessionProjectionMap merge.
export type * from './types.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Whether plan mode is in force from this point on: log-only, non-surface,
* whole-value replace. The last `plan/mode` wins; a log with none folds to
* inactive through {@link foldPlanMode}.
*/
'plan/mode': { active: boolean }
}
}
declare module 'cordis' {
interface Context {
planMode: PlanModeService
}
}
/**
* The model-facing exit tool's name. It stays registered while plan mode is
* inactive so the request tool catalog is stable across transitions.
*/
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/** Deployment-owned plan guidance. */
export interface PlanModeConfig {
/** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
section: string
}
/** The review question's approve option label. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Use only in plan mode. 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 (carry out the plan from 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 deployment-owned plan guidance. Missing, blank, non-string, or
* unknown fields fail at plugin load rather than silently shaping nothing.
*
* @param config Raw plugin config.
* @returns A detached validated config.
*/
export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
const section = (config as Partial<PlanModeConfig>).section
if (typeof section !== 'string') {
throw new Error('PlanModeConfig needs a string `section`')
}
if (section.trim() === '') {
throw new Error('PlanModeConfig needs a non-empty `section`')
}
const unknown = Object.keys(config).filter(key => key !== 'section')
if (unknown.length > 0) {
throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)
}
return { section }
}
/**
* Whether plan mode is active after the first `end` events. The last
* `plan/mode` wins; a prefix with none is inactive.
*
* @param events The session log or any prefix of it.
* @param end Fold `events[0, end)`; defaults to the whole log.
* @returns Whether plan mode is active.
*/
export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
let active = false
let index = 0
for (const event of events) {
if (index >= end) break
index++
if (event.type === 'plan/mode') active = event.data.active
}
return active
}
/**
* Projection unit state: the logged mode plus the latest logged `/plan`
* selection (`command/run`) not yet resolved by a `plan/mode` commit. Plain
* JSON (persisted-cache precondition).
*/
interface PlanUnitState {
active: boolean
/** The selection's target mode; null when no selection is outstanding. */
wanted: boolean | null
}
/** Wire payload schema of the `plan` projection. */
const planProjectionSchema: ZodType<PlanProjection> = zod.object({
active: zod.boolean(),
pending: zod.boolean(),
})
/** Whether the log holds an opened turn without its closing `turn/end`. */
function hasOpenTurn(events: readonly SessionEvent[]): boolean {
let open = false
for (const event of events) {
if (event.type === 'turn/start') open = true
else if (event.type === 'turn/end') open = false
}
return open
}
/** Plan state at the last logged request header, or `undefined` before the first header. */
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
let lastHeader = -1
let index = 0
for (const event of events) {
if (event.type === 'request/header') lastHeader = index
index++
}
if (lastHeader < 0) return undefined
return foldPlanMode(events, lastHeader + 1)
}
/**
* `ctx.planMode`: owns logged plan state, boundary application and narration,
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
* UIs observe committed flips through `session/event`; there is no live mirror.
*/
export class PlanModeService extends Service {
static inject = ['tools', 'systemPrompt']
/** Validated deployment-owned guidance. */
private readonly section: string
/**
* Latest selection per session awaiting an in-turn request-boundary flush.
* `narrate` is true for user selections and false for the exit tool, whose
* result already narrates the transition.
*/
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
super(ctx, 'planMode')
this.section = resolveConfig(config).section
let disposed = false
// The boundary flush uses the loop's `agent/step` interception seam, not
// post-commit `session/event` observation. `agent/step` runs inside the
// open turn before every request derivation (including turn 1 step 1), so
// it is the sole flush point: prompt admission happens pre-turn, where a
// `plan/mode` append would land outside any open turn. Failures are
// contained so policy cannot block a turn; a failed append remains
// pending for a later boundary.
ctx.on('agent/step', (agent) => {
if (disposed) return
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
ctx.systemPrompt.section({
name: 'plan:policy',
order: 50,
text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
? this.section
: '',
})
// The plan projection unit (session-projection RFC): a pure double-event
// fold serving clients the whole {active, pending} value. `command/run`
// records the user's logged /plan selection (the handler calls `set()`
// before any failing path, so log and run-plane cannot fork); `plan/mode`
// is the boundary commit that resolves it. Pending is thereby a pure
// replay quantity: host restarts, other tabs, and cold reads all recover
// it from the log alone. The unit child activates only when a projection
// registry is composed (headless assemblies stay unaffected).
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'plan', PlanUnitState>({
key: 'plan',
schema: planProjectionSchema,
init: () => ({ active: false, wanted: null }),
apply: (state, event) => {
if (event.type === 'command/run' && event.data.name === 'plan') {
const wanted = event.data.args.trim() !== 'off'
return wanted === state.wanted ? state : { active: state.active, wanted }
}
if (event.type === 'plan/mode') {
return { active: event.data.active, wanted: null }
}
return state
},
view: state => ({
active: state.active,
pending: state.wanted !== null && state.wanted !== state.active,
}),
stateVersion: 1,
})
})
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'plan',
description: 'Enter or leave plan mode',
input: { hint: '[off|message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
if (message === 'off') {
switch (this.set(agent, false)) {
case 'committed':
return { kind: 'success', text: 'Plan mode off.' }
case 'queued':
return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
case 'cancelled':
return { kind: 'success', text: 'Plan mode entry cancelled.' }
case 'noop':
// Repeat the queued wording while an exit still awaits its
// boundary; only a truly inactive session reads idempotent.
return foldPlanMode(agent.session.events)
? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
: { kind: 'success', text: 'Plan mode is already inactive.' }
}
}
const outcome = this.set(agent, true)
if (message !== '') agent.steer(createUserMessage({ content: [{ type: 'text', text: message }], source: { kind: 'user' } }))
return {
kind: 'success',
text: outcome === 'committed'
? 'Plan mode on. Use /plan off to leave.'
: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
}
},
})
})
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.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
approved: { type: 'boolean', const: true, required: true },
},
},
render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],
},
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 (!foldPlanMode(agent.session.events)) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
if (!/^#\s+\S/.test(args.plan.trim())) {
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
}
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?',
detail: args.plan,
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
signal: exec.signal,
})
// A review may outlive this plugin fiber. Without boundary listeners,
// an approved result could never land, so fail and keep planning.
if (disposed) {
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
}
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
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}`)
}
// Keep plan guidance for the rest of this assistant tool batch. The
// silent intent flushes after the step, before the next assembly.
this.pendingIntents.set(agent.session, { active: false, narrate: false })
return { approved: true }
},
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,
}),
}))
}
/**
* Read the logged plan state and any selected state awaiting a boundary.
*
* @param agent The agent to read.
* @returns Current logged state plus a pending selection, when present.
*/
get(agent: Agent): { active: boolean; pending?: boolean } {
const active = foldPlanMode(agent.session.events)
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { active } : { active, pending: pending.active }
}
/**
* Select whether plan mode should be active. Between turns the change
* commits immediately — no request boundary would arrive until the next
* prompt, so a queued intent would hang (the open-turn fold is the idle
* signal: agent status stays `running` through post-turn checkpointing,
* where a boundary equally never comes). During an open turn the
* selection is held as pending intent for the next in-turn request
* boundary. Repeated selection of the current or already-pending state is
* a no-op.
*
* @param agent The agent to switch.
* @param active Whether plan mode should be active.
* @returns what happened: `committed` (logged now), `queued` (awaiting the
* next boundary), `cancelled` (an opposite pending selection was cleared;
* the logged state already matches), or `noop` (already in that state).
*/
set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' {
const session = agent.session
const pending = this.pendingIntents.get(session)
const target = pending?.active ?? foldPlanMode(session.events)
if (active === target) return 'noop'
if (hasOpenTurn(session.events)) {
this.pendingIntents.set(session, { active, narrate: true })
return foldPlanMode(session.events) === active ? 'cancelled' : 'queued'
}
// No open turn: commit now. Delete only after append succeeds so a
// failed durable write leaves the selection retryable, not dropped.
if (active === foldPlanMode(session.events)) {
this.pendingIntents.delete(session)
return 'cancelled'
}
session.append('plan/mode', { active })
this.pendingIntents.delete(session)
this.narrate(session, active)
return 'committed'
}
/** Flush one pending selection before the next request assembly. */
private onBoundary(agent: Agent): void {
const session = agent.session
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.active
if (target === foldPlanMode(session.events)) {
this.pendingIntents.delete(session)
return
}
session.append('plan/mode', { active: target })
// Delete only after append succeeds so a later boundary can retry a failed
// durable write.
this.pendingIntents.delete(session)
if (pending.narrate) this.narrate(session, target)
}
/** Tell the model about a user switch when the last logged header described the other mode. */
private narrate(session: Session, target: boolean): void {
const told = planModeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target
? 'The user switched this session to plan mode.'
: 'The user switched this session back to the default mode.'
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'plan-mode' },
}), { surfaceOp: 'append' })
}
}
export default PlanModeService