Browse Source

fix(workspace-context): require rendered instruction content

ZiyaZhang 2 months ago
parent
commit
36b8efd2c6

+ 8 - 7
packages/context/workspace-context/src/files.ts

@@ -12,7 +12,14 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
 import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
 import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
 import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
 import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
 import { trimmedInstructionDigest } from './digest.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. */
 /** An instruction candidate identified by absolute and model-facing paths. */
 export interface InstructionFile {
 export interface InstructionFile {
@@ -54,12 +61,6 @@ interface LoadOptions extends DiscoverOptions {
   maxSourceBytes?: number
   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. */
 /** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
 export type ScopeInstructionProbe =
 export type ScopeInstructionProbe =
   | { kind: 'present'; file: ProbedInstructionFile }
   | { kind: 'present'; file: ProbedInstructionFile }

+ 34 - 13
packages/context/workspace-context/src/render.ts

@@ -29,7 +29,17 @@ export interface RenderedWorkspaceContext {
 }
 }
 
 
 interface RenderedInstructionContext extends 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[]
   included: LoadedInstructionFile[]
 }
 }
 
 
@@ -56,6 +66,12 @@ function byteLength(value: string): number {
   return Buffer.byteLength(value, 'utf8')
   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 {
 function truncateUtf8(value: string, maxBytes: number): string {
   let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8')
   let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8')
   while (byteLength(truncated) > maxBytes) {
   while (byteLength(truncated) > maxBytes) {
@@ -179,10 +195,14 @@ export function renderInstructionChanges(
     },
     },
   }
   }
   const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
   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 {
   return {
     text: rendered.text,
     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,
   style: RenderStyle,
 ): RenderedInstructionContext {
 ): RenderedInstructionContext {
   if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
   if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
-    return { text: '', omitted: files, truncated: [], included: [] }
+    return { text: '', omitted: files, truncated: [], represented: [] }
   }
   }
 
 
   const fullText = buildInstructionText(files, maxBytes, [], [], style)
   const fullText = buildInstructionText(files, maxBytes, [], [], style)
   if (byteLength(fullText) <= maxBytes) {
   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) {
   for (let start = 1; start < files.length; start += 1) {
     const included = files.slice(start)
     const included = files.slice(start)
     const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
     const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
     const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
     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)
   const mostSpecific = files.at(-1)
   /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
   /* 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 }))
   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 }]) {
   for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
@@ -280,7 +300,7 @@ function renderInstructionContext(
       includedBytes: byteLength(truncatedFile.content),
       includedBytes: byteLength(truncatedFile.content),
     }]
     }]
     const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
     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 = [{
   const truncated = [{
@@ -293,10 +313,10 @@ function renderInstructionContext(
     [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
     [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
   )
   )
   if (byteLength(compactWithHeading) <= maxBytes) {
   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)
   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(
 export function renderWorkspaceInstructionSet(
   files: LoadedInstructionFile[],
   files: LoadedInstructionFile[],
   options: { maxBytes: number },
   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)) }
 }
 }
 
 
 /**
 /**

+ 61 - 3
packages/context/workspace-context/tests/workspace-context.spec.ts

@@ -860,6 +860,26 @@ describe('workspace context rendering', () => {
     expect(rendered.changes).toEqual([change])
     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', () => {
   it('keeps compact truncation notices within budget when a multibyte display path is cut', () => {
     const rendered = renderWorkspaceContext([
     const rendered = renderWorkspaceContext([
       { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
       { 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 root = await tempRepo()
     const home = await tempRepo()
     const home = await tempRepo()
     try {
     try {
       await mkdir(join(root, '.git'), { recursive: true })
       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()
       const ctx = new Context()
-      await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 10 })
+      await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 120 })
       const agent = stubAgent(root)
       const agent = stubAgent(root)
 
 
       await composeBaselinePrefix(ctx, agent)
       await composeBaselinePrefix(ctx, agent)
@@ -1336,6 +1356,8 @@ describe('workspace context request injection', () => {
       expect(contexts).toHaveLength(1)
       expect(contexts).toHaveLength(1)
       const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined
       const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined
       expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([])
       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:')
       expect(derivedText(agent)).not.toContain('workspace-context:')
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true })
       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 () => {
   it('does not attach nested instructions after a failed file read', async () => {
     const root = await tempRepo()
     const root = await tempRepo()
     const home = await tempRepo()
     const home = await tempRepo()