index.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. /**
  2. * Workspace instruction loader for AGENTS.md-compatible files.
  3. *
  4. * Baseline instructions are frozen into `agent/session-prefix`; 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 { Message } 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. export function apply(ctx: Context, config: Config): void {
  45. const resolved: ResolvedConfig = resolveConfig(config)
  46. const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
  47. const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
  48. const instructionVersions: InstructionVersionCache = new WeakMap()
  49. const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
  50. const pendingByParent = new Map<ToolExecutionToken, {
  51. agent: Agent
  52. changes: WorkspaceInstructionChange[]
  53. versionUpdates: InstructionVersionUpdate[]
  54. }>()
  55. ctx.on('session/event', (session, event) => {
  56. observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
  57. })
  58. ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
  59. const rest = await next()
  60. if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest
  61. const fileSystem = ctx.get('fs')
  62. if (fileSystem === undefined) return rest
  63. /* v8 ignore next -- normal agents carry an absolute session cwd. */
  64. const cwd = agent.session.header.cwd ?? process.cwd()
  65. const instructions = await loadBaselineInstructionSet({
  66. cwd,
  67. dshHome: resolved.dshHome,
  68. projectRootMarkers: resolved.projectRootMarkers,
  69. maxBytes: resolved.maxBytes,
  70. maxSourceBytes: resolved.maxSourceBytes,
  71. instructionFileCandidates: resolved.instructionFileCandidates,
  72. localInstructionFileCandidates: resolved.localInstructionFileCandidates,
  73. signal,
  74. }, fileSystem)
  75. const baseline = baselineInstructionState(instructions?.included ?? [])
  76. baselineInstructionStates.set(agent.session, baseline.changes)
  77. instructionVersions.set(agent.session, baseline.versions)
  78. const update = await reconcileInstructionContext(
  79. agent,
  80. resolved,
  81. pendingNestedChanges,
  82. baselineInstructionStates,
  83. instructionVersions,
  84. fileSystem,
  85. { includeBaselineScopes: false, signal },
  86. )
  87. if (update !== undefined) {
  88. agent.inject(update.context.content, {
  89. source: update.context.source,
  90. meta: update.context.meta,
  91. })
  92. applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
  93. }
  94. if (instructions === undefined || instructions.rendered.text.length === 0) return rest
  95. return [workspaceContextMessage(instructions.rendered.text), ...rest]
  96. })
  97. ctx.on('tools/post-execute', async (
  98. exec: ToolExecution,
  99. result: ToolExecutionResult,
  100. next,
  101. ): Promise<PostToolDecision> => {
  102. const downstream = await next()
  103. // A downstream listener/policy blocked this call: the registry turns it
  104. // into a final `isError` result, so treat it like a failed fs touch and
  105. // load nothing. Reconciling here would surface workspace instructions from
  106. // a call the pipeline rejected, violating the "successful fs tool touches"
  107. // contract, and would advance the nested/baseline tracking state off a
  108. // touch that never really happened.
  109. if (downstream.kind === 'block') return downstream
  110. const fileSystem = ctx.get('fs')
  111. if (fileSystem === undefined) return downstream
  112. const update = await dynamicInstructionContext(
  113. exec.agent,
  114. exec,
  115. result,
  116. resolved,
  117. pendingNestedChanges,
  118. baselineInstructionStates,
  119. instructionVersions,
  120. fileSystem,
  121. )
  122. if (update === undefined) return downstream
  123. pendingVersionUpdates.set(exec.token, update.versionUpdates)
  124. return {
  125. ...downstream,
  126. additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
  127. }
  128. })
  129. ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
  130. const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
  131. pendingVersionUpdates.delete(exec.token)
  132. if (exec.parent !== undefined) {
  133. if (exec.agent === undefined) return
  134. // Child contexts participate in duplicate suppression within one composite
  135. // run, but remain provisional until the parent reaches its final policy.
  136. const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
  137. if (changes.length === 0) return
  138. const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
  139. const staged = pendingByParent.get(exec.parent)
  140. if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
  141. else {
  142. staged.changes.push(...changes)
  143. staged.versionUpdates.push(...versionUpdates)
  144. }
  145. return
  146. }
  147. // The parent result is authoritative: remove every provisional child change,
  148. // then commit only contexts that survived outer post-execute policy.
  149. const staged = pendingByParent.get(exec.token)
  150. if (staged !== undefined) {
  151. pendingByParent.delete(exec.token)
  152. rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
  153. }
  154. if (exec.agent === undefined) return
  155. const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
  156. const stagedVersionUpdates = staged?.versionUpdates ?? []
  157. const versionUpdates = retainedInstructionVersionUpdates(
  158. [...stagedVersionUpdates, ...ownVersionUpdates],
  159. committed,
  160. )
  161. applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
  162. })
  163. }