state.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. /**
  2. * Session-visible workspace instruction state and dynamic reconciliation.
  3. *
  4. * @module @deepseek-ai/dsh-workspace-context/state
  5. */
  6. import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
  7. import type { Message } from '@deepseek-ai/dsh-llm'
  8. import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
  9. import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
  10. import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  11. import type { ResolvedConfig } from './config.ts'
  12. import { instructionContentSha1 } 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. renderInstructionChanges,
  24. scopeForDisplayPath,
  25. type ChangeRenderItem,
  26. type WorkspaceInstructionChange,
  27. } from './render.ts'
  28. export const name = 'workspace-context'
  29. const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
  30. const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
  31. /** Dynamic state waiting for the loop to append its returned context event. */
  32. export interface PendingInstructionChange {
  33. change: WorkspaceInstructionChange
  34. afterSeq: number
  35. step?: { turn: number; step: number }
  36. }
  37. /** Per-scope metadata cache; instruction prose is deliberately not retained. */
  38. export interface InstructionVersionState {
  39. path: string
  40. version: FsVersion
  41. digest: string
  42. }
  43. /** Session-isolated fast-path state keyed by logical instruction scope. */
  44. export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
  45. /** A cache transition coupled to the model-visible change that authorizes it. */
  46. export interface InstructionVersionUpdate {
  47. change: WorkspaceInstructionChange
  48. state?: InstructionVersionState
  49. }
  50. /** Rendered reconciliation plus cache transitions awaiting final policy. */
  51. export interface ReconciledInstructionContext {
  52. context: WorkspaceHookContext
  53. versionUpdates: InstructionVersionUpdate[]
  54. }
  55. /** Plugin-owned raw context with required replay metadata. */
  56. export interface WorkspaceHookContext extends HookContext {
  57. envelope: 'raw'
  58. meta: JsonValue
  59. }
  60. function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
  61. const serializedChanges: JsonValue[] = changes.map(change => ({
  62. action: change.action,
  63. scope: change.scope,
  64. path: change.path,
  65. ...change.previousPath !== undefined ? { previousPath: change.previousPath } : {},
  66. ...change.digest !== undefined ? { digest: change.digest } : {},
  67. }))
  68. const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
  69. return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta }
  70. }
  71. /**
  72. * Build the request-prefix message for a rendered baseline.
  73. * @param text - complete plugin-owned system-reminder text.
  74. * @returns a user-role prefix message.
  75. */
  76. export function workspaceContextMessage(text: string): Message {
  77. return { role: 'user', content: [{ type: 'text', text }] }
  78. }
  79. function filePathFromExecution(exec: ToolExecution): string | undefined {
  80. if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
  81. if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
  82. if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
  83. const filePath = exec.arguments.file_path.trim()
  84. return filePath.length > 0 ? filePath : undefined
  85. }
  86. function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE {
  87. return typeof source === 'object' && source !== null
  88. && 'kind' in source && source.kind === 'plugin'
  89. && 'plugin' in source && source.plugin === name
  90. }
  91. function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
  92. return typeof value === 'object' && value !== null && !Array.isArray(value)
  93. }
  94. function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] {
  95. if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return []
  96. const changes: WorkspaceInstructionChange[] = []
  97. for (const value of meta.changes) {
  98. if (!isRecord(value)) continue
  99. if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
  100. if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
  101. if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue
  102. if (value.digest !== undefined && typeof value.digest !== 'string') continue
  103. changes.push({
  104. action: value.action,
  105. scope: value.scope,
  106. path: value.path,
  107. ...value.previousPath !== undefined ? { previousPath: value.previousPath } : {},
  108. ...value.digest !== undefined ? { digest: value.digest } : {},
  109. })
  110. }
  111. return changes
  112. }
  113. function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstructionChange): boolean {
  114. return a.action === b.action
  115. && a.scope === b.scope
  116. && a.path === b.path
  117. && a.previousPath === b.previousPath
  118. && a.digest === b.digest
  119. }
  120. function visibleInstructionChanges(
  121. agent: Agent,
  122. pending: Map<string, PendingInstructionChange>,
  123. ): Map<string, WorkspaceInstructionChange> {
  124. const visibleSeqs = new Set(agent.session.surface.nodes)
  125. const visible = new Map<string, WorkspaceInstructionChange>()
  126. for (const [seq, event] of agent.session.events.entries()) {
  127. if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
  128. const changes = workspaceInstructionChanges(event.data.meta)
  129. for (const change of changes) {
  130. const waiting = pending.get(change.scope)
  131. if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
  132. pending.delete(change.scope)
  133. }
  134. if (visibleSeqs.has(seq)) visible.set(change.scope, change)
  135. }
  136. }
  137. for (const { change } of pending.values()) visible.set(change.scope, change)
  138. return visible
  139. }
  140. /**
  141. * Convert retained baseline files into comparison and metadata-cache state.
  142. * @param files - baseline files that survived rendering.
  143. * @returns latest baseline changes and provider versions keyed by logical scope.
  144. */
  145. export function baselineInstructionState(files: LoadedInstructionFile[]): {
  146. changes: Map<string, WorkspaceInstructionChange>
  147. versions: Map<string, InstructionVersionState>
  148. } {
  149. const changes = new Map<string, WorkspaceInstructionChange>()
  150. const versions = new Map<string, InstructionVersionState>()
  151. for (const file of files) {
  152. const digest = instructionContentSha1(file.content)
  153. const change: WorkspaceInstructionChange = {
  154. action: 'set',
  155. scope: scopeForDisplayPath(file.displayPath),
  156. path: file.displayPath,
  157. digest,
  158. }
  159. changes.set(change.scope, change)
  160. if (file.version !== undefined) {
  161. versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
  162. }
  163. }
  164. return { changes, versions }
  165. }
  166. function versionStatesFor(session: Session, cache: InstructionVersionCache): Map<string, InstructionVersionState> {
  167. let states = cache.get(session)
  168. if (states === undefined) {
  169. states = new Map()
  170. cache.set(session, states)
  171. }
  172. return states
  173. }
  174. /**
  175. * Keep only cache updates whose model-visible changes survived final policy.
  176. * @param updates - proposed updates from one or more reconciliations.
  177. * @param committedChanges - transitions retained on the authoritative result.
  178. * @returns updates authorized by an exact retained transition.
  179. */
  180. export function retainedInstructionVersionUpdates(
  181. updates: readonly InstructionVersionUpdate[],
  182. committedChanges: readonly WorkspaceInstructionChange[],
  183. ): InstructionVersionUpdate[] {
  184. return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change)))
  185. }
  186. /**
  187. * Apply authorized metadata-cache transitions without retaining instruction prose.
  188. * @param session - owning session.
  189. * @param updates - ordered set/delete transitions.
  190. * @param cache - session-isolated metadata cache.
  191. */
  192. export function applyInstructionVersionUpdates(
  193. session: Session,
  194. updates: readonly InstructionVersionUpdate[],
  195. cache: InstructionVersionCache,
  196. ): void {
  197. if (updates.length === 0) return
  198. const states = versionStatesFor(session, cache)
  199. for (const update of updates) {
  200. if (update.state === undefined) states.delete(update.change.scope)
  201. else states.set(update.change.scope, update.state)
  202. }
  203. if (states.size === 0) cache.delete(session)
  204. }
  205. function pendingChangesFor(
  206. session: object,
  207. pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
  208. ): Map<string, PendingInstructionChange> {
  209. let pending = pendingBySession.get(session)
  210. if (pending === undefined) {
  211. pending = new Map()
  212. pendingBySession.set(session, pending)
  213. }
  214. return pending
  215. }
  216. function openStep(session: Session): { turn: number; step: number } | undefined {
  217. const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
  218. return boundary?.type === 'step/start' ? boundary.data : undefined
  219. }
  220. function invalidateInstructionVersions(
  221. session: Session,
  222. scopes: readonly string[],
  223. cache: InstructionVersionCache,
  224. ): void {
  225. const states = cache.get(session)
  226. if (states === undefined) return
  227. for (const scope of scopes) states.delete(scope)
  228. if (states.size === 0) cache.delete(session)
  229. }
  230. /**
  231. * Settle provisional tool-result state against durable session events.
  232. * A matching context event confirms the transition. If its owning step closes
  233. * first, both duplicate suppression and the metadata fast path are re-armed for
  234. * the next successful touch.
  235. * @param session - session whose append-only log emitted `event`.
  236. * @param event - newly committed session event.
  237. * @param pendingBySession - provisional transitions awaiting log confirmation.
  238. * @param versionCache - metadata fast path coupled to those transitions.
  239. */
  240. export function observeInstructionSessionEvent(
  241. session: Session,
  242. event: SessionEvent,
  243. pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
  244. versionCache: InstructionVersionCache,
  245. ): void {
  246. const pending = pendingBySession.get(session)
  247. if (pending === undefined) return
  248. switch (event.type) {
  249. case 'context/message': {
  250. if (!isWorkspaceContextSource(event.data.source)) return
  251. for (const change of workspaceInstructionChanges(event.data.meta)) {
  252. const waiting = pending.get(change.scope)
  253. if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
  254. pending.delete(change.scope)
  255. }
  256. }
  257. if (pending.size === 0) pendingBySession.delete(session)
  258. return
  259. }
  260. case 'step/end': {
  261. const discardedScopes: string[] = []
  262. for (const [scope, waiting] of pending) {
  263. const step = waiting.step
  264. if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
  265. pending.delete(scope)
  266. discardedScopes.push(scope)
  267. }
  268. if (pending.size === 0) pendingBySession.delete(session)
  269. invalidateInstructionVersions(session, discardedScopes, versionCache)
  270. return
  271. }
  272. default:
  273. // SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
  274. return
  275. }
  276. }
  277. /**
  278. * Commit only workspace contexts that survived the complete tool pipeline.
  279. * The observe-only `tools/result` notification calls this before the loop can
  280. * append the returned contexts, closing that short pending window without
  281. * trusting an intermediate post-execute decision.
  282. * @param agent - session that will receive the final result contexts.
  283. * @param contexts - immutable contexts on the authoritative top-level result.
  284. * @param pendingBySession - per-session pending transition maps.
  285. * @returns transitions committed into the short pending window.
  286. */
  287. export function commitPendingInstructionContexts(
  288. agent: Agent,
  289. contexts: readonly HookContext[] | undefined,
  290. pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
  291. ): WorkspaceInstructionChange[] {
  292. const committed: WorkspaceInstructionChange[] = []
  293. const step = openStep(agent.session)
  294. for (const context of contexts ?? []) {
  295. if (!isWorkspaceContextSource(context.source)) continue
  296. const changes = workspaceInstructionChanges(context.meta)
  297. if (changes.length === 0) continue
  298. const pending = pendingChangesFor(agent.session, pendingBySession)
  299. for (const change of changes) {
  300. pending.set(change.scope, {
  301. change,
  302. afterSeq: agent.session.seq,
  303. ...step === undefined ? {} : { step },
  304. })
  305. committed.push(change)
  306. }
  307. }
  308. return committed
  309. }
  310. /**
  311. * Roll back parent-token state when an enclosing tool result discards deferred
  312. * contexts. A newer transition for the same scope is left intact.
  313. * @param agent - session whose pending state was staged.
  314. * @param changes - exact staged transitions to remove when still current.
  315. * @param pendingBySession - per-session pending transition maps.
  316. */
  317. export function rollbackPendingInstructionChanges(
  318. agent: Agent,
  319. changes: readonly WorkspaceInstructionChange[],
  320. pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
  321. ): void {
  322. const pending = pendingBySession.get(agent.session)
  323. if (pending === undefined) return
  324. for (const change of changes) {
  325. const current = pending.get(change.scope)
  326. if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
  327. }
  328. if (pending.size === 0) pendingBySession.delete(agent.session)
  329. }
  330. function relativeScope(projectRoot: string, dir: string): string {
  331. const scope = relativeDisplay(projectRoot, dir)
  332. return scope.length === 0 ? '.' : scope
  333. }
  334. /**
  335. * Compare visible/pending state with provider-visible files and render transitions.
  336. * @param agent - session owner whose visible surface supplies durable state.
  337. * @param resolved - normalized plugin configuration.
  338. * @param pendingBySession - short pending window before returned context is logged.
  339. * @param baselineBySession - frozen baseline comparison state per session.
  340. * @param versionCache - per-session scope metadata used to skip unchanged reads.
  341. * @param fileSystem - provider used for current file probes.
  342. * @param options - touched path and whether baseline scopes should be checked.
  343. * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
  344. */
  345. export async function reconcileInstructionContext(
  346. agent: Agent,
  347. resolved: ResolvedConfig,
  348. pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
  349. baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
  350. versionCache: InstructionVersionCache,
  351. fileSystem: FileSystem,
  352. options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
  353. ): Promise<ReconciledInstructionContext | undefined> {
  354. const session = agent.session
  355. const pending = pendingChangesFor(session, pendingBySession)
  356. const visible = visibleInstructionChanges(agent, pending)
  357. const effective = new Map(baselineBySession.get(session) ?? [])
  358. for (const [scope, change] of visible) effective.set(scope, change)
  359. /* v8 ignore next -- normal agents carry an absolute session cwd. */
  360. const cwd = session.header.cwd ?? process.cwd()
  361. // TODO(frozen-project-root): retain the baseline root for the loop instance;
  362. // recomputing it after marker edits reinterprets the existing relative scope keys.
  363. const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
  364. const scopes = new Set<string>()
  365. if (options.includeBaselineScopes) {
  366. scopes.add('user-global')
  367. for (const dir of ancestorChain(projectRoot, cwd)) scopes.add(relativeScope(projectRoot, dir))
  368. }
  369. for (const scope of effective.keys()) scopes.add(scope)
  370. if (options.touchedPath !== undefined) {
  371. for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
  372. }
  373. const versions = versionStatesFor(session, versionCache)
  374. const seenAbsolutePaths = new Set<string>()
  375. const items: ChangeRenderItem[] = []
  376. const versionUpdates: InstructionVersionUpdate[] = []
  377. for (const scope of scopes) {
  378. const previous = effective.get(scope)
  379. const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
  380. if (probe.kind === 'unavailable') continue
  381. if (probe.kind === 'absent') {
  382. if (previous === undefined || previous.action === 'remove') {
  383. versions.delete(scope)
  384. continue
  385. }
  386. const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
  387. items.push({
  388. change,
  389. file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
  390. })
  391. versionUpdates.push({ change })
  392. continue
  393. }
  394. const { file: probedFile } = probe
  395. if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
  396. seenAbsolutePaths.add(probedFile.absolutePath)
  397. const cached = versions.get(scope)
  398. if (
  399. cached !== undefined
  400. && cached.path === probedFile.displayPath
  401. && cached.version === probedFile.version
  402. && previous !== undefined
  403. && previous.action !== 'remove'
  404. && previous.path === cached.path
  405. && previous.digest === cached.digest
  406. ) continue
  407. const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
  408. if (file === undefined) continue
  409. const currentDigest = instructionContentSha1(file.content)
  410. const nextVersion: InstructionVersionState = {
  411. path: file.displayPath,
  412. version: probedFile.version,
  413. digest: currentDigest,
  414. }
  415. if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
  416. versions.set(scope, nextVersion)
  417. continue
  418. }
  419. const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
  420. const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
  421. ? previous.path
  422. : undefined
  423. const change: WorkspaceInstructionChange = {
  424. action,
  425. scope,
  426. path: file.displayPath,
  427. ...previousPath === undefined ? {} : { previousPath },
  428. digest: currentDigest,
  429. }
  430. items.push({ change, file })
  431. versionUpdates.push({ change, state: nextVersion })
  432. }
  433. if (items.length === 0) return undefined
  434. const rendered = renderInstructionChanges(items, resolved.maxBytes)
  435. if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
  436. return {
  437. context: workspaceContextHook(rendered.text, rendered.changes),
  438. versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
  439. }
  440. }
  441. /**
  442. * Validate a successful structured file touch and reconcile its applicable scopes.
  443. * @param agent - optional agent attached to the tool execution.
  444. * @param exec - completed tool execution descriptor.
  445. * @param result - original tool result before post-execute decisions.
  446. * @param resolved - normalized plugin configuration.
  447. * @param pendingNestedChanges - per-session pending transition maps.
  448. * @param baselineInstructionStates - retained baseline comparison state.
  449. * @param versionCache - per-session scope metadata used to skip unchanged reads.
  450. * @param fileSystem - provider used for current file probes.
  451. * @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
  452. */
  453. export async function dynamicInstructionContext(
  454. agent: Agent | undefined,
  455. exec: ToolExecution,
  456. result: ToolExecutionResult,
  457. resolved: ResolvedConfig,
  458. pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
  459. baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
  460. versionCache: InstructionVersionCache,
  461. fileSystem: FileSystem,
  462. ): Promise<ReconciledInstructionContext | undefined> {
  463. if (agent === undefined || result.isError) return undefined
  464. const touchedPath = filePathFromExecution(exec)
  465. if (touchedPath === undefined) return undefined
  466. return reconcileInstructionContext(
  467. agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
  468. {
  469. touchedPath,
  470. includeBaselineScopes: baselineInstructionStates.has(agent.session),
  471. ...exec.signal === undefined ? {} : { signal: exec.signal },
  472. },
  473. )
  474. }