/** * Workspace instruction loader for AGENTS.md-compatible files. * * Baseline instructions enter durable context before the first request; successful fs * tool touches project nested, changed, and removed instructions into the inbox. * Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products * mount it as a no-op. * * @module @deepseek-ai/dsh-workspace-context */ import type { Context } from '@deepseek-ai/cordis' import { isDeepStrictEqual } from 'node:util' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, name, reconcileInstructionContext, workspaceContextMessage, type InstructionVersionCache, type WorkspaceInstructionSource, } from './state.ts' import type { WorkspaceInstructionChange } from './render.ts' export { Config, name } export { discoverBaselineInstructionFiles, loadBaselineInstructions, } from './files.ts' export type { InstructionFile, LoadedInstructionFile, } from './files.ts' export { renderWorkspaceContext } from './render.ts' export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts' function visibleBaselineSource( agent: Agent, authorityMessages: readonly UserMessage[], ): WorkspaceInstructionSource | undefined { for (const message of authorityMessages.toReversed()) { if (message.source.kind === 'workspace-instructions' && message.source.baseline === true) { return message.source } } for (const seq of agent.session.surface.nodes.toReversed()) { const event = agent.session.events[seq] if (event?.type === 'user/message' && event.data.source.kind === 'workspace-instructions' && event.data.source.baseline === true) return event.data.source } return undefined } function isWorkspaceContext(message: UserMessage): boolean { return message.source.kind === 'workspace-instructions' } function sameContextPayload(left: UserMessage, right: UserMessage): boolean { return isDeepStrictEqual(left.content, right.content) && isDeepStrictEqual(left.source, right.source) } const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) function filePathFromExecution(exec: ToolExecution): string | undefined { if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined const filePath = exec.arguments.file_path.trim() return filePath.length > 0 ? filePath : undefined } export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap }>() const projectionLifecycle = new AbortController() type ProjectionTouch = { agent: Agent; path: string } const executionTouches = new Map() ctx.effect( () => () => { projectionLifecycle.abort(new Error('workspace-context disposed')) executionTouches.clear() }, 'workspace-context.projectionLifecycle', ) // Emit listeners are not awaited, so each projection must compose against the // inbox produced by earlier file results for the same agent. const projectionTails = new WeakMap>() // Execution ancestry and the enclosing durable step are the two commit // boundaries before an asynchronous projection may mutate the agent inbox. const openSteps = new WeakMap() const stepTouches = new WeakMap() const compose = async ( agent: Agent, signal: AbortSignal, claimed: readonly UserMessage[], pending: readonly UserMessage[], touchedPaths: readonly string[] = [], ): Promise => { signal.throwIfAborted() if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { return undefined } const fileSystem = ctx.get('fs') if (fileSystem === undefined) return undefined if (touchedPaths.length === 0 && pending.length > 0) return pending[0] const content: UserMessage['content'][number][] = [] const changes: WorkspaceInstructionChange[] = [] let desiredBaseline = false const authorityMessages = [...claimed] /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = agent.session.header.cwd ?? process.cwd() const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal) const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot) const visibleBaseline = visibleBaselineSource(agent, authorityMessages) const baselinePresent = visibleBaseline !== undefined const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity const prepared = baselinePreparations.get(agent.session) let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity ? prepared.excludedScopes : undefined let nextPreparation: { identity: string; excludedScopes: ReadonlySet } | undefined if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === undefined) { const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline const instructions = await loadBaselineInstructionSet({ cwd, dshHome: resolved.dshHome, projectRootMarkers: resolved.projectRootMarkers, maxBytes: resolved.maxBytes, maxSourceBytes: resolved.maxSourceBytes, instructionFileCandidates: resolved.instructionFileCandidates, localInstructionFileCandidates: resolved.localInstructionFileCandidates, projectRoot, replacePreviousBaseline, signal, }, fileSystem) const baseline = baselineInstructionState(instructions?.included ?? []) const observedBaseline = baselineInstructionState(instructions?.observed ?? []) const excludedScopes = new Set(observedBaseline.changes.keys()) for (const scope of baseline.changes.keys()) excludedScopes.delete(scope) excludedBaselineScopes = excludedScopes nextPreparation = { identity, excludedScopes } let versionStates = instructionVersions.get(agent.session) if (versionStates === undefined && baseline.versions.size > 0) { versionStates = new Map() instructionVersions.set(agent.session, versionStates) } for (const [scope, state] of baseline.versions) versionStates?.set(scope, state) if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { const baselineContent = workspaceContextMessage(instructions.rendered.text).content content.push(...baselineContent) const replacementScopes = new Set(baseline.changes.keys()) const replacementRemovals = replacePreviousBaseline ? visibleBaseline.changes.flatMap(change => ( change.action === 'remove' || replacementScopes.has(change.scope) ? [] : [{ action: 'remove' as const, scope: change.scope, path: change.path }] )) : [] const baselineChanges = [...replacementRemovals, ...baseline.changes.values()] changes.push(...baselineChanges) authorityMessages.push(createUserMessage({ content: baselineContent, source: { kind: 'workspace-instructions', form: 'instructions', baseline: true, baselineIdentity: identity, changes: baselineChanges, }, })) desiredBaseline = true } } const update = await reconcileInstructionContext( agent, resolved, instructionVersions, fileSystem, { authorityMessages, scopeMessages: pending, includeBaselineScopes: keepVisibleBaseline, ...keepVisibleBaseline ? { excludedBaselineScopes } : {}, touchedPaths, projectRoot, signal, }, ) if (update !== undefined) { content.push(...update.context.content) /* v8 ignore next -- reconciliation constructs only workspace-instructions contexts. */ if (update.context.source.kind === 'workspace-instructions') { changes.push(...update.context.source.changes) } applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } if (nextPreparation !== undefined) baselinePreparations.set(agent.session, nextPreparation) if (content.length === 0) return undefined return createUserMessage({ content, source: { kind: 'workspace-instructions', form: 'instructions', ...desiredBaseline ? { baseline: true } : {}, ...desiredBaseline ? { baselineIdentity: identity } : {}, changes, }, }) } const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => { const pending = agent.inbox.nextStep.filter(isWorkspaceContext) const alreadySupplied = desired !== undefined && ( claimed.some(message => sameContextPayload(message, desired)) || agent.session.surface.nodes.some((seq) => { const event = agent.session.events[seq] return event?.type === 'user/message' && sameContextPayload(event.data, desired) }) ) if (desired === undefined || alreadySupplied) { for (const message of pending) agent.inbox.remove(message.id) return } const reusable = pending.find(message => sameContextPayload(message, desired)) if (reusable !== undefined) { for (const message of pending) { if (message !== reusable) agent.inbox.remove(message.id) } return } const replaced = pending[0] if (replaced === undefined) agent.inbox.prepend('next-step', desired) else agent.inbox.replace(replaced.id, desired) for (const message of pending.slice(1)) agent.inbox.remove(message.id) } const composeAndSync = async ( agent: Agent, signal: AbortSignal, claimed: readonly UserMessage[], touchedPaths: readonly string[] = [], ): Promise => { const pending = agent.inbox.nextStep.filter(isWorkspaceContext) const desired = await compose(agent, signal, claimed, pending, touchedPaths) signal.throwIfAborted() syncInbox(agent, claimed, desired) } const queueProjection = ( agent: Agent, touchedPath: string, ): void => { const previous = projectionTails.get(agent) ?? Promise.resolve() const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath])) .catch((error: unknown) => { if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error) }) projectionTails.set(agent, current) void current.then(() => { if (projectionTails.get(agent) === current) projectionTails.delete(agent) }) } const waitForProjections = async (agent: Agent): Promise => { let projection: Promise | undefined while ((projection = projectionTails.get(agent)) !== undefined) await projection } const stepIsOpen = (session: Session): boolean => { const known = openSteps.get(session) if (known !== undefined) return known let open = false for (const event of session.events) { if (event.type === 'step/start') open = true else if (event.type === 'step/end' || event.type === 'turn/end') open = false } openSteps.set(session, open) return open } const projectTouch = (touch: ProjectionTouch): void => { const session = touch.agent.session if (!stepIsOpen(session)) { queueProjection(touch.agent, touch.path) return } const pending = stepTouches.get(session) if (pending === undefined) stepTouches.set(session, [touch]) else pending.push(touch) } ctx.on('session/event', (session, event) => { if (event.type === 'step/start') { openSteps.set(session, true) return } if (event.type === 'turn/end') { openSteps.set(session, false) return } if (event.type !== 'step/end') return openSteps.set(session, false) const pending = stepTouches.get(session) if (pending === undefined) return stepTouches.delete(session) for (const touch of pending) queueProjection(touch.agent, touch.path) }) ctx.on('agent/pre-step', async ( { agent, messages, step, signal }, next, ): Promise => { const decision = await next() await waitForProjections(agent) const pending = agent.inbox.nextStep.filter(isWorkspaceContext) const desired = await compose(agent, signal, messages, pending) signal.throwIfAborted() // An empty first entry owns a no-step turn; keep context pending instead // of turning it into a standalone request. Later entries may be tool continuations. if (decision.kind === 'reject' || (step === 1 && decision.messages.length === 0)) { syncInbox(agent, messages, desired) return decision } // A proceeding step settles the pending context: it either enters below as // `desired`, or its payload is already covered by the batch, so nothing stays pending. for (const message of pending) agent.inbox.remove(message.id) if (desired === undefined || decision.messages.some(message => sameContextPayload(message, desired))) { return decision } // Fold the context right after the claimed batch, so the direct prompt // precedes it and the driver-appended runtime context follows it. const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message)) const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired) return { kind: 'enter', messages: entered } }) ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => { const touches = executionTouches.get(exec.token) ?? [] executionTouches.delete(exec.token) if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) { const ownPath = filePathFromExecution(exec) if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath }) } if (exec.parent !== undefined) { if (touches.length > 0) { const parentTouches = executionTouches.get(exec.parent) if (parentTouches === undefined) executionTouches.set(exec.parent, touches) else parentTouches.push(...touches) } return } for (const touch of touches) projectTouch(touch) }) }