| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- /**
- * 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 { Session, UserMessage } from '@deepseek-ai/dsh-session'
- import type { ToolExecution, ToolExecutionResult } 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<Session, {
- identity: string
- excludedScopes: ReadonlySet<string>
- }>()
- 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<Agent, Promise<void>>()
- const compose = async (
- agent: Agent,
- signal: AbortSignal,
- claimed: readonly UserMessage[],
- pending: readonly UserMessage[],
- touchedPaths: readonly string[] = [],
- ): Promise<UserMessage | undefined> => {
- 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<string> } | 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<void> => {
- 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<void> => {
- let projection: Promise<void> | undefined
- while ((projection = projectionTails.get(agent)) !== undefined) await projection
- }
- ctx.on('agent/pre-step', async (
- { agent, messages, step, signal },
- next,
- ): Promise<PreStepDecision> => {
- 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)
- })
- }
|