1
/**2
* Workspace instruction loader for AGENTS.md-compatible files.3
*4
* Baseline instructions enter durable context before the first request; successful fs5
* tool touches project nested, changed, and removed instructions into the inbox.6
* Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products7
* mount it as a no-op.8
*9
* @module @deepseek-ai/dsh-agent-instructions10
*/12
import type { Context } from '@deepseek-ai/cordis'13
import { isDeepStrictEqual } from 'node:util'14
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'15
import { createUserMessage } from '@deepseek-ai/dsh-llm'16
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'17
import type {} from '@deepseek-ai/dsh-session-projection'18
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'19
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'20
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'21
import {22
applyInstructionVersionUpdates,23
baselineInstructionState,24
name,25
reconcileInstructionContext,26
agentInstructionsMessage,27
type InstructionVersionCache,28
type AgentInstructionSource,29
} from './state.ts'30
import type { AgentInstructionChange } from './render.ts'32
export { Config, name }33
/** Services required by workspace instruction projection. */34
export const inject = ['sessionProjections']35
export {36
discoverBaselineInstructionFiles,37
loadBaselineInstructions,38
} from './files.ts'39
export type {40
InstructionFile,41
LoadedInstructionFile,42
} from './files.ts'43
export { renderAgentInstructions } from './render.ts'44
export type { RenderedAgentInstructions, TruncatedInstruction } from './render.ts'46
function visibleBaselineSource(47
agent: Agent,48
authorityMessages: readonly UserMessage[],49
): AgentInstructionSource | undefined {50
for (const message of authorityMessages.toReversed()) {51
if (message.source.kind === 'agent-instructions' && message.source.baseline === true) {52
return message.source53
}54
}55
for (const seq of agent.session.surface.nodes.toReversed()) {56
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.57
const event = agent.session.eventAt(seq)58
if (event?.type === 'user/message'59
&& event.data.source.kind === 'agent-instructions'60
&& event.data.source.baseline === true) return event.data.source61
}62
return undefined63
}65
function isAgentInstructionsMessage(message: UserMessage): boolean {66
return message.source.kind === 'agent-instructions'67
}69
function sameContextPayload(left: UserMessage, right: UserMessage): boolean {70
return isDeepStrictEqual(left.content, right.content)71
&& isDeepStrictEqual(left.source, right.source)72
}74
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])76
function filePathFromExecution(exec: ToolExecution): string | undefined {77
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined78
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined79
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined80
const filePath = exec.arguments.file_path.trim()81
return filePath.length > 0 ? filePath : undefined82
}84
export function apply(ctx: Context, config: Config): void {85
const resolved: ResolvedConfig = resolveConfig(config)86
const instructionVersions: InstructionVersionCache = new WeakMap()87
const baselinePreparations = new WeakMap<Session, {88
identity: string89
excludedScopes: ReadonlySet<string>90
}>()91
const projectionLifecycle = new AbortController()92
type ProjectionTouch = { agent: Agent; path: string }93
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()94
ctx.effect(95
() => () => {96
projectionLifecycle.abort(new Error('agent-instructions disposed'))97
executionTouches.clear()98
},99
'agent-instructions.projectionLifecycle',100
)101
// Emit listeners are not awaited, so each projection must compose against the102
// inbox produced by earlier file results for the same agent.103
const projectionTails = new WeakMap<Agent, Promise<void>>()104
// Execution ancestry and the enclosing durable step are the two commit105
// boundaries before an asynchronous projection may mutate the agent inbox.106
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()108
const compose = async (109
agent: Agent,110
signal: AbortSignal,111
claimed: readonly UserMessage[],112
pending: readonly UserMessage[],113
touchedPaths: readonly string[] = [],114
): Promise<UserMessage | undefined> => {115
signal.throwIfAborted()116
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {117
return undefined118
}119
const fileSystem = ctx.get('fs')120
if (fileSystem === undefined) return undefined121
if (touchedPaths.length === 0 && pending.length > 0) return pending[0]122
const content: UserMessage['content'][number][] = []123
const changes: AgentInstructionChange[] = []124
let desiredBaseline = false125
const authorityMessages = [...claimed]126
/* v8 ignore next -- normal agents carry an absolute session cwd. */127
const cwd = agent.session.header.cwd ?? process.cwd()128
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal)129
const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot)130
const visibleBaseline = visibleBaselineSource(agent, authorityMessages)131
const baselinePresent = visibleBaseline !== undefined132
const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity133
const prepared = baselinePreparations.get(agent.session)134
let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity135
? prepared.excludedScopes136
: undefined137
let nextPreparation: { identity: string; excludedScopes: ReadonlySet<string> } | undefined138
if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === undefined) {139
const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline140
const instructions = await loadBaselineInstructionSet({141
cwd,142
dshHome: resolved.dshHome,143
projectRootMarkers: resolved.projectRootMarkers,144
maxBytes: resolved.maxBytes,145
maxSourceBytes: resolved.maxSourceBytes,146
instructionFileCandidates: resolved.instructionFileCandidates,147
localInstructionFileCandidates: resolved.localInstructionFileCandidates,148
projectRoot,149
replacePreviousBaseline,150
signal,151
}, fileSystem)152
const baseline = baselineInstructionState(instructions?.included ?? [])153
const observedBaseline = baselineInstructionState(instructions?.observed ?? [])154
const excludedScopes = new Set(observedBaseline.changes.keys())155
for (const scope of baseline.changes.keys()) excludedScopes.delete(scope)156
excludedBaselineScopes = excludedScopes157
nextPreparation = { identity, excludedScopes }158
let versionStates = instructionVersions.get(agent.session)159
if (versionStates === undefined && baseline.versions.size > 0) {160
versionStates = new Map()161
instructionVersions.set(agent.session, versionStates)162
}163
for (const [scope, state] of baseline.versions) versionStates?.set(scope, state)164
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {165
const baselineContent = agentInstructionsMessage(instructions.rendered.text).content166
content.push(...baselineContent)167
const replacementScopes = new Set(baseline.changes.keys())168
const replacementRemovals = replacePreviousBaseline169
? visibleBaseline.changes.flatMap(change => (170
change.action === 'remove' || replacementScopes.has(change.scope)171
? []172
: [{ action: 'remove' as const, scope: change.scope, path: change.path }]173
))174
: []175
const baselineChanges = [...replacementRemovals, ...baseline.changes.values()]176
changes.push(...baselineChanges)177
authorityMessages.push(createUserMessage({178
content: baselineContent,179
source: {180
kind: 'agent-instructions',181
form: 'instructions',182
baseline: true,183
baselineIdentity: identity,184
changes: baselineChanges,185
},186
}))187
desiredBaseline = true188
}189
}190
const update = await reconcileInstructionContext(191
agent,192
resolved,193
instructionVersions,194
fileSystem,195
{196
authorityMessages,197
scopeMessages: pending,198
includeBaselineScopes: keepVisibleBaseline,199
...keepVisibleBaseline ? { excludedBaselineScopes } : {},200
touchedPaths,201
projectRoot,202
signal,203
},204
)205
if (update !== undefined) {206
content.push(...update.context.content)207
/* v8 ignore next -- reconciliation constructs only agent-instructions contexts. */208
if (update.context.source.kind === 'agent-instructions') {209
changes.push(...update.context.source.changes)210
}211
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)212
}213
if (nextPreparation !== undefined) baselinePreparations.set(agent.session, nextPreparation)214
if (content.length === 0) return undefined215
return createUserMessage({216
content,217
source: {218
kind: 'agent-instructions',219
form: 'instructions',220
...desiredBaseline ? { baseline: true } : {},221
...desiredBaseline ? { baselineIdentity: identity } : {},222
changes,223
},224
})225
}227
const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => {228
const pending = agent.inbox.nextStep.filter(isAgentInstructionsMessage)229
const alreadySupplied = desired !== undefined && (230
claimed.some(message => sameContextPayload(message, desired))231
|| agent.session.surface.nodes.some((seq) => {232
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.233
const event = agent.session.eventAt(seq)234
return event?.type === 'user/message' && sameContextPayload(event.data, desired)235
})236
)237
if (desired === undefined || alreadySupplied) {238
for (const message of pending) agent.inbox.remove(message.id)239
return240
}241
const reusable = pending.find(message => sameContextPayload(message, desired))242
if (reusable !== undefined) {243
for (const message of pending) {244
if (message !== reusable) agent.inbox.remove(message.id)245
}246
return247
}248
const replaced = pending[0]249
if (replaced === undefined) agent.inbox.prepend('next-step', desired)250
else agent.inbox.replace(replaced.id, desired)251
for (const message of pending.slice(1)) agent.inbox.remove(message.id)252
}254
const composeAndSync = async (255
agent: Agent,256
signal: AbortSignal,257
claimed: readonly UserMessage[],258
touchedPaths: readonly string[] = [],259
): Promise<void> => {260
const pending = agent.inbox.nextStep.filter(isAgentInstructionsMessage)261
const desired = await compose(agent, signal, claimed, pending, touchedPaths)262
signal.throwIfAborted()263
syncInbox(agent, claimed, desired)264
}266
const queueProjection = (267
agent: Agent,268
touchedPath: string,269
): void => {270
const previous = projectionTails.get(agent) ?? Promise.resolve()271
const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath]))272
.catch((error: unknown) => {273
if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)274
})275
projectionTails.set(agent, current)276
void current.then(() => {277
if (projectionTails.get(agent) === current) projectionTails.delete(agent)278
})279
}281
const waitForProjections = async (agent: Agent): Promise<void> => {282
let projection: Promise<void> | undefined283
while ((projection = projectionTails.get(agent)) !== undefined) await projection284
}286
const stepIsOpen = (session: Session): boolean => {287
const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary')288
if (boundary === undefined) {289
throw new Error('agent-instructions requires the turnBoundary session projection')290
}291
return boundary.openTurnStartSeq !== null292
&& boundary.lastStepBoundary?.kind === 'start'293
&& boundary.lastStepBoundary.seq > boundary.openTurnStartSeq294
}296
const projectTouch = (touch: ProjectionTouch): void => {297
const session = touch.agent.session298
if (!stepIsOpen(session)) {299
queueProjection(touch.agent, touch.path)300
return301
}302
const pending = stepTouches.get(session)303
if (pending === undefined) stepTouches.set(session, [touch])304
else pending.push(touch)305
}307
ctx.on('session/event', (session, event) => {308
if (event.type !== 'step/end') return309
const pending = stepTouches.get(session)310
if (pending === undefined) return311
stepTouches.delete(session)312
for (const touch of pending) queueProjection(touch.agent, touch.path)313
})315
ctx.on('agent/pre-step', async (316
{ agent, messages, step, signal },317
next,318
): Promise<PreStepDecision> => {319
const decision = await next()320
await waitForProjections(agent)321
const pending = agent.inbox.nextStep.filter(isAgentInstructionsMessage)322
const desired = await compose(agent, signal, messages, pending)323
signal.throwIfAborted()324
// An empty first entry owns a no-step turn; keep context pending instead325
// of turning it into a standalone request. Later entries may be tool continuations.326
if (decision.kind === 'reject' || (step === 1 && decision.messages.length === 0)) {327
syncInbox(agent, messages, desired)328
return decision329
}330
// A proceeding step settles the pending context: it either enters below as331
// `desired`, or its payload is already covered by the batch, so nothing stays pending.332
for (const message of pending) agent.inbox.remove(message.id)333
if (desired === undefined || decision.messages.some(message => sameContextPayload(message, desired))) {334
return decision335
}336
// Fold the context right after the claimed batch, so the direct prompt337
// precedes it and the driver-appended runtime context follows it.338
const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message))339
const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired)340
return { ...decision, messages: entered }341
})343
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {344
const touches = executionTouches.get(exec.token) ?? []345
executionTouches.delete(exec.token)346
if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) {347
const ownPath = filePathFromExecution(exec)348
if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath })349
}350
if (exec.parent !== undefined) {351
if (touches.length > 0) {352
const parentTouches = executionTouches.get(exec.parent)353
if (parentTouches === undefined) executionTouches.set(exec.parent, touches)354
else parentTouches.push(...touches)355
}356
return357
}358
for (const touch of touches) projectTouch(touch)359
})360
}