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.
|
- 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.
|
- 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.
|
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.
|
`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
|
## Alternatives considered
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
|||||||
## Behavior
|
## 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.
|
- **`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.
|
- **`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`.
|
- **`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`).
|
- **`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 { randomUUID } from 'node:crypto'
|
||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
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 { basename, dirname, join, resolve } from 'node:path'
|
||||||
import { TextDecoder } from 'node:util'
|
import { TextDecoder } from 'node:util'
|
||||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
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. */
|
/** Opaque version token from high-resolution identity and freshness metadata. */
|
||||||
function versionOf(info: Stats): FsVersion {
|
function versionOf(info: BigIntStats): FsVersion {
|
||||||
return FsVersion(`${info.mtimeMs}:${info.size}`)
|
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.isFile()) return 'file'
|
||||||
if (info.isDirectory()) return 'directory'
|
if (info.isDirectory()) return 'directory'
|
||||||
return 'other'
|
return 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
function pathLinkType(info: Stats): PathLinkInfo['type'] {
|
function pathLinkType(info: Stats | BigIntStats): PathLinkInfo['type'] {
|
||||||
if (info.isSymbolicLink()) return 'symlink'
|
if (info.isSymbolicLink()) return 'symlink'
|
||||||
return pathType(info)
|
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 {
|
try {
|
||||||
return await readStats(absolutePath)
|
return await readStats(absolutePath)
|
||||||
} catch (error: unknown) {
|
} 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.
|
* @returns the metadata, or null when the path — or a parent segment — does not exist.
|
||||||
*/
|
*/
|
||||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
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
|
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.
|
* @returns path-entry metadata, or null when the entry is absent.
|
||||||
*/
|
*/
|
||||||
export async function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null> {
|
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
|
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 ---
|
// --- Directory listing ---
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
@@ -99,6 +99,20 @@ describe('stat', () => {
|
|||||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
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 () => {
|
it('honors a pre-aborted signal', async () => {
|
||||||
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
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')
|
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||||
const target = await fs.resolve('a.txt')
|
const target = await fs.resolve('a.txt')
|
||||||
const before = await versionOf(target)
|
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 })
|
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
|
||||||
expect(outcome.version).not.toBe(before)
|
expect(outcome.version).not.toBe(before)
|
||||||
expect(outcome.version).toBe(await versionOf(target))
|
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.
|
* 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
|
* The local backend derives it from high-resolution stat identity and freshness
|
||||||
* revision id. The policy layer records it for stale checks; consumers may
|
* fields; a remote backend might use a revision id. The policy layer records it
|
||||||
* display related metadata but MUST NOT interpret this token.
|
* for stale checks; consumers may display related metadata but MUST NOT
|
||||||
|
* interpret this token.
|
||||||
*/
|
*/
|
||||||
export type FsVersion = Branded<'FsVersion'>
|
export type FsVersion = Branded<'FsVersion'>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brand a string as an {@link FsVersion}. For backend use only — a consumer
|
* 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.
|
* 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.
|
* @returns the same string, branded; no validation is performed.
|
||||||
*/
|
*/
|
||||||
export function FsVersion(v: string): FsVersion {
|
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.
|
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.
|
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`.
|
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
|
## Non-goals
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { lstat, stat } from 'node:fs/promises'
|
import { lstat, stat } from 'node:fs/promises'
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
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 { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||||
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.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. */
|
/** An instruction file whose UTF-8 content was read successfully. */
|
||||||
export interface LoadedInstructionFile extends InstructionFile {
|
export interface LoadedInstructionFile extends InstructionFile {
|
||||||
content: string
|
content: string
|
||||||
|
/** Provider freshness token when the file was loaded through `ctx.fs`. */
|
||||||
|
version?: FsVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoveredInstructionFile extends InstructionFile {
|
interface DiscoveredInstructionFile extends InstructionFile {
|
||||||
target?: FsTarget
|
target?: FsTarget
|
||||||
size?: number
|
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 {
|
interface DiscoverOptions {
|
||||||
@@ -49,7 +59,7 @@ export interface RenderedInstructionSet {
|
|||||||
|
|
||||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||||
export type ScopeInstructionProbe =
|
export type ScopeInstructionProbe =
|
||||||
| { kind: 'present'; file: LoadedInstructionFile }
|
| { kind: 'present'; file: ProbedInstructionFile }
|
||||||
| { kind: 'absent' }
|
| { kind: 'absent' }
|
||||||
| { kind: 'unavailable' }
|
| { kind: 'unavailable' }
|
||||||
|
|
||||||
@@ -75,14 +85,14 @@ async function fsStatFile(
|
|||||||
path: string,
|
path: string,
|
||||||
fileSystem: FileSystem,
|
fileSystem: FileSystem,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ target: FsTarget; size?: number } | undefined> {
|
): Promise<{ target: FsTarget; size?: number; version: FsVersion } | undefined> {
|
||||||
try {
|
try {
|
||||||
const pathInfo = await fileSystem.lstat(path, undefined, signal)
|
const pathInfo = await fileSystem.lstat(path, undefined, signal)
|
||||||
if (pathInfo?.type !== 'file') return undefined
|
if (pathInfo?.type !== 'file') return undefined
|
||||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||||
const info = await fileSystem.stat(target, signal)
|
const info = await fileSystem.stat(target, signal)
|
||||||
if (info?.type !== 'file') return undefined
|
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 {
|
} catch {
|
||||||
signal?.throwIfAborted()
|
signal?.throwIfAborted()
|
||||||
// Provider absence and discovery races are both non-fatal.
|
// Provider absence and discovery races are both non-fatal.
|
||||||
@@ -94,7 +104,7 @@ async function statFile(
|
|||||||
path: string,
|
path: string,
|
||||||
fileSystem?: FileSystem,
|
fileSystem?: FileSystem,
|
||||||
signal?: AbortSignal,
|
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)
|
return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +326,14 @@ export async function loadBaselineInstructionSet(
|
|||||||
const loaded: LoadedInstructionFile[] = []
|
const loaded: LoadedInstructionFile[] = []
|
||||||
for (const file of discovered) {
|
for (const file of discovered) {
|
||||||
const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal)
|
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
|
if (loaded.length === 0) return undefined
|
||||||
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
|
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 scope - `user-global`, `.`, or a project-relative directory.
|
||||||
* @param projectRoot - project root used to resolve and display project scopes.
|
* @param projectRoot - project root used to resolve and display project scopes.
|
||||||
* @param resolved - normalized plugin configuration.
|
* @param resolved - normalized plugin configuration.
|
||||||
* @param fileSystem - provider used for no-follow probing and reading.
|
* @param fileSystem - provider used for no-follow probing.
|
||||||
* @param signal - cancellation for provider probes and streaming.
|
* @param signal - cancellation for provider probes.
|
||||||
* @returns present content, confirmed absence, or temporary unavailability.
|
* @returns present metadata, confirmed absence, or temporary unavailability.
|
||||||
*/
|
*/
|
||||||
export async function loadScopeInstruction(
|
export async function probeScopeInstruction(
|
||||||
scope: string,
|
scope: string,
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
resolved: ResolvedConfig,
|
resolved: ResolvedConfig,
|
||||||
@@ -364,19 +381,42 @@ export async function loadScopeInstruction(
|
|||||||
return { kind: 'unavailable' }
|
return { kind: 'unavailable' }
|
||||||
}
|
}
|
||||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||||
const discovered: DiscoveredInstructionFile = {
|
const file: ProbedInstructionFile = {
|
||||||
absolutePath,
|
absolutePath,
|
||||||
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||||
target,
|
target,
|
||||||
|
version: info.version,
|
||||||
...info.size === undefined ? {} : { size: info.size },
|
...info.size === undefined ? {} : { size: info.size },
|
||||||
}
|
}
|
||||||
const content = await readBounded(discovered, resolved.maxSourceBytes, fileSystem, signal)
|
return { kind: 'present', file }
|
||||||
if (content === undefined) return { kind: 'unavailable' }
|
|
||||||
return { kind: 'present', file: { absolutePath, displayPath: discovered.displayPath, content } }
|
|
||||||
}
|
}
|
||||||
return { kind: 'absent' }
|
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 {
|
function userGlobalDisplayPath(dshHome: string): string {
|
||||||
return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
|
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 { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||||
import { loadBaselineInstructionSet } from './files.ts'
|
import { loadBaselineInstructionSet } from './files.ts'
|
||||||
import {
|
import {
|
||||||
baselineInstructionChanges,
|
applyInstructionVersionUpdates,
|
||||||
|
baselineInstructionState,
|
||||||
commitPendingInstructionContexts,
|
commitPendingInstructionContexts,
|
||||||
dynamicInstructionContext,
|
dynamicInstructionContext,
|
||||||
name,
|
name,
|
||||||
reconcileInstructionContext,
|
reconcileInstructionContext,
|
||||||
|
retainedInstructionVersionUpdates,
|
||||||
rollbackPendingInstructionChanges,
|
rollbackPendingInstructionChanges,
|
||||||
workspaceContextMessage,
|
workspaceContextMessage,
|
||||||
|
type InstructionVersionCache,
|
||||||
|
type InstructionVersionUpdate,
|
||||||
type PendingInstructionChange,
|
type PendingInstructionChange,
|
||||||
} from './state.ts'
|
} from './state.ts'
|
||||||
import type { WorkspaceInstructionChange } from './render.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 resolved: ResolvedConfig = resolveConfig(config)
|
||||||
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
|
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
|
||||||
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
|
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[]> => {
|
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
|
||||||
const rest = await next()
|
const rest = await next()
|
||||||
@@ -61,22 +71,26 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||||
signal,
|
signal,
|
||||||
}, fileSystem)
|
}, 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(
|
const update = await reconcileInstructionContext(
|
||||||
agent,
|
agent,
|
||||||
resolved,
|
resolved,
|
||||||
pendingNestedChanges,
|
pendingNestedChanges,
|
||||||
baselineInstructionStates,
|
baselineInstructionStates,
|
||||||
|
instructionVersions,
|
||||||
fileSystem,
|
fileSystem,
|
||||||
{ includeBaselineScopes: false, signal },
|
{ includeBaselineScopes: false, signal },
|
||||||
)
|
)
|
||||||
if (update !== undefined) {
|
if (update !== undefined) {
|
||||||
agent.inject(update.content, {
|
agent.inject(update.context.content, {
|
||||||
source: update.source,
|
source: update.context.source,
|
||||||
envelope: update.envelope,
|
envelope: update.context.envelope,
|
||||||
meta: update.meta,
|
meta: update.context.meta,
|
||||||
})
|
})
|
||||||
|
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||||
}
|
}
|
||||||
if (instructions === undefined || instructions.rendered.text.length === 0) return rest
|
if (instructions === undefined || instructions.rendered.text.length === 0) return rest
|
||||||
return [workspaceContextMessage(instructions.rendered.text), ...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
|
if (downstream.kind === 'block') return downstream
|
||||||
const fileSystem = ctx.get('fs')
|
const fileSystem = ctx.get('fs')
|
||||||
if (fileSystem === undefined) return downstream
|
if (fileSystem === undefined) return downstream
|
||||||
const context = await dynamicInstructionContext(
|
const update = await dynamicInstructionContext(
|
||||||
exec.agent,
|
exec.agent,
|
||||||
exec,
|
exec,
|
||||||
result,
|
result,
|
||||||
resolved,
|
resolved,
|
||||||
pendingNestedChanges,
|
pendingNestedChanges,
|
||||||
baselineInstructionStates,
|
baselineInstructionStates,
|
||||||
|
instructionVersions,
|
||||||
fileSystem,
|
fileSystem,
|
||||||
)
|
)
|
||||||
if (context === undefined) return downstream
|
if (update === undefined) return downstream
|
||||||
|
pendingVersionUpdates.set(exec.token, update.versionUpdates)
|
||||||
return {
|
return {
|
||||||
kind: 'accept',
|
kind: 'accept',
|
||||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||||
additionalContexts: [context, ...downstream.additionalContexts ?? []],
|
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||||
|
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
|
||||||
|
pendingVersionUpdates.delete(exec.token)
|
||||||
if (exec.parent !== undefined) {
|
if (exec.parent !== undefined) {
|
||||||
if (exec.agent === undefined) return
|
if (exec.agent === undefined) return
|
||||||
// Child contexts participate in duplicate suppression within one composite
|
// Child contexts participate in duplicate suppression within one composite
|
||||||
// run, but remain provisional until the parent reaches its final policy.
|
// run, but remain provisional until the parent reaches its final policy.
|
||||||
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||||
if (changes.length === 0) return
|
if (changes.length === 0) return
|
||||||
|
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
|
||||||
const staged = pendingByParent.get(exec.parent)
|
const staged = pendingByParent.get(exec.parent)
|
||||||
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes })
|
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
|
||||||
else staged.changes.push(...changes)
|
else {
|
||||||
|
staged.changes.push(...changes)
|
||||||
|
staged.versionUpdates.push(...versionUpdates)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +157,12 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
|
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
|
||||||
}
|
}
|
||||||
if (exec.agent === undefined) return
|
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 { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
|
||||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||||
import type { ResolvedConfig } from './config.ts'
|
import type { ResolvedConfig } from './config.ts'
|
||||||
import { instructionContentSha1 } from './digest.ts'
|
import { instructionContentSha1 } from './digest.ts'
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
ancestorChain,
|
ancestorChain,
|
||||||
descendantDirsBetween,
|
descendantDirsBetween,
|
||||||
findProjectRoot,
|
findProjectRoot,
|
||||||
loadScopeInstruction,
|
probeScopeInstruction,
|
||||||
|
readScopeInstruction,
|
||||||
relativeDisplay,
|
relativeDisplay,
|
||||||
type LoadedInstructionFile,
|
type LoadedInstructionFile,
|
||||||
} from './files.ts'
|
} from './files.ts'
|
||||||
@@ -37,6 +38,28 @@ export interface PendingInstructionChange {
|
|||||||
afterSeq: number
|
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. */
|
/** Plugin-owned raw context with required replay metadata. */
|
||||||
export interface WorkspaceHookContext extends HookContext {
|
export interface WorkspaceHookContext extends HookContext {
|
||||||
envelope: 'raw'
|
envelope: 'raw'
|
||||||
@@ -103,7 +126,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstructionChange): boolean {
|
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(
|
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.
|
* @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> {
|
export function baselineInstructionState(files: LoadedInstructionFile[]): {
|
||||||
return new Map(files.map((file) => {
|
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 = {
|
const change: WorkspaceInstructionChange = {
|
||||||
action: 'set',
|
action: 'set',
|
||||||
scope: scopeForDisplayPath(file.displayPath),
|
scope: scopeForDisplayPath(file.displayPath),
|
||||||
path: 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(
|
function pendingChangesFor(
|
||||||
@@ -217,18 +296,20 @@ function relativeScope(projectRoot: string, dir: string): string {
|
|||||||
* @param resolved - normalized plugin configuration.
|
* @param resolved - normalized plugin configuration.
|
||||||
* @param pendingBySession - short pending window before returned context is logged.
|
* @param pendingBySession - short pending window before returned context is logged.
|
||||||
* @param baselineBySession - frozen baseline comparison state per session.
|
* @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 fileSystem - provider used for current file probes.
|
||||||
* @param options - touched path and whether baseline scopes should be checked.
|
* @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(
|
export async function reconcileInstructionContext(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
resolved: ResolvedConfig,
|
resolved: ResolvedConfig,
|
||||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||||
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||||
|
versionCache: InstructionVersionCache,
|
||||||
fileSystem: FileSystem,
|
fileSystem: FileSystem,
|
||||||
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
|
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
|
||||||
): Promise<WorkspaceHookContext | undefined> {
|
): Promise<ReconciledInstructionContext | undefined> {
|
||||||
const session = agent.session
|
const session = agent.session
|
||||||
const pending = pendingChangesFor(session, pendingBySession)
|
const pending = pendingChangesFor(session, pendingBySession)
|
||||||
const visible = visibleInstructionChanges(agent, pending)
|
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))
|
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = new Map<string, LoadedInstructionFile>()
|
const versions = versionStatesFor(session, versionCache)
|
||||||
const unavailable = new Set<string>()
|
|
||||||
const seenAbsolutePaths = new Set<string>()
|
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 items: ChangeRenderItem[] = []
|
||||||
|
const versionUpdates: InstructionVersionUpdate[] = []
|
||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
if (unavailable.has(scope)) continue
|
|
||||||
const previous = effective.get(scope)
|
const previous = effective.get(scope)
|
||||||
const file = current.get(scope)
|
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||||
if (file === undefined) {
|
if (probe.kind === 'unavailable') continue
|
||||||
if (previous !== undefined && previous.action !== 'remove') {
|
if (probe.kind === 'absent') {
|
||||||
items.push({
|
if (previous === undefined || previous.action === 'remove') {
|
||||||
change: { action: 'remove', scope, path: previous.path },
|
versions.delete(scope)
|
||||||
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
|
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
|
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)
|
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 action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||||
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
||||||
? previous.path
|
? previous.path
|
||||||
: undefined
|
: undefined
|
||||||
items.push({
|
const change: WorkspaceInstructionChange = {
|
||||||
change: {
|
action,
|
||||||
action,
|
scope,
|
||||||
scope,
|
path: file.displayPath,
|
||||||
path: file.displayPath,
|
...previousPath === undefined ? {} : { previousPath },
|
||||||
...previousPath === undefined ? {} : { previousPath },
|
digest: currentDigest,
|
||||||
digest: currentDigest,
|
}
|
||||||
},
|
items.push({ change, file })
|
||||||
file,
|
versionUpdates.push({ change, state: nextVersion })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (items.length === 0) return undefined
|
if (items.length === 0) return undefined
|
||||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
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 resolved - normalized plugin configuration.
|
||||||
* @param pendingNestedChanges - per-session pending transition maps.
|
* @param pendingNestedChanges - per-session pending transition maps.
|
||||||
* @param baselineInstructionStates - retained baseline comparison state.
|
* @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.
|
* @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(
|
export async function dynamicInstructionContext(
|
||||||
agent: Agent | undefined,
|
agent: Agent | undefined,
|
||||||
@@ -318,13 +417,14 @@ export async function dynamicInstructionContext(
|
|||||||
resolved: ResolvedConfig,
|
resolved: ResolvedConfig,
|
||||||
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||||
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||||
|
versionCache: InstructionVersionCache,
|
||||||
fileSystem: FileSystem,
|
fileSystem: FileSystem,
|
||||||
): Promise<WorkspaceHookContext | undefined> {
|
): Promise<ReconciledInstructionContext | undefined> {
|
||||||
if (agent === undefined || result.isError) return undefined
|
if (agent === undefined || result.isError) return undefined
|
||||||
const touchedPath = filePathFromExecution(exec)
|
const touchedPath = filePathFromExecution(exec)
|
||||||
if (touchedPath === undefined) return undefined
|
if (touchedPath === undefined) return undefined
|
||||||
return reconcileInstructionContext(
|
return reconcileInstructionContext(
|
||||||
agent, resolved, pendingNestedChanges, baselineInstructionStates, fileSystem,
|
agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
|
||||||
{
|
{
|
||||||
touchedPath,
|
touchedPath,
|
||||||
includeBaselineScopes: baselineInstructionStates.has(agent.session),
|
includeBaselineScopes: baselineInstructionStates.has(agent.session),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
renderWorkspaceContext,
|
renderWorkspaceContext,
|
||||||
} from '@deepseek-ai/dsh-workspace-context'
|
} from '@deepseek-ai/dsh-workspace-context'
|
||||||
import {
|
import {
|
||||||
|
baselineInstructionState,
|
||||||
commitPendingInstructionContexts,
|
commitPendingInstructionContexts,
|
||||||
rollbackPendingInstructionChanges,
|
rollbackPendingInstructionChanges,
|
||||||
type PendingInstructionChange,
|
type PendingInstructionChange,
|
||||||
@@ -46,7 +47,7 @@ async function write(path: string, content: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RecordingFileSystem extends FileSystem {
|
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']>()
|
lstatTypes = new Map<string, FsPathInfo['type']>()
|
||||||
throwOnStat = new Set<string>()
|
throwOnStat = new Set<string>()
|
||||||
omitSizes = new Set<string>()
|
omitSizes = new Set<string>()
|
||||||
@@ -68,7 +69,7 @@ class RecordingFileSystem extends FileSystem {
|
|||||||
const entry = this.entries.get(target.targetKey)
|
const entry = this.entries.get(target.targetKey)
|
||||||
if (entry === undefined) return undefined
|
if (entry === undefined) return undefined
|
||||||
const info: FsInfo = {
|
const info: FsInfo = {
|
||||||
version: FsVersion(`v:${target.targetKey}`),
|
version: entry.version ?? FsVersion(`v:${target.targetKey}:${entry.type}:${entry.content ?? ''}`),
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
}
|
}
|
||||||
if (entry.content !== undefined && !this.omitSizes.has(target.targetKey)) info.size = Buffer.byteLength(entry.content, 'utf8')
|
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)).toContain('ctx.fs rule')
|
||||||
expect(derivedText(agent)).not.toContain('node 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 {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true })
|
await rm(root, { recursive: true, force: true })
|
||||||
await rm(home, { recursive: true, force: true })
|
await rm(home, { recursive: true, force: true })
|
||||||
@@ -1084,7 +1085,7 @@ describe('workspace context request injection', () => {
|
|||||||
await composeBaselinePrefix(ctx, agent)
|
await composeBaselinePrefix(ctx, agent)
|
||||||
|
|
||||||
expect(derivedText(agent)).toContain('provider-only rule')
|
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 {
|
} finally {
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
await rm(dirname(root), { recursive: true, force: true })
|
await rm(dirname(root), { recursive: true, force: true })
|
||||||
@@ -1490,6 +1491,22 @@ describe('workspace context request injection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('dynamic nested workspace context 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 () => {
|
it('propagates the tool execution signal into dynamic filesystem reconciliation', async () => {
|
||||||
const root = join(await tempRepo(), 'virtual-repo')
|
const root = join(await tempRepo(), 'virtual-repo')
|
||||||
const home = join(await tempRepo(), 'virtual-home')
|
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 () => {
|
it('replaces previously loaded instructions when the same file content changes', async () => {
|
||||||
const root = await tempRepo()
|
const root = await tempRepo()
|
||||||
const home = await tempRepo()
|
const home = await tempRepo()
|
||||||
|
|||||||
Reference in New Issue
Block a user