mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Optimize workspace instruction change detection
This commit is contained in:
@@ -81,7 +81,7 @@ Resolved targets must expose at least three concepts:
|
||||
- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path.
|
||||
- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend.
|
||||
|
||||
Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
|
||||
Read and mutation results must include an opaque file `version`. The local backend derives its token from bigint stat metadata (`dev`, `ino`, `size`, `mtimeNs`, and `ctimeNs`) so same-size rewrites and inode replacement invalidate consumers reliably; a remote backend can use a revision id or hash-like token. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
|
||||
|
||||
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views.
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc
|
||||
|
||||
`maxBytes` is required and applies separately to a rendered baseline or one dynamic reconciliation batch; there is no implicit or unbounded render budget. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes.
|
||||
|
||||
`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide content cache: every reconciliation observes the current bounded text, then computes the SHA-1 used by visible structured duplicate-suppression state.
|
||||
`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap<Session, Map<scope, state>>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Cache transitions for model-visible changes commit only when the corresponding context survives the complete tool-result policy chain.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
## Behavior
|
||||
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
|
||||
- **`stat`** — returns `FsInfo` (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Dirent, Stats } from 'node:fs'
|
||||
import type { BigIntStats, Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
@@ -76,9 +76,9 @@ async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', si
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque version token from a stat: millisecond mtime plus byte size. */
|
||||
function versionOf(info: Stats): FsVersion {
|
||||
return FsVersion(`${info.mtimeMs}:${info.size}`)
|
||||
/** Opaque version token from high-resolution identity and freshness metadata. */
|
||||
function versionOf(info: BigIntStats): FsVersion {
|
||||
return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,18 +176,21 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
}
|
||||
}
|
||||
|
||||
function pathType(info: Stats): PathInfo['type'] {
|
||||
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
|
||||
if (info.isFile()) return 'file'
|
||||
if (info.isDirectory()) return 'directory'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function pathLinkType(info: Stats): PathLinkInfo['type'] {
|
||||
function pathLinkType(info: Stats | BigIntStats): PathLinkInfo['type'] {
|
||||
if (info.isSymbolicLink()) return 'symlink'
|
||||
return pathType(info)
|
||||
}
|
||||
|
||||
async function probeStats(absolutePath: string, readStats: (path: string) => Promise<Stats>): Promise<Stats | null> {
|
||||
async function probeStats<T extends Stats | BigIntStats>(
|
||||
absolutePath: string,
|
||||
readStats: (path: string) => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await readStats(absolutePath)
|
||||
} catch (error: unknown) {
|
||||
@@ -206,9 +209,14 @@ async function probeStats(absolutePath: string, readStats: (path: string) => Pro
|
||||
* @returns the metadata, or null when the path — or a parent segment — does not exist.
|
||||
*/
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
const info = await probeStats(absolutePath, stat)
|
||||
const info = await probeStats(absolutePath, path => stat(path, { bigint: true }))
|
||||
if (!info) return null
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type: pathType(info), size: info.size }
|
||||
return {
|
||||
version: versionOf(info),
|
||||
mode: Number(info.mode & 0o777n),
|
||||
type: pathType(info),
|
||||
size: Number(info.size),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,9 +225,14 @@ export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
* @returns path-entry metadata, or null when the entry is absent.
|
||||
*/
|
||||
export async function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null> {
|
||||
const info = await probeStats(absolutePath, lstat)
|
||||
const info = await probeStats(absolutePath, path => lstat(path, { bigint: true }))
|
||||
if (!info) return null
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type: pathLinkType(info), size: info.size }
|
||||
return {
|
||||
version: versionOf(info),
|
||||
mode: Number(info.mode & 0o777n),
|
||||
type: pathLinkType(info),
|
||||
size: Number(info.size),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Directory listing ---
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
@@ -99,6 +99,20 @@ describe('stat', () => {
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('changes version after a same-size rewrite even when mtime is restored', async () => {
|
||||
const path = join(dir, 'same-size.txt')
|
||||
await writeFile(path, 'first')
|
||||
const target = await fs.resolve(path)
|
||||
const beforeInfo = await stat(path)
|
||||
const beforeVersion = await versionOf(target)
|
||||
|
||||
await fs.writeText(target, 'other')
|
||||
await utimes(path, beforeInfo.atime, beforeInfo.mtime)
|
||||
|
||||
expect((await stat(path)).size).toBe(beforeInfo.size)
|
||||
expect(await versionOf(target)).not.toBe(beforeVersion)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
@@ -327,9 +341,6 @@ describe('writeText', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const before = await versionOf(target)
|
||||
// Change the byte length so the mtimeMs:size token provably differs (a
|
||||
// same-size same-tick rewrite can collide — the documented version-token
|
||||
// limitation; not what this test is about).
|
||||
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
|
||||
expect(outcome.version).not.toBe(before)
|
||||
expect(outcome.version).toBe(await versionOf(target))
|
||||
|
||||
@@ -41,16 +41,17 @@ export function FsTargetKey(key: string): FsTargetKey {
|
||||
|
||||
/**
|
||||
* Opaque file-version token — the freshness token a write/edit guards against.
|
||||
* The local backend derives it from mtime+size; a remote backend might use a
|
||||
* revision id. The policy layer records it for stale checks; consumers may
|
||||
* display related metadata but MUST NOT interpret this token.
|
||||
* The local backend derives it from high-resolution stat identity and freshness
|
||||
* fields; a remote backend might use a revision id. The policy layer records it
|
||||
* for stale checks; consumers may display related metadata but MUST NOT
|
||||
* interpret this token.
|
||||
*/
|
||||
export type FsVersion = Branded<'FsVersion'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link FsVersion}. For backend use only — a consumer
|
||||
* never manufactures a version, it receives one from `stat`/write/edit outcomes.
|
||||
* @param v - the backend's raw version string (the local backend derives it from mtime+size).
|
||||
* @param v - the backend's raw version string.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function FsVersion(v: string): FsVersion {
|
||||
|
||||
@@ -48,7 +48,7 @@ The core `context/message` envelope is disabled for these messages because the p
|
||||
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. Resume works because visible metadata is persisted in the session log. Compaction re-arms a scope after its context event leaves the visible surface. A removal is a tombstone, so a later candidate reappearance is loaded again. Only changes actually rendered within the byte budget enter metadata and pending state; an omitted change remains eligible for a later touch.
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: 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. Resume works because SHA-1 state is persisted in the session log, 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 metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
|
||||
|
||||
The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
|
||||
|
||||
@@ -72,7 +72,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only co
|
||||
|
||||
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
|
||||
|
||||
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide prose cache: every reconciliation observes current content, computes its SHA-1 only after the bounded read, and relies on persisted structured metadata for duplicate suppression.
|
||||
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { lstat, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
|
||||
@@ -21,11 +21,21 @@ export interface InstructionFile {
|
||||
/** An instruction file whose UTF-8 content was read successfully. */
|
||||
export interface LoadedInstructionFile extends InstructionFile {
|
||||
content: string
|
||||
/** Provider freshness token when the file was loaded through `ctx.fs`. */
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
interface DiscoveredInstructionFile extends InstructionFile {
|
||||
target?: FsTarget
|
||||
size?: number
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
/** Provider metadata for a winning scope candidate before its content is read. */
|
||||
export interface ProbedInstructionFile extends InstructionFile {
|
||||
target: FsTarget
|
||||
version: FsVersion
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface DiscoverOptions {
|
||||
@@ -49,7 +59,7 @@ export interface RenderedInstructionSet {
|
||||
|
||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||
export type ScopeInstructionProbe =
|
||||
| { kind: 'present'; file: LoadedInstructionFile }
|
||||
| { kind: 'present'; file: ProbedInstructionFile }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
@@ -75,14 +85,14 @@ async function fsStatFile(
|
||||
path: string,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ target: FsTarget; size?: number } | undefined> {
|
||||
): Promise<{ target: FsTarget; size?: number; version: FsVersion } | undefined> {
|
||||
try {
|
||||
const pathInfo = await fileSystem.lstat(path, undefined, signal)
|
||||
if (pathInfo?.type !== 'file') return undefined
|
||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||
const info = await fileSystem.stat(target, signal)
|
||||
if (info?.type !== 'file') return undefined
|
||||
return { target, ...info.size === undefined ? {} : { size: info.size } }
|
||||
return { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } }
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
// Provider absence and discovery races are both non-fatal.
|
||||
@@ -94,7 +104,7 @@ async function statFile(
|
||||
path: string,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ target?: FsTarget; size?: number } | undefined> {
|
||||
): Promise<{ target?: FsTarget; size?: number; version?: FsVersion } | undefined> {
|
||||
return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
|
||||
}
|
||||
|
||||
@@ -316,7 +326,14 @@ export async function loadBaselineInstructionSet(
|
||||
const loaded: LoadedInstructionFile[] = []
|
||||
for (const file of discovered) {
|
||||
const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal)
|
||||
if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
|
||||
if (content !== undefined) {
|
||||
loaded.push({
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
...file.version === undefined ? {} : { version: file.version },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (loaded.length === 0) return undefined
|
||||
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
|
||||
@@ -329,11 +346,11 @@ export async function loadBaselineInstructionSet(
|
||||
* @param scope - `user-global`, `.`, or a project-relative directory.
|
||||
* @param projectRoot - project root used to resolve and display project scopes.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param fileSystem - provider used for no-follow probing and reading.
|
||||
* @param signal - cancellation for provider probes and streaming.
|
||||
* @returns present content, confirmed absence, or temporary unavailability.
|
||||
* @param fileSystem - provider used for no-follow probing.
|
||||
* @param signal - cancellation for provider probes.
|
||||
* @returns present metadata, confirmed absence, or temporary unavailability.
|
||||
*/
|
||||
export async function loadScopeInstruction(
|
||||
export async function probeScopeInstruction(
|
||||
scope: string,
|
||||
projectRoot: string,
|
||||
resolved: ResolvedConfig,
|
||||
@@ -364,19 +381,42 @@ export async function loadScopeInstruction(
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||
const discovered: DiscoveredInstructionFile = {
|
||||
const file: ProbedInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
target,
|
||||
version: info.version,
|
||||
...info.size === undefined ? {} : { size: info.size },
|
||||
}
|
||||
const content = await readBounded(discovered, resolved.maxSourceBytes, fileSystem, signal)
|
||||
if (content === undefined) return { kind: 'unavailable' }
|
||||
return { kind: 'present', file: { absolutePath, displayPath: discovered.displayPath, content } }
|
||||
return { kind: 'present', file }
|
||||
}
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one already-probed scope candidate under the configured source cap.
|
||||
* @param file - winning provider candidate and its metadata snapshot.
|
||||
* @param maxSourceBytes - maximum UTF-8 bytes accepted from the source.
|
||||
* @param fileSystem - provider used for the streaming read.
|
||||
* @param signal - cancellation for provider streaming.
|
||||
* @returns loaded content with the probed version, or undefined when unavailable.
|
||||
*/
|
||||
export async function readScopeInstruction(
|
||||
file: ProbedInstructionFile,
|
||||
maxSourceBytes: number,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LoadedInstructionFile | undefined> {
|
||||
const content = await readBounded(file, maxSourceBytes, fileSystem, signal)
|
||||
if (content === undefined) return undefined
|
||||
return {
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
version: file.version,
|
||||
}
|
||||
}
|
||||
|
||||
function userGlobalDisplayPath(dshHome: string): string {
|
||||
return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
|
||||
}
|
||||
|
||||
@@ -16,13 +16,17 @@ import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutio
|
||||
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import { loadBaselineInstructionSet } from './files.ts'
|
||||
import {
|
||||
baselineInstructionChanges,
|
||||
applyInstructionVersionUpdates,
|
||||
baselineInstructionState,
|
||||
commitPendingInstructionContexts,
|
||||
dynamicInstructionContext,
|
||||
name,
|
||||
reconcileInstructionContext,
|
||||
retainedInstructionVersionUpdates,
|
||||
rollbackPendingInstructionChanges,
|
||||
workspaceContextMessage,
|
||||
type InstructionVersionCache,
|
||||
type InstructionVersionUpdate,
|
||||
type PendingInstructionChange,
|
||||
} from './state.ts'
|
||||
import type { WorkspaceInstructionChange } from './render.ts'
|
||||
@@ -43,7 +47,13 @@ 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 pendingByParent = new Map<ToolExecutionToken, { agent: Agent; changes: WorkspaceInstructionChange[] }>()
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
|
||||
const pendingByParent = new Map<ToolExecutionToken, {
|
||||
agent: Agent
|
||||
changes: WorkspaceInstructionChange[]
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}>()
|
||||
|
||||
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
|
||||
const rest = await next()
|
||||
@@ -61,22 +71,26 @@ export function apply(ctx: Context, config: Config): void {
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
signal,
|
||||
}, fileSystem)
|
||||
baselineInstructionStates.set(agent.session, baselineInstructionChanges(instructions?.included ?? []))
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
baselineInstructionStates.set(agent.session, baseline.changes)
|
||||
instructionVersions.set(agent.session, baseline.versions)
|
||||
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{ includeBaselineScopes: false, signal },
|
||||
)
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.content, {
|
||||
source: update.source,
|
||||
envelope: update.envelope,
|
||||
meta: update.meta,
|
||||
agent.inject(update.context.content, {
|
||||
source: update.context.source,
|
||||
envelope: update.context.envelope,
|
||||
meta: update.context.meta,
|
||||
})
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (instructions === undefined || instructions.rendered.text.length === 0) return rest
|
||||
return [workspaceContextMessage(instructions.rendered.text), ...rest]
|
||||
@@ -97,33 +111,41 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (downstream.kind === 'block') return downstream
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return downstream
|
||||
const context = await dynamicInstructionContext(
|
||||
const update = await dynamicInstructionContext(
|
||||
exec.agent,
|
||||
exec,
|
||||
result,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
)
|
||||
if (context === undefined) return downstream
|
||||
if (update === undefined) return downstream
|
||||
pendingVersionUpdates.set(exec.token, update.versionUpdates)
|
||||
return {
|
||||
kind: 'accept',
|
||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||
additionalContexts: [context, ...downstream.additionalContexts ?? []],
|
||||
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
|
||||
pendingVersionUpdates.delete(exec.token)
|
||||
if (exec.parent !== undefined) {
|
||||
if (exec.agent === undefined) return
|
||||
// Child contexts participate in duplicate suppression within one composite
|
||||
// run, but remain provisional until the parent reaches its final policy.
|
||||
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
if (changes.length === 0) return
|
||||
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
|
||||
const staged = pendingByParent.get(exec.parent)
|
||||
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes })
|
||||
else staged.changes.push(...changes)
|
||||
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
|
||||
else {
|
||||
staged.changes.push(...changes)
|
||||
staged.versionUpdates.push(...versionUpdates)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,6 +157,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
|
||||
}
|
||||
if (exec.agent === undefined) return
|
||||
commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
const stagedVersionUpdates = staged?.versionUpdates ?? []
|
||||
const versionUpdates = retainedInstructionVersionUpdates(
|
||||
[...stagedVersionUpdates, ...ownVersionUpdates],
|
||||
committed,
|
||||
)
|
||||
applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1 } from './digest.ts'
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
findProjectRoot,
|
||||
loadScopeInstruction,
|
||||
probeScopeInstruction,
|
||||
readScopeInstruction,
|
||||
relativeDisplay,
|
||||
type LoadedInstructionFile,
|
||||
} from './files.ts'
|
||||
@@ -37,6 +38,28 @@ export interface PendingInstructionChange {
|
||||
afterSeq: number
|
||||
}
|
||||
|
||||
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
|
||||
export interface InstructionVersionState {
|
||||
path: string
|
||||
version: FsVersion
|
||||
digest: string
|
||||
}
|
||||
|
||||
/** Session-isolated fast-path state keyed by logical instruction scope. */
|
||||
export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
|
||||
|
||||
/** A cache transition coupled to the model-visible change that authorizes it. */
|
||||
export interface InstructionVersionUpdate {
|
||||
change: WorkspaceInstructionChange
|
||||
state?: InstructionVersionState
|
||||
}
|
||||
|
||||
/** Rendered reconciliation plus cache transitions awaiting final policy. */
|
||||
export interface ReconciledInstructionContext {
|
||||
context: WorkspaceHookContext
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}
|
||||
|
||||
/** Plugin-owned raw context with required replay metadata. */
|
||||
export interface WorkspaceHookContext extends HookContext {
|
||||
envelope: 'raw'
|
||||
@@ -103,7 +126,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst
|
||||
}
|
||||
|
||||
function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstructionChange): boolean {
|
||||
return a.action === b.action && a.scope === b.scope && a.path === b.path && a.digest === b.digest
|
||||
return a.action === b.action
|
||||
&& a.scope === b.scope
|
||||
&& a.path === b.path
|
||||
&& a.previousPath === b.previousPath
|
||||
&& a.digest === b.digest
|
||||
}
|
||||
|
||||
function visibleInstructionChanges(
|
||||
@@ -128,20 +155,72 @@ function visibleInstructionChanges(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert retained baseline files into scope/path/digest comparison state.
|
||||
* Convert retained baseline files into comparison and metadata-cache state.
|
||||
* @param files - baseline files that survived rendering.
|
||||
* @returns latest baseline state keyed by logical scope.
|
||||
* @returns latest baseline changes and provider versions keyed by logical scope.
|
||||
*/
|
||||
export function baselineInstructionChanges(files: LoadedInstructionFile[]): Map<string, WorkspaceInstructionChange> {
|
||||
return new Map(files.map((file) => {
|
||||
export function baselineInstructionState(files: LoadedInstructionFile[]): {
|
||||
changes: Map<string, WorkspaceInstructionChange>
|
||||
versions: Map<string, InstructionVersionState>
|
||||
} {
|
||||
const changes = new Map<string, WorkspaceInstructionChange>()
|
||||
const versions = new Map<string, InstructionVersionState>()
|
||||
for (const file of files) {
|
||||
const digest = instructionContentSha1(file.content)
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action: 'set',
|
||||
scope: scopeForDisplayPath(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest: instructionContentSha1(file.content),
|
||||
digest,
|
||||
}
|
||||
return [change.scope, change]
|
||||
}))
|
||||
changes.set(change.scope, change)
|
||||
if (file.version !== undefined) {
|
||||
versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
|
||||
}
|
||||
}
|
||||
return { changes, versions }
|
||||
}
|
||||
|
||||
function versionStatesFor(session: Session, cache: InstructionVersionCache): Map<string, InstructionVersionState> {
|
||||
let states = cache.get(session)
|
||||
if (states === undefined) {
|
||||
states = new Map()
|
||||
cache.set(session, states)
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only cache updates whose model-visible changes survived final policy.
|
||||
* @param updates - proposed updates from one or more reconciliations.
|
||||
* @param committedChanges - transitions retained on the authoritative result.
|
||||
* @returns updates authorized by an exact retained transition.
|
||||
*/
|
||||
export function retainedInstructionVersionUpdates(
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
committedChanges: readonly WorkspaceInstructionChange[],
|
||||
): InstructionVersionUpdate[] {
|
||||
return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply authorized metadata-cache transitions without retaining instruction prose.
|
||||
* @param session - owning session.
|
||||
* @param updates - ordered set/delete transitions.
|
||||
* @param cache - session-isolated metadata cache.
|
||||
*/
|
||||
export function applyInstructionVersionUpdates(
|
||||
session: Session,
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
cache: InstructionVersionCache,
|
||||
): void {
|
||||
if (updates.length === 0) return
|
||||
const states = versionStatesFor(session, cache)
|
||||
for (const update of updates) {
|
||||
if (update.state === undefined) states.delete(update.change.scope)
|
||||
else states.set(update.change.scope, update.state)
|
||||
}
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
function pendingChangesFor(
|
||||
@@ -217,18 +296,20 @@ function relativeScope(projectRoot: string, dir: string): string {
|
||||
* @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.
|
||||
* @returns a structured context update, or undefined when state is unchanged/unavailable.
|
||||
* @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<WorkspaceHookContext | undefined> {
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
const session = agent.session
|
||||
const pending = pendingChangesFor(session, pendingBySession)
|
||||
const visible = visibleInstructionChanges(agent, pending)
|
||||
@@ -247,57 +328,74 @@ export async function reconcileInstructionContext(
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
|
||||
}
|
||||
|
||||
const current = new Map<string, LoadedInstructionFile>()
|
||||
const unavailable = new Set<string>()
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
const seenAbsolutePaths = new Set<string>()
|
||||
for (const scope of scopes) {
|
||||
const probe = await loadScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') {
|
||||
unavailable.add(scope)
|
||||
continue
|
||||
}
|
||||
if (probe.kind === 'absent') continue
|
||||
const { file } = probe
|
||||
if (seenAbsolutePaths.has(file.absolutePath)) continue
|
||||
seenAbsolutePaths.add(file.absolutePath)
|
||||
current.set(scope, file)
|
||||
}
|
||||
|
||||
const items: ChangeRenderItem[] = []
|
||||
const versionUpdates: InstructionVersionUpdate[] = []
|
||||
for (const scope of scopes) {
|
||||
if (unavailable.has(scope)) continue
|
||||
const previous = effective.get(scope)
|
||||
const file = current.get(scope)
|
||||
if (file === undefined) {
|
||||
if (previous !== undefined && previous.action !== 'remove') {
|
||||
items.push({
|
||||
change: { action: 'remove', scope, path: previous.path },
|
||||
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
|
||||
})
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') continue
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') {
|
||||
versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
|
||||
items.push({
|
||||
change,
|
||||
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
|
||||
})
|
||||
versionUpdates.push({ change })
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
|
||||
seenAbsolutePaths.add(probedFile.absolutePath)
|
||||
const cached = versions.get(scope)
|
||||
if (
|
||||
cached !== undefined
|
||||
&& cached.path === probedFile.displayPath
|
||||
&& cached.version === probedFile.version
|
||||
&& previous !== undefined
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) continue
|
||||
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) continue
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
||||
? previous.path
|
||||
: undefined
|
||||
items.push({
|
||||
change: {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
...previousPath === undefined ? {} : { previousPath },
|
||||
digest: currentDigest,
|
||||
},
|
||||
file,
|
||||
})
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
...previousPath === undefined ? {} : { previousPath },
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
versionUpdates.push({ change, state: nextVersion })
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
return workspaceContextHook(rendered.text, rendered.changes)
|
||||
return {
|
||||
context: workspaceContextHook(rendered.text, rendered.changes),
|
||||
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,8 +406,9 @@ export async function reconcileInstructionContext(
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @param baselineInstructionStates - retained baseline comparison state.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @returns a structured context update, or undefined for irrelevant/failed/unchanged calls.
|
||||
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
|
||||
*/
|
||||
export async function dynamicInstructionContext(
|
||||
agent: Agent | undefined,
|
||||
@@ -318,13 +417,14 @@ export async function dynamicInstructionContext(
|
||||
resolved: ResolvedConfig,
|
||||
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
): Promise<WorkspaceHookContext | undefined> {
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
if (agent === undefined || result.isError) return undefined
|
||||
const touchedPath = filePathFromExecution(exec)
|
||||
if (touchedPath === undefined) return undefined
|
||||
return reconcileInstructionContext(
|
||||
agent, resolved, pendingNestedChanges, baselineInstructionStates, fileSystem,
|
||||
agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
|
||||
{
|
||||
touchedPath,
|
||||
includeBaselineScopes: baselineInstructionStates.has(agent.session),
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
renderWorkspaceContext,
|
||||
} from '@deepseek-ai/dsh-workspace-context'
|
||||
import {
|
||||
baselineInstructionState,
|
||||
commitPendingInstructionContexts,
|
||||
rollbackPendingInstructionChanges,
|
||||
type PendingInstructionChange,
|
||||
@@ -46,7 +47,7 @@ async function write(path: string, content: string): Promise<void> {
|
||||
}
|
||||
|
||||
class RecordingFileSystem extends FileSystem {
|
||||
entries = new Map<string, { type: FsInfo['type']; content?: string }>()
|
||||
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
|
||||
lstatTypes = new Map<string, FsPathInfo['type']>()
|
||||
throwOnStat = new Set<string>()
|
||||
omitSizes = new Set<string>()
|
||||
@@ -68,7 +69,7 @@ class RecordingFileSystem extends FileSystem {
|
||||
const entry = this.entries.get(target.targetKey)
|
||||
if (entry === undefined) return undefined
|
||||
const info: FsInfo = {
|
||||
version: FsVersion(`v:${target.targetKey}`),
|
||||
version: entry.version ?? FsVersion(`v:${target.targetKey}:${entry.type}:${entry.content ?? ''}`),
|
||||
type: entry.type,
|
||||
}
|
||||
if (entry.content !== undefined && !this.omitSizes.has(target.targetKey)) info.size = Buffer.byteLength(entry.content, 'utf8')
|
||||
@@ -1062,7 +1063,7 @@ describe('workspace context request injection', () => {
|
||||
|
||||
expect(derivedText(agent)).toContain('ctx.fs rule')
|
||||
expect(derivedText(agent)).not.toContain('node fs rule')
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1084,7 +1085,7 @@ describe('workspace context request injection', () => {
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('provider-only rule')
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dirname(root), { recursive: true, force: true })
|
||||
@@ -1490,6 +1491,22 @@ describe('workspace context request injection', () => {
|
||||
})
|
||||
|
||||
describe('dynamic nested workspace context injection', () => {
|
||||
it('builds persisted digest state without inventing a provider version', () => {
|
||||
const state = baselineInstructionState([{
|
||||
absolutePath: '/repo/AGENTS.md',
|
||||
displayPath: 'AGENTS.md',
|
||||
content: 'root rule',
|
||||
}])
|
||||
|
||||
const change = state.changes.get('.')
|
||||
expect(change).toMatchObject({
|
||||
action: 'set',
|
||||
path: 'AGENTS.md',
|
||||
})
|
||||
expect(change?.digest).toMatch(/^[a-f0-9]{40}$/)
|
||||
expect(state.versions).toEqual(new Map())
|
||||
})
|
||||
|
||||
it('propagates the tool execution signal into dynamic filesystem reconciliation', async () => {
|
||||
const root = join(await tempRepo(), 'virtual-repo')
|
||||
const home = join(await tempRepo(), 'virtual-home')
|
||||
@@ -1647,6 +1664,113 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('skips instruction content reads while provider version and effective state are unchanged', 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: 'nested package rule' })
|
||||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
const second = await ctx.tools.execute({
|
||||
callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
expect(first.additionalContexts).toBeDefined()
|
||||
expect(second.additionalContexts).toBeUndefined()
|
||||
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(1)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dirname(root), { recursive: true, force: true })
|
||||
await rm(dirname(home), { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('re-reads a changed provider version, then refreshes metadata when SHA-1 is unchanged', 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: 'same package rule', version: FsVersion('revision-1') })
|
||||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') })
|
||||
const afterVersionChange = await ctx.tools.execute({
|
||||
callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
const afterRefresh = await ctx.tools.execute({
|
||||
callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
expect(afterVersionChange.additionalContexts).toBeUndefined()
|
||||
expect(afterRefresh.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('isolates instruction version caches between sessions that touch the same scope', 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: 'shared path, separate sessions' })
|
||||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
|
||||
})
|
||||
const second = await ctx.tools.execute({
|
||||
callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
|
||||
})
|
||||
|
||||
expect(first.additionalContexts).toBeDefined()
|
||||
expect(second.additionalContexts).toBeDefined()
|
||||
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('replaces previously loaded instructions when the same file content changes', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
|
||||
Reference in New Issue
Block a user