返回源码地图

packages/context/agent-instructions/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 09;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Workspace instruction loader for AGENTS.md-compatible files.
3 *
4 * Baseline instructions enter durable context before the first request; successful fs
5 * tool touches project nested, changed, and removed instructions into the inbox.
6 * Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products
7 * mount it as a no-op.
8 *
9 * @module @deepseek-ai/dsh-agent-instructions
10 */
11
12import type { Context } from '@deepseek-ai/cordis'
13import { isDeepStrictEqual } from 'node:util'
14import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
15import { createUserMessage } from '@deepseek-ai/dsh-llm'
16import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
17import type {} from '@deepseek-ai/dsh-session-projection'
18import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
19import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
20import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
21import {
22 applyInstructionVersionUpdates,
23 baselineInstructionState,
24 name,
25 reconcileInstructionContext,
26 agentInstructionsMessage,
27 type InstructionVersionCache,
28 type AgentInstructionSource,
29} from './state.ts'
30import type { AgentInstructionChange } from './render.ts'
31
32export { Config, name }
33/** Services required by workspace instruction projection. */
34export const inject = ['sessionProjections']
35export {
36 discoverBaselineInstructionFiles,
37 loadBaselineInstructions,
38} from './files.ts'
39export type {
40 InstructionFile,
41 LoadedInstructionFile,
42} from './files.ts'
43export { renderAgentInstructions } from './render.ts'
44export type { RenderedAgentInstructions, TruncatedInstruction } from './render.ts'
45
46function 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.source
53 }
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.source
61 }
62 return undefined
63}
64
65function isAgentInstructionsMessage(message: UserMessage): boolean {
66 return message.source.kind === 'agent-instructions'
67}
68
69function sameContextPayload(left: UserMessage, right: UserMessage): boolean {
70 return isDeepStrictEqual(left.content, right.content)
71 && isDeepStrictEqual(left.source, right.source)
72}
73
74const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
75
76function filePathFromExecution(exec: ToolExecution): string | undefined {
77 if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
78 if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
79 if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
80 const filePath = exec.arguments.file_path.trim()
81 return filePath.length > 0 ? filePath : undefined
82}
83
84export 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: string
89 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 the
102 // 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 commit
105 // boundaries before an asynchronous projection may mutate the agent inbox.
106 const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
107
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 undefined
118 }
119 const fileSystem = ctx.get('fs')
120 if (fileSystem === undefined) return undefined
121 if (touchedPaths.length === 0 && pending.length > 0) return pending[0]
122 const content: UserMessage['content'][number][] = []
123 const changes: AgentInstructionChange[] = []
124 let desiredBaseline = false
125 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 !== undefined
132 const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity
133 const prepared = baselinePreparations.get(agent.session)
134 let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity
135 ? prepared.excludedScopes
136 : undefined
137 let nextPreparation: { identity: string; excludedScopes: ReadonlySet<string> } | undefined
138 if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === undefined) {
139 const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline
140 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 = excludedScopes
157 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).content
166 content.push(...baselineContent)
167 const replacementScopes = new Set(baseline.changes.keys())
168 const replacementRemovals = replacePreviousBaseline
169 ? 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 = true
188 }
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 undefined
215 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 }
226
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 return
240 }
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 return
247 }
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 }
253
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 }
265
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 }
280
281 const waitForProjections = async (agent: Agent): Promise<void> => {
282 let projection: Promise<void> | undefined
283 while ((projection = projectionTails.get(agent)) !== undefined) await projection
284 }
285
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 !== null
292 && boundary.lastStepBoundary?.kind === 'start'
293 && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq
294 }
295
296 const projectTouch = (touch: ProjectionTouch): void => {
297 const session = touch.agent.session
298 if (!stepIsOpen(session)) {
299 queueProjection(touch.agent, touch.path)
300 return
301 }
302 const pending = stepTouches.get(session)
303 if (pending === undefined) stepTouches.set(session, [touch])
304 else pending.push(touch)
305 }
306
307 ctx.on('session/event', (session, event) => {
308 if (event.type !== 'step/end') return
309 const pending = stepTouches.get(session)
310 if (pending === undefined) return
311 stepTouches.delete(session)
312 for (const touch of pending) queueProjection(touch.agent, touch.path)
313 })
314
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 instead
325 // 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 decision
329 }
330 // A proceeding step settles the pending context: it either enters below as
331 // `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 decision
335 }
336 // Fold the context right after the claimed batch, so the direct prompt
337 // 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 })
342
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 return
357 }
358 for (const touch of touches) projectTouch(touch)
359 })
360}