index.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. /**
  2. * Workspace instruction loader for AGENTS.md-compatible files.
  3. *
  4. * Baseline instructions enter durable context before the first request; successful fs
  5. * tool touches reconcile nested, changed, and removed instructions through
  6. * `tools/post-execute` for the next model request. Plugin lifecycle reads use
  7. * the optional `ctx.fs` provider, so providerless products mount it as a no-op.
  8. *
  9. * @module @deepseek-ai/dsh-workspace-context
  10. */
  11. import type { Context } from 'cordis'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
  14. import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
  15. import { loadBaselineInstructionSet } from './files.ts'
  16. import {
  17. applyInstructionVersionUpdates,
  18. baselineInstructionState,
  19. commitPendingInstructionContexts,
  20. dynamicInstructionContext,
  21. name,
  22. observeInstructionSessionEvent,
  23. reconcileInstructionContext,
  24. retainedInstructionVersionUpdates,
  25. rollbackPendingInstructionChanges,
  26. workspaceContextMessage,
  27. type InstructionVersionCache,
  28. type InstructionVersionUpdate,
  29. type PendingInstructionChange,
  30. } from './state.ts'
  31. import type { WorkspaceInstructionChange } from './render.ts'
  32. export { Config, name }
  33. export {
  34. discoverBaselineInstructionFiles,
  35. loadBaselineInstructions,
  36. } from './files.ts'
  37. export type {
  38. InstructionFile,
  39. LoadedInstructionFile,
  40. } from './files.ts'
  41. export { renderWorkspaceContext } from './render.ts'
  42. export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
  43. function hasVisibleBaseline(agent: Agent): boolean {
  44. return agent.session.surface.nodes.some((seq) => {
  45. const event = agent.session.events[seq]
  46. return event?.type === 'user/message'
  47. && event.data.source.kind === 'workspace-instructions'
  48. && event.data.source.baseline === true
  49. })
  50. }
  51. export function apply(ctx: Context, config: Config): void {
  52. const resolved: ResolvedConfig = resolveConfig(config)
  53. const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
  54. const baselineSessions = new WeakSet<object>()
  55. const instructionVersions: InstructionVersionCache = new WeakMap()
  56. const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
  57. const baselineLoaded = new WeakSet<object>()
  58. // Sessions whose lifecycle start this mount witnessed. A startup or resume
  59. // emits agent/session-start before the first step; a hot remount attaches to
  60. // an already-live session and never sees it. Resumes always re-compose the
  61. // baseline from current files. Hot remounts retain a baseline only while its
  62. // typed event remains model-visible.
  63. const lifecycleWitnessed = new WeakSet<object>()
  64. const pendingByParent = new Map<ToolExecutionToken, {
  65. agent: Agent
  66. changes: WorkspaceInstructionChange[]
  67. versionUpdates: InstructionVersionUpdate[]
  68. }>()
  69. ctx.on('agent/session-start', (agent: Agent) => {
  70. lifecycleWitnessed.add(agent.session)
  71. })
  72. ctx.on('session/event', (session, event) => {
  73. observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
  74. })
  75. ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
  76. if (baselineLoaded.has(agent.session)) return
  77. if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
  78. baselineLoaded.add(agent.session)
  79. return
  80. }
  81. const fileSystem = ctx.get('fs')
  82. if (fileSystem === undefined) {
  83. baselineLoaded.add(agent.session)
  84. return
  85. }
  86. /* v8 ignore next -- normal agents carry an absolute session cwd. */
  87. const cwd = agent.session.header.cwd ?? process.cwd()
  88. const instructions = await loadBaselineInstructionSet({
  89. cwd,
  90. dshHome: resolved.dshHome,
  91. projectRootMarkers: resolved.projectRootMarkers,
  92. maxBytes: resolved.maxBytes,
  93. maxSourceBytes: resolved.maxSourceBytes,
  94. instructionFileCandidates: resolved.instructionFileCandidates,
  95. localInstructionFileCandidates: resolved.localInstructionFileCandidates,
  96. signal,
  97. }, fileSystem)
  98. const baseline = baselineInstructionState(instructions?.included ?? [])
  99. baselineSessions.add(agent.session)
  100. instructionVersions.set(agent.session, baseline.versions)
  101. const update = await reconcileInstructionContext(
  102. agent,
  103. resolved,
  104. pendingNestedChanges,
  105. instructionVersions,
  106. fileSystem,
  107. { includeBaselineScopes: false, signal },
  108. )
  109. if (update !== undefined) {
  110. agent.inject({ content: update.context.content, source: update.context.source })
  111. applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
  112. }
  113. const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
  114. if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
  115. const baselineMessage = workspaceContextMessage(instructions.rendered.text)
  116. agent.inject({
  117. content: baselineMessage.content,
  118. source: {
  119. kind: 'workspace-instructions',
  120. baseline: true,
  121. changes: [...baseline.changes.values()],
  122. },
  123. })
  124. }
  125. baselineLoaded.add(agent.session)
  126. })
  127. ctx.on('tools/post-execute', async (
  128. exec: ToolExecution,
  129. result: ToolExecutionResult,
  130. next,
  131. ): Promise<PostToolDecision> => {
  132. const downstream = await next()
  133. // A downstream listener/policy blocked this call: the registry turns it
  134. // into a final `isError` result, so treat it like a failed fs touch and
  135. // load nothing. Reconciling here would surface workspace instructions from
  136. // a call the pipeline rejected, violating the "successful fs tool touches"
  137. // contract, and would advance the nested/baseline tracking state off a
  138. // touch that never really happened.
  139. if (downstream.kind === 'block') return downstream
  140. const fileSystem = ctx.get('fs')
  141. if (fileSystem === undefined) return downstream
  142. const update = await dynamicInstructionContext(
  143. exec.agent,
  144. exec,
  145. result,
  146. resolved,
  147. pendingNestedChanges,
  148. baselineSessions,
  149. instructionVersions,
  150. fileSystem,
  151. )
  152. if (update === undefined) return downstream
  153. pendingVersionUpdates.set(exec.token, update.versionUpdates)
  154. return {
  155. ...downstream,
  156. additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
  157. }
  158. })
  159. ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
  160. const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
  161. pendingVersionUpdates.delete(exec.token)
  162. if (exec.parent !== undefined) {
  163. if (exec.agent === undefined) return
  164. // Child contexts participate in duplicate suppression within one composite
  165. // run, but remain provisional until the parent reaches its final policy.
  166. const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
  167. if (changes.length === 0) return
  168. const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
  169. const staged = pendingByParent.get(exec.parent)
  170. if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
  171. else {
  172. staged.changes.push(...changes)
  173. staged.versionUpdates.push(...versionUpdates)
  174. }
  175. return
  176. }
  177. // The parent result is authoritative: remove every provisional child change,
  178. // then commit only contexts that survived outer post-execute policy.
  179. const staged = pendingByParent.get(exec.token)
  180. if (staged !== undefined) {
  181. pendingByParent.delete(exec.token)
  182. rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
  183. }
  184. if (exec.agent === undefined) return
  185. const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
  186. const stagedVersionUpdates = staged?.versionUpdates ?? []
  187. const versionUpdates = retainedInstructionVersionUpdates(
  188. [...stagedVersionUpdates, ...ownVersionUpdates],
  189. committed,
  190. )
  191. applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
  192. })
  193. }