index.ts 15 KB

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