index.ts 7.1 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. signal,
  73. }, fileSystem)
  74. const baseline = baselineInstructionState(instructions?.included ?? [])
  75. baselineInstructionStates.set(agent.session, baseline.changes)
  76. instructionVersions.set(agent.session, baseline.versions)
  77. const update = await reconcileInstructionContext(
  78. agent,
  79. resolved,
  80. pendingNestedChanges,
  81. baselineInstructionStates,
  82. instructionVersions,
  83. fileSystem,
  84. { includeBaselineScopes: false, signal },
  85. )
  86. if (update !== undefined) {
  87. agent.inject(update.context.content, {
  88. source: update.context.source,
  89. meta: update.context.meta,
  90. })
  91. applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
  92. }
  93. if (instructions === undefined || instructions.rendered.text.length === 0) return rest
  94. return [workspaceContextMessage(instructions.rendered.text), ...rest]
  95. })
  96. ctx.on('tools/post-execute', async (
  97. exec: ToolExecution,
  98. result: ToolExecutionResult,
  99. next,
  100. ): Promise<PostToolDecision> => {
  101. const downstream = await next()
  102. // A downstream listener/policy blocked this call: the registry turns it
  103. // into a final `isError` result, so treat it like a failed fs touch and
  104. // load nothing. Reconciling here would surface workspace instructions from
  105. // a call the pipeline rejected, violating the "successful fs tool touches"
  106. // contract, and would advance the nested/baseline tracking state off a
  107. // touch that never really happened.
  108. if (downstream.kind === 'block') return downstream
  109. const fileSystem = ctx.get('fs')
  110. if (fileSystem === undefined) return downstream
  111. const update = await dynamicInstructionContext(
  112. exec.agent,
  113. exec,
  114. result,
  115. resolved,
  116. pendingNestedChanges,
  117. baselineInstructionStates,
  118. instructionVersions,
  119. fileSystem,
  120. )
  121. if (update === undefined) return downstream
  122. pendingVersionUpdates.set(exec.token, update.versionUpdates)
  123. return {
  124. kind: 'accept',
  125. ...downstream.content !== undefined ? { content: downstream.content } : {},
  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. }