返回源码地图

packages/plan/plan-mode/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 17 / 17 / 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Plan mode is logged per-agent collaboration state: while active, a
3 * deployment-owned guidance section is included in each model request, and
4 * `exit_plan_mode` presents the completed plan for user review, while the
5 * `/plan off` command lets a user leave directly. Sandbox mode and approval
6 * policy enforce restrictions independently and do not read or write plan
7 * state.
8 *
9 * The `plan` projection folds the session log, so resume and fork restore the
10 * state. User selections remain pending until the next accepted in-turn
11 * pre-step. The service includes the selected state in the proposed step
12 * assembly, then appends `plan/mode` from `agent/pre-step` only when the step
13 * is accepted. Same-step request retries reuse their assembly.
14 *
15 * The exit tool remains registered while plan mode is inactive, so entering
16 * or leaving plan mode changes only the prompt section, not the request tool
17 * catalog.
18 *
19 * See packages/plan/plan-mode/README.md.
20 *
21 * @module @deepseek-ai/dsh-plan-mode
22 */
23
24import { Context, Service } from '@deepseek-ai/cordis'
25import { brandString } from '@deepseek-ai/dsh-brand'
26import { z as zod } from 'zod'
27import type { ZodType } from 'zod'
28import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
29import { createUserMessage } from '@deepseek-ai/dsh-llm'
30import type { ContextFormed } from '@deepseek-ai/dsh-llm'
31import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
32import { defineTool } from '@deepseek-ai/dsh-tools'
33import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
34import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands'
35import type {} from '@deepseek-ai/dsh-session-projection'
36import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
37import type { PlanProjection, PlanUnitState } from './types.ts'
38declare module '@deepseek-ai/dsh-llm' {
39 interface MessageSourceMap {
40 'plan-mode': { kind: 'plan-mode' } & ContextFormed
41 }
42}
43export type * from './types.ts'
44
45declare 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 to
50 * inactive through the projection unit's fold.
51 */
52 'plan/mode': { active: boolean }
53 }
54}
55
56declare module '@deepseek-ai/cordis' {
57 interface Context {
58 planMode: PlanModeController
59 }
60}
61
62/**
63 * The model-facing exit tool's name. It stays registered while plan mode is
64 * inactive so the request tool catalog is stable across transitions.
65 */
66export const EXIT_PLAN_MODE = 'exit_plan_mode'
67
68/** Deployment-owned plan guidance. */
69export interface PlanModeConfig {
70 /** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
71 section: string
72}
73
74/** The review question's id, echoed in the answer this tool reads. */
75const REVIEW_ID = 'plan-review'
76
77/** The review question's approve option label. */
78const APPROVE_LABEL = 'Approve'
79
80/** The review question's keep-planning option label. */
81const KEEP_PLANNING_LABEL = 'Keep planning'
82
83const EXIT_DESCRIPTION
84 = '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.'
87
88/** The plan's first markdown heading (any level), or `undefined` when it has none. */
89function 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 undefined
95}
96
97/**
98 * Validate deployment-owned plan guidance. Missing, blank, non-string, or
99 * unknown fields fail at plugin load rather than being ignored.
100 *
101 * @param config Raw plugin config.
102 * @returns A detached validated config.
103 */
104export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
105 const section = (config as Partial<PlanModeConfig>).section
106 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}
118
119const 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()
128
129/** Wire payload schema of the `plan` projection. */
130const planProjectionSchema: ZodType<PlanProjection> = zod.object({
131 active: zod.boolean(),
132 pending: zod.boolean(),
133})
134
135/** Projection of logged plan selections and committed mode. */
136export 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 state
144 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.active
149 ? state.running.wanted
150 : null
151 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 state
160 },
161 wire: {
162 viewSchema: planProjectionSchema,
163 view: (state) => {
164 const wanted = state.running?.wanted ?? state.wanted
165 return { active: state.active, pending: wanted !== null && wanted !== state.active }
166 },
167 },
168} satisfies ProjectionDefinition<'plan', PlanUnitState>
169
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 */
175export class PlanModeController extends Service {
176 static inject = ['tools', 'systemPrompt', 'sessionProjections']
177
178 /** Validated deployment-owned guidance. */
179 private readonly section: string
180
181 /**
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, whose
184 * result already narrates the transition.
185 */
186 private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
187
188 constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
189 super(ctx, 'planMode')
190 this.section = resolveConfig(config).section
191 let disposed = false
192 // Pre-step is outside Session.append publication, so it can append the
193 // 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 decision
203 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 decision
209 }
210 return !pending.narrate || narration === undefined
211 ? decision
212 : { ...decision, messages: [...decision.messages, narration] }
213 })
214 ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime')
215
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 })
225
226 ctx.sessionProjections.register(planProjectionDefinition)
227
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 the
250 // next accepted pre-step; only a truly inactive session reads
251 // 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 })
276
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.agent
295 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 review
317 // decision instead of a generic question, and answers with one of
318 // 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 back
325 // to say something the two options do not cover. Say so, because the
326 // generic channel message names ask_user_question, which the model
327 // never called. An abort (turn cancel, provider teardown) keeps its
328 // 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 cause
334 })
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] : undefined
342 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. The
349 // 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 }
367
368 private loggedActive(session: Session): boolean {
369 return this.planState(session).active
370 }
371
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 !== null
376 }
377
378 private loggedActiveAtLastHeader(session: Session): boolean | undefined {
379 return this.planState(session).activeAtLastHeader ?? undefined
380 }
381
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 state
387 }
388
389 /**
390 * Read the logged plan state and any selected state awaiting the next
391 * 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 }
401
402 /**
403 * Select whether plan mode should be active. Between turns the method
404 * appends the change immediately because no in-turn pre-step will run until
405 * another prompt starts a turn. The open-turn fold is the idle signal:
406 * agent status stays `running` through post-turn checkpointing, when no
407 * further in-turn pre-step runs. During an open turn the selection remains
408 * pending until the next accepted in-turn pre-step. Repeated selection of
409 * 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 the
414 * next accepted in-turn pre-step), `cancelled` (an opposite pending selection
415 * was cleared; the logged state already matches), or `noop` (already in that
416 * state).
417 */
418 set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' {
419 const session = agent.session
420 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 a
428 // 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 }
439
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) return
444 const target = pending.active
445 if (target === this.loggedActive(session)) {
446 this.pendingIntents.delete(session)
447 return
448 }
449 session.append('plan/mode', { active: target })
450 // Delete only after append succeeds so a later accepted in-turn pre-step
451 // can retry a failed durable write.
452 this.pendingIntents.delete(session)
453 }
454
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) return
459 const text = target
460 ? '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}
469
470export default PlanModeController