Merge pull request #967 from deepseek-harness/fix/workspace-context-rendered-change-proof
fix(workspace-context): commit only represented changes
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md
|
||||
2026-06-24-workspace-context.md: e7a3724847b9dc8cfad11e87b2c96a3ef442bcba
|
||||
2026-06-24-workspace-context.zh.md: 39c3f52da10b7299301d10bd8b78330cbae8e19c
|
||||
2026-06-24-workspace-context.md: 4398fbb1606e764dde9fad8a8f3c325f5e0fead4
|
||||
2026-06-24-workspace-context.zh.md: 72ae077be08e95ce582584e66945c2187255abdd
|
||||
@@ -52,7 +52,7 @@ Every workspace context event stores versioned metadata with `{ action, scope, p
|
||||
|
||||
At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. Successful nested touches aggregate under their parent execution token, including when a later composite result is blocked; the top-level result transfers them either to the open session step or directly to a per-agent projection queue. A `step/end` releases its staged touches only after that boundary is durable, and the next `agent/pre-step` waits for the serialized projections. Each projection composes against visible history plus the current inbox and replaces the single pending workspace context instead of accumulating intermediate renderings.
|
||||
|
||||
An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata: a compatible visible baseline supplies comparison state rather than causing another complete baseline to be appended. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch.
|
||||
An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata: a compatible visible baseline supplies comparison state rather than causing another complete baseline to be appended. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. A change enters metadata or pending state only when its file-specific section retains at least one content byte, or when the original content is genuinely empty. Partial truncation commits the full-content digest once any byte survives; zero-content truncation remains eligible on a later touch. A baseline may retain budget diagnostics with no committed changes. A dynamic batch with no committed change is withheld entirely and retried on a later touch. An omitted file remains eligible on a later touch.
|
||||
|
||||
The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Resume retains a compatible baseline and reconciles current baseline and visible dynamic scopes, so changes made while the agent was offline append transitions before the first resumed request. When compaction shadows the baseline event, the next entering pre-step composes the complete current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append baseline edits or removals as dynamic messages. Neither path rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,
|
||||
|
||||
协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。即使后续复合结果被拦截,成功的嵌套 touch 也会聚合到父级执行 token 下;顶层结果会将它们交给打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界持久化后释放其暂存的 touch;下一次 `agent/pre-step` 会等待串行投影完成。每次投影都会根据可见历史和当前 inbox 进行组合,并替换唯一一条待处理工作区上下文,而不会累积中间渲染结果。
|
||||
|
||||
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作:兼容的可见基线会提供比较状态,而不会导致再次追加完整基线。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
|
||||
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作:兼容的可见基线会提供比较状态,而不会导致再次追加完整基线。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入元数据或待处理状态。只要任一内容字节保留下来,部分截断就会提交完整内容 digest;截断到零内容仍可在后续触碰中处理。基线可以保留字节预算诊断而不提交任何变更。动态批次若没有可提交变更,则整批不注入,并在后续触碰中重试。被省略的文件仍可在之后的触碰中加载。
|
||||
|
||||
只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。恢复会保留兼容的基线,并对账当前基线和可见的动态 scope,因此 agent 离线期间的变更会在第一次恢复请求前追加为转换。当压缩遮蔽基线事件时,下一次进入步骤的 pre-step 会组合完整的当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统触碰重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息。两条路径都不会重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md
|
||||
README.md: 7ab21bbf8c72f8424bc8d4fdad9153c7ed8bb7e9
|
||||
README.zh.md: eb807bfc0611854d54eda3ff26c97c6af51da529
|
||||
README.md: 1ae47bef728a81c61f0db637872c93321a7bc687
|
||||
README.zh.md: 8087412057b3e5924764df179ae293db5699f9ae
|
||||
@@ -50,7 +50,7 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
|
||||
|
||||
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. A baseline may still publish its budget diagnostic with an empty change list. A dynamic batch with no committed change is not injected at all, and a later touch retries it.
|
||||
|
||||
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. When compaction shadows the event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. At the first pre-step after resume or hot remount, a compatible visible baseline is retained and compared with the files retained by the current complete rendering. Unchanged and budget-omitted files append nothing; offline additions, edits, removals, and files leaving the retained budget set append `set`, `replace`, or `remove` transitions. An incompatible visible baseline is superseded by one complete current baseline, including an explicit empty baseline when no candidate remains. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a resumed session reconciles its baseline, or when an entering pre-step restores a shadowed baseline.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;若被拒绝,当前上下文则继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch;串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
|
||||
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。基线即使带空变更列表,仍可发布字节预算诊断。动态批次若没有可提交变更,则完全不注入,并在后续 touch 时重试。
|
||||
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。当压缩遮蔽该事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统 touch 重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。恢复或插件热重挂后的第一次 pre-step 会保留兼容的可见基线,并将它与当前完整渲染所保留的文件进行比较。未变化和被预算省略的文件不追加任何内容;agent 离线期间新增、编辑、移除或不再属于预算保留集的文件会追加 `set`、`replace` 或 `remove` 转换。不兼容的可见基线会被一条完整的当前基线取代;如果没有候选文件,这条当前基线会是显式空基线。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复后的会话对账其基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ 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, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts'
|
||||
import {
|
||||
decodeScopeKey,
|
||||
renderWorkspaceInstructionSet,
|
||||
type RenderedWorkspaceContext,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
} from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
export interface InstructionFile {
|
||||
@@ -64,7 +70,6 @@ export interface RenderedInstructionSet {
|
||||
/** Candidates retained by content deduplication and byte budgeting. */
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||
export type ScopeInstructionProbe =
|
||||
| { kind: 'present'; file: ProbedInstructionFile }
|
||||
@@ -420,26 +425,26 @@ export async function loadBaselineInstructionSet(
|
||||
const deduped = dedupInstructionFilesByDirectory(loaded)
|
||||
if (deduped.length === 0) {
|
||||
if (options.replacePreviousBaseline !== true) return undefined
|
||||
const { rendered, included } = renderWorkspaceInstructionSet([], {
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
})
|
||||
return {
|
||||
rendered: renderWorkspaceContext([], {
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
}),
|
||||
rendered,
|
||||
observed: [],
|
||||
included: [],
|
||||
included,
|
||||
}
|
||||
}
|
||||
const rendered = renderWorkspaceContext(deduped, {
|
||||
const { rendered, included } = renderWorkspaceInstructionSet(deduped, {
|
||||
maxBytes: config.maxBytes,
|
||||
...options.replacePreviousBaseline === undefined
|
||||
? {}
|
||||
: { replacePreviousBaseline: options.replacePreviousBaseline },
|
||||
})
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
return {
|
||||
rendered,
|
||||
observed: loaded,
|
||||
included: deduped.filter(file => !omitted.has(file.absolutePath)),
|
||||
included,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ export interface RenderedWorkspaceContext {
|
||||
truncated: TruncatedInstruction[]
|
||||
}
|
||||
|
||||
interface RenderedInstructionContext extends RenderedWorkspaceContext {
|
||||
/**
|
||||
* Original files semantically represented by rendered section text. 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. A genuinely
|
||||
* empty file counts when its heading survives because that heading conveys
|
||||
* that the instruction exists and has no content.
|
||||
*/
|
||||
represented: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||
export interface WorkspaceInstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
@@ -56,11 +67,15 @@ function byteLength(value: string): number {
|
||||
}
|
||||
|
||||
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) {
|
||||
truncated = truncated.slice(0, -1)
|
||||
const bytes = Buffer.from(value, 'utf8')
|
||||
if (bytes.length <= maxBytes) return value
|
||||
let end = Math.max(0, Math.trunc(maxBytes))
|
||||
// If the first excluded byte is a UTF-8 continuation byte, the budget cut
|
||||
// through that code point. Back up to its lead byte and exclude it too.
|
||||
while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) {
|
||||
end -= 1
|
||||
}
|
||||
return truncated
|
||||
return bytes.subarray(0, end).toString('utf8')
|
||||
}
|
||||
|
||||
function escapeInstructionFrameBody(body: string): string {
|
||||
@@ -143,6 +158,16 @@ function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
|
||||
const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
|
||||
|
||||
function baselineRenderStyle(files: LoadedInstructionFile[], replacePreviousBaseline: boolean | undefined): RenderStyle {
|
||||
if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE
|
||||
return {
|
||||
...BASELINE_RENDER_STYLE,
|
||||
intro: files.length === 0
|
||||
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
|
||||
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
|
||||
}
|
||||
}
|
||||
|
||||
function changedSectionText(item: ChangeRenderItem): string {
|
||||
const { change, file } = item
|
||||
if (change.action === 'set') return additionalSectionText(file)
|
||||
@@ -178,13 +203,12 @@ export function renderInstructionChanges(
|
||||
},
|
||||
}
|
||||
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
const represented = new Set(rendered.represented.map(file => file.absolutePath))
|
||||
return {
|
||||
text: rendered.text,
|
||||
// TODO(rendered-change-proof): retain a transition only when its semantic
|
||||
// notice survived rendering; a tiny compact budget can currently return
|
||||
// unrelated notice text while still committing the full state transition.
|
||||
changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change),
|
||||
changes: items
|
||||
.filter(item => represented.has(item.file.absolutePath))
|
||||
.map(item => item.change),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,47 +276,75 @@ function renderInstructionContext(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
style: RenderStyle,
|
||||
): RenderedWorkspaceContext {
|
||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] }
|
||||
): RenderedInstructionContext {
|
||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
|
||||
return { text: '', omitted: files, truncated: [], represented: [] }
|
||||
}
|
||||
|
||||
const fullText = buildInstructionText(files, maxBytes, [], [], style)
|
||||
if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] }
|
||||
if (byteLength(fullText) <= maxBytes) {
|
||||
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: [] }
|
||||
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: [] }
|
||||
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] }
|
||||
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
const originalBytes = byteLength(mostSpecific.content)
|
||||
|
||||
for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
|
||||
const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
|
||||
const includedBytes = byteLength(truncatedFile.content)
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes: byteLength(mostSpecific.content),
|
||||
includedBytes: byteLength(truncatedFile.content),
|
||||
originalBytes,
|
||||
includedBytes,
|
||||
}]
|
||||
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
|
||||
if (byteLength(text) <= maxBytes) return { text, omitted, truncated }
|
||||
if (byteLength(text) <= maxBytes) {
|
||||
const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text, omitted, truncated, represented }
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes: byteLength(mostSpecific.content),
|
||||
originalBytes,
|
||||
includedBytes: 0,
|
||||
}]
|
||||
const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated))
|
||||
const compactWithHeading = escapeInstructionFrameBody(
|
||||
[compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
|
||||
)
|
||||
if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated }
|
||||
if (byteLength(compactWithHeading) <= maxBytes) {
|
||||
const represented = originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text: compactWithHeading, omitted, truncated, represented }
|
||||
}
|
||||
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
|
||||
return { text, omitted, truncated }
|
||||
return { text, omitted, truncated, represented: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a baseline together with the exact source files semantically represented in it.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @returns bounded public rendering plus files with surviving content, including genuinely empty files.
|
||||
* @internal
|
||||
*/
|
||||
export function renderWorkspaceInstructionSet(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } {
|
||||
const style = baselineRenderStyle(files, options.replacePreviousBaseline)
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style)
|
||||
return { rendered, included: represented }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,13 +357,5 @@ export function renderWorkspaceContext(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): RenderedWorkspaceContext {
|
||||
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)
|
||||
return renderWorkspaceInstructionSet(files, options).rendered
|
||||
}
|
||||
@@ -422,6 +422,10 @@ export async function reconcileInstructionContext(
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
// When no transition survived rendering (tiny budgets render notice-only
|
||||
// text), emit nothing and commit nothing — the uncommitted versions make the
|
||||
// next pass retry instead of spamming notice-only contexts.
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
return {
|
||||
context: workspaceContextHook(rendered.text, rendered.changes),
|
||||
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type InstructionVersionCache,
|
||||
} from '../src/state.ts'
|
||||
import { resolveConfig } from '../src/config.ts'
|
||||
import { candidateScopeKey, renderInstructionChanges, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts'
|
||||
import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/** Per-candidate reconciliation scope key: directory paired with the file name. */
|
||||
@@ -855,6 +855,33 @@ describe('workspace context rendering', () => {
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(120)
|
||||
})
|
||||
|
||||
it('represents a genuinely empty instruction when its compact heading fits', () => {
|
||||
const file = { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '' }
|
||||
const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 117 })
|
||||
|
||||
expect(rendered.rendered.text).toContain('truncated pkg/AGENTS.md from 0 to 0 bytes')
|
||||
expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md')
|
||||
expect(rendered.included).toEqual([file])
|
||||
})
|
||||
|
||||
it('represents a genuinely empty instruction through the framed compact-intro path', () => {
|
||||
const file = {
|
||||
absolutePath: '/repo/pkg/AGENTS.md',
|
||||
displayPath: 'pkg/AGENTS.md',
|
||||
content: '',
|
||||
}
|
||||
|
||||
const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 300 })
|
||||
|
||||
expect(rendered.rendered.text).toContain('<system-reminder>')
|
||||
expect(rendered.rendered.text).toContain('Workspace instructions were omitted or truncated')
|
||||
expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md')
|
||||
expect(rendered.rendered.truncated).toEqual([
|
||||
{ displayPath: 'pkg/AGENTS.md', originalBytes: 0, includedBytes: 0 },
|
||||
])
|
||||
expect(rendered.included).toEqual([file])
|
||||
})
|
||||
|
||||
it('truncates the compact notice itself when the render budget is smaller than the notice', () => {
|
||||
const rendered = renderWorkspaceContext([
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||||
@@ -865,6 +892,76 @@ describe('workspace context rendering', () => {
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20)
|
||||
})
|
||||
|
||||
it('does not commit a change when only the generic compact notice survives', () => {
|
||||
const change = {
|
||||
action: 'set' as const,
|
||||
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) },
|
||||
}], 20)
|
||||
|
||||
expect(rendered.text).toBe('Workspace instructio')
|
||||
expect(rendered.changes).toEqual([])
|
||||
})
|
||||
|
||||
it('commits a change when its file-specific semantic section survives truncation', () => {
|
||||
const change = {
|
||||
action: 'replace' as const,
|
||||
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) },
|
||||
}], 400)
|
||||
|
||||
expect(rendered.text).toContain('Updated instructions from: pkg/AGENTS.md')
|
||||
expect(rendered.changes).toEqual([change])
|
||||
})
|
||||
|
||||
// Each prose-derived budget is the smallest current value that retains the named heading plus a zero-byte marker.
|
||||
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('does not commit a multibyte change when the budget cuts its first code point', () => {
|
||||
const change = {
|
||||
action: 'set' as const,
|
||||
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: '😀'.repeat(100) },
|
||||
}], 366)
|
||||
|
||||
expect(rendered.text).not.toContain('�')
|
||||
expect(rendered.text).not.toContain('😀')
|
||||
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) },
|
||||
@@ -1832,21 +1929,30 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => {
|
||||
it.each([10, 120])('does not expose state markers when baseline content is omitted at %i bytes', async (maxBytes) => {
|
||||
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 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(agent.session.events.filter(event =>
|
||||
const contexts = agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind !== 'user',
|
||||
)).toHaveLength(1)
|
||||
)
|
||||
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([])
|
||||
if (maxBytes === 120) {
|
||||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
|
||||
expect(derivedText(agent)).toContain('from 1000 to 0 bytes')
|
||||
} else {
|
||||
expect(derivedText(agent)).not.toContain('Instructions from: AGENTS.md')
|
||||
}
|
||||
expect(derivedText(agent)).not.toContain('workspace-context:')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -4167,6 +4273,47 @@ 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,
|
||||
})
|
||||
await syncWorkspaceContext(ctx, 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,
|
||||
})
|
||||
await syncWorkspaceContext(ctx, agent)
|
||||
|
||||
expect(first.additionalContexts).toBeUndefined()
|
||||
expect(second.additionalContexts).toBeUndefined()
|
||||
// Nothing was emitted, and the uncommitted version made the second sync
|
||||
// probe the instruction file again — the retry.
|
||||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||||
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()
|
||||
@@ -4253,7 +4400,7 @@ describe('workspace context inbox synchronization', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a dynamic change within a one-byte positive render budget', async () => {
|
||||
it('holds back a dynamic change a one-byte positive render budget cannot represent', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
@@ -4271,8 +4418,10 @@ describe('workspace context inbox synchronization', () => {
|
||||
|
||||
await syncWorkspaceContext(ctx, agent)
|
||||
|
||||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||||
expect(Buffer.byteLength(blocksText(agent.inbox.nextStep[0]?.content), 'utf8')).toBeLessThanOrEqual(1)
|
||||
// One byte cannot semantically represent the transition, so nothing is
|
||||
// emitted and nothing commits — the uncommitted version retries on the
|
||||
// next touch instead of committing state the model never saw.
|
||||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
|
||||
Reference in New Issue
Block a user