diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index b67dc088cd..92e2272676 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/README.i18n.yaml @@ -93,8 +93,8 @@ en: 2fbf2175168e8244 zh: f681f89596f500f5 /deepseek-ai-dsh-agent-instructions/known-limitations-and-deferred-work: - en: 0d10d036408264e8 - zh: ffb6d978c7d4756f + en: 9b50e4414fac4a79 + zh: 5957bfd23f79b336 /deepseek-ai-dsh-agent-instructions/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index 7a984a23b9..8f3f9c2628 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -217,7 +217,7 @@ These limits define when instruction loading is a poor fit or needs operational - **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. - **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when resume reconciles a visible baseline, or when an entering pre-step restores a shadowed baseline. - **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but neither user-global root has a local overlay and other custom names require explicit candidate configuration. A project directory named like a user-global scope directory renders as `.//...`, so it keeps its own scope instead of resolving to the global root. -- **Deduplication is content-based within one candidate group** — candidates collapse only when byte-identical after trimming leading and trailing whitespace. The two user-global roots share one group, so the shared-root file collapses behind a matching harness-home file; sibling project candidates collapse the same way, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it. A duplicate of budget-retained content stays reconciled: when the retained candidate changes or disappears, the duplicate becomes visible again. +- **Deduplication is content-based within one candidate group** — candidates collapse only when byte-identical after trimming leading and trailing whitespace. The two user-global roots share one group, so the shared-root file collapses behind a matching harness-home file; sibling project candidates collapse the same way, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it. A duplicate of budget-retained content stays reconciled: when the retained candidate changes or disappears, the duplicate becomes visible again. Unchanged hidden duplicates, including files removed from visible state as duplicates, use cached metadata instead of content reads. - **One candidate group shares failure fate** — when metadata or content cannot be read for any candidate, including a hidden duplicate, the group keeps its last-good state for that pass. An unreadable shared agents root therefore also postpones a harness-home change notice until a later pass observes both roots. - **A byte-budget omission does not re-enter reconciliation** — a candidate the baseline dropped for budget reasons, including any content duplicate of that candidate, is not probed again while that visible baseline holds; it becomes visible only after the baseline is rebuilt. - **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories. diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index d68662ff2e..6fe491f6c1 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -217,7 +217,7 @@ The previously loaded instructions from this file no longer apply. - **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用的 shell 状态不是可靠的文件系统 seam。 - **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时、恢复对账可见基线时,或进入步骤的 pre-step 恢复被遮蔽基线时可见。 - **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但两个用户全局根目录都没有本地 overlay,其他自定义名称需要显式候选配置。与用户全局 scope 目录同名的项目目录会渲染为 `.//...`,从而保留自己的 scope,而不会解析到全局根目录。 -- **去重在同一候选组内基于内容**:候选只有在去除首尾空白后字节完全一致时才折叠。两个用户全局根目录共享同一个组,因此共享根文件会折叠在内容一致的 harness home 文件之后;同级项目候选同样折叠,而从 `AGENTS.md` 漂移的独立副本则会与它一起完整加载。预算保留内容的重复候选仍参与对账:保留的候选一旦改变或消失,它就会重新可见。 +- **去重在同一候选组内基于内容**:候选只有在去除首尾空白后字节完全一致时才折叠。两个用户全局根目录共享同一个组,因此共享根文件会折叠在内容一致的 harness home 文件之后;同级项目候选同样折叠,而从 `AGENTS.md` 漂移的独立副本则会与它一起完整加载。预算保留内容的重复候选仍参与对账:保留的候选一旦改变或消失,它就会重新可见。未改变的隐藏重复项(包括因重复而从可见状态移除的文件)使用缓存元数据,不重新读取内容。 - **同一候选组共享失败命运**:组内任一候选(包括隐藏的重复项)的元数据或内容无法读取时,整个组在该轮保留最后一次成功状态。因此共享 agents 根目录不可读时,harness home 的变更通知也会推迟到之后某一轮同时观测到两个根目录时。 - **字节预算省略的候选不重新进入对账**:基线因预算丢弃的候选及其内容重复项在可见基线存续期间不再被探测,只有基线重建后才会重新可见。 - **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。 diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index 03a99ebd92..63edebc1d8 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -61,7 +61,7 @@ export interface InstructionVersionState { digest: string /** * Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress - * per-directory duplicates on the metadata fast path without re-reading a sibling. + * candidate-group duplicates on the metadata fast path without re-reading a sibling. */ trimmedDigest: string } @@ -72,6 +72,7 @@ export type InstructionVersionCache = WeakMap { + const pushRemoval = (scope: string, path: string, state?: InstructionVersionState): void => { const change: AgentInstructionChange = { action: 'remove', scope, path } items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } }) - versionUpdates.push({ change }) + versionUpdates.push({ change, ...state === undefined ? {} : { state } }) } const scopesByGroup = new Map() for (const scope of scopes) { @@ -394,12 +395,13 @@ export async function reconcileInstructionContext( if (rendered) { // Unchanged and previously rendered: keep it, but an earlier group member // that now matches its trimmed content makes this the duplicate to remove. - if (registerKeptTrimmed(group, cached.trimmedDigest)) pushRemoval(scope, previous.path) + if (registerKeptTrimmed(group, cached.trimmedDigest)) pushRemoval(scope, previous.path, cached) continue } - // A known duplicate that was never rendered stays dropped while an earlier - // group member still matches it; once none does, the read below promotes it. - if (previous === undefined && hasKeptTrimmed(group, cached.trimmedDigest)) continue + // Hidden duplicates include files removed from visible state after + // becoming duplicates. Only promotion requires another content read. + if ((previous === undefined || previous.action === 'remove') + && hasKeptTrimmed(group, cached.trimmedDigest)) continue } const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal) @@ -420,8 +422,7 @@ export async function reconcileInstructionContext( // group: drop it, removing any copy that was previously rendered, and keep // its metadata so the next pass decides without reading it again. if (previous !== undefined && previous.action !== 'remove') { - pushRemoval(scope, previous.path) - versions.delete(scope) + pushRemoval(scope, previous.path, nextVersion) } else { versions.set(scope, nextVersion) } diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 91add7828a..322cc38498 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -2336,6 +2336,103 @@ describe('workspace context request injection', () => { } }) + it.each([ + { formation: 'shared-edited', winner: 'edit' }, + { formation: 'shared-edited', winner: 'delete' }, + { formation: 'home-edited', winner: 'edit' }, + { formation: 'home-edited', winner: 'delete' }, + ] as const)( + 'caches a removed duplicate after $formation and promotes it after winner $winner', + async ({ formation, winner }) => { + const root = await tempRepo() + const home = await tempRepo() + const agents = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + const harnessPath = join(home, USER_GLOBAL_FILE) + const sharedPath = join(agents, USER_GLOBAL_FILE) + const duplicateText = 'global rule B' + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(harnessPath, { + type: 'file', + content: formation === 'shared-edited' ? duplicateText : 'harness rule A', + version: FsVersion('harness-v1'), + }) + fs.entries.set(sharedPath, { + type: 'file', + content: formation === 'shared-edited' ? 'shared rule A' : duplicateText, + version: FsVersion('shared-v1'), + }) + pinHarnessHome(home) + pinAgentsHome(agents) + await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) + const agent = await stubAgent(root) + await composeBaselinePrefix(ctx, agent) + expect(derivedText(agent)).toContain('Instructions from: $DSH_HOME/AGENTS.md') + expect(derivedText(agent)).toContain('Instructions from: $DSH_AGENTS_HOME/AGENTS.md') + + if (formation === 'shared-edited') { + fs.entries.set(sharedPath, { type: 'file', content: duplicateText, version: FsVersion('shared-v2') }) + } else { + fs.entries.set(harnessPath, { type: 'file', content: duplicateText, version: FsVersion('harness-v2') }) + } + const removal = await syncedAgentInstructions(ctx, agent) + if (removal.source.kind !== 'agent-instructions') throw new Error('missing instruction removal source') + expect(removal.source.changes).toContainEqual({ + action: 'remove', + scope: sk(AGENTS_GLOBAL_DIRECTORY, USER_GLOBAL_FILE), + path: '$DSH_AGENTS_HOME/AGENTS.md', + }) + expect(blocksText(removal.content)).toContain('Instructions removed: $DSH_AGENTS_HOME/AGENTS.md') + await appendAdditionalContexts(ctx, agent) + + fs.readTargets.length = 0 + await syncAgentInstructions(ctx, agent) + await syncAgentInstructions(ctx, agent) + expect(agent.inbox.nextStep.filter(message => message.source.kind === 'agent-instructions')).toEqual([]) + expect(fs.readTargets).not.toContain(sharedPath) + + if (winner === 'edit') { + fs.entries.set(harnessPath, { type: 'file', content: 'harness rule C', version: FsVersion('harness-v3') }) + } else { + fs.entries.delete(harnessPath) + } + const promotion = await syncedAgentInstructions(ctx, agent) + expect(promotion.source).toMatchObject({ + kind: 'agent-instructions', + changes: [ + { + action: winner === 'edit' ? 'replace' : 'remove', + scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE), + path: '$DSH_HOME/AGENTS.md', + }, + { action: 'set', scope: sk(AGENTS_GLOBAL_DIRECTORY, USER_GLOBAL_FILE), path: '$DSH_AGENTS_HOME/AGENTS.md' }, + ], + }) + const promotedText = blocksText(promotion.content) + expect(promotedText).toContain('Additional instructions from: $DSH_AGENTS_HOME/AGENTS.md') + expect(promotedText).toContain('These user-global instructions apply to all work.') + expect(promotedText.match(/global rule B/g)).toHaveLength(1) + expect(fs.readTargets.filter(path => path === sharedPath)).toHaveLength(1) + await appendAdditionalContexts(ctx, agent) + + await syncAgentInstructions(ctx, agent) + await syncAgentInstructions(ctx, agent) + expect(agent.inbox.nextStep.filter(message => message.source.kind === 'agent-instructions')).toEqual([]) + expect(fs.readTargets.filter(path => path === sharedPath)).toHaveLength(1) + expect(baselineEvents(agent)).toHaveLength(1) + } finally { + vi.unstubAllEnvs() + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + await rm(agents, { recursive: true, force: true }) + } + }, + ) + it('keeps a shared file created later with identical content suppressed without re-reading it', async () => { const root = await tempRepo() const home = await tempRepo()