refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
367
packages/context/agent-instructions/src/index.ts
Normal file
367
packages/context/agent-instructions/src/index.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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-agent-instructions
|
||||
*/
|
||||
|
||||
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 AgentInstructionSource,
|
||||
} from './state.ts'
|
||||
import type { AgentInstructionChange } 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[],
|
||||
): AgentInstructionSource | undefined {
|
||||
for (const message of authorityMessages.toReversed()) {
|
||||
if (message.source.kind === 'agent-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 === 'agent-instructions'
|
||||
&& event.data.source.baseline === true) return event.data.source
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isWorkspaceContext(message: UserMessage): boolean {
|
||||
return message.source.kind === 'agent-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()
|
||||
type ProjectionTouch = { agent: Agent; path: string }
|
||||
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
projectionLifecycle.abort(new Error('agent-instructions disposed'))
|
||||
executionTouches.clear()
|
||||
},
|
||||
'agent-instructions.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>>()
|
||||
// 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<Session, boolean>()
|
||||
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
|
||||
|
||||
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: AgentInstructionChange[] = []
|
||||
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: 'agent-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 agent-instructions contexts. */
|
||||
if (update.context.source.kind === 'agent-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: 'agent-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
|
||||
}
|
||||
|
||||
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<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) => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user