fix(workspace-context): require explicit byte budgets

This commit is contained in:
Tianyi Cui
2026-07-12 12:09:35 +08:00
parent 855abe9d60
commit e9a54f0e71
32 changed files with 263 additions and 162 deletions

View File

@@ -40,11 +40,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-core'
// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext? } — the schema intersects the owner schemas,
// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext } — workspaceContext requires { maxBytes } or false;
// so validation and defaulting can never drift from the owners.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and `workspaceContext` to `dsh-workspace-context` (`false` disables automatic instruction-file loading). Workspace instructions are registered before the skill catalog so their session-prefix message renders first. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it). Workspace instructions are registered before the skill catalog so their session-prefix message renders first. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
## Why a code bundle, not a shared YAML include

View File

@@ -80,10 +80,11 @@ export interface SkillConfig {
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* `skills` to the skill registry/local provider/tool consumer, and
* `workspaceContext` to the workspace-context plugin. Every field is optional
* INPUT here because each owner's schema supplies the default; the schema is
* the INTERSECTION of the owners' own schemas (with child schemas nested under
* their bundle keys), so validation and defaulting can never drift from them.
* `workspaceContext` to the workspace-context plugin. Workspace context must
* be configured explicitly with a byte budget or disabled with `false`; the
* other fields remain optional inputs whose owner schemas supply defaults. The
* schema is the INTERSECTION of the owners' own schemas (with child schemas
* nested under their bundle keys), so validation and defaulting cannot drift.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -94,8 +95,8 @@ export interface Config {
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
/** Workspace-context loader controls; set `false` for hermetic prompts. */
workspaceContext?: workspaceContext.Config | false
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig
}
@@ -114,7 +115,7 @@ export const Config = z.intersect([
z.object({
tools: ToolRegistry.Config,
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
}) as unknown as z<Pick<Config, 'tools' | 'skills' | 'workspaceContext'>>,
]) as unknown as z<Config>
@@ -122,7 +123,7 @@ export const Config = z.intersect([
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
* forwarded `persona` and `toolOrder`. Workspace-context receives its own
* forwarded config or loads with defaults. Load order is irrelevant (cordis
* explicitly forwarded config. Load order is irrelevant (cordis
* pends each fiber on its `inject` until the services it needs exist), but the
* listing mirrors the dependency layering for readability: the LLM vocabulary
* and core registries first, then extension plugins that wrap request/tool
@@ -149,7 +150,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(invariants)
ctx.plugin(toolBash)
if (config.workspaceContext !== false) {
ctx.plugin(workspaceContext, config.workspaceContext ?? {})
ctx.plugin(workspaceContext, config.workspaceContext)
}
// Both plugins prepend session-prefix messages. Registration order is the
// rendered order, so workspace instructions must precede the skill catalog.

View File

@@ -30,7 +30,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
* Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
* bin smokes; here we assert the composition + config forwarding.
*/
async function mount(config?: agentCore.Config): Promise<Context> {
async function mount(config: agentCore.Config): Promise<Context> {
const oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
@@ -94,7 +94,7 @@ function messageText(message: Message | undefined): string {
describe('dsh-agent-core bundle', () => {
it('brings up the full default spine', async () => {
const ctx = await mount()
const ctx = await mount({ workspaceContext: false })
// One service from each layer of the spine proves the children loaded.
expect(ctx.get('timer')).toBeDefined()
expect(ctx.get('llm')).toBeDefined()
@@ -108,7 +108,7 @@ describe('dsh-agent-core bundle', () => {
})
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
const ctx = await mount()
const ctx = await mount({ workspaceContext: false })
expect(ctx.skills).toBeDefined()
expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill')
@@ -118,7 +118,7 @@ describe('dsh-agent-core bundle', () => {
})
it('defaults the agents list to empty (no pre-created agents)', async () => {
const ctx = await mount()
const ctx = await mount({ workspaceContext: false })
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -127,6 +127,7 @@ describe('dsh-agent-core bundle', () => {
const ctx = await mount({
agents: [{ id: AgentId('main'), model: 'mock' }],
persona: 'You are main.',
workspaceContext: false,
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
const assembly = await ctx.get('systemPrompt')!.assemble()
@@ -138,7 +139,7 @@ describe('dsh-agent-core bundle', () => {
// ctx.plugin validates + defaults the bundle config first; a direct apply
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
const ctx = new Context()
agentCore.apply(ctx, {})
agentCore.apply(ctx, { workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('agents')?.list()).toHaveLength(0)
@@ -153,7 +154,7 @@ describe('dsh-agent-core bundle', () => {
await mkdir(join(root, '.git'), { recursive: true })
await writeFile(join(root, 'AGENTS.md'), 'bundled project rule')
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount()
const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
@@ -213,6 +214,7 @@ describe('dsh-agent-core bundle', () => {
await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
const ctx = await mount({
agents: [],
workspaceContext: false,
skills: {
registry: { collectCacheMaxEntries: 4 },
local: {
@@ -234,7 +236,7 @@ describe('dsh-agent-core bundle', () => {
await mkdir(join(root, '.git'), { recursive: true })
await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills')
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount()
const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.skills.register({
@@ -265,7 +267,7 @@ describe('dsh-agent-core bundle', () => {
it('uses the default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
agentCore.apply(ctx, { agents: [] })
agentCore.apply(ctx, { agents: [], workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([])
@@ -274,7 +276,7 @@ describe('dsh-agent-core bundle', () => {
})
it('forwards toolOrder to the system-prompt assembly', async () => {
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST], workspaceContext: false })
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.
for (const name of ['alpha', 'zulu']) {

View File

@@ -76,7 +76,7 @@ async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', si
}
}
/** Opaque version token from a stat: mtime (ns precision) + size. */
/** Opaque version token from a stat: millisecond mtime plus byte size. */
function versionOf(info: Stats): FsVersion {
return FsVersion(`${info.mtimeMs}:${info.size}`)
}

View File

@@ -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 returned by `tools/post-execute` but not yet appended by the loop.
An unchanged path and SHA-256 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. 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.
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.
@@ -58,12 +58,12 @@ The frozen baseline itself is not rewritten mid-instance. Its initial path/diges
export interface Config {
dshHome?: string
projectRootMarkers?: string[]
maxBytes?: number
maxBytes: number
instructionFileCandidates?: string[]
}
```
`projectRootMarkers` defaults to `['.git']`, `maxBytes` to `65536`, and `instructionFileCandidates` to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite byte budget disables both baseline and dynamic loading.
@@ -71,7 +71,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`.
File text is cached by normalized absolute path plus the provider's opaque version and optional size. A signature change causes a re-read. Discovery carries the signature into reading so a cache hit does not stat the same file twice in one pass. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression.
Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Comparing the digest after reading prevents a same-version, same-size rewrite from returning stale cached text. Discovery carries the provider version into reading so one pass does not stat the same file twice. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression.
## Non-goals

View File

@@ -1,7 +1,12 @@
/**
* Configuration normalization for workspace instruction discovery and rendering.
*
* @module @deepseek-ai/dsh-workspace-context/config
*/
import z from 'schemastery'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const DEFAULT_MAX_BYTES = 64 * 1024
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
@@ -12,8 +17,8 @@ export interface Config {
dshHome?: string
/** Directory entries that identify the project root while walking upward from the session cwd. */
projectRootMarkers?: string[]
/** Maximum UTF-8 bytes in one rendered baseline or dynamic instruction batch; non-positive disables loading. */
maxBytes?: number
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
maxBytes: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
instructionFileCandidates?: string[]
}
@@ -21,28 +26,45 @@ export interface Config {
export const Config: z<Config> = z.object({
dshHome: z.string(),
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
maxBytes: z.number().default(DEFAULT_MAX_BYTES),
maxBytes: z.number().required(),
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
})
/** Fully defaulted configuration used by discovery and reconciliation. */
export interface ResolvedConfig {
/** Normalized instruction discovery configuration. */
export interface ResolvedDiscoveryConfig {
dshHome: string
projectRootMarkers: string[]
maxBytes: number
instructionFileCandidates: string[]
}
/** Normalized configuration used by discovery and reconciliation. */
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
maxBytes: number
}
/**
* Resolve defaults, the harness home, and valid same-directory candidates.
* @param config - user-facing plugin configuration.
* @returns normalized runtime configuration.
*/
export function resolveConfig(config: Config): ResolvedConfig {
return {
...resolveDiscoveryConfig(config),
maxBytes: config.maxBytes,
}
}
/**
* Resolve the subset of configuration used before instruction content is rendered.
* @param config - optional discovery controls.
* @returns normalized home, root markers, and instruction candidates.
*/
export function resolveDiscoveryConfig(
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
): ResolvedDiscoveryConfig {
return {
dshHome: resolveDshHome(config.dshHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
maxBytes: config.maxBytes ?? DEFAULT_MAX_BYTES,
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
}
}

View File

@@ -0,0 +1,16 @@
/**
* Content identity for workspace instruction caching and duplicate suppression.
*
* @module @deepseek-ai/dsh-workspace-context/digest
*/
import { createHash } from 'node:crypto'
/**
* Compute the content identity used across instruction loading and session state.
* @param content - exact UTF-8 instruction text.
* @returns lowercase SHA-1 digest in hexadecimal form.
*/
export function instructionContentSha1(content: string): string {
return createHash('sha1').update(content).digest('hex')
}

View File

@@ -1,8 +1,15 @@
/**
* Instruction-file discovery, provider reads, and content-aware caching.
*
* @module @deepseek-ai/dsh-workspace-context/files
*/
import { lstat, readFile, 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 { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
import { resolveConfig, type ResolvedConfig } from './config.ts'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { instructionContentSha1 } from './digest.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
@@ -18,10 +25,10 @@ export interface LoadedInstructionFile extends InstructionFile {
interface FileSignature {
version: string
size: number | undefined
}
interface CachedContent extends FileSignature {
sha1: string
content: string
}
@@ -30,7 +37,7 @@ interface DiscoveredInstructionFile extends InstructionFile {
target?: FsTarget
}
/** Provider-signature-keyed content cache shared across plugin hooks. */
/** Provider-version and SHA-1 keyed content cache shared across plugin hooks. */
export type InstructionContentCache = Map<string, CachedContent>
interface DiscoverOptions {
@@ -41,7 +48,7 @@ interface DiscoverOptions {
}
interface LoadOptions extends DiscoverOptions {
maxBytes?: number
maxBytes: number
cache?: InstructionContentCache
}
@@ -61,7 +68,7 @@ async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
try {
const info = await lstat(path)
if (!info.isFile()) return undefined
return { version: `${info.mtimeMs}:${info.size}`, size: info.size }
return { version: String(info.mtimeMs) }
} catch {
// Candidates can disappear while discovery is in progress.
return undefined
@@ -78,7 +85,7 @@ async function fsStatFile(
const target = await fileSystem.resolve(path)
const info = await fileSystem.stat(target)
if (info?.type !== 'file') return undefined
return { version: info.version, size: info.size, target }
return { version: info.version, target }
} catch {
// Provider absence and discovery races are both non-fatal.
return undefined
@@ -204,7 +211,7 @@ async function discoverInstructionFiles(
options: DiscoverOptions,
fileSystem?: FileSystem,
): Promise<DiscoveredInstructionFile[]> {
const config = resolveConfig(options)
const config = resolveDiscoveryConfig(options)
const files: DiscoveredInstructionFile[] = []
const seen = new Set<string>()
const addFile = (file: DiscoveredInstructionFile): void => {
@@ -250,15 +257,14 @@ async function readCached(
): Promise<string | undefined> {
const path = file.absolutePath
const { signature } = file
const cached = cache.get(path)
if (cached !== undefined && cached.version === signature.version && cached.size === signature.size) {
return cached.content
}
try {
const content = fileSystem === undefined || file.target === undefined
? await readFile(path, 'utf8')
: await fileSystem.readText(file.target)
cache.set(path, { ...signature, content })
const sha1 = instructionContentSha1(content)
const cached = cache.get(path)
if (cached !== undefined && cached.version === signature.version && cached.sha1 === sha1) return cached.content
cache.set(path, { ...signature, sha1, content })
return content
} catch {
// A file may disappear or become unreadable after its metadata probe.
@@ -345,7 +351,7 @@ export async function loadScopeInstruction(
const discovered: DiscoveredInstructionFile = {
absolutePath,
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
signature: { version: info.version, size: info.size },
signature: { version: info.version },
target,
}
const content = await readCached(discovered, cache, fileSystem)

View File

@@ -1,3 +1,9 @@
/**
* Model-facing workspace instruction rendering within an explicit byte budget.
*
* @module @deepseek-ai/dsh-workspace-context/render
*/
import { dirname } from 'node:path'
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
@@ -15,7 +21,7 @@ export interface TruncatedInstruction {
includedBytes: number
}
/** Bounded model-facing text plus omitted and truncated source records. */
/** Model-facing text plus omitted and truncated source records. */
export interface RenderedWorkspaceContext {
text: string
omitted: InstructionFile[]
@@ -232,7 +238,7 @@ function renderInstructionContext(
/**
* Render the baseline instruction chain with deterministic precedence budgeting.
* @param files - loaded files ordered from broadest to most specific.
* @param options - rendering byte budget.
* @param options - required rendering byte budget.
* @returns bounded baseline prompt text and budget diagnostics.
*/
export function renderWorkspaceContext(

View File

@@ -1,10 +1,16 @@
import { createHash } from 'node:crypto'
/**
* Session-visible workspace instruction state and dynamic reconciliation.
*
* @module @deepseek-ai/dsh-workspace-context/state
*/
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { renderContextContent, type JsonValue } from '@deepseek-ai/dsh-session'
import type { FileSystem } 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'
import {
ancestorChain,
descendantDirsBetween,
@@ -38,10 +44,6 @@ export interface WorkspaceHookContext extends HookContext {
meta: JsonValue
}
function digest(content: string): string {
return createHash('sha256').update(content).digest('hex')
}
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
const serializedChanges: JsonValue[] = changes.map(change => ({
action: change.action,
@@ -154,7 +156,7 @@ export function baselineInstructionChanges(files: LoadedInstructionFile[]): Map<
action: 'set',
scope: scopeForDisplayPath(file.displayPath),
path: file.displayPath,
digest: digest(file.content),
digest: instructionContentSha1(file.content),
}
return [change.scope, change]
}))
@@ -181,7 +183,7 @@ function relativeScope(projectRoot: string, dir: string): string {
* Compare visible/pending state with provider-visible files and render transitions.
* @param agent - session owner whose visible surface supplies durable state.
* @param resolved - normalized plugin configuration.
* @param cache - shared provider-signature content cache.
* @param cache - shared provider-version and content-digest cache.
* @param pendingBySession - short pending window before returned context is logged.
* @param baselineBySession - frozen baseline comparison state per session.
* @param fileSystem - provider used for current file probes.
@@ -245,7 +247,7 @@ export async function reconcileInstructionContext(
}
continue
}
const currentDigest = digest(file.content)
const currentDigest = instructionContentSha1(file.content)
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) continue
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
@@ -275,7 +277,7 @@ export async function reconcileInstructionContext(
* @param exec - completed tool execution descriptor.
* @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration.
* @param cache - shared provider-signature content cache.
* @param cache - shared provider-version and content-digest cache.
* @param pendingNestedChanges - per-session pending transition maps.
* @param baselineInstructionStates - retained baseline comparison state.
* @param fileSystem - provider used for current file probes.

View File

@@ -42,7 +42,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
await ctx.plugin(WorkspaceContext)
await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
const handle = ctx.agents.create({

View File

@@ -1,4 +1,4 @@
import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
@@ -219,7 +219,7 @@ describe('workspace context instruction discovery', () => {
}
})
it('re-walks the baseline path and re-reads content when file signatures change', async () => {
it('refreshes cached content after a same-version, same-size rewrite', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -228,19 +228,20 @@ describe('workspace context instruction discovery', () => {
await mkdir(cwd, { recursive: true })
const cache: InstructionContentCache = new Map()
expect(await loadBaselineInstructions({ cwd, dshHome: home, cache })).toBeUndefined()
expect(await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })).toBeUndefined()
const leaf = join(cwd, 'AGENTS.md')
await write(leaf, 'first')
const first = await loadBaselineInstructions({ cwd, dshHome: home, cache })
const first = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
expect(first?.text).toContain('first')
const cached = await loadBaselineInstructions({ cwd, dshHome: home, cache })
const cached = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
expect(cached?.text).toContain('first')
await new Promise(resolve => setTimeout(resolve, 5))
await writeFile(leaf, 'second and longer')
const second = await loadBaselineInstructions({ cwd, dshHome: home, cache })
expect(second?.text).toContain('second and longer')
const before = await stat(leaf)
await writeFile(leaf, 'other')
await utimes(leaf, before.atime, before.mtime)
const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
expect(second?.text).toContain('other')
expect(second?.text).not.toContain('first')
} finally {
await rm(root, { recursive: true, force: true })
@@ -259,7 +260,7 @@ describe('workspace context instruction discovery', () => {
await write(leaf, 'secret-ish rule')
await chmod(leaf, 0)
const loaded = await loadBaselineInstructions({ cwd, dshHome: home })
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
expect(loaded).toBeUndefined()
await chmod(leaf, 0o600)
@@ -279,7 +280,7 @@ describe('workspace context instruction discovery', () => {
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home })
const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home })
const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
expect(files).toEqual([])
expect(loaded).toBeUndefined()
@@ -299,7 +300,7 @@ describe('workspace context instruction discovery', () => {
await write(join(outside, 'secret.txt'), 'outside secret')
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home })
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -655,11 +656,17 @@ describe('workspace context rendering', () => {
})
describe('workspace context request injection', () => {
it('requires an explicit maxBytes configuration', async () => {
const ctx = new Context()
await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/)
})
it('mounts without requiring a filesystem provider', async () => {
const ctx = new Context()
try {
const outcome = await Promise.race([
ctx.plugin(workspaceContext, {}).then(() => {
ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => {
return 'settled' as const
}),
new Promise<'pending'>((resolve) => {
@@ -682,7 +689,7 @@ describe('workspace context request injection', () => {
it('does not inject baseline context when no filesystem provider is present', async () => {
const ctx = new Context()
try {
await ctx.plugin(workspaceContext, {})
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const agent = stubAgent('/virtual/repo')
await composeBaselinePrefix(ctx, agent)
@@ -696,7 +703,7 @@ describe('workspace context request injection', () => {
it('leaves post-execute decisions unchanged when no filesystem provider is present', async () => {
const ctx = new Context()
try {
await ctx.plugin(workspaceContext, {})
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const decision = await ctx.waterfall('tools/post-execute', {
callId: CallId('no-fs-post-execute'),
@@ -725,7 +732,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home })
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -750,7 +757,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home })
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await composeBaselinePrefix(ctx, agent)
@@ -794,7 +801,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home })
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
const rest = await next()
return [{ role: 'user', content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }] }, ...rest]
@@ -819,7 +826,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'old root rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -847,7 +854,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'root rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -873,7 +880,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'shared root and global rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -919,14 +926,14 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' })
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('ctx.fs rule')
expect(derivedText(agent)).not.toContain('node fs rule')
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -942,13 +949,13 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' })
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('provider-only rule')
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
@@ -969,7 +976,7 @@ describe('workspace context request injection', () => {
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' })
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' })
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -995,7 +1002,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -1019,7 +1026,7 @@ describe('workspace context request injection', () => {
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -1042,7 +1049,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' })
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -1065,7 +1072,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.throwOnStat.add(join(root, 'AGENTS.md'))
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -1088,7 +1095,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem
fs.throwOnStat.add(join(root, '.git'))
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -1110,7 +1117,7 @@ describe('workspace context request injection', () => {
await write(join(repoA, 'AGENTS.md'), 'repo A only')
await write(join(repoB, 'AGENTS.md'), 'repo B only')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home })
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agentA = stubAgent(repoA)
const agentB = stubAgent(repoB)
@@ -1138,7 +1145,7 @@ describe('workspace context request injection', () => {
await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(workspaceContext, {})
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const agent = stubAgent(cwd)
await composeBaselinePrefix(ctx, agent)
@@ -1158,7 +1165,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
const fiber = await mountWorkspaceContext(ctx, { dshHome: home })
const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
await fiber.dispose()
const agent = stubAgent(root)
@@ -1215,7 +1222,7 @@ describe('workspace context request injection', () => {
try {
await mkdir(join(root, '.git'), { recursive: true })
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home })
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
@@ -1241,7 +1248,7 @@ describe('workspace context request injection', () => {
}
})
it('reuses the discovery lstat signature when reading cached content', async () => {
it('does not repeat a candidate metadata probe during one discovery and read pass', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1263,9 +1270,9 @@ describe('workspace context request injection', () => {
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const cache: InstructionContentCache = new Map()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache })
observedStats.clear()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache })
expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
} finally {
@@ -1287,7 +1294,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const result = await ctx.tools.execute({
@@ -1316,7 +1323,7 @@ describe('dynamic nested workspace context injection', () => {
const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
? firstChange.digest
: undefined
expect(changeDigest).toMatch(/^[a-f0-9]{64}$/)
expect(changeDigest).toMatch(/^[a-f0-9]{40}$/)
const text = blocksText(result.additionalContext?.content)
expect(text).toBe([
'<system-reminder>',
@@ -1346,6 +1353,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
})
@@ -1374,7 +1382,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
@@ -1406,7 +1414,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'old package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
@@ -1446,7 +1454,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/CLAUDE.md'), 'fallback package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
@@ -1485,7 +1493,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
@@ -1523,7 +1531,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'first package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
@@ -1565,7 +1573,7 @@ describe('dynamic nested workspace context injection', () => {
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' })
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
@@ -1594,7 +1602,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
callId: CallId('read-before-resume'),
@@ -1631,7 +1639,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'old nested rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
const first = await ctx.tools.execute({
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original,
@@ -1661,7 +1669,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
callId: CallId('read-before-compact'),
@@ -1709,7 +1717,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule')
await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
callId: CallId('read-package'),
@@ -1780,7 +1788,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
agent.session.append('context/message', {
content: [
@@ -1838,7 +1846,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const rootResult = await ctx.tools.execute({
@@ -1872,7 +1880,7 @@ describe('dynamic nested workspace context injection', () => {
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file')
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
await ctx.plugin(workspaceContext, { dshHome: home })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const result = {
callId: CallId('provider-probe-result'),
@@ -1908,7 +1916,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/deep/file.txt'), 'hello')
await chmod(nested, 0)
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
callId: CallId('read-with-unreadable-nested-instruction'),
@@ -1934,7 +1942,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'downstream replacement' }],
@@ -1977,7 +1985,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('tools/post-execute', async () => ({
kind: 'block' as const,
feedback: [{ type: 'text' as const, text: 'blocked downstream' }],
@@ -2010,7 +2018,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const result = {
callId: CallId('manual'),
@@ -2073,7 +2081,7 @@ describe('dynamic nested workspace context injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
callId: CallId('read-missing'),
@@ -2098,7 +2106,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
await fiber.dispose()
const result = await ctx.tools.execute({

View File

@@ -61,8 +61,8 @@ export interface Config {
tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */
workspaceContext?: agentCore.Config['workspaceContext']
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
skills?: agentCore.SkillConfig
}
@@ -76,9 +76,9 @@ export const Config: z<Config> = z.object({
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
persistenceRoot: z.string().default('./.sessions'),
workspaceContext: z.union([z.const(false), workspaceContext.Config]),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
}) as unknown as z<Config>
})
/**
* Compose the spine with the ACP front door. The agent-core bundle pre-creates
@@ -92,7 +92,7 @@ export function apply(ctx: Context, config: Config): void {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
...config.workspaceContext !== undefined ? { workspaceContext: config.workspaceContext } : {},
workspaceContext: config.workspaceContext,
...config.skills !== undefined ? { skills: config.skills } : {},
})
ctx.plugin(UserInteractionService)

View File

@@ -67,7 +67,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
describe('dsh-acp-agent composition', () => {
it('brings up the spine + persistence + the ACP bridge', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig(), workspaceContext: false })
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
@@ -86,7 +86,7 @@ describe('dsh-acp-agent composition', () => {
// persistenceRoot, so the runtime fallback is the one that fires.
const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too.
acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('sessionPersistence')).toBeDefined()
await ctx.fiber.dispose()
@@ -107,7 +107,7 @@ describe('dsh-acp-agent composition', () => {
it('uses default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
acpAgent.apply(ctx, { model: 'mock' })
acpAgent.apply(ctx, { model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([])
@@ -116,7 +116,7 @@ describe('dsh-acp-agent composition', () => {
})
it('forwards skill config into agent-core', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false })
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
await ctx.fiber.dispose()
@@ -132,6 +132,7 @@ describe('dsh-acp-agent composition', () => {
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order',
workspaceContext: false,
})
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.

View File

@@ -84,8 +84,8 @@ export interface Config {
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
*/
resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */
workspaceContext?: agentCore.Config['workspaceContext']
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
export const Config: z<Config> = z.object({
@@ -100,8 +100,8 @@ export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
skills: agentCore.SkillConfigSchema,
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]),
}) as unknown as z<Config>
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/**
* Compose the spine with the stdio front door. The console logger comes first
@@ -122,7 +122,7 @@ export function apply(ctx: Context, config: Config): void {
cwd: process.cwd(),
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
}],
...config.workspaceContext !== undefined ? { workspaceContext: config.workspaceContext } : {},
workspaceContext: config.workspaceContext,
...config.skills !== undefined ? { skills: config.skills } : {},
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })

View File

@@ -74,7 +74,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
describe('dsh-stdio-agent app', () => {
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig(), workspaceContext: false })
// The spine services (brought up by the agent-core bundle) are all present.
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
@@ -95,7 +95,7 @@ describe('dsh-stdio-agent app', () => {
// schema-bypassing direct-mount caller.
const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too.
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
@@ -116,7 +116,7 @@ describe('dsh-stdio-agent app', () => {
it('uses default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
stdioAgent.apply(ctx, { model: 'mock' })
stdioAgent.apply(ctx, { model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([])
@@ -134,13 +134,14 @@ describe('dsh-stdio-agent app', () => {
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
resumeSessionId: 'no-such-session',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('forwards skill config into agent-core', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false })
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
await ctx.fiber.dispose()
@@ -156,6 +157,7 @@ describe('dsh-stdio-agent app', () => {
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
workspaceContext: false,
})
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.