fix(workspace-context): require rendered instruction content

This commit is contained in:
ZiyaZhang
2026-07-30 02:07:38 -07:00
parent 211b404469
commit 36b8efd2c6
3 changed files with 103 additions and 23 deletions

View File

@@ -12,7 +12,14 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { trimmedInstructionDigest } from './digest.ts'
import { decodeScopeKey, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts'
import {
decodeScopeKey,
renderWorkspaceInstructionSet,
USER_GLOBAL_DIRECTORY,
USER_GLOBAL_FILE,
type RenderedInstructionSet,
type RenderedWorkspaceContext,
} from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
export interface InstructionFile {
@@ -54,12 +61,6 @@ interface LoadOptions extends DiscoverOptions {
maxSourceBytes?: number
}
/** Rendered baseline plus the files that survived byte budgeting. */
export interface RenderedInstructionSet {
rendered: RenderedWorkspaceContext
included: LoadedInstructionFile[]
}
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
export type ScopeInstructionProbe =
| { kind: 'present'; file: ProbedInstructionFile }

View File

@@ -29,7 +29,17 @@ export interface RenderedWorkspaceContext {
}
interface RenderedInstructionContext extends RenderedWorkspaceContext {
/** Original files whose file-specific semantic section survived rendering. */
/**
* Original files whose file-specific section text survived rendering. This
* is not the complement of `omitted`: a truncated file may be represented
* here and in `truncated`, while a notice-only file appears in neither.
*/
represented: LoadedInstructionFile[]
}
/** Rendered baseline plus the files whose current content survived budgeting. */
export interface RenderedInstructionSet {
rendered: RenderedWorkspaceContext
included: LoadedInstructionFile[]
}
@@ -56,6 +66,12 @@ function byteLength(value: string): number {
return Buffer.byteLength(value, 'utf8')
}
function zeroContentTruncatedPaths(truncated: TruncatedInstruction[]): Set<string> {
return new Set(truncated
.filter(item => item.originalBytes > 0 && item.includedBytes === 0)
.map(item => item.displayPath))
}
function truncateUtf8(value: string, maxBytes: number): string {
let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8')
while (byteLength(truncated) > maxBytes) {
@@ -179,10 +195,14 @@ export function renderInstructionChanges(
},
}
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
const included = new Set(rendered.included.map(file => file.absolutePath))
const represented = new Set(rendered.represented.map(file => file.absolutePath))
const contentOmitted = zeroContentTruncatedPaths(rendered.truncated)
return {
text: rendered.text,
changes: items.filter(item => included.has(item.file.absolutePath)).map(item => item.change),
changes: items
.filter(item => represented.has(item.file.absolutePath)
&& (item.change.action === 'remove' || !contentOmitted.has(item.file.displayPath)))
.map(item => item.change),
}
}
@@ -252,24 +272,24 @@ function renderInstructionContext(
style: RenderStyle,
): RenderedInstructionContext {
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
return { text: '', omitted: files, truncated: [], included: [] }
return { text: '', omitted: files, truncated: [], represented: [] }
}
const fullText = buildInstructionText(files, maxBytes, [], [], style)
if (byteLength(fullText) <= maxBytes) {
return { text: fullText, omitted: [], truncated: [], included: files }
return { text: fullText, omitted: [], truncated: [], represented: files }
}
for (let start = 1; start < files.length; start += 1) {
const included = files.slice(start)
const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], included }
if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], represented: included }
}
const mostSpecific = files.at(-1)
/* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], included: [] }
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] }
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
@@ -280,7 +300,7 @@ function renderInstructionContext(
includedBytes: byteLength(truncatedFile.content),
}]
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
if (byteLength(text) <= maxBytes) return { text, omitted, truncated, included: [mostSpecific] }
if (byteLength(text) <= maxBytes) return { text, omitted, truncated, represented: [mostSpecific] }
}
const truncated = [{
@@ -293,10 +313,10 @@ function renderInstructionContext(
[compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
)
if (byteLength(compactWithHeading) <= maxBytes) {
return { text: compactWithHeading, omitted, truncated, included: [mostSpecific] }
return { text: compactWithHeading, omitted, truncated, represented: [mostSpecific] }
}
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
return { text, omitted, truncated, included: [] }
return { text, omitted, truncated, represented: [] }
}
/**
@@ -309,9 +329,10 @@ function renderInstructionContext(
export function renderWorkspaceInstructionSet(
files: LoadedInstructionFile[],
options: { maxBytes: number },
): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } {
const { included, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
return { rendered, included }
): RenderedInstructionSet {
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
const contentOmitted = zeroContentTruncatedPaths(rendered.truncated)
return { rendered, included: represented.filter(file => !contentOmitted.has(file.displayPath)) }
}
/**

View File

@@ -860,6 +860,26 @@ describe('workspace context rendering', () => {
expect(rendered.changes).toEqual([change])
})
it.each([
{ action: 'set' as const, maxBytes: 327, heading: 'Additional instructions from:' },
{ action: 'replace' as const, maxBytes: 256, heading: 'Updated instructions from:' },
])('does not commit a $action change when its heading survives with zero content bytes', ({ action, maxBytes, heading }) => {
const change = {
action,
scope: sk('pkg', 'AGENTS.md'),
path: 'pkg/AGENTS.md',
digest: 'digest',
}
const rendered = renderInstructionChanges([{
change,
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
}], maxBytes)
expect(rendered.text).toContain(heading)
expect(rendered.text).toContain('from 1000 to 0 bytes')
expect(rendered.changes).toEqual([])
})
it('keeps compact truncation notices within budget when a multibyte display path is cut', () => {
const rendered = renderWorkspaceContext([
{ absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
@@ -1318,14 +1338,14 @@ describe('workspace context request injection', () => {
}
})
it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => {
it('does not expose state markers when a baseline heading survives with zero content bytes', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
await write(join(root, 'AGENTS.md'), 'x'.repeat(1000))
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 10 })
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 120 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -1336,6 +1356,8 @@ describe('workspace context request injection', () => {
expect(contexts).toHaveLength(1)
const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined
expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([])
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
expect(derivedText(agent)).toContain('from 1000 to 0 bytes')
expect(derivedText(agent)).not.toContain('workspace-context:')
} finally {
await rm(root, { recursive: true, force: true })
@@ -3385,6 +3407,42 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('retries a nested instruction touch when only a truncated budget notice was rendered', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
const instructionPath = join(root, 'pkg/AGENTS.md')
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) })
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
const second = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(first.additionalContexts).toBeUndefined()
expect(second.additionalContexts).toBeUndefined()
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2)
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
await rm(dirname(home), { recursive: true, force: true })
}
})
it('does not attach nested instructions after a failed file read', async () => {
const root = await tempRepo()
const home = await tempRepo()