fix(workspace-context): require explicit byte budgets
This commit is contained in:
32 files changed
+263
-162
No files matched your search
+15
-14
@@ -56,8 +56,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
|
||||
}
|
||||
@@ -77,10 +77,11 @@ Source: [`packages/ui/acp-agent/src/index.ts:53`](../packages/ui/acp-agent/src/i
|
||||
* 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`). */
|
||||
@@ -91,8 +92,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
|
||||
}
|
||||
@@ -110,7 +111,7 @@ export interface SkillConfig {
|
||||
|
||||
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/prompt/workspace-context/src/index.ts)
|
||||
|
||||
Source: [`packages/core/agent-core/src/index.ts:88`](../packages/core/agent-core/src/index.ts)
|
||||
Source: [`packages/core/agent-core/src/index.ts:89`](../packages/core/agent-core/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-loop`
|
||||
|
||||
@@ -641,8 +642,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']
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1145,14 +1146,14 @@ 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[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/prompt/workspace-context/src/config.ts:10`](../packages/prompt/workspace-context/src/config.ts)
|
||||
Source: [`packages/prompt/workspace-context/src/config.ts:15`](../packages/prompt/workspace-context/src/config.ts)
|
||||
|
||||
## Loadable plugins with no config
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells,
|
||||
|
||||
### Duplicate Suppression And Change Detection
|
||||
|
||||
Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, previousPath?, digest? }`, where `digest` is SHA-256 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
|
||||
Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, previousPath?, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
|
||||
|
||||
At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map covers the interval after `tools/post-execute` returns `additionalContext` but before the loop appends that context to the log. Once an equal event appears at or after the pending sequence boundary, the pending entry is removed.
|
||||
|
||||
@@ -58,9 +58,9 @@ There is intentionally no watcher. Detection occurs at the next successful struc
|
||||
|
||||
### Byte Budget And Cache
|
||||
|
||||
`maxBytes` defaults to 64 KiB and applies separately to a rendered baseline or one dynamic reconciliation batch. 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 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.
|
||||
|
||||
File content 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 the read pass so one pass does not stat the same instruction twice. The cache is an I/O optimization only; visible structured metadata is the source of duplicate-suppression state.
|
||||
Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Hashing the read content prevents same-version, same-size rewrites from staying stale. Discovery carries the provider version into the read pass so one pass does not stat the same instruction twice. Visible structured metadata remains the source of duplicate-suppression state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
tools:
|
||||
mode: both
|
||||
persona: |
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
tools:
|
||||
mode: both
|
||||
persona: |
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
tools:
|
||||
mode: code
|
||||
persona: |
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
tools:
|
||||
mode: code
|
||||
persona: |
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
# The persona: identity + behavior only, nothing about transports or
|
||||
# tooling — tool guidance lives with each tool plugin (descriptions +
|
||||
# prompt sections). {{model}} and {{cwd}} are prompt variables the agent
|
||||
|
||||
File diff suppressed because one or more lines are too long.
@@ -0,0 +1,16 @@
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.
|
||||
|
||||
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.
|
||||
|
||||
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
|
||||
|
||||
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
|
||||
@@ -15,15 +15,14 @@
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
dshHome: !!js process.cwd() + '/.dsh'
|
||||
projectRootMarkers:
|
||||
- .dsh-project
|
||||
persona: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working
|
||||
directory is {{cwd}}.
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and
|
||||
factual.
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
- insert:
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
@@ -12,12 +12,11 @@
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
dshHome: !!js process.cwd() + '/.dsh'
|
||||
projectRootMarkers:
|
||||
- .dsh-project
|
||||
persona: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working
|
||||
directory is {{cwd}}.
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and
|
||||
factual.
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
@@ -19,6 +19,8 @@
|
||||
model: deepseek-v4-flash
|
||||
resumeSessionId: !!js process.env.RESUME_SESSION_ID
|
||||
persistenceRoot: './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
tools:
|
||||
mode: code
|
||||
welcome: 'code-mode agent ready. Give it a multi-tool task.'
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
# under ./.sessions); unset starts a fresh session each run.
|
||||
resumeSessionId: !!js process.env.RESUME_SESSION_ID
|
||||
persistenceRoot: './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
welcome: 'agent REPL ready. Give it a coding task.'
|
||||
# The persona: identity + behavior only, nothing about transports or
|
||||
# tooling — tool guidance lives with each tool plugin (descriptions +
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
model: deepseek-v4-flash
|
||||
resumeSessionId: !!js process.env.RESUME_SESSION_ID
|
||||
persistenceRoot: './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.'
|
||||
persona: |
|
||||
You are cordis-agent, a self-referential harness demo powered by the
|
||||
|
||||
@@ -43,3 +43,5 @@
|
||||
persona: 'You are echo-agent, a demo agent.'
|
||||
welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).'
|
||||
persistenceRoot: './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
@@ -54,6 +54,8 @@
|
||||
# sets it (so a record run's logs land where the harness harvests them),
|
||||
# else the local ./.sessions default.
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
persona: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user