| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434 |
- /**
- * Session-visible workspace instruction state and dynamic reconciliation.
- *
- * @module @deepseek-ai/dsh-agent-instructions/state
- */
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
- import type { Message } from '@deepseek-ai/dsh-llm'
- import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
- import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
- import type { ResolvedConfig } from './config.ts'
- import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
- import {
- ancestorChain,
- descendantDirsBetween,
- findProjectRoot,
- probeScopeInstruction,
- readScopeInstruction,
- relativeDisplay,
- type LoadedInstructionFile,
- } from './files.ts'
- import {
- candidateScopeKey,
- decodeScopeKey,
- instructionScopeKey,
- renderInstructionChanges,
- USER_GLOBAL_DIRECTORY,
- USER_GLOBAL_FILE,
- type ChangeRenderItem,
- type AgentInstructionChange,
- } from './render.ts'
- export const name = 'agent-instructions'
- /** Durable producer, file, and reconciliation facts for one workspace context. */
- export interface AgentInstructionSource {
- kind: 'agent-instructions'
- /** Every workspace context carries instructions read out of a file (the `instructions` context form). */
- form: 'instructions'
- /** Marks the complete startup/resume baseline rather than a later delta. */
- baseline?: true
- /** Discovery, precedence, and budget identity used to validate a resumed baseline. */
- baselineIdentity?: string
- changes: AgentInstructionChange[]
- }
- declare module '@deepseek-ai/dsh-llm' {
- interface MessageSourceMap {
- 'agent-instructions': AgentInstructionSource
- }
- }
- /** Per-scope metadata cache; instruction prose is deliberately not retained. */
- export interface InstructionVersionState {
- path: string
- version: FsVersion
- digest: string
- /**
- * Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
- * per-directory duplicates on the metadata fast path without re-reading a sibling.
- */
- trimmedDigest: string
- }
- /** Session-isolated fast-path state keyed by logical instruction scope. */
- export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
- /** A metadata-cache transition associated with one rendered instruction change. */
- export interface InstructionVersionUpdate {
- change: AgentInstructionChange
- state?: InstructionVersionState
- }
- /** Rendered reconciliation plus its metadata-cache transitions. */
- export interface ReconciledInstructionContext {
- context: UserMessage
- versionUpdates: InstructionVersionUpdate[]
- }
- function agentInstructionsHook(text: string, changes: AgentInstructionChange[]): UserMessage {
- return createUserMessage({
- content: [{ type: 'text', text }],
- source: { kind: 'agent-instructions', form: 'instructions', changes },
- })
- }
- /**
- * Build the user-role message for a rendered baseline.
- * @param text - complete plugin-owned system-reminder text.
- * @returns a user-role prefix message.
- */
- export function agentInstructionsMessage(text: string): Message {
- return createUserMessage({
- content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: name },
- })
- }
- function isAgentInstructionsSource(
- source: unknown,
- ): source is { kind: 'agent-instructions'; changes: unknown[] } {
- return typeof source === 'object' && source !== null
- && 'kind' in source && source.kind === 'agent-instructions'
- && 'changes' in source && Array.isArray(source.changes)
- }
- function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
- }
- function workspaceInstructionChanges(source: { changes: unknown[] }): AgentInstructionChange[] {
- const changes: AgentInstructionChange[] = []
- for (const value of source.changes) {
- if (!isRecord(value)) continue
- if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
- if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
- if (value.digest !== undefined && typeof value.digest !== 'string') continue
- changes.push({
- action: value.action,
- scope: value.scope,
- path: value.path,
- ...value.digest !== undefined ? { digest: value.digest } : {},
- })
- }
- return changes
- }
- function sameInstructionChange(a: AgentInstructionChange, b: AgentInstructionChange): boolean {
- return a.action === b.action
- && a.scope === b.scope
- && a.path === b.path
- && a.digest === b.digest
- }
- function visibleInstructionChanges(
- agent: Agent,
- authorityMessages: readonly UserMessage[],
- ): Map<string, AgentInstructionChange> {
- const visible = new Map<string, AgentInstructionChange>()
- for (const seq of agent.session.surface.nodes) {
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
- const event = agent.session.eventAt(seq)
- if (event?.type !== 'user/message' || !isAgentInstructionsSource(event.data.source)) continue
- const changes = workspaceInstructionChanges(event.data.source)
- for (const change of changes) {
- visible.set(change.scope, change)
- }
- }
- for (const message of authorityMessages) {
- if (!isAgentInstructionsSource(message.source)) continue
- for (const change of workspaceInstructionChanges(message.source)) {
- visible.set(change.scope, change)
- }
- }
- return visible
- }
- /**
- * Convert retained baseline files into comparison and metadata-cache state.
- * @param files - baseline files that survived rendering.
- * @returns latest baseline changes and provider versions keyed by logical scope.
- */
- export function baselineInstructionState(files: LoadedInstructionFile[]): {
- changes: Map<string, AgentInstructionChange>
- versions: Map<string, InstructionVersionState>
- } {
- const changes = new Map<string, AgentInstructionChange>()
- const versions = new Map<string, InstructionVersionState>()
- for (const file of files) {
- const digest = instructionContentSha1(file.content)
- const change: AgentInstructionChange = {
- action: 'set',
- scope: instructionScopeKey(file.displayPath),
- path: file.displayPath,
- digest,
- }
- changes.set(change.scope, change)
- if (file.version !== undefined) {
- versions.set(change.scope, {
- path: file.displayPath,
- version: file.version,
- digest,
- trimmedDigest: trimmedInstructionDigest(file.content),
- })
- }
- }
- return { changes, versions }
- }
- function versionStatesFor(session: Session, cache: InstructionVersionCache): Map<string, InstructionVersionState> {
- let states = cache.get(session)
- if (states === undefined) {
- states = new Map()
- cache.set(session, states)
- }
- return states
- }
- /**
- * Keep only cache updates represented by rendered changes.
- * @param updates - proposed updates from one or more reconciliations.
- * @param renderedChanges - transitions retained by the renderer.
- * @returns updates represented by an exact retained transition.
- */
- export function retainedInstructionVersionUpdates(
- updates: readonly InstructionVersionUpdate[],
- renderedChanges: readonly AgentInstructionChange[],
- ): InstructionVersionUpdate[] {
- return updates.filter(update => renderedChanges.some(change => sameInstructionChange(update.change, change)))
- }
- /**
- * Apply metadata-cache transitions without retaining instruction prose.
- * @param session - owning session.
- * @param updates - ordered set/delete transitions.
- * @param cache - session-isolated metadata cache.
- */
- export function applyInstructionVersionUpdates(
- session: Session,
- updates: readonly InstructionVersionUpdate[],
- cache: InstructionVersionCache,
- ): void {
- if (updates.length === 0) return
- const states = versionStatesFor(session, cache)
- for (const update of updates) {
- if (update.state === undefined) states.delete(update.change.scope)
- else states.set(update.change.scope, update.state)
- }
- if (states.size === 0) cache.delete(session)
- }
- function relativeScope(projectRoot: string, dir: string): string {
- const scope = relativeDisplay(projectRoot, dir)
- return scope.length === 0 ? '.' : scope
- }
- /**
- * Compare visible state with provider-visible files and render transitions.
- * @param agent - session owner whose visible surface supplies durable state.
- * @param resolved - normalized plugin configuration.
- * @param versionCache - per-session scope metadata used to skip unchanged reads.
- * @param fileSystem - provider used for current file probes.
- * @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
- * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
- */
- export async function reconcileInstructionContext(
- agent: Agent,
- resolved: ResolvedConfig,
- versionCache: InstructionVersionCache,
- fileSystem: FileSystem,
- options: {
- authorityMessages: readonly UserMessage[]
- scopeMessages: readonly UserMessage[]
- touchedPaths: readonly string[]
- includeBaselineScopes: boolean
- excludedBaselineScopes?: ReadonlySet<string>
- projectRoot?: string
- signal?: AbortSignal
- },
- ): Promise<ReconciledInstructionContext | undefined> {
- const session = agent.session
- const effective = visibleInstructionChanges(agent, options.authorityMessages)
- /* v8 ignore next -- normal agents carry an absolute session cwd. */
- const cwd = session.header.cwd ?? process.cwd()
- // TODO(frozen-project-root): retain the baseline root for the loop instance;
- // recomputing it after marker edits reinterprets the existing relative scope keys.
- const projectRoot = options.projectRoot
- ?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
- const scopes = new Set<string>()
- const baselineScopes = new Set<string>()
- const addDirScopes = (target: Set<string>, directory: string): void => {
- for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
- for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
- }
- const addProjectScopes = (target: Set<string>, dir: string): void => {
- addDirScopes(target, relativeScope(projectRoot, dir))
- }
- baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
- for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir)
- if (options.includeBaselineScopes) {
- for (const scope of baselineScopes) scopes.add(scope)
- }
- for (const message of options.scopeMessages) {
- /* v8 ignore next -- the plugin passes its workspace-only pending projection. */
- if (!isAgentInstructionsSource(message.source)) continue
- for (const change of workspaceInstructionChanges(message.source)) {
- if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue
- scopes.add(change.scope)
- }
- }
- for (const scope of effective.keys()) {
- if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
- const { directory } = decodeScopeKey(scope)
- if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
- else addDirScopes(scopes, directory)
- }
- for (const touchedPath of options.touchedPaths) {
- for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir)
- }
- const versions = versionStatesFor(session, versionCache)
- const seenAbsolutePaths = new Set<string>()
- // Per-directory trimmed-content identities kept so far this pass, iterated in
- // candidate order (base before local); a later sibling matching an earlier one
- // is a duplicate and is dropped or removed rather than rendered twice.
- const keptTrimmedByDir = new Map<string, Set<string>>()
- const registerKeptTrimmed = (directory: string, digest: string): boolean => {
- let digests = keptTrimmedByDir.get(directory)
- if (digests === undefined) {
- digests = new Set()
- keptTrimmedByDir.set(directory, digests)
- }
- if (digests.has(digest)) return true
- digests.add(digest)
- return false
- }
- const items: ChangeRenderItem[] = []
- const versionUpdates: InstructionVersionUpdate[] = []
- const pushRemoval = (scope: string, path: string): void => {
- const change: AgentInstructionChange = { action: 'remove', scope, path }
- items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
- versionUpdates.push({ change })
- }
- const scopesByDirectory = new Map<string, string[]>()
- for (const scope of scopes) {
- const { directory } = decodeScopeKey(scope)
- const directoryScopes = scopesByDirectory.get(directory)
- if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope])
- else directoryScopes.push(scope)
- }
- for (const [directory, directoryScopes] of scopesByDirectory) {
- const probedScopes: string[] = []
- for (const scope of directoryScopes) {
- if (options.excludedBaselineScopes !== undefined
- && baselineScopes.has(scope)
- && options.excludedBaselineScopes.has(scope)) {
- const previous = effective.get(scope)
- if (previous === undefined || previous.action === 'remove') versions.delete(scope)
- else pushRemoval(scope, previous.path)
- } else {
- probedScopes.push(scope)
- }
- }
- const itemStart = items.length
- const versionUpdateStart = versionUpdates.length
- const addedAbsolutePaths: string[] = []
- const priorVersions = new Map(probedScopes.map(scope => [scope, versions.get(scope)]))
- for (const scope of probedScopes) {
- const previous = effective.get(scope)
- const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
- if (probe.kind === 'unavailable') {
- if (previous === undefined || previous.action === 'remove') continue
- // Same-directory candidates form one deduplicated authority group. If an
- // active member cannot be observed, preserve the entire last-good group;
- // cache warmth must never decide whether a sibling transition is emitted.
- items.splice(itemStart)
- versionUpdates.splice(versionUpdateStart)
- for (const [candidateScope, prior] of priorVersions) {
- if (prior === undefined) versions.delete(candidateScope)
- else versions.set(candidateScope, prior)
- }
- for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath)
- keptTrimmedByDir.delete(directory)
- break
- }
- if (probe.kind === 'absent') {
- if (previous === undefined || previous.action === 'remove') versions.delete(scope)
- else pushRemoval(scope, previous.path)
- continue
- }
- const { file: probedFile } = probe
- if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
- seenAbsolutePaths.add(probedFile.absolutePath)
- addedAbsolutePaths.push(probedFile.absolutePath)
- const cached = versions.get(scope)
- if (
- cached !== undefined
- && cached.path === probedFile.displayPath
- && cached.version === probedFile.version
- && previous !== undefined
- && previous.action !== 'remove'
- && previous.path === cached.path
- && previous.digest === cached.digest
- ) {
- // Unchanged and previously rendered: keep it, but an earlier sibling that
- // now matches its trimmed content makes this the duplicate to remove.
- if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
- continue
- }
- const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
- if (file === undefined) continue
- const currentDigest = instructionContentSha1(file.content)
- const trimmedDigest = trimmedInstructionDigest(file.content)
- if (registerKeptTrimmed(directory, trimmedDigest)) {
- // A distinct file whose trimmed content already appeared earlier in this
- // directory: drop it, removing any copy that was previously rendered.
- if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
- else versions.delete(scope)
- continue
- }
- const nextVersion: InstructionVersionState = {
- path: file.displayPath,
- version: probedFile.version,
- digest: currentDigest,
- trimmedDigest,
- }
- if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
- versions.set(scope, nextVersion)
- continue
- }
- const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
- const change: AgentInstructionChange = {
- action,
- scope,
- path: file.displayPath,
- digest: currentDigest,
- }
- items.push({ change, file })
- versionUpdates.push({ change, state: nextVersion })
- }
- }
- if (items.length === 0) return undefined
- const rendered = renderInstructionChanges(items, resolved.maxBytes)
- // When no transition survived rendering (tiny budgets render notice-only
- // text), emit nothing and commit nothing — the uncommitted versions make the
- // next pass retry instead of spamming notice-only contexts.
- if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
- return {
- context: agentInstructionsHook(rendered.text, rendered.changes),
- versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
- }
- }
|