fix(workspace-context): reconcile resumed baselines

This commit is contained in:
fz
2026-08-04 23:11:15 +08:00
parent 01fa4ceb6e
commit 119c55e35e
16 files changed
+463 -51

No files matched your search

@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-workspace-context/config
*/
import { relative } from 'node:path'
import z from 'schemastery'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -58,6 +59,28 @@ export interface ResolvedConfig extends ResolvedDiscoveryConfig {
maxSourceBytes: number
}
/**
* Identify the discovery, precedence, and budget semantics of one baseline.
* @param config - normalized plugin configuration.
* @param cwd - absolute session working directory.
* @param projectRoot - project root selected for the current baseline.
* @returns stable serialized identity for compatibility checks on resume.
*/
export function workspaceBaselineIdentity(
config: ResolvedConfig,
cwd: string,
projectRoot: string,
): string {
return JSON.stringify({
projectRoot: relative(cwd, projectRoot),
projectRootMarkers: config.projectRootMarkers,
maxBytes: config.maxBytes,
maxSourceBytes: config.maxSourceBytes,
instructionFileCandidates: config.instructionFileCandidates,
localInstructionFileCandidates: config.localInstructionFileCandidates,
})
}
/**
* Resolve defaults, the harness home, and valid same-directory candidates.
* @param config - user-facing plugin configuration.
@@ -46,12 +46,14 @@ interface DiscoverOptions {
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
projectRoot?: string
signal?: AbortSignal
}
interface LoadOptions extends DiscoverOptions {
maxBytes: number
maxSourceBytes?: number
replacePreviousBaseline?: boolean
}
/** Rendered baseline plus the files that survived byte budgeting. */
@@ -286,7 +288,8 @@ async function discoverInstructionFiles(
}
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
const projectRoot = options.projectRoot
?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
@@ -389,7 +392,7 @@ export async function loadBaselineInstructions(
* Load a baseline together with the files retained after rendering.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
* @param fileSystem - optional provider used instead of host filesystem reads.
* @returns rendered context and retained files, or undefined when empty or disabled.
* @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled.
*/
export async function loadBaselineInstructionSet(
options: LoadOptions,
@@ -412,8 +415,22 @@ export async function loadBaselineInstructionSet(
}
}
const deduped = dedupInstructionFilesByDirectory(loaded)
if (deduped.length === 0) return undefined
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
if (deduped.length === 0) {
if (options.replacePreviousBaseline !== true) return undefined
return {
rendered: renderWorkspaceContext([], {
maxBytes: config.maxBytes,
replacePreviousBaseline: true,
}),
included: [],
}
}
const rendered = renderWorkspaceContext(deduped, {
maxBytes: config.maxBytes,
...options.replacePreviousBaseline === undefined
? {}
: { replacePreviousBaseline: options.replacePreviousBaseline },
})
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
}
+49 -12
View File
@@ -15,8 +15,8 @@ import type { Agent } 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 { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
@@ -31,6 +31,7 @@ import {
type InstructionVersionCache,
type InstructionVersionUpdate,
type PendingInstructionChange,
type WorkspaceInstructionSource,
} from './state.ts'
import type { WorkspaceInstructionChange } from './render.ts'
@@ -46,13 +47,18 @@ export type {
export { renderWorkspaceContext } from './render.ts'
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
function hasVisibleBaseline(session: Agent['session']): boolean {
return session.surface.nodes.some((seq) => {
function visibleBaselineSource(session: Agent['session']): WorkspaceInstructionSource | undefined {
for (const seq of session.surface.nodes.toReversed()) {
const event = session.events[seq]
return event?.type === 'user/message'
if (event?.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline === true
})
&& event.data.source.baseline === true) return event.data.source
}
return undefined
}
function hasVisibleBaseline(session: Agent['session']): boolean {
return visibleBaselineSource(session) !== undefined
}
function hasBaselineHistory(session: Agent['session']): boolean {
@@ -88,7 +94,7 @@ export function apply(ctx: Context, config: Config): void {
const prepareBaseline = async (
agent: Agent,
signal: AbortSignal | undefined,
keepVisibleBaseline: boolean,
retainCompatibleBaseline: boolean,
deduplicateRestore = false,
): Promise<void> => {
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
@@ -106,6 +112,21 @@ export function apply(ctx: Context, config: Config): void {
}
/* 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.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,
@@ -114,6 +135,8 @@ export function apply(ctx: Context, config: Config): void {
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
projectRoot,
replacePreviousBaseline,
...signal === undefined ? {} : { signal },
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
@@ -126,7 +149,12 @@ export function apply(ctx: Context, config: Config): void {
pendingNestedChanges,
instructionVersions,
fileSystem,
{ includeBaselineScopes: keepVisibleBaseline, ...signal === undefined ? {} : { signal } },
{
includeBaselineScopes: keepVisibleBaseline,
...keepVisibleBaseline ? { retainedBaselineScopes: new Set(baseline.changes.keys()) } : {},
projectRoot,
...signal === undefined ? {} : { signal },
},
)
signal?.throwIfAborted()
const generation = agent.session.surface.replaceGeneration
@@ -141,6 +169,15 @@ export function apply(ctx: Context, config: Config): void {
}
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 {
@@ -149,7 +186,8 @@ export function apply(ctx: Context, config: Config): void {
source: {
kind: 'workspace-instructions',
baseline: true,
changes: [...baseline.changes.values()],
baselineIdentity: identity,
changes: [...replacementRemovals, ...baseline.changes.values()],
},
}))
} catch (error: unknown) {
@@ -165,8 +203,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
if (baselineLoaded.has(agent.session)) return
const keepVisibleBaseline = hasVisibleBaseline(agent.session)
await prepareBaseline(agent, signal, keepVisibleBaseline)
await prepareBaseline(agent, signal, true)
})
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
@@ -12,6 +12,10 @@ const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. '
+ 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. '
+ 'They do not override system, developer, or direct user instructions.'
const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ WORKSPACE_CONTEXT_INTRO
const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ 'No workspace instructions are currently active.'
const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
/** Byte-accounting record for one truncated instruction file. */
@@ -294,12 +298,20 @@ function renderInstructionContext(
/**
* Render the baseline instruction chain with deterministic precedence budgeting.
* @param files - loaded files ordered from broadest to most specific.
* @param options - required rendering byte budget.
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
* @returns bounded baseline prompt text and budget diagnostics.
*/
export function renderWorkspaceContext(
files: LoadedInstructionFile[],
options: { maxBytes: number },
options: { maxBytes: number; replacePreviousBaseline?: boolean },
): RenderedWorkspaceContext {
return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
const style = options.replacePreviousBaseline === true
? {
...BASELINE_RENDER_STYLE,
intro: files.length === 0
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
}
: BASELINE_RENDER_STYLE
return renderInstructionContext(files, options.maxBytes, style)
}
@@ -41,6 +41,8 @@ export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
/** Marks a complete baseline rather than a later delta. */
baseline?: true
/** Discovery, precedence, and budget identity for safe baseline reuse. */
baselineIdentity?: string
changes: WorkspaceInstructionChange[]
}
@@ -386,7 +388,7 @@ function relativeScope(projectRoot: string, dir: string): string {
* @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 whether baseline scopes should participate.
* @param options - touched path and baseline-scope selection.
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
*/
export async function reconcileInstructionContext(
@@ -395,7 +397,13 @@ export async function reconcileInstructionContext(
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
options: {
touchedPath?: string
includeBaselineScopes: boolean
retainedBaselineScopes?: ReadonlySet<string>
projectRoot?: string
signal?: AbortSignal
},
): Promise<ReconciledInstructionContext | undefined> {
const session = agent.session
const pending = pendingChangesFor(session, pendingBySession)
@@ -404,7 +412,8 @@ export async function reconcileInstructionContext(
const cwd = session.header.cwd ?? process.cwd()
// TODO(frozen-project-root): retain the baseline root for the loop instance;
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const projectRoot = options.projectRoot
?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
const baselineScopes = new Set<string>()
const addDirScopes = (target: Set<string>, directory: string): void => {
@@ -455,6 +464,13 @@ export async function reconcileInstructionContext(
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