index.ts 13 KB

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