state.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. /**
  2. * Session-visible workspace instruction state and dynamic reconciliation.
  3. *
  4. * @module @deepseek-ai/dsh-agent-instructions/state
  5. */
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  8. import type { Message } from '@deepseek-ai/dsh-llm'
  9. import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
  10. import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
  11. import type { ResolvedConfig } from './config.ts'
  12. import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
  13. import {
  14. ancestorChain,
  15. descendantDirsBetween,
  16. findProjectRoot,
  17. probeScopeInstruction,
  18. readScopeInstruction,
  19. relativeDisplay,
  20. type LoadedInstructionFile,
  21. } from './files.ts'
  22. import {
  23. candidateScopeKey,
  24. decodeScopeKey,
  25. instructionScopeKey,
  26. renderInstructionChanges,
  27. USER_GLOBAL_DIRECTORY,
  28. USER_GLOBAL_FILE,
  29. type ChangeRenderItem,
  30. type AgentInstructionChange,
  31. } from './render.ts'
  32. export const name = 'agent-instructions'
  33. /** Durable producer, file, and reconciliation facts for one workspace context. */
  34. export interface AgentInstructionSource {
  35. kind: 'agent-instructions'
  36. /** Every workspace context carries instructions read out of a file (the `instructions` context form). */
  37. form: 'instructions'
  38. /** Marks the complete startup/resume baseline rather than a later delta. */
  39. baseline?: true
  40. /** Discovery, precedence, and budget identity used to validate a resumed baseline. */
  41. baselineIdentity?: string
  42. changes: AgentInstructionChange[]
  43. }
  44. declare module '@deepseek-ai/dsh-llm' {
  45. interface MessageSourceMap {
  46. 'agent-instructions': AgentInstructionSource
  47. }
  48. }
  49. /** Per-scope metadata cache; instruction prose is deliberately not retained. */
  50. export interface InstructionVersionState {
  51. path: string
  52. version: FsVersion
  53. digest: string
  54. /**
  55. * Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
  56. * per-directory duplicates on the metadata fast path without re-reading a sibling.
  57. */
  58. trimmedDigest: string
  59. }
  60. /** Session-isolated fast-path state keyed by logical instruction scope. */
  61. export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
  62. /** A metadata-cache transition associated with one rendered instruction change. */
  63. export interface InstructionVersionUpdate {
  64. change: AgentInstructionChange
  65. state?: InstructionVersionState
  66. }
  67. /** Rendered reconciliation plus its metadata-cache transitions. */
  68. export interface ReconciledInstructionContext {
  69. context: UserMessage
  70. versionUpdates: InstructionVersionUpdate[]
  71. }
  72. function agentInstructionsHook(text: string, changes: AgentInstructionChange[]): UserMessage {
  73. return createUserMessage({
  74. content: [{ type: 'text', text }],
  75. source: { kind: 'agent-instructions', form: 'instructions', changes },
  76. })
  77. }
  78. /**
  79. * Build the user-role message for a rendered baseline.
  80. * @param text - complete plugin-owned system-reminder text.
  81. * @returns a user-role prefix message.
  82. */
  83. export function agentInstructionsMessage(text: string): Message {
  84. return createUserMessage({
  85. content: [{ type: 'text', text }],
  86. source: { kind: 'plugin', plugin: name },
  87. })
  88. }
  89. function isAgentInstructionsSource(
  90. source: unknown,
  91. ): source is { kind: 'agent-instructions'; changes: unknown[] } {
  92. return typeof source === 'object' && source !== null
  93. && 'kind' in source && source.kind === 'agent-instructions'
  94. && 'changes' in source && Array.isArray(source.changes)
  95. }
  96. function isRecord(value: unknown): value is Record<string, unknown> {
  97. return typeof value === 'object' && value !== null && !Array.isArray(value)
  98. }
  99. function workspaceInstructionChanges(source: { changes: unknown[] }): AgentInstructionChange[] {
  100. const changes: AgentInstructionChange[] = []
  101. for (const value of source.changes) {
  102. if (!isRecord(value)) continue
  103. if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
  104. if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
  105. if (value.digest !== undefined && typeof value.digest !== 'string') continue
  106. changes.push({
  107. action: value.action,
  108. scope: value.scope,
  109. path: value.path,
  110. ...value.digest !== undefined ? { digest: value.digest } : {},
  111. })
  112. }
  113. return changes
  114. }
  115. function sameInstructionChange(a: AgentInstructionChange, b: AgentInstructionChange): boolean {
  116. return a.action === b.action
  117. && a.scope === b.scope
  118. && a.path === b.path
  119. && a.digest === b.digest
  120. }
  121. function visibleInstructionChanges(
  122. agent: Agent,
  123. authorityMessages: readonly UserMessage[],
  124. ): Map<string, AgentInstructionChange> {
  125. const visible = new Map<string, AgentInstructionChange>()
  126. for (const seq of agent.session.surface.nodes) {
  127. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  128. const event = agent.session.eventAt(seq)
  129. if (event?.type !== 'user/message' || !isAgentInstructionsSource(event.data.source)) continue
  130. const changes = workspaceInstructionChanges(event.data.source)
  131. for (const change of changes) {
  132. visible.set(change.scope, change)
  133. }
  134. }
  135. for (const message of authorityMessages) {
  136. if (!isAgentInstructionsSource(message.source)) continue
  137. for (const change of workspaceInstructionChanges(message.source)) {
  138. visible.set(change.scope, change)
  139. }
  140. }
  141. return visible
  142. }
  143. /**
  144. * Convert retained baseline files into comparison and metadata-cache state.
  145. * @param files - baseline files that survived rendering.
  146. * @returns latest baseline changes and provider versions keyed by logical scope.
  147. */
  148. export function baselineInstructionState(files: LoadedInstructionFile[]): {
  149. changes: Map<string, AgentInstructionChange>
  150. versions: Map<string, InstructionVersionState>
  151. } {
  152. const changes = new Map<string, AgentInstructionChange>()
  153. const versions = new Map<string, InstructionVersionState>()
  154. for (const file of files) {
  155. const digest = instructionContentSha1(file.content)
  156. const change: AgentInstructionChange = {
  157. action: 'set',
  158. scope: instructionScopeKey(file.displayPath),
  159. path: file.displayPath,
  160. digest,
  161. }
  162. changes.set(change.scope, change)
  163. if (file.version !== undefined) {
  164. versions.set(change.scope, {
  165. path: file.displayPath,
  166. version: file.version,
  167. digest,
  168. trimmedDigest: trimmedInstructionDigest(file.content),
  169. })
  170. }
  171. }
  172. return { changes, versions }
  173. }
  174. function versionStatesFor(session: Session, cache: InstructionVersionCache): Map<string, InstructionVersionState> {
  175. let states = cache.get(session)
  176. if (states === undefined) {
  177. states = new Map()
  178. cache.set(session, states)
  179. }
  180. return states
  181. }
  182. /**
  183. * Keep only cache updates represented by rendered changes.
  184. * @param updates - proposed updates from one or more reconciliations.
  185. * @param renderedChanges - transitions retained by the renderer.
  186. * @returns updates represented by an exact retained transition.
  187. */
  188. export function retainedInstructionVersionUpdates(
  189. updates: readonly InstructionVersionUpdate[],
  190. renderedChanges: readonly AgentInstructionChange[],
  191. ): InstructionVersionUpdate[] {
  192. return updates.filter(update => renderedChanges.some(change => sameInstructionChange(update.change, change)))
  193. }
  194. /**
  195. * Apply metadata-cache transitions without retaining instruction prose.
  196. * @param session - owning session.
  197. * @param updates - ordered set/delete transitions.
  198. * @param cache - session-isolated metadata cache.
  199. */
  200. export function applyInstructionVersionUpdates(
  201. session: Session,
  202. updates: readonly InstructionVersionUpdate[],
  203. cache: InstructionVersionCache,
  204. ): void {
  205. if (updates.length === 0) return
  206. const states = versionStatesFor(session, cache)
  207. for (const update of updates) {
  208. if (update.state === undefined) states.delete(update.change.scope)
  209. else states.set(update.change.scope, update.state)
  210. }
  211. if (states.size === 0) cache.delete(session)
  212. }
  213. function relativeScope(projectRoot: string, dir: string): string {
  214. const scope = relativeDisplay(projectRoot, dir)
  215. return scope.length === 0 ? '.' : scope
  216. }
  217. /**
  218. * Compare visible state with provider-visible files and render transitions.
  219. * @param agent - session owner whose visible surface supplies durable state.
  220. * @param resolved - normalized plugin configuration.
  221. * @param versionCache - per-session scope metadata used to skip unchanged reads.
  222. * @param fileSystem - provider used for current file probes.
  223. * @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
  224. * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
  225. */
  226. export async function reconcileInstructionContext(
  227. agent: Agent,
  228. resolved: ResolvedConfig,
  229. versionCache: InstructionVersionCache,
  230. fileSystem: FileSystem,
  231. options: {
  232. authorityMessages: readonly UserMessage[]
  233. scopeMessages: readonly UserMessage[]
  234. touchedPaths: readonly string[]
  235. includeBaselineScopes: boolean
  236. excludedBaselineScopes?: ReadonlySet<string>
  237. projectRoot?: string
  238. signal?: AbortSignal
  239. },
  240. ): Promise<ReconciledInstructionContext | undefined> {
  241. const session = agent.session
  242. const effective = visibleInstructionChanges(agent, options.authorityMessages)
  243. /* v8 ignore next -- normal agents carry an absolute session cwd. */
  244. const cwd = session.header.cwd ?? process.cwd()
  245. // TODO(frozen-project-root): retain the baseline root for the loop instance;
  246. // recomputing it after marker edits reinterprets the existing relative scope keys.
  247. const projectRoot = options.projectRoot
  248. ?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
  249. const scopes = new Set<string>()
  250. const baselineScopes = new Set<string>()
  251. const addDirScopes = (target: Set<string>, directory: string): void => {
  252. for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
  253. for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
  254. }
  255. const addProjectScopes = (target: Set<string>, dir: string): void => {
  256. addDirScopes(target, relativeScope(projectRoot, dir))
  257. }
  258. baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
  259. for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir)
  260. if (options.includeBaselineScopes) {
  261. for (const scope of baselineScopes) scopes.add(scope)
  262. }
  263. for (const message of options.scopeMessages) {
  264. /* v8 ignore next -- the plugin passes its workspace-only pending projection. */
  265. if (!isAgentInstructionsSource(message.source)) continue
  266. for (const change of workspaceInstructionChanges(message.source)) {
  267. if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue
  268. scopes.add(change.scope)
  269. }
  270. }
  271. for (const scope of effective.keys()) {
  272. if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
  273. const { directory } = decodeScopeKey(scope)
  274. if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
  275. else addDirScopes(scopes, directory)
  276. }
  277. for (const touchedPath of options.touchedPaths) {
  278. for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir)
  279. }
  280. const versions = versionStatesFor(session, versionCache)
  281. const seenAbsolutePaths = new Set<string>()
  282. // Per-directory trimmed-content identities kept so far this pass, iterated in
  283. // candidate order (base before local); a later sibling matching an earlier one
  284. // is a duplicate and is dropped or removed rather than rendered twice.
  285. const keptTrimmedByDir = new Map<string, Set<string>>()
  286. const registerKeptTrimmed = (directory: string, digest: string): boolean => {
  287. let digests = keptTrimmedByDir.get(directory)
  288. if (digests === undefined) {
  289. digests = new Set()
  290. keptTrimmedByDir.set(directory, digests)
  291. }
  292. if (digests.has(digest)) return true
  293. digests.add(digest)
  294. return false
  295. }
  296. const items: ChangeRenderItem[] = []
  297. const versionUpdates: InstructionVersionUpdate[] = []
  298. const pushRemoval = (scope: string, path: string): void => {
  299. const change: AgentInstructionChange = { action: 'remove', scope, path }
  300. items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
  301. versionUpdates.push({ change })
  302. }
  303. const scopesByDirectory = new Map<string, string[]>()
  304. for (const scope of scopes) {
  305. const { directory } = decodeScopeKey(scope)
  306. const directoryScopes = scopesByDirectory.get(directory)
  307. if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope])
  308. else directoryScopes.push(scope)
  309. }
  310. for (const [directory, directoryScopes] of scopesByDirectory) {
  311. const probedScopes: string[] = []
  312. for (const scope of directoryScopes) {
  313. if (options.excludedBaselineScopes !== undefined
  314. && baselineScopes.has(scope)
  315. && options.excludedBaselineScopes.has(scope)) {
  316. const previous = effective.get(scope)
  317. if (previous === undefined || previous.action === 'remove') versions.delete(scope)
  318. else pushRemoval(scope, previous.path)
  319. } else {
  320. probedScopes.push(scope)
  321. }
  322. }
  323. const itemStart = items.length
  324. const versionUpdateStart = versionUpdates.length
  325. const addedAbsolutePaths: string[] = []
  326. const priorVersions = new Map(probedScopes.map(scope => [scope, versions.get(scope)]))
  327. for (const scope of probedScopes) {
  328. const previous = effective.get(scope)
  329. const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
  330. if (probe.kind === 'unavailable') {
  331. if (previous === undefined || previous.action === 'remove') continue
  332. // Same-directory candidates form one deduplicated authority group. If an
  333. // active member cannot be observed, preserve the entire last-good group;
  334. // cache warmth must never decide whether a sibling transition is emitted.
  335. items.splice(itemStart)
  336. versionUpdates.splice(versionUpdateStart)
  337. for (const [candidateScope, prior] of priorVersions) {
  338. if (prior === undefined) versions.delete(candidateScope)
  339. else versions.set(candidateScope, prior)
  340. }
  341. for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath)
  342. keptTrimmedByDir.delete(directory)
  343. break
  344. }
  345. if (probe.kind === 'absent') {
  346. if (previous === undefined || previous.action === 'remove') versions.delete(scope)
  347. else pushRemoval(scope, previous.path)
  348. continue
  349. }
  350. const { file: probedFile } = probe
  351. if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
  352. seenAbsolutePaths.add(probedFile.absolutePath)
  353. addedAbsolutePaths.push(probedFile.absolutePath)
  354. const cached = versions.get(scope)
  355. if (
  356. cached !== undefined
  357. && cached.path === probedFile.displayPath
  358. && cached.version === probedFile.version
  359. && previous !== undefined
  360. && previous.action !== 'remove'
  361. && previous.path === cached.path
  362. && previous.digest === cached.digest
  363. ) {
  364. // Unchanged and previously rendered: keep it, but an earlier sibling that
  365. // now matches its trimmed content makes this the duplicate to remove.
  366. if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
  367. continue
  368. }
  369. const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
  370. if (file === undefined) continue
  371. const currentDigest = instructionContentSha1(file.content)
  372. const trimmedDigest = trimmedInstructionDigest(file.content)
  373. if (registerKeptTrimmed(directory, trimmedDigest)) {
  374. // A distinct file whose trimmed content already appeared earlier in this
  375. // directory: drop it, removing any copy that was previously rendered.
  376. if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
  377. else versions.delete(scope)
  378. continue
  379. }
  380. const nextVersion: InstructionVersionState = {
  381. path: file.displayPath,
  382. version: probedFile.version,
  383. digest: currentDigest,
  384. trimmedDigest,
  385. }
  386. if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
  387. versions.set(scope, nextVersion)
  388. continue
  389. }
  390. const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
  391. const change: AgentInstructionChange = {
  392. action,
  393. scope,
  394. path: file.displayPath,
  395. digest: currentDigest,
  396. }
  397. items.push({ change, file })
  398. versionUpdates.push({ change, state: nextVersion })
  399. }
  400. }
  401. if (items.length === 0) return undefined
  402. const rendered = renderInstructionChanges(items, resolved.maxBytes)
  403. // When no transition survived rendering (tiny budgets render notice-only
  404. // text), emit nothing and commit nothing — the uncommitted versions make the
  405. // next pass retry instead of spamming notice-only contexts.
  406. if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
  407. return {
  408. context: agentInstructionsHook(rendered.text, rendered.changes),
  409. versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
  410. }
  411. }