1
/**2
* Plan mode is logged per-agent collaboration state: while active, a3
* deployment-owned guidance section is included in each model request, and4
* `exit_plan_mode` presents the completed plan for user review, while the5
* `/plan off` command lets a user leave directly. Sandbox mode and approval6
* policy enforce restrictions independently and do not read or write plan7
* state.8
*9
* The `plan` projection folds the session log, so resume and fork restore the10
* state. User selections remain pending until the next accepted in-turn11
* pre-step. The service includes the selected state in the proposed step12
* assembly, then appends `plan/mode` from `agent/pre-step` only when the step13
* is accepted. Same-step request retries reuse their assembly.14
*15
* The exit tool remains registered while plan mode is inactive, so entering16
* or leaving plan mode changes only the prompt section, not the request tool17
* catalog.18
*19
* See packages/plan/plan-mode/README.md.20
*21
* @module @deepseek-ai/dsh-plan-mode22
*/24
import { Context, Service } from '@deepseek-ai/cordis'25
import { brandString } from '@deepseek-ai/dsh-brand'26
import { z as zod } from 'zod'27
import type { ZodType } from 'zod'28
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'29
import { createUserMessage } from '@deepseek-ai/dsh-llm'30
import type { ContextFormed } from '@deepseek-ai/dsh-llm'31
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'32
import { defineTool } from '@deepseek-ai/dsh-tools'33
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'34
import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands'35
import type {} from '@deepseek-ai/dsh-session-projection'36
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'37
import type { PlanProjection, PlanUnitState } from './types.ts'38
declare module '@deepseek-ai/dsh-llm' {39
interface MessageSourceMap {40
'plan-mode': { kind: 'plan-mode' } & ContextFormed41
}42
}43
export type * from './types.ts'45
declare module '@deepseek-ai/dsh-session/types' {46
interface SessionEventMap {47
/**48
* Whether plan mode is in force from this point on: log-only, non-surface,49
* whole-value replace. The last `plan/mode` wins; a log with none folds to50
* inactive through the projection unit's fold.51
*/52
'plan/mode': { active: boolean }53
}54
}56
declare module '@deepseek-ai/cordis' {57
interface Context {58
planMode: PlanModeController59
}60
}62
/**63
* The model-facing exit tool's name. It stays registered while plan mode is64
* inactive so the request tool catalog is stable across transitions.65
*/66
export const EXIT_PLAN_MODE = 'exit_plan_mode'68
/** Deployment-owned plan guidance. */69
export interface PlanModeConfig {70
/** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */71
section: string72
}74
/** The review question's id, echoed in the answer this tool reads. */75
const REVIEW_ID = 'plan-review'77
/** The review question's approve option label. */78
const APPROVE_LABEL = 'Approve'80
/** The review question's keep-planning option label. */81
const KEEP_PLANNING_LABEL = 'Keep planning'83
const EXIT_DESCRIPTION84
= 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '85
+ 'The user may approve (carry out the plan from your next step) or keep '86
+ 'planning — their feedback comes back in the tool result; revise and present again.'88
/** The plan's first markdown heading (any level), or `undefined` when it has none. */89
function firstHeading(plan: string): string | undefined {90
for (const line of plan.split('\n')) {91
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)92
if (match) return match[1]93
}94
return undefined95
}97
/**98
* Validate deployment-owned plan guidance. Missing, blank, non-string, or99
* unknown fields fail at plugin load rather than being ignored.100
*101
* @param config Raw plugin config.102
* @returns A detached validated config.103
*/104
export function resolveConfig(config: PlanModeConfig): PlanModeConfig {105
const section = (config as Partial<PlanModeConfig>).section106
if (typeof section !== 'string') {107
throw new Error('PlanModeConfig needs a string `section`')108
}109
if (section.trim() === '') {110
throw new Error('PlanModeConfig needs a non-empty `section`')111
}112
const unknown = Object.keys(config).filter(key => key !== 'section')113
if (unknown.length > 0) {114
throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)115
}116
return { section }117
}119
const planUnitStateSchema: ZodType<PlanUnitState> = zod.object({120
active: zod.boolean(),121
wanted: zod.boolean().nullable(),122
running: zod.object({123
commandId: zod.string() as unknown as ZodType<CommandId>,124
wanted: zod.boolean(),125
}).strict().nullable(),126
activeAtLastHeader: zod.boolean().nullable(),127
}).strict()129
/** Wire payload schema of the `plan` projection. */130
const planProjectionSchema: ZodType<PlanProjection> = zod.object({131
active: zod.boolean(),132
pending: zod.boolean(),133
})135
/** Projection of logged plan selections and committed mode. */136
export const planProjectionDefinition = {137
key: 'plan',138
stateVersion: 3,139
stateSchema: planUnitStateSchema,140
init: () => ({ active: false, wanted: null, running: null, activeAtLastHeader: null }),141
apply: (state, event) => {142
if (event.type === 'command/run' && event.data.name === 'plan') {143
if (event.data.args === undefined) return state144
const wanted = event.data.args.trim() !== 'off'145
return { ...state, running: { commandId: event.data.commandId, wanted } }146
}147
if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) {148
const wanted = event.data.kind === 'success' && state.running.wanted !== state.active149
? state.running.wanted150
: null151
return { ...state, wanted, running: null }152
}153
if (event.type === 'plan/mode') {154
return { ...state, active: event.data.active, wanted: null }155
}156
if (event.type === 'request/header') {157
return { ...state, activeAtLastHeader: state.active }158
}159
return state160
},161
wire: {162
viewSchema: planProjectionSchema,163
view: (state) => {164
const wanted = state.running?.wanted ?? state.wanted165
return { active: state.active, pending: wanted !== null && wanted !== state.active }166
},167
},168
} satisfies ProjectionDefinition<'plan', PlanUnitState>170
/**171
* `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,172
* the `plan:policy` section, the `/plan` command, and the stable exit tool.173
* Client carriers expose the projection's cropped `{ active, pending }` view.174
*/175
export class PlanModeController extends Service {176
static inject = ['tools', 'systemPrompt', 'sessionProjections']178
/** Validated deployment-owned guidance. */179
private readonly section: string181
/**182
* Latest selection per session awaiting the next accepted in-turn pre-step.183
* `narrate` is true for user selections and false for the exit tool, whose184
* result already narrates the transition.185
*/186
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()188
constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {189
super(ctx, 'planMode')190
this.section = resolveConfig(config).section191
let disposed = false192
// Pre-step is outside Session.append publication, so it can append the193
// log-only mode event inside an open turn without re-entering the session.194
// A failed append remains pending for a later accepted in-turn pre-step,195
// and policy cannot block the step.196
ctx.on('agent/pre-step', async (197
{ agent, signal },198
next,199
): Promise<PreStepDecision> => {200
const decision = await next()201
const pending = this.pendingIntents.get(agent.session)202
if (decision.kind === 'reject' || signal.aborted || pending === undefined) return decision203
const narration = this.narration(agent.session, pending.active)204
try {205
this.onBoundary(agent.session)206
} catch (error) {207
ctx.logger.warn('dsh-plan-mode: failed to append selected plan mode at step start: %o', error)208
return decision209
}210
return !pending.narrate || narration === undefined211
? decision212
: { ...decision, messages: [...decision.messages, narration] }213
})214
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime')216
ctx.systemPrompt.section({217
name: 'plan:policy',218
order: ctx.systemPrompt.getSectionOrder('PLAN_POLICY'),219
text: (context) => {220
if (context.agent === undefined) return ''221
const pending = this.pendingIntents.get(context.agent.session)222
return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : ''223
},224
})226
ctx.sessionProjections.register(planProjectionDefinition)228
// The command child activates only when a command registry is composed.229
ctx.inject(['commands'], (commandCtx) => {230
commandCtx.commands.register({231
definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-plan-mode'),232
name: 'plan',233
description: 'Enter or leave plan mode',234
input: { hint: '[off|message]', attachments: true },235
handler: ({ agent, rawInput, attachments }) => {236
const message = rawInput.trim()237
if (message === 'off' && attachments.length > 0) {238
return { kind: 'error', text: 'Attachments cannot accompany /plan off.' }239
}240
if (message === 'off') {241
switch (this.set(agent, false)) {242
case 'committed':243
return { kind: 'success', text: 'Plan mode off.' }244
case 'queued':245
return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }246
case 'cancelled':247
return { kind: 'success', text: 'Plan mode entry cancelled.' }248
case 'noop':249
// Repeat the queued wording while an exit still awaits the250
// next accepted pre-step; only a truly inactive session reads251
// idempotent.252
return this.loggedActive(agent.session)253
? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }254
: { kind: 'success', text: 'Plan mode is already inactive.' }255
}256
}257
const outcome = this.set(agent, true)258
if (message !== '' || attachments.length > 0) {259
agent.steer(createUserMessage({260
content: [261
...attachments,262
...(message === '' ? [] : [{ type: 'text' as const, text: message }]),263
],264
source: { kind: 'user' },265
}))266
}267
return {268
kind: 'success',269
text: outcome === 'committed'270
? 'Plan mode on. Use /plan off to leave.'271
: 'Entering plan mode (applies from the next step). Use /plan off to leave.',272
}273
},274
})275
})277
ctx.tools.register(defineTool({278
name: EXIT_PLAN_MODE,279
description: EXIT_DESCRIPTION,280
parameters: {281
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },282
},283
output: {284
schema: {285
type: 'object',286
additionalProperties: false,287
properties: {288
approved: { type: 'boolean', const: true, required: true },289
},290
},291
render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],292
},293
execute: async (args, exec) => {294
const agent = exec.agent295
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)296
if (!this.loggedActive(agent.session)) {297
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)298
}299
if (!/^#\s+\S/.test(args.plan.trim())) {300
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)301
}302
const interaction = ctx.get('userQuestions')303
if (interaction === undefined) {304
throw new Error('no user-questions channel is available to review the plan; ask the user to switch the session mode instead')305
}306
const answer = await interaction.ask({307
questions: [{308
id: REVIEW_ID,309
header: 'Plan review',310
question: 'Approve this plan and leave plan mode?',311
detail: args.plan,312
options: [313
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },314
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },315
],316
// Presentation only: a capable UI renders the plan as a review317
// decision instead of a generic question, and answers with one of318
// the labels above either way.319
intent: { kind: 'plan-review', approve: APPROVE_LABEL, callId: exec.callId },320
}],321
agent,322
signal: exec.signal,323
}).catch((cause: unknown) => {324
// A dismissed review is not a failed one: the user took the turn back325
// to say something the two options do not cover. Say so, because the326
// generic channel message names ask_user_question, which the model327
// never called. An abort (turn cancel, provider teardown) keeps its328
// own message — there is no user to wait for.329
if (cause instanceof UserQuestionError && cause.code === 'ASK_CANCELLED') {330
throw new Error('The user dismissed the plan review to speak instead; '331
+ 'stay in plan mode, stop here, and wait for their message.')332
}333
throw cause334
})335
// A review may outlive this plugin fiber. Without its pre-step listener,336
// an approved selection could never be appended, so fail and keep planning.337
if (disposed) {338
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')339
}340
const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID)341
const item = reviewItems.length === 1 ? reviewItems[0] : undefined342
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {343
const feedback = item?.custom ?? ''344
throw new Error(feedback === ''345
? 'The user chose to keep planning; revise the plan and present it again.'346
: `The user chose to keep planning; their feedback: ${feedback}`)347
}348
// Keep plan guidance for the rest of this assistant tool batch. The349
// silent selection is appended at the next accepted in-turn pre-step,350
// before its request assembly.351
this.pendingIntents.set(agent.session, { active: false, narrate: false })352
return { approved: true }353
},354
presentCall: args => ({355
card: 'generic',356
title: firstHeading(args.plan) ?? 'Plan',357
kind: 'other',358
content: [{ type: 'text', text: args.plan }],359
}),360
presentResult: (_args, result) => ({361
card: 'generic',362
title: 'Plan review',363
content: result.content,364
}),365
}))366
}368
private loggedActive(session: Session): boolean {369
return this.planState(session).active370
}372
private hasOpenTurn(session: Session): boolean {373
const state = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')374
if (state === undefined) throw new Error('plan-mode requires the turnBoundary session projection')375
return state.openTurnStartSeq !== null376
}378
private loggedActiveAtLastHeader(session: Session): boolean | undefined {379
return this.planState(session).activeAtLastHeader ?? undefined380
}382
/** Read the required plan projection state or fail at the first service access. */383
private planState(session: Session): PlanUnitState {384
const state = this.ctx.sessionProjections.stateOf(session, 'plan')385
if (state === undefined) throw new Error('plan-mode requires the plan session projection')386
return state387
}389
/**390
* Read the logged plan state and any selected state awaiting the next391
* accepted in-turn pre-step.392
*393
* @param agent The agent to read.394
* @returns Current logged state plus a pending selection, when present.395
*/396
get(agent: Agent): { active: boolean; pending?: boolean } {397
const active = this.loggedActive(agent.session)398
const pending = this.pendingIntents.get(agent.session)399
return pending === undefined ? { active } : { active, pending: pending.active }400
}402
/**403
* Select whether plan mode should be active. Between turns the method404
* appends the change immediately because no in-turn pre-step will run until405
* another prompt starts a turn. The open-turn fold is the idle signal:406
* agent status stays `running` through post-turn checkpointing, when no407
* further in-turn pre-step runs. During an open turn the selection remains408
* pending until the next accepted in-turn pre-step. Repeated selection of409
* the current or already-pending state is a no-op.410
*411
* @param agent The agent to switch.412
* @param active Whether plan mode should be active.413
* @returns what happened: `committed` (logged now), `queued` (awaiting the414
* next accepted in-turn pre-step), `cancelled` (an opposite pending selection415
* was cleared; the logged state already matches), or `noop` (already in that416
* state).417
*/418
set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' {419
const session = agent.session420
const pending = this.pendingIntents.get(session)421
const target = pending?.active ?? this.loggedActive(session)422
if (active === target) return 'noop'423
if (this.hasOpenTurn(session)) {424
this.pendingIntents.set(session, { active, narrate: true })425
return this.loggedActive(session) === active ? 'cancelled' : 'queued'426
}427
// No open turn: commit now. Delete only after append succeeds so a428
// failed durable write leaves the selection retryable, not dropped.429
if (active === this.loggedActive(session)) {430
this.pendingIntents.delete(session)431
return 'cancelled'432
}433
session.append('plan/mode', { active })434
this.pendingIntents.delete(session)435
const narration = this.narration(session, active)436
if (narration !== undefined) agent.inject(narration)437
return 'committed'438
}440
/** Append one pending selection before the next request assembly. */441
private onBoundary(session: Session): void {442
const pending = this.pendingIntents.get(session)443
if (pending === undefined) return444
const target = pending.active445
if (target === this.loggedActive(session)) {446
this.pendingIntents.delete(session)447
return448
}449
session.append('plan/mode', { active: target })450
// Delete only after append succeeds so a later accepted in-turn pre-step451
// can retry a failed durable write.452
this.pendingIntents.delete(session)453
}455
/** Build a user-switch notice when the last logged header described the other mode. */456
private narration(session: Session, target: boolean): UserMessage | undefined {457
const told = this.loggedActiveAtLastHeader(session)458
if (told === undefined || told === target) return459
const text = target460
? 'The user switched this session to plan mode.'461
: 'The user switched this session back to the default mode.'462
return createUserMessage({463
content: [{ type: 'text', text }],464
// The narration is already one sentence, so it is its own summary.465
source: { kind: 'plan-mode', form: 'notice', summary: text },466
})467
}468
}470
export default PlanModeController