/** * 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 'cordis' import { isDeepStrictEqual } from 'node:util' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' import { loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, name, reconcileInstructionContext, workspaceContextMessage, type InstructionVersionCache, } 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 hasVisibleBaseline(agent: Agent): boolean { return agent.session.surface.nodes.some((seq) => { const event = agent.session.events[seq] return event?.type === 'user/message' && event.data.source.kind === 'workspace-instructions' && event.data.source.baseline === true }) } 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 projectionLifecycle = new AbortController() ctx.effect( () => () => { projectionLifecycle.abort(new Error('workspace-context disposed')) }, '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>() 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] const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message => message.source.kind === 'workspace-instructions' && message.source.baseline === true) if (!baselinePresent) { /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = agent.session.header.cwd ?? process.cwd() const instructions = await loadBaselineInstructionSet({ cwd, dshHome: resolved.dshHome, projectRootMarkers: resolved.projectRootMarkers, maxBytes: resolved.maxBytes, maxSourceBytes: resolved.maxSourceBytes, instructionFileCandidates: resolved.instructionFileCandidates, localInstructionFileCandidates: resolved.localInstructionFileCandidates, signal, }, fileSystem) const baseline = baselineInstructionState(instructions?.included ?? []) 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 (instructions !== undefined && instructions.rendered.text.length > 0) { content.push(...workspaceContextMessage(instructions.rendered.text).content) changes.push(...baseline.changes.values()) desiredBaseline = true } } const update = await reconcileInstructionContext( agent, resolved, instructionVersions, fileSystem, { authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, 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 (content.length === 0) return undefined return createUserMessage({ content, source: { kind: 'workspace-instructions', ...desiredBaseline ? { baseline: true } : {}, 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 } ctx.on('agent/pre-step', async ( agent: 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) => { if (result.isError || exec.agent === undefined || exec.signal.aborted) return const ownPath = filePathFromExecution(exec) if (ownPath === undefined) return queueProjection(exec.agent, ownPath) }) }