index.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. /**
  2. * Plan mode is logged per-agent collaboration state: while active, a
  3. * deployment-owned guidance section shapes 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. Plan mode is independent of
  6. * sandbox mode and approval policy; those enforcement axes do not read or
  7. * write plan state.
  8. *
  9. * The state in force is folded from the session log (`plan/mode`, last one
  10. * wins), so resume and fork restore it without a live mirror. User selections
  11. * are held as pending intent until a turn boundary because every session event
  12. * is turn-enclosed. The service flushes before the affected request assembly
  13. * on prompt submission and each request step (including retry turns).
  14. *
  15. * The exit tool remains registered while plan mode is inactive so crossing a
  16. * boundary changes only the prompt section, not the request tool catalog.
  17. *
  18. * Agent Notes:
  19. * - .agents/notes/implemented/feature/2026-07-07-plan-mode.md
  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 'cordis'
  25. import type { Agent } from '@deepseek-ai/dsh-agent'
  26. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  27. import { defineTool } from '@deepseek-ai/dsh-tools'
  28. import type {} from '@deepseek-ai/dsh-system-prompt'
  29. import type {} from '@deepseek-ai/dsh-user-interaction'
  30. // Type-only edge: resolves `ctx.commands` for the optional command child.
  31. import type {} from '@deepseek-ai/dsh-commands'
  32. declare module '@deepseek-ai/dsh-session' {
  33. interface SessionEventMap {
  34. /**
  35. * Whether plan mode is in force from this point on: log-only, non-surface,
  36. * whole-value replace. The last `plan/mode` wins; a log with none folds to
  37. * inactive through {@link foldPlanMode}.
  38. */
  39. 'plan/mode': { active: boolean }
  40. }
  41. }
  42. declare module 'cordis' {
  43. interface Context {
  44. planMode: PlanModeService
  45. }
  46. }
  47. /**
  48. * The model-facing exit tool's name. It stays registered while plan mode is
  49. * inactive so the request tool catalog is stable across transitions.
  50. */
  51. export const EXIT_PLAN_MODE = 'exit_plan_mode'
  52. /** Deployment-owned plan guidance. */
  53. export interface PlanModeConfig {
  54. /** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
  55. section: string
  56. }
  57. /** The review question's approve option label. */
  58. const APPROVE_LABEL = 'Approve'
  59. /** The review question's keep-planning option label. */
  60. const KEEP_PLANNING_LABEL = 'Keep planning'
  61. const EXIT_DESCRIPTION
  62. = 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
  63. + 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
  64. + 'The user may approve (carry out the plan from your next step) or keep '
  65. + 'planning — their feedback comes back in the tool result; revise and present again.'
  66. /** The plan's first markdown heading (any level), or `undefined` when it has none. */
  67. function firstHeading(plan: string): string | undefined {
  68. for (const line of plan.split('\n')) {
  69. const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
  70. if (match) return match[1]
  71. }
  72. return undefined
  73. }
  74. /**
  75. * Validate deployment-owned plan guidance. Missing, blank, non-string, or
  76. * unknown fields fail at plugin load rather than silently shaping nothing.
  77. *
  78. * @param config Raw plugin config.
  79. * @returns A detached validated config.
  80. */
  81. export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
  82. const section = (config as Partial<PlanModeConfig>).section
  83. if (typeof section !== 'string') {
  84. throw new Error('PlanModeConfig needs a string `section`')
  85. }
  86. if (section.trim() === '') {
  87. throw new Error('PlanModeConfig needs a non-empty `section`')
  88. }
  89. const unknown = Object.keys(config).filter(key => key !== 'section')
  90. if (unknown.length > 0) {
  91. throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)
  92. }
  93. return { section }
  94. }
  95. /**
  96. * Whether plan mode is active after the first `end` events. The last
  97. * `plan/mode` wins; a prefix with none is inactive.
  98. *
  99. * @param events The session log or any prefix of it.
  100. * @param end Fold `events[0, end)`; defaults to the whole log.
  101. * @returns Whether plan mode is active.
  102. */
  103. export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
  104. let active = false
  105. let index = 0
  106. for (const event of events) {
  107. if (index >= end) break
  108. index++
  109. if (event.type === 'plan/mode') active = event.data.active
  110. }
  111. return active
  112. }
  113. /** Plan state at the last logged request header, or `undefined` before the first header. */
  114. function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
  115. let lastHeader = -1
  116. let index = 0
  117. for (const event of events) {
  118. if (event.type === 'request/header') lastHeader = index
  119. index++
  120. }
  121. if (lastHeader < 0) return undefined
  122. return foldPlanMode(events, lastHeader + 1)
  123. }
  124. /**
  125. * `ctx.planMode`: owns logged plan state, boundary application and narration,
  126. * the `plan:policy` section, the `/plan` command, and the stable exit tool.
  127. * UIs observe committed flips through `session/event`; there is no live mirror.
  128. */
  129. export class PlanModeService extends Service {
  130. static inject = ['tools', 'systemPrompt']
  131. /** Validated deployment-owned guidance. */
  132. private readonly section: string
  133. /**
  134. * Latest selection per session awaiting a turn-boundary flush. `narrate` is
  135. * true for user selections and false for the exit tool, whose result already
  136. * narrates the transition.
  137. */
  138. private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
  139. constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
  140. super(ctx, 'planMode')
  141. this.section = resolveConfig(config).section
  142. let disposed = false
  143. // The boundary flush uses the loop's `agent/step` interception seam, not
  144. // post-commit `session/event` observation. `agent/step` runs inside the
  145. // open turn before every request derivation (including turn 1 step 1), so
  146. // it is the sole flush point: prompt admission happens pre-turn, where a
  147. // `plan/mode` append would land outside any open turn. Failures are
  148. // contained so policy cannot block a turn; a failed append remains
  149. // pending for a later boundary.
  150. ctx.on('agent/step', (agent) => {
  151. if (disposed) return
  152. try {
  153. this.onBoundary(agent)
  154. } catch (error) {
  155. ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
  156. }
  157. }, { prepend: true })
  158. ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
  159. ctx.systemPrompt.section({
  160. name: 'plan:policy',
  161. order: 50,
  162. text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
  163. ? this.section
  164. : '',
  165. })
  166. // The command child activates only when a command registry is composed.
  167. ctx.inject(['commands'], (commandCtx) => {
  168. commandCtx.commands.register({
  169. name: 'plan',
  170. description: 'Enter or leave plan mode',
  171. input: { hint: '[off|message]' },
  172. handler: ({ agent, rawInput }) => {
  173. const message = rawInput.trim()
  174. if (message === 'off') {
  175. const state = this.get(agent)
  176. this.set(agent, false)
  177. if (state.active) {
  178. return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
  179. }
  180. if (state.pending === true) {
  181. return { kind: 'success', text: 'Plan mode entry cancelled.' }
  182. }
  183. return { kind: 'success', text: 'Plan mode is already inactive.' }
  184. }
  185. this.set(agent, true)
  186. if (message !== '') agent.steer({ content: [{ type: 'text', text: message }], source: { kind: 'user' } })
  187. return {
  188. kind: 'success',
  189. text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
  190. }
  191. },
  192. })
  193. })
  194. ctx.tools.register(defineTool({
  195. name: EXIT_PLAN_MODE,
  196. description: EXIT_DESCRIPTION,
  197. parameters: {
  198. plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
  199. },
  200. output: {
  201. schema: {
  202. type: 'object',
  203. additionalProperties: false,
  204. properties: {
  205. approved: { type: 'boolean', const: true, required: true },
  206. },
  207. },
  208. render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],
  209. },
  210. execute: async (args, exec) => {
  211. const agent = exec.agent
  212. if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
  213. if (!foldPlanMode(agent.session.events)) {
  214. throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
  215. }
  216. if (!/^#\s+\S/.test(args.plan.trim())) {
  217. throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
  218. }
  219. const interaction = ctx.get('userInteraction')
  220. if (interaction === undefined) {
  221. throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
  222. }
  223. const answer = await interaction.ask({
  224. questions: [{
  225. id: 'plan-review',
  226. header: 'Plan review',
  227. question: 'Approve this plan and leave plan mode?',
  228. detail: args.plan,
  229. options: [
  230. { label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
  231. { label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
  232. ],
  233. }],
  234. agent,
  235. signal: exec.signal,
  236. })
  237. // A review may outlive this plugin fiber. Without boundary listeners,
  238. // an approved result could never land, so fail and keep planning.
  239. if (disposed) {
  240. throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
  241. }
  242. const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
  243. const item = reviewItems.length === 1 ? reviewItems[0] : undefined
  244. if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
  245. const feedback = item?.custom ?? ''
  246. throw new Error(feedback === ''
  247. ? 'The user chose to keep planning; revise the plan and present it again.'
  248. : `The user chose to keep planning; their feedback: ${feedback}`)
  249. }
  250. // Keep plan guidance for the rest of this assistant tool batch. The
  251. // silent intent flushes after the step, before the next assembly.
  252. this.pendingIntents.set(agent.session, { active: false, narrate: false })
  253. return { approved: true }
  254. },
  255. presentCall: args => ({
  256. card: 'generic',
  257. title: firstHeading(args.plan) ?? 'Plan',
  258. kind: 'other',
  259. content: [{ type: 'text', text: args.plan }],
  260. }),
  261. presentResult: (_args, result) => ({
  262. card: 'generic',
  263. title: 'Plan review',
  264. content: result.content,
  265. }),
  266. }))
  267. }
  268. /**
  269. * Read the logged plan state and any selected state awaiting a boundary.
  270. *
  271. * @param agent The agent to read.
  272. * @returns Current logged state plus a pending selection, when present.
  273. */
  274. get(agent: Agent): { active: boolean; pending?: boolean } {
  275. const active = foldPlanMode(agent.session.events)
  276. const pending = this.pendingIntents.get(agent.session)
  277. return pending === undefined ? { active } : { active, pending: pending.active }
  278. }
  279. /**
  280. * Select whether plan mode should be active from the next turn boundary.
  281. * Repeated selection of the current or already-pending state is a no-op.
  282. *
  283. * @param agent The agent to switch.
  284. * @param active Whether plan mode should be active.
  285. */
  286. set(agent: Agent, active: boolean): void {
  287. const session = agent.session
  288. const target = this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events)
  289. if (active === target) return
  290. this.pendingIntents.set(session, { active, narrate: true })
  291. }
  292. /** Flush one pending selection before the next request assembly. */
  293. private onBoundary(agent: Agent): void {
  294. const session = agent.session
  295. const pending = this.pendingIntents.get(session)
  296. if (pending === undefined) return
  297. const target = pending.active
  298. if (target === foldPlanMode(session.events)) {
  299. this.pendingIntents.delete(session)
  300. return
  301. }
  302. session.append('plan/mode', { active: target })
  303. // Delete only after append succeeds so a later boundary can retry a failed
  304. // durable write.
  305. this.pendingIntents.delete(session)
  306. if (!pending.narrate) return
  307. const told = planModeAtLastHeader(session.events)
  308. if (told === undefined || told === target) return
  309. const text = target
  310. ? 'The user switched this session to plan mode.'
  311. : 'The user switched this session back to the default mode.'
  312. session.append('user/message', {
  313. content: [{ type: 'text', text }],
  314. source: { kind: 'plugin', plugin: 'plan-mode' },
  315. }, { surfaceOp: 'append' })
  316. }
  317. }
  318. export default PlanModeService