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

@@ -56,8 +56,8 @@ export interface Config {
tools?: ToolsConfig tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string persistenceRoot?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */ /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext?: agentCore.Config['workspaceContext'] workspaceContext: agentCore.Config['workspaceContext']
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
skills?: agentCore.SkillConfig 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 * plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`), * order), the `tools` object to the tool registry (its presentation `mode`),
* `skills` to the skill registry/local provider/tool consumer, and * `skills` to the skill registry/local provider/tool consumer, and
* `workspaceContext` to the workspace-context plugin. Every field is optional * `workspaceContext` to the workspace-context plugin. Workspace context must
* INPUT here because each owner's schema supplies the default; the schema is * be configured explicitly with a byte budget or disabled with `false`; the
* the INTERSECTION of the owners' own schemas (with child schemas nested under * other fields remain optional inputs whose owner schemas supply defaults. The
* their bundle keys), so validation and defaulting can never drift from them. * 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 { export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -91,8 +92,8 @@ export interface Config {
toolOrder?: SystemPromptConfig['toolOrder'] toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig tools?: ToolsConfig
/** Workspace-context loader controls; set `false` for hermetic prompts. */ /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext?: workspaceContext.Config | false workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */ /** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig 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) 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` ## `@deepseek-ai/dsh-agent-loop`
@@ -641,8 +642,8 @@ export interface Config {
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
*/ */
resumeSessionId?: string resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */ /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext?: agentCore.Config['workspaceContext'] workspaceContext: agentCore.Config['workspaceContext']
} }
``` ```
@@ -1145,14 +1146,14 @@ export interface Config {
dshHome?: string dshHome?: string
/** Directory entries that identify the project root while walking upward from the session cwd. */ /** Directory entries that identify the project root while walking upward from the session cwd. */
projectRootMarkers?: string[] projectRootMarkers?: string[]
/** Maximum UTF-8 bytes in one rendered baseline or dynamic instruction batch; non-positive disables loading. */ /** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
maxBytes?: number maxBytes: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
instructionFileCandidates?: string[] 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 ## Loadable plugins with no config

View File

@@ -46,7 +46,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells,
### Duplicate Suppression And Change Detection ### 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. 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 ### 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 ## Alternatives considered

View File

@@ -17,6 +17,8 @@
config: config:
model: deepseek-v4-flash model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext:
maxBytes: 65536
tools: tools:
mode: both mode: both
persona: | persona: |

View File

@@ -16,6 +16,8 @@
config: config:
model: deepseek-v4-flash model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext:
maxBytes: 65536
tools: tools:
mode: both mode: both
persona: | persona: |

View File

@@ -17,6 +17,8 @@
config: config:
model: deepseek-v4-flash model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext:
maxBytes: 65536
tools: tools:
mode: code mode: code
persona: | persona: |

View File

@@ -17,6 +17,8 @@
config: config:
model: deepseek-v4-flash model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext:
maxBytes: 65536
tools: tools:
mode: code mode: code
persona: | persona: |

View File

@@ -38,6 +38,8 @@
config: config:
model: deepseek-v4-flash model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext:
maxBytes: 65536
# The persona: identity + behavior only, nothing about transports or # The persona: identity + behavior only, nothing about transports or
# tooling — tool guidance lives with each tool plugin (descriptions + # tooling — tool guidance lives with each tool plugin (descriptions +
# prompt sections). {{model}} and {{cwd}} are prompt variables the agent # prompt sections). {{model}} and {{cwd}} are prompt variables the agent

File diff suppressed because one or more lines are too long

View File

@@ -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.

View File

@@ -15,15 +15,14 @@
model: deepseek-v4-flash model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext: workspaceContext:
maxBytes: 65536
dshHome: !!js process.cwd() + '/.dsh' dshHome: !!js process.cwd() + '/.dsh'
projectRootMarkers: projectRootMarkers:
- .dsh-project - .dsh-project
persona: | persona: |
You are a coding assistant powered by the {{model}} model. Your working You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
directory is {{cwd}}.
Verify your work by running the code or tests. Keep answers brief and Verify your work by running the code or tests. Keep answers brief and factual.
factual.
- insert: - insert:
- id: llm-replay - id: llm-replay
name: '@deepseek-ai/dsh-llm-replay' name: '@deepseek-ai/dsh-llm-replay'

View File

@@ -12,12 +12,11 @@
model: deepseek-v4-flash model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext: workspaceContext:
maxBytes: 65536
dshHome: !!js process.cwd() + '/.dsh' dshHome: !!js process.cwd() + '/.dsh'
projectRootMarkers: projectRootMarkers:
- .dsh-project - .dsh-project
persona: | persona: |
You are a coding assistant powered by the {{model}} model. Your working You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
directory is {{cwd}}.
Verify your work by running the code or tests. Keep answers brief and Verify your work by running the code or tests. Keep answers brief and factual.
factual.

View File

@@ -19,6 +19,8 @@
model: deepseek-v4-flash model: deepseek-v4-flash
resumeSessionId: !!js process.env.RESUME_SESSION_ID resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions' persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
tools: tools:
mode: code mode: code
welcome: 'code-mode agent ready. Give it a multi-tool task.' welcome: 'code-mode agent ready. Give it a multi-tool task.'

View File

@@ -45,6 +45,8 @@
# under ./.sessions); unset starts a fresh session each run. # under ./.sessions); unset starts a fresh session each run.
resumeSessionId: !!js process.env.RESUME_SESSION_ID resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions' persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
welcome: 'agent REPL ready. Give it a coding task.' welcome: 'agent REPL ready. Give it a coding task.'
# The persona: identity + behavior only, nothing about transports or # The persona: identity + behavior only, nothing about transports or
# tooling — tool guidance lives with each tool plugin (descriptions + # tooling — tool guidance lives with each tool plugin (descriptions +

View File

@@ -61,6 +61,8 @@
model: deepseek-v4-flash model: deepseek-v4-flash
resumeSessionId: !!js process.env.RESUME_SESSION_ID resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions' persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.' welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.'
persona: | persona: |
You are cordis-agent, a self-referential harness demo powered by the You are cordis-agent, a self-referential harness demo powered by the

View File

@@ -43,3 +43,5 @@
persona: 'You are echo-agent, a demo agent.' persona: 'You are echo-agent, a demo agent.'
welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).' welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).'
persistenceRoot: './.sessions' persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536

View File

@@ -54,6 +54,8 @@
# sets it (so a record run's logs land where the harness harvests them), # sets it (so a record run's logs land where the harness harvests them),
# else the local ./.sessions default. # else the local ./.sessions default.
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
workspaceContext:
maxBytes: 65536
persona: | 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. 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.

View File

@@ -40,11 +40,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts ```ts
import type { Config } from '@deepseek-ai/dsh-agent-core' 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. // 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 ## 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 * plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`), * order), the `tools` object to the tool registry (its presentation `mode`),
* `skills` to the skill registry/local provider/tool consumer, and * `skills` to the skill registry/local provider/tool consumer, and
* `workspaceContext` to the workspace-context plugin. Every field is optional * `workspaceContext` to the workspace-context plugin. Workspace context must
* INPUT here because each owner's schema supplies the default; the schema is * be configured explicitly with a byte budget or disabled with `false`; the
* the INTERSECTION of the owners' own schemas (with child schemas nested under * other fields remain optional inputs whose owner schemas supply defaults. The
* their bundle keys), so validation and defaulting can never drift from them. * 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 { export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -94,8 +95,8 @@ export interface Config {
toolOrder?: SystemPromptConfig['toolOrder'] toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig tools?: ToolsConfig
/** Workspace-context loader controls; set `false` for hermetic prompts. */ /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext?: workspaceContext.Config | false workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */ /** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig skills?: SkillConfig
} }
@@ -114,7 +115,7 @@ export const Config = z.intersect([
z.object({ z.object({
tools: ToolRegistry.Config, tools: ToolRegistry.Config,
skills: SkillConfigSchema, 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<Pick<Config, 'tools' | 'skills' | 'workspaceContext'>>,
]) as unknown as z<Config> ]) 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; * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the * `agent-loop` receives the forwarded `agents` list and `system-prompt` the
* forwarded `persona` and `toolOrder`. Workspace-context receives its own * 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 * pends each fiber on its `inject` until the services it needs exist), but the
* listing mirrors the dependency layering for readability: the LLM vocabulary * listing mirrors the dependency layering for readability: the LLM vocabulary
* and core registries first, then extension plugins that wrap request/tool * 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(invariants)
ctx.plugin(toolBash) ctx.plugin(toolBash)
if (config.workspaceContext !== false) { if (config.workspaceContext !== false) {
ctx.plugin(workspaceContext, config.workspaceContext ?? {}) ctx.plugin(workspaceContext, config.workspaceContext)
} }
// Both plugins prepend session-prefix messages. Registration order is the // Both plugins prepend session-prefix messages. Registration order is the
// rendered order, so workspace instructions must precede the skill catalog. // 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 * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
* bin smokes; here we assert the composition + config forwarding. * 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 oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-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', () => { describe('dsh-agent-core bundle', () => {
it('brings up the full default spine', async () => { 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. // One service from each layer of the spine proves the children loaded.
expect(ctx.get('timer')).toBeDefined() expect(ctx.get('timer')).toBeDefined()
expect(ctx.get('llm')).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 () => { 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.skills).toBeDefined()
expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill') 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 () => { 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() expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
@@ -127,6 +127,7 @@ describe('dsh-agent-core bundle', () => {
const ctx = await mount({ const ctx = await mount({
agents: [{ id: AgentId('main'), model: 'mock' }], agents: [{ id: AgentId('main'), model: 'mock' }],
persona: 'You are main.', persona: 'You are main.',
workspaceContext: false,
}) })
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
const assembly = await ctx.get('systemPrompt')!.assemble() 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 // ctx.plugin validates + defaults the bundle config first; a direct apply
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire. // skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
const ctx = new Context() const ctx = new Context()
agentCore.apply(ctx, {}) agentCore.apply(ctx, { workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50)) await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('agents')?.list()).toHaveLength(0) expect(ctx.get('agents')?.list()).toHaveLength(0)
@@ -153,7 +154,7 @@ describe('dsh-agent-core bundle', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await writeFile(join(root, 'AGENTS.md'), 'bundled project rule') await writeFile(join(root, 'AGENTS.md'), 'bundled project rule')
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount() const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({ 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') await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
const ctx = await mount({ const ctx = await mount({
agents: [], agents: [],
workspaceContext: false,
skills: { skills: {
registry: { collectCacheMaxEntries: 4 }, registry: { collectCacheMaxEntries: 4 },
local: { local: {
@@ -234,7 +236,7 @@ describe('dsh-agent-core bundle', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills') await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills')
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount() const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
ctx.skills.register({ 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 () => { it('uses the default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => { await withIsolatedSkillHomes(async () => {
const ctx = new Context() const ctx = new Context()
agentCore.apply(ctx, { agents: [] }) agentCore.apply(ctx, { agents: [], workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50)) await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.skills).toBeDefined() expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([]) expect(await ctx.skills.list()).toEqual([])
@@ -274,7 +276,7 @@ describe('dsh-agent-core bundle', () => {
}) })
it('forwards toolOrder to the system-prompt assembly', async () => { 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 // The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order. // this providerless mount, so register two plain tools to order.
for (const name of ['alpha', 'zulu']) { 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 { function versionOf(info: Stats): FsVersion {
return FsVersion(`${info.mtimeMs}:${info.size}`) 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. 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. 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 { export interface Config {
dshHome?: string dshHome?: string
projectRootMarkers?: string[] projectRootMarkers?: string[]
maxBytes?: number maxBytes: number
instructionFileCandidates?: string[] 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. 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`. 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 ## 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 z from 'schemastery'
import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const DEFAULT_MAX_BYTES = 64 * 1024
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..']) const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
@@ -12,8 +17,8 @@ export interface Config {
dshHome?: string dshHome?: string
/** Directory entries that identify the project root while walking upward from the session cwd. */ /** Directory entries that identify the project root while walking upward from the session cwd. */
projectRootMarkers?: string[] projectRootMarkers?: string[]
/** Maximum UTF-8 bytes in one rendered baseline or dynamic instruction batch; non-positive disables loading. */ /** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
maxBytes?: number maxBytes: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
instructionFileCandidates?: string[] instructionFileCandidates?: string[]
} }
@@ -21,28 +26,45 @@ export interface Config {
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
dshHome: z.string(), dshHome: z.string(),
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]), 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]), instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
}) })
/** Fully defaulted configuration used by discovery and reconciliation. */ /** Normalized instruction discovery configuration. */
export interface ResolvedConfig { export interface ResolvedDiscoveryConfig {
dshHome: string dshHome: string
projectRootMarkers: string[] projectRootMarkers: string[]
maxBytes: number
instructionFileCandidates: string[] 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. * Resolve defaults, the harness home, and valid same-directory candidates.
* @param config - user-facing plugin configuration. * @param config - user-facing plugin configuration.
* @returns normalized runtime configuration. * @returns normalized runtime configuration.
*/ */
export function resolveConfig(config: Config): ResolvedConfig { 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 { return {
dshHome: resolveDshHome(config.dshHome), dshHome: resolveDshHome(config.dshHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS], projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
maxBytes: config.maxBytes ?? DEFAULT_MAX_BYTES,
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates), 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 { lstat, readFile, stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs' import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths' import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
import { resolveConfig, 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' import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */ /** An instruction candidate identified by absolute and model-facing paths. */
@@ -18,10 +25,10 @@ export interface LoadedInstructionFile extends InstructionFile {
interface FileSignature { interface FileSignature {
version: string version: string
size: number | undefined
} }
interface CachedContent extends FileSignature { interface CachedContent extends FileSignature {
sha1: string
content: string content: string
} }
@@ -30,7 +37,7 @@ interface DiscoveredInstructionFile extends InstructionFile {
target?: FsTarget 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> export type InstructionContentCache = Map<string, CachedContent>
interface DiscoverOptions { interface DiscoverOptions {
@@ -41,7 +48,7 @@ interface DiscoverOptions {
} }
interface LoadOptions extends DiscoverOptions { interface LoadOptions extends DiscoverOptions {
maxBytes?: number maxBytes: number
cache?: InstructionContentCache cache?: InstructionContentCache
} }
@@ -61,7 +68,7 @@ async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
try { try {
const info = await lstat(path) const info = await lstat(path)
if (!info.isFile()) return undefined if (!info.isFile()) return undefined
return { version: `${info.mtimeMs}:${info.size}`, size: info.size } return { version: String(info.mtimeMs) }
} catch { } catch {
// Candidates can disappear while discovery is in progress. // Candidates can disappear while discovery is in progress.
return undefined return undefined
@@ -78,7 +85,7 @@ async function fsStatFile(
const target = await fileSystem.resolve(path) const target = await fileSystem.resolve(path)
const info = await fileSystem.stat(target) const info = await fileSystem.stat(target)
if (info?.type !== 'file') return undefined if (info?.type !== 'file') return undefined
return { version: info.version, size: info.size, target } return { version: info.version, target }
} catch { } catch {
// Provider absence and discovery races are both non-fatal. // Provider absence and discovery races are both non-fatal.
return undefined return undefined
@@ -204,7 +211,7 @@ async function discoverInstructionFiles(
options: DiscoverOptions, options: DiscoverOptions,
fileSystem?: FileSystem, fileSystem?: FileSystem,
): Promise<DiscoveredInstructionFile[]> { ): Promise<DiscoveredInstructionFile[]> {
const config = resolveConfig(options) const config = resolveDiscoveryConfig(options)
const files: DiscoveredInstructionFile[] = [] const files: DiscoveredInstructionFile[] = []
const seen = new Set<string>() const seen = new Set<string>()
const addFile = (file: DiscoveredInstructionFile): void => { const addFile = (file: DiscoveredInstructionFile): void => {
@@ -250,15 +257,14 @@ async function readCached(
): Promise<string | undefined> { ): Promise<string | undefined> {
const path = file.absolutePath const path = file.absolutePath
const { signature } = file const { signature } = file
const cached = cache.get(path)
if (cached !== undefined && cached.version === signature.version && cached.size === signature.size) {
return cached.content
}
try { try {
const content = fileSystem === undefined || file.target === undefined const content = fileSystem === undefined || file.target === undefined
? await readFile(path, 'utf8') ? await readFile(path, 'utf8')
: await fileSystem.readText(file.target) : 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 return content
} catch { } catch {
// A file may disappear or become unreadable after its metadata probe. // A file may disappear or become unreadable after its metadata probe.
@@ -345,7 +351,7 @@ export async function loadScopeInstruction(
const discovered: DiscoveredInstructionFile = { const discovered: DiscoveredInstructionFile = {
absolutePath, absolutePath,
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath), displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
signature: { version: info.version, size: info.size }, signature: { version: info.version },
target, target,
} }
const content = await readCached(discovered, cache, fileSystem) 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 { dirname } from 'node:path'
import type { InstructionFile, LoadedInstructionFile } from './files.ts' import type { InstructionFile, LoadedInstructionFile } from './files.ts'
@@ -15,7 +21,7 @@ export interface TruncatedInstruction {
includedBytes: number includedBytes: number
} }
/** Bounded model-facing text plus omitted and truncated source records. */ /** Model-facing text plus omitted and truncated source records. */
export interface RenderedWorkspaceContext { export interface RenderedWorkspaceContext {
text: string text: string
omitted: InstructionFile[] omitted: InstructionFile[]
@@ -232,7 +238,7 @@ function renderInstructionContext(
/** /**
* Render the baseline instruction chain with deterministic precedence budgeting. * Render the baseline instruction chain with deterministic precedence budgeting.
* @param files - loaded files ordered from broadest to most specific. * @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. * @returns bounded baseline prompt text and budget diagnostics.
*/ */
export function renderWorkspaceContext( 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 { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm'
import { renderContextContent, type JsonValue } from '@deepseek-ai/dsh-session' import { renderContextContent, type JsonValue } from '@deepseek-ai/dsh-session'
import type { FileSystem } from '@deepseek-ai/dsh-fs' import type { FileSystem } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts' import type { ResolvedConfig } from './config.ts'
import { instructionContentSha1 } from './digest.ts'
import { import {
ancestorChain, ancestorChain,
descendantDirsBetween, descendantDirsBetween,
@@ -38,10 +44,6 @@ export interface WorkspaceHookContext extends HookContext {
meta: JsonValue meta: JsonValue
} }
function digest(content: string): string {
return createHash('sha256').update(content).digest('hex')
}
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext { function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
const serializedChanges: JsonValue[] = changes.map(change => ({ const serializedChanges: JsonValue[] = changes.map(change => ({
action: change.action, action: change.action,
@@ -154,7 +156,7 @@ export function baselineInstructionChanges(files: LoadedInstructionFile[]): Map<
action: 'set', action: 'set',
scope: scopeForDisplayPath(file.displayPath), scope: scopeForDisplayPath(file.displayPath),
path: file.displayPath, path: file.displayPath,
digest: digest(file.content), digest: instructionContentSha1(file.content),
} }
return [change.scope, change] 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. * Compare visible/pending state with provider-visible files and render transitions.
* @param agent - session owner whose visible surface supplies durable state. * @param agent - session owner whose visible surface supplies durable state.
* @param resolved - normalized plugin configuration. * @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 pendingBySession - short pending window before returned context is logged.
* @param baselineBySession - frozen baseline comparison state per session. * @param baselineBySession - frozen baseline comparison state per session.
* @param fileSystem - provider used for current file probes. * @param fileSystem - provider used for current file probes.
@@ -245,7 +247,7 @@ export async function reconcileInstructionContext(
} }
continue 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 if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) continue
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace' const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
@@ -275,7 +277,7 @@ export async function reconcileInstructionContext(
* @param exec - completed tool execution descriptor. * @param exec - completed tool execution descriptor.
* @param result - original tool result before post-execute decisions. * @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration. * @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 pendingNestedChanges - per-session pending transition maps.
* @param baselineInstructionStates - retained baseline comparison state. * @param baselineInstructionStates - retained baseline comparison state.
* @param fileSystem - provider used for current file probes. * @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(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs) await ctx.plugin(ToolFs)
await ctx.plugin(WorkspaceContext) await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
const handle = ctx.agents.create({ 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 { dirname, join } from 'node:path'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest' 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 root = await tempRepo()
const home = await tempRepo() const home = await tempRepo()
try { try {
@@ -228,19 +228,20 @@ describe('workspace context instruction discovery', () => {
await mkdir(cwd, { recursive: true }) await mkdir(cwd, { recursive: true })
const cache: InstructionContentCache = new Map() 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') const leaf = join(cwd, 'AGENTS.md')
await write(leaf, 'first') 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') 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') expect(cached?.text).toContain('first')
await new Promise(resolve => setTimeout(resolve, 5)) const before = await stat(leaf)
await writeFile(leaf, 'second and longer') await writeFile(leaf, 'other')
const second = await loadBaselineInstructions({ cwd, dshHome: home, cache }) await utimes(leaf, before.atime, before.mtime)
expect(second?.text).toContain('second and longer') const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
expect(second?.text).toContain('other')
expect(second?.text).not.toContain('first') expect(second?.text).not.toContain('first')
} finally { } finally {
await rm(root, { recursive: true, force: true }) await rm(root, { recursive: true, force: true })
@@ -259,7 +260,7 @@ describe('workspace context instruction discovery', () => {
await write(leaf, 'secret-ish rule') await write(leaf, 'secret-ish rule')
await chmod(leaf, 0) await chmod(leaf, 0)
const loaded = await loadBaselineInstructions({ cwd, dshHome: home }) const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
expect(loaded).toBeUndefined() expect(loaded).toBeUndefined()
await chmod(leaf, 0o600) await chmod(leaf, 0o600)
@@ -279,7 +280,7 @@ describe('workspace context instruction discovery', () => {
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home }) 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(files).toEqual([])
expect(loaded).toBeUndefined() expect(loaded).toBeUndefined()
@@ -299,7 +300,7 @@ describe('workspace context instruction discovery', () => {
await write(join(outside, 'secret.txt'), 'outside secret') await write(join(outside, 'secret.txt'), 'outside secret')
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
const ctx = new Context() const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home }) await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -655,11 +656,17 @@ describe('workspace context rendering', () => {
}) })
describe('workspace context request injection', () => { 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 () => { it('mounts without requiring a filesystem provider', async () => {
const ctx = new Context() const ctx = new Context()
try { try {
const outcome = await Promise.race([ const outcome = await Promise.race([
ctx.plugin(workspaceContext, {}).then(() => { ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => {
return 'settled' as const return 'settled' as const
}), }),
new Promise<'pending'>((resolve) => { 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 () => { it('does not inject baseline context when no filesystem provider is present', async () => {
const ctx = new Context() const ctx = new Context()
try { try {
await ctx.plugin(workspaceContext, {}) await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const agent = stubAgent('/virtual/repo') const agent = stubAgent('/virtual/repo')
await composeBaselinePrefix(ctx, agent) 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 () => { it('leaves post-execute decisions unchanged when no filesystem provider is present', async () => {
const ctx = new Context() const ctx = new Context()
try { try {
await ctx.plugin(workspaceContext, {}) await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const decision = await ctx.waterfall('tools/post-execute', { const decision = await ctx.waterfall('tools/post-execute', {
callId: CallId('no-fs-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 mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home }) await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -750,7 +757,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home }) await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await composeBaselinePrefix(ctx, agent) const first = await composeBaselinePrefix(ctx, agent)
@@ -794,7 +801,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() 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) => { ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
const rest = await next() const rest = await next()
return [{ role: 'user', content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }] }, ...rest] 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, 'AGENTS.md'), 'old root rule')
await write(join(root, 'file.txt'), 'hello') await write(join(root, 'file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) 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, 'AGENTS.md'), 'root rule')
await write(join(root, 'file.txt'), 'hello') await write(join(root, 'file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) 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, 'AGENTS.md'), 'shared root and global rule')
await write(join(root, 'file.txt'), 'hello') await write(join(root, 'file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -919,14 +926,14 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('ctx.fs rule') expect(derivedText(agent)).toContain('ctx.fs rule')
expect(derivedText(agent)).not.toContain('node fs rule') expect(derivedText(agent)).not.toContain('node fs rule')
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')]) expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
} finally { } finally {
await rm(root, { recursive: true, force: true }) await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true }) await rm(home, { recursive: true, force: true })
@@ -942,13 +949,13 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('provider-only rule') expect(derivedText(agent)).toContain('provider-only rule')
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')]) expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
} finally { } finally {
await ctx.fiber.dispose() await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true }) await rm(dirname(root), { recursive: true, force: true })
@@ -969,7 +976,7 @@ describe('workspace context request injection', () => {
fs.entries.set(join(root, '.git'), { type: 'directory' }) 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(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' })
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -995,7 +1002,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) 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, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file') 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -1042,7 +1049,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -1065,7 +1072,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.throwOnStat.add(join(root, 'AGENTS.md')) 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -1088,7 +1095,7 @@ describe('workspace context request injection', () => {
const fs = ctx.fs as RecordingFileSystem const fs = ctx.fs as RecordingFileSystem
fs.throwOnStat.add(join(root, '.git')) fs.throwOnStat.add(join(root, '.git'))
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) 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) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) 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(repoA, 'AGENTS.md'), 'repo A only')
await write(join(repoB, 'AGENTS.md'), 'repo B only') await write(join(repoB, 'AGENTS.md'), 'repo B only')
const ctx = new Context() const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home }) await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agentA = stubAgent(repoA) const agentA = stubAgent(repoA)
const agentB = stubAgent(repoB) const agentB = stubAgent(repoB)
@@ -1138,7 +1145,7 @@ describe('workspace context request injection', () => {
await write(join(cwd, 'AGENTS.md'), 'child schema default rule') await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(workspaceContext, {}) await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const agent = stubAgent(cwd) const agent = stubAgent(cwd)
await composeBaselinePrefix(ctx, agent) await composeBaselinePrefix(ctx, agent)
@@ -1158,7 +1165,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() const ctx = new Context()
const fiber = await mountWorkspaceContext(ctx, { dshHome: home }) const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
await fiber.dispose() await fiber.dispose()
const agent = stubAgent(root) const agent = stubAgent(root)
@@ -1215,7 +1222,7 @@ describe('workspace context request injection', () => {
try { try {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
const ctx = new Context() const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home }) await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent) 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 root = await tempRepo()
const home = await tempRepo() const home = await tempRepo()
try { try {
@@ -1263,9 +1270,9 @@ describe('workspace context request injection', () => {
const isolated = await import('@deepseek-ai/dsh-workspace-context') const isolated = await import('@deepseek-ai/dsh-workspace-context')
const cache: InstructionContentCache = new Map() 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() 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) expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
} finally { } 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const result = await ctx.tools.execute({ 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) const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
? firstChange.digest ? firstChange.digest
: undefined : undefined
expect(changeDigest).toMatch(/^[a-f0-9]{64}$/) expect(changeDigest).toMatch(/^[a-f0-9]{40}$/)
const text = blocksText(result.additionalContext?.content) const text = blocksText(result.additionalContext?.content)
expect(text).toBe([ expect(text).toBe([
'<system-reminder>', '<system-reminder>',
@@ -1346,6 +1353,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { await mountFileToolsAndWorkspaceContext(ctx, {
dshHome: home, dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'], 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ 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/AGENTS.md'), 'old package rule')
await write(join(root, 'pkg/file.txt'), 'hello') await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ 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/CLAUDE.md'), 'fallback package rule')
await write(join(root, 'pkg/file.txt'), 'hello') await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ 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/AGENTS.md'), 'package rule')
await write(join(root, 'pkg/file.txt'), 'hello') await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ 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/AGENTS.md'), 'first package rule')
await write(join(root, 'pkg/file.txt'), 'hello') await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ 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/AGENTS.md'), { type: 'file', content: 'provider package rule' })
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
await ctx.plugin(ToolFs) await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home }) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ const first = await ctx.tools.execute({
callId: CallId('read-before-resume'), 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/AGENTS.md'), 'old nested rule')
await write(join(root, 'pkg/file.txt'), 'hello') await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root) const original = stubAgent(root)
const first = await ctx.tools.execute({ const first = await ctx.tools.execute({
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original, 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ const first = await ctx.tools.execute({
callId: CallId('read-before-compact'), 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/AGENTS.md'), 'subtree rule')
await write(join(root, 'pkg/sub/file.txt'), 'subtree file') await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const first = await ctx.tools.execute({ const first = await ctx.tools.execute({
callId: CallId('read-package'), 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
agent.session.append('context/message', { agent.session.append('context/message', {
content: [ 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const rootResult = await ctx.tools.execute({ 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.entries.set(join(root, '.git'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file') fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file')
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) 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 agent = stubAgent(root)
const result = { const result = {
callId: CallId('provider-probe-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 write(join(root, 'pkg/deep/file.txt'), 'hello')
await chmod(nested, 0) await chmod(nested, 0)
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({ const result = await ctx.tools.execute({
callId: CallId('read-with-unreadable-nested-instruction'), 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('tools/post-execute', async () => ({ ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const, kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'downstream replacement' }], 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('tools/post-execute', async () => ({ ctx.on('tools/post-execute', async () => ({
kind: 'block' as const, kind: 'block' as const,
feedback: [{ type: 'text' as const, text: 'blocked downstream' }], 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root) const agent = stubAgent(root)
const result = { const result = {
callId: CallId('manual'), callId: CallId('manual'),
@@ -2073,7 +2081,7 @@ describe('dynamic nested workspace context injection', () => {
await mkdir(join(root, '.git'), { recursive: true }) await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
const ctx = new Context() const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({ const result = await ctx.tools.execute({
callId: CallId('read-missing'), 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/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello') await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context() const ctx = new Context()
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
await fiber.dispose() await fiber.dispose()
const result = await ctx.tools.execute({ const result = await ctx.tools.execute({

View File

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

View File

@@ -67,7 +67,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
describe('dsh-acp-agent composition', () => { describe('dsh-acp-agent composition', () => {
it('brings up the spine + persistence + the ACP bridge', async () => { 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('agents')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).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. // persistenceRoot, so the runtime fallback is the one that fires.
const ctx = new Context() const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too. // 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)) await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined()
await ctx.fiber.dispose() 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 () => { it('uses default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => { await withIsolatedSkillHomes(async () => {
const ctx = new Context() const ctx = new Context()
acpAgent.apply(ctx, { model: 'mock' }) acpAgent.apply(ctx, { model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50)) await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.skills).toBeDefined() expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([]) expect(await ctx.skills.list()).toEqual([])
@@ -116,7 +116,7 @@ describe('dsh-acp-agent composition', () => {
}) })
it('forwards skill config into agent-core', async () => { 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' }) ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...') expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
await ctx.fiber.dispose() await ctx.fiber.dispose()
@@ -132,6 +132,7 @@ describe('dsh-acp-agent composition', () => {
model: 'mock', model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST], toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order', persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order',
workspaceContext: false,
}) })
// The bundle's own bash tools pend on the absent `ctx.bash` executor in // The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order. // 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: !!js process.env.RESUME_SESSION_ID`).
*/ */
resumeSessionId?: string resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */ /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext?: agentCore.Config['workspaceContext'] workspaceContext: agentCore.Config['workspaceContext']
} }
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
@@ -100,8 +100,8 @@ export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'), welcome: z.string().default('ready.'),
skills: agentCore.SkillConfigSchema, skills: agentCore.SkillConfigSchema,
resumeSessionId: z.string(), resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
}) as unknown as z<Config> })
/** /**
* Compose the spine with the stdio front door. The console logger comes first * 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(), cwd: process.cwd(),
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
}], }],
...config.workspaceContext !== undefined ? { workspaceContext: config.workspaceContext } : {}, workspaceContext: config.workspaceContext,
...config.skills !== undefined ? { skills: config.skills } : {}, ...config.skills !== undefined ? { skills: config.skills } : {},
}) })
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) 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', () => { describe('dsh-stdio-agent app', () => {
it('composes the spine + front-door cluster and pre-creates the main agent', async () => { 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. // The spine services (brought up by the agent-core bundle) are all present.
expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined()
@@ -95,7 +95,7 @@ describe('dsh-stdio-agent app', () => {
// schema-bypassing direct-mount caller. // schema-bypassing direct-mount caller.
const ctx = new Context() const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too. // 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)) await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).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 () => { it('uses default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => { await withIsolatedSkillHomes(async () => {
const ctx = new Context() const ctx = new Context()
stdioAgent.apply(ctx, { model: 'mock' }) stdioAgent.apply(ctx, { model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 80)) await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.skills).toBeDefined() expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([]) expect(await ctx.skills.list()).toEqual([])
@@ -134,13 +134,14 @@ describe('dsh-stdio-agent app', () => {
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume', persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
resumeSessionId: 'no-such-session', resumeSessionId: 'no-such-session',
skills: await isolatedSkillsConfig(), skills: await isolatedSkillsConfig(),
workspaceContext: false,
}) })
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('forwards skill config into agent-core', async () => { 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' }) ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...') expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
await ctx.fiber.dispose() await ctx.fiber.dispose()
@@ -156,6 +157,7 @@ describe('dsh-stdio-agent app', () => {
model: 'mock', model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST], toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order', persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
workspaceContext: false,
}) })
// The bundle's own bash tools pend on the absent `ctx.bash` executor in // The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order. // this providerless mount, so register two plain tools to order.