index.ts 19 KB

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