fix(workspace-context): rearm compacted baseline

This commit is contained in:
_Kerman
2026-07-27 18:27:39 +08:00
parent cf49101d9a
commit 376bf7f365
9 changed files with 198 additions and 76 deletions

View File

@@ -43,19 +43,27 @@ export type {
export { renderWorkspaceContext } from './render.ts'
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
function hasVisibleBaseline(agent: Agent): boolean {
return agent.session.surface.nodes.some((seq) => {
const event = agent.session.events[seq]
return event?.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline === true
})
}
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
const baselineSessions = new WeakSet<object>()
const instructionVersions: InstructionVersionCache = new WeakMap()
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
const baselineLoaded = new WeakSet<object>()
// Sessions whose lifecycle start this mount witnessed. A startup or resume
// emits agent/session-start before the first step; a hot remount attaches to
// an already-live session and never sees it. That difference is the only
// reliable way to tell a resumed session (re-compose the baseline from
// current files) from a remount over a live one (keep the single baseline
// already in the log) — the durable log looks identical in both cases.
// an already-live session and never sees it. Resumes always re-compose the
// baseline from current files. Hot remounts retain a baseline only while its
// typed event remains model-visible.
const lifecycleWitnessed = new WeakSet<object>()
const pendingByParent = new Map<ToolExecutionToken, {
agent: Agent
@@ -73,15 +81,6 @@ 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
// A baseline already in the log with no witnessed lifecycle start is a hot
// remount over a live session: keep that single baseline and skip. A
// resumed session witnessed its start, so it falls through and re-composes.
if (!lifecycleWitnessed.has(agent.session)
&& agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'workspace-context')) {
baselineLoaded.add(agent.session)
return
}
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
baselineLoaded.add(agent.session)
return
@@ -104,14 +103,13 @@ export function apply(ctx: Context, config: Config): void {
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
baselineInstructionStates.set(agent.session, baseline.changes)
baselineSessions.add(agent.session)
instructionVersions.set(agent.session, baseline.versions)
const update = await reconcileInstructionContext(
agent,
resolved,
pendingNestedChanges,
baselineInstructionStates,
instructionVersions,
fileSystem,
{ includeBaselineScopes: false, signal },
@@ -120,9 +118,17 @@ export function apply(ctx: Context, config: Config): void {
agent.inject({ content: update.context.content, source: update.context.source })
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}
if (instructions !== undefined && instructions.rendered.text.length > 0) {
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
agent.inject({ content: baselineMessage.content, source: { kind: 'plugin', plugin: 'workspace-context' } })
agent.inject({
content: baselineMessage.content,
source: {
kind: 'workspace-instructions',
baseline: true,
changes: [...baseline.changes.values()],
},
})
}
baselineLoaded.add(agent.session)
})
@@ -148,7 +154,7 @@ export function apply(ctx: Context, config: Config): void {
result,
resolved,
pendingNestedChanges,
baselineInstructionStates,
baselineSessions,
instructionVersions,
fileSystem,
)

View File

@@ -38,6 +38,8 @@ 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 the complete startup/resume baseline rather than a later delta. */
baseline?: true
changes: WorkspaceInstructionChange[]
}
@@ -89,7 +91,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
}
/**
* Build the request-prefix message for a rendered baseline.
* Build the user-role message for a rendered baseline.
* @param text - complete plugin-owned system-reminder text.
* @returns a user-role prefix message.
*/
@@ -378,50 +380,49 @@ function relativeScope(projectRoot: string, dir: string): string {
* @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 baselineBySession - frozen baseline comparison state per session.
* @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 be checked.
* @param options - touched path and whether baseline scopes should participate.
* @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>>,
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
): Promise<ReconciledInstructionContext | undefined> {
const session = agent.session
const pending = pendingChangesFor(session, pendingBySession)
const visible = visibleInstructionChanges(agent, pending)
const effective = new Map(baselineBySession.get(session) ?? [])
for (const [scope, change] of visible) effective.set(scope, change)
const effective = visibleInstructionChanges(agent, pending)
/* 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;
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
const addDirScopes = (directory: string): void => {
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
const baselineScopes = new Set<string>()
const addDirScopes = (target: Set<string>, directory: string): void => {
for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
}
const addProjectScopes = (dir: string): void => {
addDirScopes(relativeScope(projectRoot, dir))
const addProjectScopes = (target: Set<string>, dir: string): void => {
addDirScopes(target, relativeScope(projectRoot, dir))
}
baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir)
if (options.includeBaselineScopes) {
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
for (const scope of baselineScopes) scopes.add(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(directory)
else addDirScopes(scopes, directory)
}
if (options.touchedPath !== undefined) {
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir)
}
const versions = versionStatesFor(session, versionCache)
@@ -533,7 +534,7 @@ export async function reconcileInstructionContext(
* @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration.
* @param pendingNestedChanges - per-session pending transition maps.
* @param baselineInstructionStates - retained baseline comparison state.
* @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.
@@ -544,7 +545,7 @@ export async function dynamicInstructionContext(
result: ToolExecutionResult,
resolved: ResolvedConfig,
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
baselineSessions: WeakSet<object>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
): Promise<ReconciledInstructionContext | undefined> {
@@ -552,10 +553,10 @@ export async function dynamicInstructionContext(
const touchedPath = filePathFromExecution(exec)
if (touchedPath === undefined) return undefined
return reconcileInstructionContext(
agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
agent, resolved, pendingNestedChanges, versionCache, fileSystem,
{
touchedPath,
includeBaselineScopes: baselineInstructionStates.has(agent.session),
includeBaselineScopes: baselineSessions.has(agent.session),
signal: exec.signal,
},
)