index.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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 project nested, changed, and removed instructions into the inbox.
  6. * Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products
  7. * mount it as a no-op.
  8. *
  9. * @module @deepseek-ai/dsh-workspace-context
  10. */
  11. import type { Context } from 'cordis'
  12. import { isDeepStrictEqual } from 'node:util'
  13. import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
  14. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  15. import type { UserMessage } from '@deepseek-ai/dsh-session'
  16. import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  17. import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
  18. import { loadBaselineInstructionSet } from './files.ts'
  19. import {
  20. applyInstructionVersionUpdates,
  21. baselineInstructionState,
  22. name,
  23. reconcileInstructionContext,
  24. workspaceContextMessage,
  25. type InstructionVersionCache,
  26. } from './state.ts'
  27. import type { WorkspaceInstructionChange } from './render.ts'
  28. export { Config, name }
  29. export {
  30. discoverBaselineInstructionFiles,
  31. loadBaselineInstructions,
  32. } from './files.ts'
  33. export type {
  34. InstructionFile,
  35. LoadedInstructionFile,
  36. } from './files.ts'
  37. export { renderWorkspaceContext } from './render.ts'
  38. export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
  39. function hasVisibleBaseline(agent: Agent): boolean {
  40. return agent.session.surface.nodes.some((seq) => {
  41. const event = agent.session.events[seq]
  42. return event?.type === 'user/message'
  43. && event.data.source.kind === 'workspace-instructions'
  44. && event.data.source.baseline === true
  45. })
  46. }
  47. function isWorkspaceContext(message: UserMessage): boolean {
  48. return message.source.kind === 'workspace-instructions'
  49. }
  50. function sameContextPayload(left: UserMessage, right: UserMessage): boolean {
  51. return isDeepStrictEqual(left.content, right.content)
  52. && isDeepStrictEqual(left.source, right.source)
  53. }
  54. const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
  55. function filePathFromExecution(exec: ToolExecution): string | undefined {
  56. if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
  57. if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
  58. if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
  59. const filePath = exec.arguments.file_path.trim()
  60. return filePath.length > 0 ? filePath : undefined
  61. }
  62. export function apply(ctx: Context, config: Config): void {
  63. const resolved: ResolvedConfig = resolveConfig(config)
  64. const instructionVersions: InstructionVersionCache = new WeakMap()
  65. const projectionLifecycle = new AbortController()
  66. ctx.effect(
  67. () => () => {
  68. projectionLifecycle.abort(new Error('workspace-context disposed'))
  69. },
  70. 'workspace-context.projectionLifecycle',
  71. )
  72. // Emit listeners are not awaited, so each projection must compose against the
  73. // inbox produced by earlier file results for the same agent.
  74. const projectionTails = new WeakMap<Agent, Promise<void>>()
  75. const compose = async (
  76. agent: Agent,
  77. signal: AbortSignal,
  78. claimed: readonly UserMessage[],
  79. pending: readonly UserMessage[],
  80. touchedPaths: readonly string[] = [],
  81. ): Promise<UserMessage | undefined> => {
  82. signal.throwIfAborted()
  83. if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
  84. return undefined
  85. }
  86. const fileSystem = ctx.get('fs')
  87. if (fileSystem === undefined) return undefined
  88. if (touchedPaths.length === 0 && pending.length > 0) return pending[0]
  89. const content: UserMessage['content'][number][] = []
  90. const changes: WorkspaceInstructionChange[] = []
  91. let desiredBaseline = false
  92. const authorityMessages = [...claimed]
  93. const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message =>
  94. message.source.kind === 'workspace-instructions' && message.source.baseline === true)
  95. if (!baselinePresent) {
  96. /* v8 ignore next -- normal agents carry an absolute session cwd. */
  97. const cwd = agent.session.header.cwd ?? process.cwd()
  98. const instructions = await loadBaselineInstructionSet({
  99. cwd,
  100. dshHome: resolved.dshHome,
  101. projectRootMarkers: resolved.projectRootMarkers,
  102. maxBytes: resolved.maxBytes,
  103. maxSourceBytes: resolved.maxSourceBytes,
  104. instructionFileCandidates: resolved.instructionFileCandidates,
  105. localInstructionFileCandidates: resolved.localInstructionFileCandidates,
  106. signal,
  107. }, fileSystem)
  108. const baseline = baselineInstructionState(instructions?.included ?? [])
  109. let versionStates = instructionVersions.get(agent.session)
  110. if (versionStates === undefined && baseline.versions.size > 0) {
  111. versionStates = new Map()
  112. instructionVersions.set(agent.session, versionStates)
  113. }
  114. for (const [scope, state] of baseline.versions) versionStates?.set(scope, state)
  115. if (instructions !== undefined && instructions.rendered.text.length > 0) {
  116. content.push(...workspaceContextMessage(instructions.rendered.text).content)
  117. changes.push(...baseline.changes.values())
  118. desiredBaseline = true
  119. }
  120. }
  121. const update = await reconcileInstructionContext(
  122. agent,
  123. resolved,
  124. instructionVersions,
  125. fileSystem,
  126. { authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal },
  127. )
  128. if (update !== undefined) {
  129. content.push(...update.context.content)
  130. /* v8 ignore next -- reconciliation constructs only workspace-instructions contexts. */
  131. if (update.context.source.kind === 'workspace-instructions') {
  132. changes.push(...update.context.source.changes)
  133. }
  134. applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
  135. }
  136. if (content.length === 0) return undefined
  137. return createUserMessage({
  138. content,
  139. source: {
  140. kind: 'workspace-instructions',
  141. form: 'instructions',
  142. ...desiredBaseline ? { baseline: true } : {},
  143. changes,
  144. },
  145. })
  146. }
  147. const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => {
  148. const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
  149. const alreadySupplied = desired !== undefined && (
  150. claimed.some(message => sameContextPayload(message, desired))
  151. || agent.session.surface.nodes.some((seq) => {
  152. const event = agent.session.events[seq]
  153. return event?.type === 'user/message' && sameContextPayload(event.data, desired)
  154. })
  155. )
  156. if (desired === undefined || alreadySupplied) {
  157. for (const message of pending) agent.inbox.remove(message.id)
  158. return
  159. }
  160. const reusable = pending.find(message => sameContextPayload(message, desired))
  161. if (reusable !== undefined) {
  162. for (const message of pending) {
  163. if (message !== reusable) agent.inbox.remove(message.id)
  164. }
  165. return
  166. }
  167. const replaced = pending[0]
  168. if (replaced === undefined) agent.inbox.prepend('next-step', desired)
  169. else agent.inbox.replace(replaced.id, desired)
  170. for (const message of pending.slice(1)) agent.inbox.remove(message.id)
  171. }
  172. const composeAndSync = async (
  173. agent: Agent,
  174. signal: AbortSignal,
  175. claimed: readonly UserMessage[],
  176. touchedPaths: readonly string[] = [],
  177. ): Promise<void> => {
  178. const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
  179. const desired = await compose(agent, signal, claimed, pending, touchedPaths)
  180. signal.throwIfAborted()
  181. syncInbox(agent, claimed, desired)
  182. }
  183. const queueProjection = (
  184. agent: Agent,
  185. touchedPath: string,
  186. ): void => {
  187. const previous = projectionTails.get(agent) ?? Promise.resolve()
  188. const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath]))
  189. .catch((error: unknown) => {
  190. if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)
  191. })
  192. projectionTails.set(agent, current)
  193. void current.then(() => {
  194. if (projectionTails.get(agent) === current) projectionTails.delete(agent)
  195. })
  196. }
  197. const waitForProjections = async (agent: Agent): Promise<void> => {
  198. let projection: Promise<void> | undefined
  199. while ((projection = projectionTails.get(agent)) !== undefined) await projection
  200. }
  201. ctx.on('agent/pre-step', async (
  202. { agent, messages, step, signal },
  203. next,
  204. ): Promise<PreStepDecision> => {
  205. const decision = await next()
  206. await waitForProjections(agent)
  207. const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
  208. const desired = await compose(agent, signal, messages, pending)
  209. signal.throwIfAborted()
  210. // An empty first entry owns a no-step turn; keep context pending instead
  211. // of turning it into a standalone request. Later entries may be tool continuations.
  212. if (decision.kind === 'reject' || (step === 1 && decision.messages.length === 0)) {
  213. syncInbox(agent, messages, desired)
  214. return decision
  215. }
  216. // A proceeding step settles the pending context: it either enters below as
  217. // `desired`, or its payload is already covered by the batch, so nothing stays pending.
  218. for (const message of pending) agent.inbox.remove(message.id)
  219. if (desired === undefined || decision.messages.some(message => sameContextPayload(message, desired))) {
  220. return decision
  221. }
  222. // Fold the context right after the claimed batch, so the direct prompt
  223. // precedes it and the driver-appended runtime context follows it.
  224. const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message))
  225. const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired)
  226. return { kind: 'enter', messages: entered }
  227. })
  228. ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
  229. if (result.isError || exec.agent === undefined || exec.signal.aborted) return
  230. const ownPath = filePathFromExecution(exec)
  231. if (ownPath === undefined) return
  232. queueProjection(exec.agent, ownPath)
  233. })
  234. }