Merge remote-tracking branch 'origin/codex/fix-compact-agents-reinjection' into codex/fix-resume-baseline-dedup
# Conflicts: # .agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml # .agents/notes/implemented/feature/2026-06-24-workspace-context.md # .agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # packages/context/workspace-context/README.i18n.yaml # packages/context/workspace-context/README.md # packages/context/workspace-context/README.zh.md # packages/context/workspace-context/src/index.ts # packages/context/workspace-context/src/state.ts # packages/context/workspace-context/tests/workspace-context.spec.ts
This commit is contained in:
@@ -56,9 +56,12 @@ interface LoadOptions extends DiscoverOptions {
|
||||
replacePreviousBaseline?: boolean
|
||||
}
|
||||
|
||||
/** Rendered baseline plus the files that survived byte budgeting. */
|
||||
/** Rendered baseline plus the successfully read and byte-budget-retained files. */
|
||||
export interface RenderedInstructionSet {
|
||||
rendered: RenderedWorkspaceContext
|
||||
/** Successfully read candidates before content deduplication and byte budgeting. */
|
||||
observed: LoadedInstructionFile[]
|
||||
/** Candidates retained by content deduplication and byte budgeting. */
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
@@ -422,6 +425,7 @@ export async function loadBaselineInstructionSet(
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
}),
|
||||
observed: [],
|
||||
included: [],
|
||||
}
|
||||
}
|
||||
@@ -432,7 +436,11 @@ export async function loadBaselineInstructionSet(
|
||||
: { replacePreviousBaseline: options.replacePreviousBaseline },
|
||||
})
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
|
||||
return {
|
||||
rendered,
|
||||
observed: loaded,
|
||||
included: deduped.filter(file => !omitted.has(file.absolutePath)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
/**
|
||||
* Workspace instruction loader for AGENTS.md-compatible files.
|
||||
*
|
||||
* Baseline instructions enter durable context before the first request and are
|
||||
* restored during model-request prompt assembly when compaction removes them. Successful fs
|
||||
* tool touches reconcile nested, changed, and removed instructions through
|
||||
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
|
||||
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
|
||||
* 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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
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,
|
||||
commitPendingInstructionContexts,
|
||||
dynamicInstructionContext,
|
||||
name,
|
||||
observeInstructionSessionEvent,
|
||||
reconcileInstructionContext,
|
||||
retainedInstructionVersionUpdates,
|
||||
rollbackPendingInstructionChanges,
|
||||
workspaceContextMessage,
|
||||
type InstructionVersionCache,
|
||||
type InstructionVersionUpdate,
|
||||
type PendingInstructionChange,
|
||||
type WorkspaceInstructionSource,
|
||||
} from './state.ts'
|
||||
import type { WorkspaceInstructionChange } from './render.ts'
|
||||
@@ -47,9 +40,17 @@ export type {
|
||||
export { renderWorkspaceContext } from './render.ts'
|
||||
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
|
||||
|
||||
function visibleBaselineSource(session: Agent['session']): WorkspaceInstructionSource | undefined {
|
||||
for (const seq of session.surface.nodes.toReversed()) {
|
||||
const event = session.events[seq]
|
||||
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
|
||||
@@ -57,235 +58,252 @@ function visibleBaselineSource(session: Agent['session']): WorkspaceInstructionS
|
||||
return undefined
|
||||
}
|
||||
|
||||
function hasVisibleBaseline(session: Agent['session']): boolean {
|
||||
return visibleBaselineSource(session) !== undefined
|
||||
function isWorkspaceContext(message: UserMessage): boolean {
|
||||
return message.source.kind === 'workspace-instructions'
|
||||
}
|
||||
|
||||
function hasBaselineHistory(session: Agent['session']): boolean {
|
||||
return session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true)
|
||||
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 pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
|
||||
const baselineSessions = new WeakSet<object>()
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
|
||||
const baselineLoaded = new WeakSet<object>()
|
||||
// Settled means this generation needed no new baseline; queued covers the
|
||||
// interval before an injected baseline becomes a durable surface event.
|
||||
const baselineSettledGeneration = new WeakMap<object, number>()
|
||||
const baselineQueuedGeneration = new WeakMap<object, number>()
|
||||
const pendingByParent = new Map<ToolExecutionToken, {
|
||||
agent: Agent
|
||||
changes: WorkspaceInstructionChange[]
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
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>>()
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true) baselineQueuedGeneration.delete(session)
|
||||
})
|
||||
|
||||
const prepareBaseline = async (
|
||||
const compose = async (
|
||||
agent: Agent,
|
||||
signal: AbortSignal | undefined,
|
||||
retainCompatibleBaseline: boolean,
|
||||
deduplicateRestore = false,
|
||||
): Promise<void> => {
|
||||
signal: AbortSignal,
|
||||
claimed: readonly UserMessage[],
|
||||
pending: readonly UserMessage[],
|
||||
touchedPaths: readonly string[] = [],
|
||||
): Promise<UserMessage | undefined> => {
|
||||
signal.throwIfAborted()
|
||||
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
|
||||
baselineLoaded.add(agent.session)
|
||||
baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) {
|
||||
baselineLoaded.add(agent.session)
|
||||
baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
return
|
||||
}
|
||||
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 projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal)
|
||||
const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot)
|
||||
const visibleBaseline = visibleBaselineSource(agent.session)
|
||||
const keepVisibleBaseline = retainCompatibleBaseline
|
||||
&& visibleBaseline !== undefined
|
||||
&& typeof visibleBaseline.baselineIdentity === 'string'
|
||||
&& visibleBaseline.baselineIdentity === identity
|
||||
const replacePreviousBaseline = retainCompatibleBaseline
|
||||
&& visibleBaseline !== undefined
|
||||
&& !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 === undefined ? {} : { signal },
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
baselineSessions.add(agent.session)
|
||||
instructionVersions.set(agent.session, baseline.versions)
|
||||
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{
|
||||
includeBaselineScopes: keepVisibleBaseline,
|
||||
...keepVisibleBaseline ? { retainedBaselineScopes: new Set(baseline.changes.keys()) } : {},
|
||||
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,
|
||||
...signal === undefined ? {} : { signal },
|
||||
},
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const generation = agent.session.surface.replaceGeneration
|
||||
if (deduplicateRestore && (
|
||||
hasVisibleBaseline(agent.session)
|
||||
|| baselineSettledGeneration.get(agent.session) === generation
|
||||
|| baselineQueuedGeneration.get(agent.session) === generation
|
||||
)) return
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.context)
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
|
||||
const replacementScopes = new Set(baseline.changes.keys())
|
||||
const visibleBaselineChanges = visibleBaseline?.changes ?? []
|
||||
const replacementRemovals = replacePreviousBaseline
|
||||
? visibleBaselineChanges.flatMap(change => (
|
||||
change.action === 'remove' || replacementScopes.has(change.scope)
|
||||
? []
|
||||
: [{ action: 'remove' as const, scope: change.scope, path: change.path }]
|
||||
))
|
||||
: []
|
||||
baselineSettledGeneration.delete(agent.session)
|
||||
baselineQueuedGeneration.set(agent.session, generation)
|
||||
try {
|
||||
agent.inject(createUserMessage({
|
||||
content: baselineMessage.content,
|
||||
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',
|
||||
baseline: true,
|
||||
baselineIdentity: identity,
|
||||
changes: [...replacementRemovals, ...baseline.changes.values()],
|
||||
changes: baselineChanges,
|
||||
},
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
throw error
|
||||
desiredBaseline = true
|
||||
}
|
||||
} else {
|
||||
baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
}
|
||||
baselineLoaded.add(agent.session)
|
||||
}
|
||||
|
||||
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
|
||||
if (baselineLoaded.has(agent.session)) return
|
||||
await prepareBaseline(agent, signal, true)
|
||||
})
|
||||
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
const assembled = await next()
|
||||
const agent = context.agent
|
||||
if (context.modelRequest !== true
|
||||
|| agent === undefined
|
||||
|| !baselineLoaded.has(agent.session)
|
||||
|| hasVisibleBaseline(agent.session)
|
||||
|| baselineSettledGeneration.get(agent.session) === agent.session.surface.replaceGeneration
|
||||
|| baselineQueuedGeneration.get(agent.session) === agent.session.surface.replaceGeneration
|
||||
|| !hasBaselineHistory(agent.session)) return assembled
|
||||
await prepareBaseline(agent, context.signal, false, true)
|
||||
return assembled
|
||||
})
|
||||
|
||||
ctx.on('tools/post-execute', async (
|
||||
exec: ToolExecution,
|
||||
result: ToolExecutionResult,
|
||||
next,
|
||||
): Promise<PostToolDecision> => {
|
||||
const downstream = await next()
|
||||
// A downstream listener/policy blocked this call: the registry turns it
|
||||
// into a final `isError` result, so treat it like a failed fs touch and
|
||||
// load nothing. Reconciling here would surface workspace instructions from
|
||||
// a call the pipeline rejected, violating the "successful fs tool touches"
|
||||
// contract, and would advance the nested/baseline tracking state off a
|
||||
// touch that never really happened.
|
||||
if (downstream.kind === 'block') return downstream
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return downstream
|
||||
const update = await dynamicInstructionContext(
|
||||
exec.agent,
|
||||
exec,
|
||||
result,
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineSessions,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{
|
||||
authorityMessages,
|
||||
scopeMessages: pending,
|
||||
includeBaselineScopes: keepVisibleBaseline,
|
||||
...keepVisibleBaseline ? { excludedBaselineScopes } : {},
|
||||
touchedPaths,
|
||||
projectRoot,
|
||||
signal,
|
||||
},
|
||||
)
|
||||
if (update === undefined) return downstream
|
||||
pendingVersionUpdates.set(exec.token, update.versionUpdates)
|
||||
return {
|
||||
...downstream,
|
||||
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
|
||||
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',
|
||||
...desiredBaseline ? { baseline: true } : {},
|
||||
...desiredBaseline ? { baselineIdentity: identity } : {},
|
||||
changes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
|
||||
pendingVersionUpdates.delete(exec.token)
|
||||
if (exec.parent !== undefined) {
|
||||
if (exec.agent === undefined) return
|
||||
// Child contexts participate in duplicate suppression within one composite
|
||||
// run, but remain provisional until the parent reaches its final policy.
|
||||
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
if (changes.length === 0) return
|
||||
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
|
||||
const staged = pendingByParent.get(exec.parent)
|
||||
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
|
||||
else {
|
||||
staged.changes.push(...changes)
|
||||
staged.versionUpdates.push(...versionUpdates)
|
||||
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)
|
||||
}
|
||||
|
||||
// The parent result is authoritative: remove every provisional child change,
|
||||
// then commit only contexts that survived outer post-execute policy.
|
||||
const staged = pendingByParent.get(exec.token)
|
||||
if (staged !== undefined) {
|
||||
pendingByParent.delete(exec.token)
|
||||
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
|
||||
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: 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
|
||||
}
|
||||
if (exec.agent === undefined) return
|
||||
const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
const stagedVersionUpdates = staged?.versionUpdates ?? []
|
||||
const versionUpdates = retainedInstructionVersionUpdates(
|
||||
[...stagedVersionUpdates, ...ownVersionUpdates],
|
||||
committed,
|
||||
)
|
||||
applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
@@ -34,14 +33,12 @@ import {
|
||||
|
||||
export const name = 'workspace-context'
|
||||
|
||||
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
|
||||
|
||||
/** Durable provenance and reconciliation facts for one workspace context. */
|
||||
export interface WorkspaceInstructionSource {
|
||||
kind: 'workspace-instructions'
|
||||
/** Marks a complete baseline rather than a later delta. */
|
||||
/** Marks the complete startup/resume baseline rather than a later delta. */
|
||||
baseline?: true
|
||||
/** Discovery, precedence, and budget identity for safe baseline reuse. */
|
||||
/** Discovery, precedence, and budget identity used to validate a resumed baseline. */
|
||||
baselineIdentity?: string
|
||||
changes: WorkspaceInstructionChange[]
|
||||
}
|
||||
@@ -52,13 +49,6 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dynamic state waiting for the loop to append its returned context event. */
|
||||
export interface PendingInstructionChange {
|
||||
change: WorkspaceInstructionChange
|
||||
afterSeq: number
|
||||
step?: { turn: number; step: number }
|
||||
}
|
||||
|
||||
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
|
||||
export interface InstructionVersionState {
|
||||
path: string
|
||||
@@ -74,13 +64,13 @@ export interface InstructionVersionState {
|
||||
/** Session-isolated fast-path state keyed by logical instruction scope. */
|
||||
export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
|
||||
|
||||
/** A cache transition coupled to the model-visible change that authorizes it. */
|
||||
/** A metadata-cache transition associated with one rendered instruction change. */
|
||||
export interface InstructionVersionUpdate {
|
||||
change: WorkspaceInstructionChange
|
||||
state?: InstructionVersionState
|
||||
}
|
||||
|
||||
/** Rendered reconciliation plus cache transitions awaiting final policy. */
|
||||
/** Rendered reconciliation plus its metadata-cache transitions. */
|
||||
export interface ReconciledInstructionContext {
|
||||
context: UserMessage
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
@@ -105,14 +95,6 @@ export function workspaceContextMessage(text: string): Message {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function isWorkspaceContextSource(
|
||||
source: unknown,
|
||||
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
|
||||
@@ -151,7 +133,7 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
|
||||
|
||||
function visibleInstructionChanges(
|
||||
agent: Agent,
|
||||
pending: Map<string, PendingInstructionChange>,
|
||||
authorityMessages: readonly UserMessage[],
|
||||
): Map<string, WorkspaceInstructionChange> {
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes)
|
||||
const visible = new Map<string, WorkspaceInstructionChange>()
|
||||
@@ -159,14 +141,15 @@ function visibleInstructionChanges(
|
||||
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
|
||||
const changes = workspaceInstructionChanges(event.data.source)
|
||||
for (const change of changes) {
|
||||
const waiting = pending.get(change.scope)
|
||||
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
|
||||
pending.delete(change.scope)
|
||||
}
|
||||
if (visibleSeqs.has(seq)) visible.set(change.scope, change)
|
||||
}
|
||||
}
|
||||
for (const { change } of pending.values()) visible.set(change.scope, change)
|
||||
for (const message of authorityMessages) {
|
||||
if (!isWorkspaceContextSource(message.source)) continue
|
||||
for (const change of workspaceInstructionChanges(message.source)) {
|
||||
visible.set(change.scope, change)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
@@ -212,20 +195,20 @@ function versionStatesFor(session: Session, cache: InstructionVersionCache): Map
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only cache updates whose model-visible changes survived final policy.
|
||||
* Keep only cache updates represented by rendered changes.
|
||||
* @param updates - proposed updates from one or more reconciliations.
|
||||
* @param committedChanges - transitions retained on the authoritative result.
|
||||
* @returns updates authorized by an exact retained transition.
|
||||
* @param renderedChanges - transitions retained by the renderer.
|
||||
* @returns updates represented by an exact retained transition.
|
||||
*/
|
||||
export function retainedInstructionVersionUpdates(
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
committedChanges: readonly WorkspaceInstructionChange[],
|
||||
renderedChanges: readonly WorkspaceInstructionChange[],
|
||||
): InstructionVersionUpdate[] {
|
||||
return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change)))
|
||||
return updates.filter(update => renderedChanges.some(change => sameInstructionChange(update.change, change)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply authorized metadata-cache transitions without retaining instruction prose.
|
||||
* Apply metadata-cache transitions without retaining instruction prose.
|
||||
* @param session - owning session.
|
||||
* @param updates - ordered set/delete transitions.
|
||||
* @param cache - session-isolated metadata cache.
|
||||
@@ -244,170 +227,37 @@ export function applyInstructionVersionUpdates(
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
function pendingChangesFor(
|
||||
session: object,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): Map<string, PendingInstructionChange> {
|
||||
let pending = pendingBySession.get(session)
|
||||
if (pending === undefined) {
|
||||
pending = new Map()
|
||||
pendingBySession.set(session, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function openStep(session: Session): { turn: number; step: number } | undefined {
|
||||
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
|
||||
return boundary?.type === 'step/start' ? boundary.data : undefined
|
||||
}
|
||||
|
||||
function invalidateInstructionVersions(
|
||||
session: Session,
|
||||
scopes: readonly string[],
|
||||
cache: InstructionVersionCache,
|
||||
): void {
|
||||
const states = cache.get(session)
|
||||
if (states === undefined) return
|
||||
for (const scope of scopes) states.delete(scope)
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle provisional tool-result state against durable session events.
|
||||
* A matching context event confirms the transition. If its owning step closes
|
||||
* first, both duplicate suppression and the metadata fast path are re-armed for
|
||||
* the next successful touch.
|
||||
* @param session - session whose append-only log emitted `event`.
|
||||
* @param event - newly committed session event.
|
||||
* @param pendingBySession - provisional transitions awaiting log confirmation.
|
||||
* @param versionCache - metadata fast path coupled to those transitions.
|
||||
*/
|
||||
export function observeInstructionSessionEvent(
|
||||
session: Session,
|
||||
event: SessionEvent,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
): void {
|
||||
const pending = pendingBySession.get(session)
|
||||
if (pending === undefined) return
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
if (!isWorkspaceContextSource(event.data.source)) return
|
||||
for (const change of workspaceInstructionChanges(event.data.source)) {
|
||||
const waiting = pending.get(change.scope)
|
||||
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
|
||||
pending.delete(change.scope)
|
||||
}
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(session)
|
||||
return
|
||||
}
|
||||
case 'step/end': {
|
||||
const discardedScopes: string[] = []
|
||||
for (const [scope, waiting] of pending) {
|
||||
const step = waiting.step
|
||||
if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
|
||||
pending.delete(scope)
|
||||
discardedScopes.push(scope)
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(session)
|
||||
invalidateInstructionVersions(session, discardedScopes, versionCache)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit only workspace contexts that survived the complete tool pipeline.
|
||||
* The observe-only `tools/result` notification calls this before the loop can
|
||||
* append the returned contexts, closing that short pending window without
|
||||
* trusting an intermediate post-execute decision.
|
||||
* @param agent - session that will receive the final result contexts.
|
||||
* @param contexts - immutable contexts on the authoritative top-level result.
|
||||
* @param pendingBySession - per-session pending transition maps.
|
||||
* @returns transitions committed into the short pending window.
|
||||
*/
|
||||
export function commitPendingInstructionContexts(
|
||||
agent: Agent,
|
||||
contexts: readonly UserMessage[] | undefined,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): WorkspaceInstructionChange[] {
|
||||
const committed: WorkspaceInstructionChange[] = []
|
||||
const step = openStep(agent.session)
|
||||
for (const context of contexts ?? []) {
|
||||
if (!isWorkspaceContextSource(context.source)) continue
|
||||
const changes = workspaceInstructionChanges(context.source)
|
||||
if (changes.length === 0) continue
|
||||
const pending = pendingChangesFor(agent.session, pendingBySession)
|
||||
for (const change of changes) {
|
||||
pending.set(change.scope, {
|
||||
change,
|
||||
afterSeq: agent.session.seq,
|
||||
...step === undefined ? {} : { step },
|
||||
})
|
||||
committed.push(change)
|
||||
}
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back parent-token state when an enclosing tool result discards deferred
|
||||
* contexts. A newer transition for the same scope is left intact.
|
||||
* @param agent - session whose pending state was staged.
|
||||
* @param changes - exact staged transitions to remove when still current.
|
||||
* @param pendingBySession - per-session pending transition maps.
|
||||
*/
|
||||
export function rollbackPendingInstructionChanges(
|
||||
agent: Agent,
|
||||
changes: readonly WorkspaceInstructionChange[],
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): void {
|
||||
const pending = pendingBySession.get(agent.session)
|
||||
if (pending === undefined) return
|
||||
for (const change of changes) {
|
||||
const current = pending.get(change.scope)
|
||||
if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(agent.session)
|
||||
}
|
||||
|
||||
function relativeScope(projectRoot: string, dir: string): string {
|
||||
const scope = relativeDisplay(projectRoot, dir)
|
||||
return scope.length === 0 ? '.' : scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare visible/pending state with provider-visible files and render transitions.
|
||||
* Compare visible state with provider-visible files and render transitions.
|
||||
* @param agent - session owner whose visible surface supplies durable state.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingBySession - short pending window before returned context is logged.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @param options - touched path and baseline-scope selection.
|
||||
* @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
|
||||
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
|
||||
*/
|
||||
export async function reconcileInstructionContext(
|
||||
agent: Agent,
|
||||
resolved: ResolvedConfig,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
options: {
|
||||
touchedPath?: string
|
||||
authorityMessages: readonly UserMessage[]
|
||||
scopeMessages: readonly UserMessage[]
|
||||
touchedPaths: readonly string[]
|
||||
includeBaselineScopes: boolean
|
||||
retainedBaselineScopes?: ReadonlySet<string>
|
||||
excludedBaselineScopes?: ReadonlySet<string>
|
||||
projectRoot?: string
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
const session = agent.session
|
||||
const pending = pendingChangesFor(session, pendingBySession)
|
||||
const effective = visibleInstructionChanges(agent, pending)
|
||||
const effective = visibleInstructionChanges(agent, options.authorityMessages)
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = session.header.cwd ?? process.cwd()
|
||||
// TODO(frozen-project-root): retain the baseline root for the loop instance;
|
||||
@@ -428,14 +278,22 @@ export async function reconcileInstructionContext(
|
||||
if (options.includeBaselineScopes) {
|
||||
for (const scope of baselineScopes) scopes.add(scope)
|
||||
}
|
||||
for (const message of options.scopeMessages) {
|
||||
/* v8 ignore next -- the plugin passes its workspace-only pending projection. */
|
||||
if (!isWorkspaceContextSource(message.source)) continue
|
||||
for (const change of workspaceInstructionChanges(message.source)) {
|
||||
if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue
|
||||
scopes.add(change.scope)
|
||||
}
|
||||
}
|
||||
for (const scope of effective.keys()) {
|
||||
if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
else addDirScopes(scopes, directory)
|
||||
}
|
||||
if (options.touchedPath !== undefined) {
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir)
|
||||
for (const touchedPath of options.touchedPaths) {
|
||||
for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir)
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
@@ -461,123 +319,109 @@ export async function reconcileInstructionContext(
|
||||
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
|
||||
versionUpdates.push({ change })
|
||||
}
|
||||
const scopesByDirectory = new Map<string, string[]>()
|
||||
for (const scope of scopes) {
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
const previous = effective.get(scope)
|
||||
if (options.retainedBaselineScopes !== undefined
|
||||
&& baselineScopes.has(scope)
|
||||
&& !options.retainedBaselineScopes.has(scope)) {
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') {
|
||||
// Last-good-state: the candidate stays effective, so its cached trimmed
|
||||
// digest must keep occupying the directory's dedup slot — otherwise an
|
||||
// identical later sibling would be emitted as a duplicate `set` until the
|
||||
// next successful reconciliation removed it again.
|
||||
const cached = versions.get(scope)
|
||||
if (cached !== undefined && previous !== undefined && previous.action !== 'remove') {
|
||||
registerKeptTrimmed(directory, cached.trimmedDigest)
|
||||
const directoryScopes = scopesByDirectory.get(directory)
|
||||
if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope])
|
||||
else directoryScopes.push(scope)
|
||||
}
|
||||
for (const [directory, directoryScopes] of scopesByDirectory) {
|
||||
const probedScopes: string[] = []
|
||||
for (const scope of directoryScopes) {
|
||||
if (options.excludedBaselineScopes !== undefined
|
||||
&& baselineScopes.has(scope)
|
||||
&& options.excludedBaselineScopes.has(scope)) {
|
||||
const previous = effective.get(scope)
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
} else {
|
||||
probedScopes.push(scope)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
|
||||
seenAbsolutePaths.add(probedFile.absolutePath)
|
||||
const cached = versions.get(scope)
|
||||
if (
|
||||
cached !== undefined
|
||||
&& cached.path === probedFile.displayPath
|
||||
&& cached.version === probedFile.version
|
||||
&& previous !== undefined
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) {
|
||||
// Unchanged and previously rendered: keep it, but an earlier sibling that
|
||||
// now matches its trimmed content makes this the duplicate to remove.
|
||||
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const itemStart = items.length
|
||||
const versionUpdateStart = versionUpdates.length
|
||||
const addedAbsolutePaths: string[] = []
|
||||
const priorVersions = new Map(probedScopes.map(scope => [scope, versions.get(scope)]))
|
||||
for (const scope of probedScopes) {
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') {
|
||||
if (previous === undefined || previous.action === 'remove') continue
|
||||
// Same-directory candidates form one deduplicated authority group. If an
|
||||
// active member cannot be observed, preserve the entire last-good group;
|
||||
// cache warmth must never decide whether a sibling transition is emitted.
|
||||
items.splice(itemStart)
|
||||
versionUpdates.splice(versionUpdateStart)
|
||||
for (const [candidateScope, prior] of priorVersions) {
|
||||
if (prior === undefined) versions.delete(candidateScope)
|
||||
else versions.set(candidateScope, prior)
|
||||
}
|
||||
for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath)
|
||||
keptTrimmedByDir.delete(directory)
|
||||
break
|
||||
}
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
|
||||
seenAbsolutePaths.add(probedFile.absolutePath)
|
||||
addedAbsolutePaths.push(probedFile.absolutePath)
|
||||
const cached = versions.get(scope)
|
||||
if (
|
||||
cached !== undefined
|
||||
&& cached.path === probedFile.displayPath
|
||||
&& cached.version === probedFile.version
|
||||
&& previous !== undefined
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) {
|
||||
// Unchanged and previously rendered: keep it, but an earlier sibling that
|
||||
// now matches its trimmed content makes this the duplicate to remove.
|
||||
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const trimmedDigest = trimmedInstructionDigest(file.content)
|
||||
if (registerKeptTrimmed(directory, trimmedDigest)) {
|
||||
// A distinct file whose trimmed content already appeared earlier in this
|
||||
// directory: drop it, removing any copy that was previously rendered.
|
||||
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
|
||||
else versions.delete(scope)
|
||||
continue
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const trimmedDigest = trimmedInstructionDigest(file.content)
|
||||
if (registerKeptTrimmed(directory, trimmedDigest)) {
|
||||
// A distinct file whose trimmed content already appeared earlier in this
|
||||
// directory: drop it, removing any copy that was previously rendered.
|
||||
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
|
||||
else versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
trimmedDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
versionUpdates.push({ change, state: nextVersion })
|
||||
}
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
trimmedDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
versionUpdates.push({ change, state: nextVersion })
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
return {
|
||||
context: workspaceContextHook(rendered.text, rendered.changes),
|
||||
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a successful structured file touch and reconcile its applicable scopes.
|
||||
* @param agent - optional agent attached to the tool execution.
|
||||
* @param exec - completed tool execution descriptor.
|
||||
* @param result - original tool result before post-execute decisions.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @param baselineSessions - sessions whose configured baseline scopes should be probed.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
|
||||
*/
|
||||
export async function dynamicInstructionContext(
|
||||
agent: Agent | undefined,
|
||||
exec: ToolExecution,
|
||||
result: ToolExecutionResult,
|
||||
resolved: ResolvedConfig,
|
||||
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineSessions: WeakSet<object>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
if (agent === undefined || result.isError) return undefined
|
||||
const touchedPath = filePathFromExecution(exec)
|
||||
if (touchedPath === undefined) return undefined
|
||||
return reconcileInstructionContext(
|
||||
agent, resolved, pendingNestedChanges, versionCache, fileSystem,
|
||||
{
|
||||
touchedPath,
|
||||
includeBaselineScopes: baselineSessions.has(agent.session),
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user