index.ts 8.2 KB

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