index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  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. * Agent Note:
  20. * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
  21. *
  22. * @module @deepseek-ai/dsh-plan-mode
  23. */
  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 { Session, UserMessage } from '@deepseek-ai/dsh-session'
  31. import { defineTool } from '@deepseek-ai/dsh-tools'
  32. import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
  33. import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands'
  34. import type {} from '@deepseek-ai/dsh-session-projection'
  35. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  36. import type { PlanProjection, PlanUnitState } from './types.ts'
  37. export type * from './types.ts'
  38. declare module '@deepseek-ai/dsh-session/types' {
  39. interface SessionEventMap {
  40. /**
  41. * Whether plan mode is in force from this point on: log-only, non-surface,
  42. * whole-value replace. The last `plan/mode` wins; a log with none folds to
  43. * inactive through the projection unit's fold.
  44. */
  45. 'plan/mode': { active: boolean }
  46. }
  47. }
  48. declare module '@deepseek-ai/cordis' {
  49. interface Context {
  50. planMode: PlanModeController
  51. }
  52. }
  53. /**
  54. * The model-facing exit tool's name. It stays registered while plan mode is
  55. * inactive so the request tool catalog is stable across transitions.
  56. */
  57. export const EXIT_PLAN_MODE = 'exit_plan_mode'
  58. /** Deployment-owned plan guidance. */
  59. export interface PlanModeConfig {
  60. /** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
  61. section: string
  62. }
  63. /** The review question's id, echoed in the answer this tool reads. */
  64. const REVIEW_ID = 'plan-review'
  65. /** The review question's approve option label. */
  66. const APPROVE_LABEL = 'Approve'
  67. /** The review question's keep-planning option label. */
  68. const KEEP_PLANNING_LABEL = 'Keep planning'
  69. const EXIT_DESCRIPTION
  70. = 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
  71. + 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
  72. + 'The user may approve (carry out the plan from your next step) or keep '
  73. + 'planning — their feedback comes back in the tool result; revise and present again.'
  74. /** The plan's first markdown heading (any level), or `undefined` when it has none. */
  75. function firstHeading(plan: string): string | undefined {
  76. for (const line of plan.split('\n')) {
  77. const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
  78. if (match) return match[1]
  79. }
  80. return undefined
  81. }
  82. /**
  83. * Validate deployment-owned plan guidance. Missing, blank, non-string, or
  84. * unknown fields fail at plugin load rather than being ignored.
  85. *
  86. * @param config Raw plugin config.
  87. * @returns A detached validated config.
  88. */
  89. export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
  90. const section = (config as Partial<PlanModeConfig>).section
  91. if (typeof section !== 'string') {
  92. throw new Error('PlanModeConfig needs a string `section`')
  93. }
  94. if (section.trim() === '') {
  95. throw new Error('PlanModeConfig needs a non-empty `section`')
  96. }
  97. const unknown = Object.keys(config).filter(key => key !== 'section')
  98. if (unknown.length > 0) {
  99. throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)
  100. }
  101. return { section }
  102. }
  103. const planUnitStateSchema: ZodType<PlanUnitState> = zod.object({
  104. active: zod.boolean(),
  105. wanted: zod.boolean().nullable(),
  106. running: zod.object({
  107. commandId: zod.string() as unknown as ZodType<CommandId>,
  108. wanted: zod.boolean(),
  109. }).strict().nullable(),
  110. activeAtLastHeader: zod.boolean().nullable(),
  111. }).strict()
  112. /** Wire payload schema of the `plan` projection. */
  113. const planProjectionSchema: ZodType<PlanProjection> = zod.object({
  114. active: zod.boolean(),
  115. pending: zod.boolean(),
  116. })
  117. /** Projection of logged plan selections and committed mode. */
  118. export const planProjectionDefinition = {
  119. key: 'plan',
  120. stateVersion: 3,
  121. stateSchema: planUnitStateSchema,
  122. init: () => ({ active: false, wanted: null, running: null, activeAtLastHeader: null }),
  123. apply: (state, event) => {
  124. if (event.type === 'command/run' && event.data.name === 'plan') {
  125. if (event.data.args === undefined) return state
  126. const wanted = event.data.args.trim() !== 'off'
  127. return { ...state, running: { commandId: event.data.commandId, wanted } }
  128. }
  129. if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) {
  130. const wanted = event.data.kind === 'success' && state.running.wanted !== state.active
  131. ? state.running.wanted
  132. : null
  133. return { ...state, wanted, running: null }
  134. }
  135. if (event.type === 'plan/mode') {
  136. return { ...state, active: event.data.active, wanted: null }
  137. }
  138. if (event.type === 'request/header') {
  139. return { ...state, activeAtLastHeader: state.active }
  140. }
  141. return state
  142. },
  143. wire: {
  144. viewSchema: planProjectionSchema,
  145. view: (state) => {
  146. const wanted = state.running?.wanted ?? state.wanted
  147. return { active: state.active, pending: wanted !== null && wanted !== state.active }
  148. },
  149. },
  150. } satisfies ProjectionDefinition<'plan', PlanUnitState>
  151. /**
  152. * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
  153. * the `plan:policy` section, the `/plan` command, and the stable exit tool.
  154. * Client carriers expose the projection's cropped `{ active, pending }` view.
  155. */
  156. export class PlanModeController extends Service {
  157. static inject = ['tools', 'systemPrompt', 'sessionProjections']
  158. /** Validated deployment-owned guidance. */
  159. private readonly section: string
  160. /**
  161. * Latest selection per session awaiting the next accepted in-turn pre-step.
  162. * `narrate` is true for user selections and false for the exit tool, whose
  163. * result already narrates the transition.
  164. */
  165. private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
  166. constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
  167. super(ctx, 'planMode')
  168. this.section = resolveConfig(config).section
  169. let disposed = false
  170. // Pre-step is outside Session.append publication, so it can append the
  171. // log-only mode event inside an open turn without re-entering the session.
  172. // A failed append remains pending for a later accepted in-turn pre-step,
  173. // and policy cannot block the step.
  174. ctx.on('agent/pre-step', async (
  175. { agent, signal },
  176. next,
  177. ): Promise<PreStepDecision> => {
  178. const decision = await next()
  179. const pending = this.pendingIntents.get(agent.session)
  180. if (decision.kind === 'reject' || signal.aborted || pending === undefined) return decision
  181. const narration = this.narration(agent.session, pending.active)
  182. try {
  183. this.onBoundary(agent.session)
  184. } catch (error) {
  185. ctx.logger.warn('dsh-plan-mode: failed to append selected plan mode at step start: %o', error)
  186. return decision
  187. }
  188. return !pending.narrate || narration === undefined
  189. ? decision
  190. : { ...decision, messages: [...decision.messages, narration] }
  191. })
  192. ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime')
  193. ctx.systemPrompt.section({
  194. name: 'plan:policy',
  195. order: ctx.systemPrompt.getSectionOrder('PLAN_POLICY'),
  196. text: (context) => {
  197. if (context.agent === undefined) return ''
  198. const pending = this.pendingIntents.get(context.agent.session)
  199. return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : ''
  200. },
  201. })
  202. ctx.sessionProjections.register(planProjectionDefinition)
  203. // The command child activates only when a command registry is composed.
  204. ctx.inject(['commands'], (commandCtx) => {
  205. commandCtx.commands.register({
  206. definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-plan-mode'),
  207. name: 'plan',
  208. description: 'Enter or leave plan mode',
  209. input: { hint: '[off|message]', attachments: true },
  210. handler: ({ agent, rawInput, attachments }) => {
  211. const message = rawInput.trim()
  212. if (message === 'off' && attachments.length > 0) {
  213. return { kind: 'error', text: 'Attachments cannot accompany /plan off.' }
  214. }
  215. if (message === 'off') {
  216. switch (this.set(agent, false)) {
  217. case 'committed':
  218. return { kind: 'success', text: 'Plan mode off.' }
  219. case 'queued':
  220. return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
  221. case 'cancelled':
  222. return { kind: 'success', text: 'Plan mode entry cancelled.' }
  223. case 'noop':
  224. // Repeat the queued wording while an exit still awaits the
  225. // next accepted pre-step; only a truly inactive session reads
  226. // idempotent.
  227. return this.loggedActive(agent.session)
  228. ? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
  229. : { kind: 'success', text: 'Plan mode is already inactive.' }
  230. }
  231. }
  232. const outcome = this.set(agent, true)
  233. if (message !== '' || attachments.length > 0) {
  234. agent.steer(createUserMessage({
  235. content: [
  236. ...attachments,
  237. ...(message === '' ? [] : [{ type: 'text' as const, text: message }]),
  238. ],
  239. source: { kind: 'user' },
  240. }))
  241. }
  242. return {
  243. kind: 'success',
  244. text: outcome === 'committed'
  245. ? 'Plan mode on. Use /plan off to leave.'
  246. : 'Entering plan mode (applies from the next step). Use /plan off to leave.',
  247. }
  248. },
  249. })
  250. })
  251. ctx.tools.register(defineTool({
  252. name: EXIT_PLAN_MODE,
  253. description: EXIT_DESCRIPTION,
  254. parameters: {
  255. plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
  256. },
  257. output: {
  258. schema: {
  259. type: 'object',
  260. additionalProperties: false,
  261. properties: {
  262. approved: { type: 'boolean', const: true, required: true },
  263. },
  264. },
  265. render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],
  266. },
  267. execute: async (args, exec) => {
  268. const agent = exec.agent
  269. if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
  270. if (!this.loggedActive(agent.session)) {
  271. throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
  272. }
  273. if (!/^#\s+\S/.test(args.plan.trim())) {
  274. throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
  275. }
  276. const interaction = ctx.get('userQuestions')
  277. if (interaction === undefined) {
  278. throw new Error('no user-questions channel is available to review the plan; ask the user to switch the session mode instead')
  279. }
  280. const answer = await interaction.ask({
  281. questions: [{
  282. id: REVIEW_ID,
  283. header: 'Plan review',
  284. question: 'Approve this plan and leave plan mode?',
  285. detail: args.plan,
  286. options: [
  287. { label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
  288. { label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
  289. ],
  290. // Presentation only: a capable UI renders the plan as a review
  291. // decision instead of a generic question, and answers with one of
  292. // the labels above either way.
  293. intent: { kind: 'plan-review', approve: APPROVE_LABEL },
  294. }],
  295. agent,
  296. signal: exec.signal,
  297. }).catch((cause: unknown) => {
  298. // A dismissed review is not a failed one: the user took the turn back
  299. // to say something the two options do not cover. Say so, because the
  300. // generic channel message names ask_user_question, which the model
  301. // never called. An abort (turn cancel, provider teardown) keeps its
  302. // own message — there is no user to wait for.
  303. if (cause instanceof UserQuestionError && cause.code === 'ASK_CANCELLED') {
  304. throw new Error('The user dismissed the plan review to speak instead; '
  305. + 'stay in plan mode, stop here, and wait for their message.')
  306. }
  307. throw cause
  308. })
  309. // A review may outlive this plugin fiber. Without its pre-step listener,
  310. // an approved selection could never be appended, so fail and keep planning.
  311. if (disposed) {
  312. throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
  313. }
  314. const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID)
  315. const item = reviewItems.length === 1 ? reviewItems[0] : undefined
  316. if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
  317. const feedback = item?.custom ?? ''
  318. throw new Error(feedback === ''
  319. ? 'The user chose to keep planning; revise the plan and present it again.'
  320. : `The user chose to keep planning; their feedback: ${feedback}`)
  321. }
  322. // Keep plan guidance for the rest of this assistant tool batch. The
  323. // silent selection is appended at the next accepted in-turn pre-step,
  324. // before its request assembly.
  325. this.pendingIntents.set(agent.session, { active: false, narrate: false })
  326. return { approved: true }
  327. },
  328. presentCall: args => ({
  329. card: 'generic',
  330. title: firstHeading(args.plan) ?? 'Plan',
  331. kind: 'other',
  332. content: [{ type: 'text', text: args.plan }],
  333. }),
  334. presentResult: (_args, result) => ({
  335. card: 'generic',
  336. title: 'Plan review',
  337. content: result.content,
  338. }),
  339. }))
  340. }
  341. private loggedActive(session: Session): boolean {
  342. return this.planState(session).active
  343. }
  344. private hasOpenTurn(session: Session): boolean {
  345. const state = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')
  346. if (state === undefined) throw new Error('plan-mode requires the turnBoundary session projection')
  347. return state.openTurnStartSeq !== null
  348. }
  349. private loggedActiveAtLastHeader(session: Session): boolean | undefined {
  350. return this.planState(session).activeAtLastHeader ?? undefined
  351. }
  352. /** Read the required plan projection state or fail at the first service access. */
  353. private planState(session: Session): PlanUnitState {
  354. const state = this.ctx.sessionProjections.stateOf(session, 'plan')
  355. if (state === undefined) throw new Error('plan-mode requires the plan session projection')
  356. return state
  357. }
  358. /**
  359. * Read the logged plan state and any selected state awaiting the next
  360. * accepted in-turn pre-step.
  361. *
  362. * @param agent The agent to read.
  363. * @returns Current logged state plus a pending selection, when present.
  364. */
  365. get(agent: Agent): { active: boolean; pending?: boolean } {
  366. const active = this.loggedActive(agent.session)
  367. const pending = this.pendingIntents.get(agent.session)
  368. return pending === undefined ? { active } : { active, pending: pending.active }
  369. }
  370. /**
  371. * Select whether plan mode should be active. Between turns the method
  372. * appends the change immediately because no in-turn pre-step will run until
  373. * another prompt starts a turn. The open-turn fold is the idle signal:
  374. * agent status stays `running` through post-turn checkpointing, when no
  375. * further in-turn pre-step runs. During an open turn the selection remains
  376. * pending until the next accepted in-turn pre-step. Repeated selection of
  377. * the current or already-pending state is a no-op.
  378. *
  379. * @param agent The agent to switch.
  380. * @param active Whether plan mode should be active.
  381. * @returns what happened: `committed` (logged now), `queued` (awaiting the
  382. * next accepted in-turn pre-step), `cancelled` (an opposite pending selection
  383. * was cleared; the logged state already matches), or `noop` (already in that
  384. * state).
  385. */
  386. set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' {
  387. const session = agent.session
  388. const pending = this.pendingIntents.get(session)
  389. const target = pending?.active ?? this.loggedActive(session)
  390. if (active === target) return 'noop'
  391. if (this.hasOpenTurn(session)) {
  392. this.pendingIntents.set(session, { active, narrate: true })
  393. return this.loggedActive(session) === active ? 'cancelled' : 'queued'
  394. }
  395. // No open turn: commit now. Delete only after append succeeds so a
  396. // failed durable write leaves the selection retryable, not dropped.
  397. if (active === this.loggedActive(session)) {
  398. this.pendingIntents.delete(session)
  399. return 'cancelled'
  400. }
  401. session.append('plan/mode', { active })
  402. this.pendingIntents.delete(session)
  403. const narration = this.narration(session, active)
  404. if (narration !== undefined) agent.inject(narration)
  405. return 'committed'
  406. }
  407. /** Append one pending selection before the next request assembly. */
  408. private onBoundary(session: Session): void {
  409. const pending = this.pendingIntents.get(session)
  410. if (pending === undefined) return
  411. const target = pending.active
  412. if (target === this.loggedActive(session)) {
  413. this.pendingIntents.delete(session)
  414. return
  415. }
  416. session.append('plan/mode', { active: target })
  417. // Delete only after append succeeds so a later accepted in-turn pre-step
  418. // can retry a failed durable write.
  419. this.pendingIntents.delete(session)
  420. }
  421. /** Build a user-switch notice when the last logged header described the other mode. */
  422. private narration(session: Session, target: boolean): UserMessage | undefined {
  423. const told = this.loggedActiveAtLastHeader(session)
  424. if (told === undefined || told === target) return
  425. const text = target
  426. ? 'The user switched this session to plan mode.'
  427. : 'The user switched this session back to the default mode.'
  428. return createUserMessage({
  429. content: [{ type: 'text', text }],
  430. // The narration is already one sentence, so it is its own summary.
  431. source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text },
  432. })
  433. }
  434. }
  435. export default PlanModeController