diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1b6b025ad5..40906845d8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -56,8 +56,8 @@ export interface Config { tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */ - workspaceContext?: agentCore.Config['workspaceContext'] + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig } @@ -77,10 +77,11 @@ Source: [`packages/ui/acp-agent/src/index.ts:53`](../packages/ui/acp-agent/src/i * plugin (the deployment's persona section and the explicit model-facing tool * order), the `tools` object to the tool registry (its presentation `mode`), * `skills` to the skill registry/local provider/tool consumer, and - * `workspaceContext` to the workspace-context plugin. Every field is optional - * INPUT here because each owner's schema supplies the default; the schema is - * the INTERSECTION of the owners' own schemas (with child schemas nested under - * their bundle keys), so validation and defaulting can never drift from them. + * `workspaceContext` to the workspace-context plugin. Workspace context must + * be configured explicitly with a byte budget or disabled with `false`; the + * other fields remain optional inputs whose owner schemas supply defaults. The + * schema is the INTERSECTION of the owners' own schemas (with child schemas + * nested under their bundle keys), so validation and defaulting cannot drift. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -91,8 +92,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig - /** Workspace-context loader controls; set `false` for hermetic prompts. */ - workspaceContext?: workspaceContext.Config | false + /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ + workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig } @@ -110,7 +111,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/prompt/workspace-context/src/index.ts) -Source: [`packages/core/agent-core/src/index.ts:88`](../packages/core/agent-core/src/index.ts) +Source: [`packages/core/agent-core/src/index.ts:89`](../packages/core/agent-core/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -641,8 +642,8 @@ export interface Config { * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */ - workspaceContext?: agentCore.Config['workspaceContext'] + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] } ``` @@ -1145,14 +1146,14 @@ export interface Config { dshHome?: string /** Directory entries that identify the project root while walking upward from the session cwd. */ projectRootMarkers?: string[] - /** Maximum UTF-8 bytes in one rendered baseline or dynamic instruction batch; non-positive disables loading. */ - maxBytes?: number + /** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */ + maxBytes: number /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ instructionFileCandidates?: string[] } ``` -Source: [`packages/prompt/workspace-context/src/config.ts:10`](../packages/prompt/workspace-context/src/config.ts) +Source: [`packages/prompt/workspace-context/src/config.ts:15`](../packages/prompt/workspace-context/src/config.ts) ## Loadable plugins with no config diff --git a/docs/rfc/implemented/feature/2026-06-24-workspace-context.md b/docs/rfc/implemented/feature/2026-06-24-workspace-context.md index 7daa242d2a..b56a8d6a83 100644 --- a/docs/rfc/implemented/feature/2026-06-24-workspace-context.md +++ b/docs/rfc/implemented/feature/2026-06-24-workspace-context.md @@ -46,7 +46,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells, ### Duplicate Suppression And Change Detection -Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, previousPath?, digest? }`, where `digest` is SHA-256 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. +Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, previousPath?, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map covers the interval after `tools/post-execute` returns `additionalContext` but before the loop appends that context to the log. Once an equal event appears at or after the pending sequence boundary, the pending entry is removed. @@ -58,9 +58,9 @@ There is intentionally no watcher. Detection occurs at the next successful struc ### Byte Budget And Cache -`maxBytes` defaults to 64 KiB and applies separately to a rendered baseline or one dynamic reconciliation batch. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes. +`maxBytes` is required and applies separately to a rendered baseline or one dynamic reconciliation batch; there is no implicit or unbounded budget. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes. -File content is cached by normalized absolute path plus the provider's opaque version and optional size. A signature change causes a re-read. Discovery carries the signature into the read pass so one pass does not stat the same instruction twice. The cache is an I/O optimization only; visible structured metadata is the source of duplicate-suppression state. +Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Hashing the read content prevents same-version, same-size rewrites from staying stale. Discovery carries the provider version into the read pass so one pass does not stat the same instruction twice. Visible structured metadata remains the source of duplicate-suppression state. ## Alternatives considered diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index 8ee54b3078..49768b322f 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -17,6 +17,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: both persona: | diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index 3dff66d60a..a2022b5546 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -16,6 +16,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: both persona: | diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index d525afc5d6..7d20168ff5 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -17,6 +17,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: code persona: | diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 323c35b5b4..a32254a387 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -17,6 +17,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: code persona: | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index dc03ea6b03..1cf638ef84 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -38,6 +38,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 # The persona: identity + behavior only, nothing about transports or # tooling — tool guidance lives with each tool plugin (descriptions + # prompt sections). {{model}} and {{cwd}} are prompt variables the agent diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 9ac7183f23..0c7bc6c078 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783778297069,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783778297070,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse 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.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783778297070,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} {"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} @@ -11,7 +11,7 @@ {"type":"assistant/message","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} {"type":"tool/result","seq":11,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} -{"type":"context/message","seq":12,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"envelope":"raw","meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"ba0e42954526e3c7ae2b225c95f77ff00fdfaf71a7c982d0339fd3c5d8889d71"}]}},"surfaceOp":"append"} +{"type":"context/message","seq":12,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"envelope":"raw","meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783778297072,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783778297072,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md new file mode 100644 index 0000000000..18f0cbcd07 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md @@ -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. diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml index da175da8e8..64d59f3296 100644 --- a/examples/acp-agent/workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -15,15 +15,14 @@ model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' workspaceContext: + maxBytes: 65536 dshHome: !!js process.cwd() + '/.dsh' projectRootMarkers: - .dsh-project persona: | - You are a coding assistant powered by the {{model}} model. Your working - directory is {{cwd}}. + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. - Verify your work by running the code or tests. Keep answers brief and - factual. + Verify your work by running the code or tests. Keep answers brief and factual. - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index 9db52c86ec..54e865d22a 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -12,12 +12,11 @@ model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' workspaceContext: + maxBytes: 65536 dshHome: !!js process.cwd() + '/.dsh' projectRootMarkers: - .dsh-project persona: | - You are a coding assistant powered by the {{model}} model. Your working - directory is {{cwd}}. + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. - Verify your work by running the code or tests. Keep answers brief and - factual. + Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml index ac4ce03570..81d80a5eef 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/coding-agent/code-mode.cordis.yml @@ -19,6 +19,8 @@ model: deepseek-v4-flash resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: code welcome: 'code-mode agent ready. Give it a multi-tool task.' diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index cf2e267e06..a2c7fdc6d8 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -45,6 +45,8 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 welcome: 'agent REPL ready. Give it a coding task.' # The persona: identity + behavior only, nothing about transports or # tooling — tool guidance lives with each tool plugin (descriptions + diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 65d5e6eb36..c2a9d5db31 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -61,6 +61,8 @@ model: deepseek-v4-flash resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.' persona: | You are cordis-agent, a self-referential harness demo powered by the diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 1c8243dd21..33059b8468 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -43,3 +43,5 @@ persona: 'You are echo-agent, a demo agent.' welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 diff --git a/examples/sandbox-acp-agent/cordis.yml b/examples/sandbox-acp-agent/cordis.yml index d02253342e..085d7738e6 100644 --- a/examples/sandbox-acp-agent/cordis.yml +++ b/examples/sandbox-acp-agent/cordis.yml @@ -54,6 +54,8 @@ # sets it (so a record run's logs land where the harness harvests them), # else the local ./.sessions default. persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 persona: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 76766ddefa..38367c0d32 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -40,11 +40,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-core' -// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext? } — the schema intersects the owner schemas, +// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext } — workspaceContext requires { maxBytes } or false; // so validation and defaulting can never drift from the owners. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and `workspaceContext` to `dsh-workspace-context` (`false` disables automatic instruction-file loading). Workspace instructions are registered before the skill catalog so their session-prefix message renders first. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it). Workspace instructions are registered before the skill catalog so their session-prefix message renders first. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index 4cca951310..6e66ea2413 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -80,10 +80,11 @@ export interface SkillConfig { * plugin (the deployment's persona section and the explicit model-facing tool * order), the `tools` object to the tool registry (its presentation `mode`), * `skills` to the skill registry/local provider/tool consumer, and - * `workspaceContext` to the workspace-context plugin. Every field is optional - * INPUT here because each owner's schema supplies the default; the schema is - * the INTERSECTION of the owners' own schemas (with child schemas nested under - * their bundle keys), so validation and defaulting can never drift from them. + * `workspaceContext` to the workspace-context plugin. Workspace context must + * be configured explicitly with a byte budget or disabled with `false`; the + * other fields remain optional inputs whose owner schemas supply defaults. The + * schema is the INTERSECTION of the owners' own schemas (with child schemas + * nested under their bundle keys), so validation and defaulting cannot drift. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -94,8 +95,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig - /** Workspace-context loader controls; set `false` for hermetic prompts. */ - workspaceContext?: workspaceContext.Config | false + /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ + workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig } @@ -114,7 +115,7 @@ export const Config = z.intersect([ z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema, - workspaceContext: z.union([z.const(false), workspaceContext.Config]), + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) as unknown as z>, ]) as unknown as z @@ -122,7 +123,7 @@ export const Config = z.intersect([ * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber; * `agent-loop` receives the forwarded `agents` list and `system-prompt` the * forwarded `persona` and `toolOrder`. Workspace-context receives its own - * forwarded config or loads with defaults. Load order is irrelevant (cordis + * explicitly forwarded config. Load order is irrelevant (cordis * pends each fiber on its `inject` until the services it needs exist), but the * listing mirrors the dependency layering for readability: the LLM vocabulary * and core registries first, then extension plugins that wrap request/tool @@ -149,7 +150,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(invariants) ctx.plugin(toolBash) if (config.workspaceContext !== false) { - ctx.plugin(workspaceContext, config.workspaceContext ?? {}) + ctx.plugin(workspaceContext, config.workspaceContext) } // Both plugins prepend session-prefix messages. Registration order is the // rendered order, so workspace instructions must precede the skill catalog. diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 8060b73846..7bf073c7f7 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -30,7 +30,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise { * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless * bin smokes; here we assert the composition + config forwarding. */ -async function mount(config?: agentCore.Config): Promise { +async function mount(config: agentCore.Config): Promise { const oldDshHome = process.env.DSH_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-')) @@ -94,7 +94,7 @@ function messageText(message: Message | undefined): string { describe('dsh-agent-core bundle', () => { it('brings up the full default spine', async () => { - const ctx = await mount() + const ctx = await mount({ workspaceContext: false }) // One service from each layer of the spine proves the children loaded. expect(ctx.get('timer')).toBeDefined() expect(ctx.get('llm')).toBeDefined() @@ -108,7 +108,7 @@ describe('dsh-agent-core bundle', () => { }) it('includes the skill registry, local provider, and skill tool without builtin skills', async () => { - const ctx = await mount() + const ctx = await mount({ workspaceContext: false }) expect(ctx.skills).toBeDefined() expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill') @@ -118,7 +118,7 @@ describe('dsh-agent-core bundle', () => { }) it('defaults the agents list to empty (no pre-created agents)', async () => { - const ctx = await mount() + const ctx = await mount({ workspaceContext: false }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -127,6 +127,7 @@ describe('dsh-agent-core bundle', () => { const ctx = await mount({ agents: [{ id: AgentId('main'), model: 'mock' }], persona: 'You are main.', + workspaceContext: false, }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() const assembly = await ctx.get('systemPrompt')!.assemble() @@ -138,7 +139,7 @@ describe('dsh-agent-core bundle', () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. const ctx = new Context() - agentCore.apply(ctx, {}) + agentCore.apply(ctx, { workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('agents')?.list()).toHaveLength(0) @@ -153,7 +154,7 @@ describe('dsh-agent-core bundle', () => { await mkdir(join(root, '.git'), { recursive: true }) await writeFile(join(root, 'AGENTS.md'), 'bundled project rule') const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await mount() + const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) const handle = ctx.agents.create({ @@ -213,6 +214,7 @@ describe('dsh-agent-core bundle', () => { await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n') const ctx = await mount({ agents: [], + workspaceContext: false, skills: { registry: { collectCacheMaxEntries: 4 }, local: { @@ -234,7 +236,7 @@ describe('dsh-agent-core bundle', () => { await mkdir(join(root, '.git'), { recursive: true }) await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills') const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await mount() + const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) ctx.skills.register({ @@ -265,7 +267,7 @@ describe('dsh-agent-core bundle', () => { it('uses the default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() - agentCore.apply(ctx, { agents: [] }) + agentCore.apply(ctx, { agents: [], workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) @@ -274,7 +276,7 @@ describe('dsh-agent-core bundle', () => { }) it('forwards toolOrder to the system-prompt assembly', async () => { - const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] }) + const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST], workspaceContext: false }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order. for (const name of ['alpha', 'zulu']) { diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 3d97c4a6b8..a70f82ad17 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -76,7 +76,7 @@ async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', si } } -/** Opaque version token from a stat: mtime (ns precision) + size. */ +/** Opaque version token from a stat: millisecond mtime plus byte size. */ function versionOf(info: Stats): FsVersion { return FsVersion(`${info.mtimeMs}:${info.size}`) } diff --git a/packages/prompt/workspace-context/README.md b/packages/prompt/workspace-context/README.md index 4dc7b26de4..deb8525e51 100644 --- a/packages/prompt/workspace-context/README.md +++ b/packages/prompt/workspace-context/README.md @@ -48,7 +48,7 @@ The core `context/message` envelope is disabled for these messages because the p Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context returned by `tools/post-execute` but not yet appended by the loop. -An unchanged path and SHA-256 content digest is not injected again. Resume works because visible metadata is persisted in the session log. Compaction re-arms a scope after its context event leaves the visible surface. A removal is a tombstone, so a later candidate reappearance is loaded again. Only changes actually rendered within the byte budget enter metadata and pending state; an omitted change remains eligible for a later touch. +An unchanged path and SHA-1 content digest is not injected again. Resume works because visible metadata is persisted in the session log. Compaction re-arms a scope after its context event leaves the visible surface. A removal is a tombstone, so a later candidate reappearance is loaded again. Only changes actually rendered within the byte budget enter metadata and pending state; an omitted change remains eligible for a later touch. The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix. @@ -58,12 +58,12 @@ The frozen baseline itself is not rewritten mid-instance. Its initial path/diges export interface Config { dshHome?: string projectRootMarkers?: string[] - maxBytes?: number + maxBytes: number instructionFileCandidates?: string[] } ``` -`projectRootMarkers` defaults to `['.git']`, `maxBytes` to `65536`, and `instructionFileCandidates` to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. +`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite byte budget disables both baseline and dynamic loading. @@ -71,7 +71,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only co Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`. -File text is cached by normalized absolute path plus the provider's opaque version and optional size. A signature change causes a re-read. Discovery carries the signature into reading so a cache hit does not stat the same file twice in one pass. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression. +Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Comparing the digest after reading prevents a same-version, same-size rewrite from returning stale cached text. Discovery carries the provider version into reading so one pass does not stat the same file twice. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression. ## Non-goals diff --git a/packages/prompt/workspace-context/src/config.ts b/packages/prompt/workspace-context/src/config.ts index 5b17411a67..6657e0446d 100644 --- a/packages/prompt/workspace-context/src/config.ts +++ b/packages/prompt/workspace-context/src/config.ts @@ -1,7 +1,12 @@ +/** + * Configuration normalization for workspace instruction discovery and rendering. + * + * @module @deepseek-ai/dsh-workspace-context/config + */ + import z from 'schemastery' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -const DEFAULT_MAX_BYTES = 64 * 1024 const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..']) @@ -12,8 +17,8 @@ export interface Config { dshHome?: string /** Directory entries that identify the project root while walking upward from the session cwd. */ projectRootMarkers?: string[] - /** Maximum UTF-8 bytes in one rendered baseline or dynamic instruction batch; non-positive disables loading. */ - maxBytes?: number + /** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */ + maxBytes: number /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ instructionFileCandidates?: string[] } @@ -21,28 +26,45 @@ export interface Config { export const Config: z = z.object({ dshHome: z.string(), projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]), - maxBytes: z.number().default(DEFAULT_MAX_BYTES), + maxBytes: z.number().required(), instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]), }) -/** Fully defaulted configuration used by discovery and reconciliation. */ -export interface ResolvedConfig { +/** Normalized instruction discovery configuration. */ +export interface ResolvedDiscoveryConfig { dshHome: string projectRootMarkers: string[] - maxBytes: number instructionFileCandidates: string[] } +/** Normalized configuration used by discovery and reconciliation. */ +export interface ResolvedConfig extends ResolvedDiscoveryConfig { + maxBytes: number +} + /** * Resolve defaults, the harness home, and valid same-directory candidates. * @param config - user-facing plugin configuration. * @returns normalized runtime configuration. */ export function resolveConfig(config: Config): ResolvedConfig { + return { + ...resolveDiscoveryConfig(config), + maxBytes: config.maxBytes, + } +} + +/** + * Resolve the subset of configuration used before instruction content is rendered. + * @param config - optional discovery controls. + * @returns normalized home, root markers, and instruction candidates. + */ +export function resolveDiscoveryConfig( + config: Pick, +): ResolvedDiscoveryConfig { return { dshHome: resolveDshHome(config.dshHome), projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS], - maxBytes: config.maxBytes ?? DEFAULT_MAX_BYTES, instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates), } } diff --git a/packages/prompt/workspace-context/src/digest.ts b/packages/prompt/workspace-context/src/digest.ts new file mode 100644 index 0000000000..36cb646b0d --- /dev/null +++ b/packages/prompt/workspace-context/src/digest.ts @@ -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') +} diff --git a/packages/prompt/workspace-context/src/files.ts b/packages/prompt/workspace-context/src/files.ts index 854b65fcff..42eeb0ba2b 100644 --- a/packages/prompt/workspace-context/src/files.ts +++ b/packages/prompt/workspace-context/src/files.ts @@ -1,8 +1,15 @@ +/** + * Instruction-file discovery, provider reads, and content-aware caching. + * + * @module @deepseek-ai/dsh-workspace-context/files + */ + import { lstat, readFile, stat } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs' import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths' -import { resolveConfig, type ResolvedConfig } from './config.ts' +import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' +import { instructionContentSha1 } from './digest.ts' import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts' /** An instruction candidate identified by absolute and model-facing paths. */ @@ -18,10 +25,10 @@ export interface LoadedInstructionFile extends InstructionFile { interface FileSignature { version: string - size: number | undefined } interface CachedContent extends FileSignature { + sha1: string content: string } @@ -30,7 +37,7 @@ interface DiscoveredInstructionFile extends InstructionFile { target?: FsTarget } -/** Provider-signature-keyed content cache shared across plugin hooks. */ +/** Provider-version and SHA-1 keyed content cache shared across plugin hooks. */ export type InstructionContentCache = Map interface DiscoverOptions { @@ -41,7 +48,7 @@ interface DiscoverOptions { } interface LoadOptions extends DiscoverOptions { - maxBytes?: number + maxBytes: number cache?: InstructionContentCache } @@ -61,7 +68,7 @@ async function nodeStatFile(path: string): Promise { try { const info = await lstat(path) if (!info.isFile()) return undefined - return { version: `${info.mtimeMs}:${info.size}`, size: info.size } + return { version: String(info.mtimeMs) } } catch { // Candidates can disappear while discovery is in progress. return undefined @@ -78,7 +85,7 @@ async function fsStatFile( const target = await fileSystem.resolve(path) const info = await fileSystem.stat(target) if (info?.type !== 'file') return undefined - return { version: info.version, size: info.size, target } + return { version: info.version, target } } catch { // Provider absence and discovery races are both non-fatal. return undefined @@ -204,7 +211,7 @@ async function discoverInstructionFiles( options: DiscoverOptions, fileSystem?: FileSystem, ): Promise { - const config = resolveConfig(options) + const config = resolveDiscoveryConfig(options) const files: DiscoveredInstructionFile[] = [] const seen = new Set() const addFile = (file: DiscoveredInstructionFile): void => { @@ -250,15 +257,14 @@ async function readCached( ): Promise { const path = file.absolutePath const { signature } = file - const cached = cache.get(path) - if (cached !== undefined && cached.version === signature.version && cached.size === signature.size) { - return cached.content - } try { const content = fileSystem === undefined || file.target === undefined ? await readFile(path, 'utf8') : await fileSystem.readText(file.target) - cache.set(path, { ...signature, content }) + const sha1 = instructionContentSha1(content) + const cached = cache.get(path) + if (cached !== undefined && cached.version === signature.version && cached.sha1 === sha1) return cached.content + cache.set(path, { ...signature, sha1, content }) return content } catch { // A file may disappear or become unreadable after its metadata probe. @@ -345,7 +351,7 @@ export async function loadScopeInstruction( const discovered: DiscoveredInstructionFile = { absolutePath, displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath), - signature: { version: info.version, size: info.size }, + signature: { version: info.version }, target, } const content = await readCached(discovered, cache, fileSystem) diff --git a/packages/prompt/workspace-context/src/render.ts b/packages/prompt/workspace-context/src/render.ts index d08dbd96b2..0151aa1796 100644 --- a/packages/prompt/workspace-context/src/render.ts +++ b/packages/prompt/workspace-context/src/render.ts @@ -1,3 +1,9 @@ +/** + * Model-facing workspace instruction rendering within an explicit byte budget. + * + * @module @deepseek-ai/dsh-workspace-context/render + */ + import { dirname } from 'node:path' import type { InstructionFile, LoadedInstructionFile } from './files.ts' @@ -15,7 +21,7 @@ export interface TruncatedInstruction { includedBytes: number } -/** Bounded model-facing text plus omitted and truncated source records. */ +/** Model-facing text plus omitted and truncated source records. */ export interface RenderedWorkspaceContext { text: string omitted: InstructionFile[] @@ -232,7 +238,7 @@ function renderInstructionContext( /** * Render the baseline instruction chain with deterministic precedence budgeting. * @param files - loaded files ordered from broadest to most specific. - * @param options - rendering byte budget. + * @param options - required rendering byte budget. * @returns bounded baseline prompt text and budget diagnostics. */ export function renderWorkspaceContext( diff --git a/packages/prompt/workspace-context/src/state.ts b/packages/prompt/workspace-context/src/state.ts index 9a40807a95..270b267758 100644 --- a/packages/prompt/workspace-context/src/state.ts +++ b/packages/prompt/workspace-context/src/state.ts @@ -1,10 +1,16 @@ -import { createHash } from 'node:crypto' +/** + * Session-visible workspace instruction state and dynamic reconciliation. + * + * @module @deepseek-ai/dsh-workspace-context/state + */ + import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' import { renderContextContent, type JsonValue } from '@deepseek-ai/dsh-session' import type { FileSystem } from '@deepseek-ai/dsh-fs' import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ResolvedConfig } from './config.ts' +import { instructionContentSha1 } from './digest.ts' import { ancestorChain, descendantDirsBetween, @@ -38,10 +44,6 @@ export interface WorkspaceHookContext extends HookContext { meta: JsonValue } -function digest(content: string): string { - return createHash('sha256').update(content).digest('hex') -} - function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext { const serializedChanges: JsonValue[] = changes.map(change => ({ action: change.action, @@ -154,7 +156,7 @@ export function baselineInstructionChanges(files: LoadedInstructionFile[]): Map< action: 'set', scope: scopeForDisplayPath(file.displayPath), path: file.displayPath, - digest: digest(file.content), + digest: instructionContentSha1(file.content), } return [change.scope, change] })) @@ -181,7 +183,7 @@ function relativeScope(projectRoot: string, dir: string): string { * Compare visible/pending state with provider-visible files and render transitions. * @param agent - session owner whose visible surface supplies durable state. * @param resolved - normalized plugin configuration. - * @param cache - shared provider-signature content cache. + * @param cache - shared provider-version and content-digest cache. * @param pendingBySession - short pending window before returned context is logged. * @param baselineBySession - frozen baseline comparison state per session. * @param fileSystem - provider used for current file probes. @@ -245,7 +247,7 @@ export async function reconcileInstructionContext( } continue } - const currentDigest = digest(file.content) + const currentDigest = instructionContentSha1(file.content) if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) continue const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace' const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath @@ -275,7 +277,7 @@ export async function reconcileInstructionContext( * @param exec - completed tool execution descriptor. * @param result - original tool result before post-execute decisions. * @param resolved - normalized plugin configuration. - * @param cache - shared provider-signature content cache. + * @param cache - shared provider-version and content-digest cache. * @param pendingNestedChanges - per-session pending transition maps. * @param baselineInstructionStates - retained baseline comparison state. * @param fileSystem - provider used for current file probes. diff --git a/packages/prompt/workspace-context/tests/workspace-context.e2e.ts b/packages/prompt/workspace-context/tests/workspace-context.e2e.ts index 17c836b1cb..d590bbadef 100644 --- a/packages/prompt/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/prompt/workspace-context/tests/workspace-context.e2e.ts @@ -42,7 +42,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - await ctx.plugin(WorkspaceContext) + await ctx.plugin(WorkspaceContext, { maxBytes: 65536 }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) const handle = ctx.agents.create({ diff --git a/packages/prompt/workspace-context/tests/workspace-context.spec.ts b/packages/prompt/workspace-context/tests/workspace-context.spec.ts index 6938a0c834..204464cc63 100644 --- a/packages/prompt/workspace-context/tests/workspace-context.spec.ts +++ b/packages/prompt/workspace-context/tests/workspace-context.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' @@ -219,7 +219,7 @@ describe('workspace context instruction discovery', () => { } }) - it('re-walks the baseline path and re-reads content when file signatures change', async () => { + it('refreshes cached content after a same-version, same-size rewrite', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -228,19 +228,20 @@ describe('workspace context instruction discovery', () => { await mkdir(cwd, { recursive: true }) const cache: InstructionContentCache = new Map() - expect(await loadBaselineInstructions({ cwd, dshHome: home, cache })).toBeUndefined() + expect(await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })).toBeUndefined() const leaf = join(cwd, 'AGENTS.md') await write(leaf, 'first') - const first = await loadBaselineInstructions({ cwd, dshHome: home, cache }) + const first = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache }) expect(first?.text).toContain('first') - const cached = await loadBaselineInstructions({ cwd, dshHome: home, cache }) + const cached = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache }) expect(cached?.text).toContain('first') - await new Promise(resolve => setTimeout(resolve, 5)) - await writeFile(leaf, 'second and longer') - const second = await loadBaselineInstructions({ cwd, dshHome: home, cache }) - expect(second?.text).toContain('second and longer') + const before = await stat(leaf) + await writeFile(leaf, 'other') + await utimes(leaf, before.atime, before.mtime) + const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache }) + expect(second?.text).toContain('other') expect(second?.text).not.toContain('first') } finally { await rm(root, { recursive: true, force: true }) @@ -259,7 +260,7 @@ describe('workspace context instruction discovery', () => { await write(leaf, 'secret-ish rule') await chmod(leaf, 0) - const loaded = await loadBaselineInstructions({ cwd, dshHome: home }) + const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }) expect(loaded).toBeUndefined() await chmod(leaf, 0o600) @@ -279,7 +280,7 @@ describe('workspace context instruction discovery', () => { await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home }) - const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home }) + const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) expect(files).toEqual([]) expect(loaded).toBeUndefined() @@ -299,7 +300,7 @@ describe('workspace context instruction discovery', () => { await write(join(outside, 'secret.txt'), 'outside secret') await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -655,11 +656,17 @@ describe('workspace context rendering', () => { }) describe('workspace context request injection', () => { + it('requires an explicit maxBytes configuration', async () => { + const ctx = new Context() + + await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + }) + it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { const outcome = await Promise.race([ - ctx.plugin(workspaceContext, {}).then(() => { + ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => { return 'settled' as const }), new Promise<'pending'>((resolve) => { @@ -682,7 +689,7 @@ describe('workspace context request injection', () => { it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, {}) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -696,7 +703,7 @@ describe('workspace context request injection', () => { it('leaves post-execute decisions unchanged when no filesystem provider is present', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, {}) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const decision = await ctx.waterfall('tools/post-execute', { callId: CallId('no-fs-post-execute'), @@ -725,7 +732,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -750,7 +757,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await composeBaselinePrefix(ctx, agent) @@ -794,7 +801,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => { const rest = await next() return [{ role: 'user', content: [{ type: 'text', text: 'Available skills' }] }, ...rest] @@ -819,7 +826,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'old root rule') await write(join(root, 'file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -847,7 +854,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'root rule') await write(join(root, 'file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -873,7 +880,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'shared root and global rule') await write(join(root, 'file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -919,14 +926,14 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('ctx.fs rule') expect(derivedText(agent)).not.toContain('node fs rule') - expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')]) + expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')]) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -942,13 +949,13 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('provider-only rule') - expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')]) + expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')]) } finally { await ctx.fiber.dispose() await rm(dirname(root), { recursive: true, force: true }) @@ -969,7 +976,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -995,7 +1002,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1019,7 +1026,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file') - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1042,7 +1049,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1065,7 +1072,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1088,7 +1095,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.throwOnStat.add(join(root, '.git')) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1110,7 +1117,7 @@ describe('workspace context request injection', () => { await write(join(repoA, 'AGENTS.md'), 'repo A only') await write(join(repoB, 'AGENTS.md'), 'repo B only') const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agentA = stubAgent(repoA) const agentB = stubAgent(repoB) @@ -1138,7 +1145,7 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'child schema default rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await ctx.plugin(workspaceContext, {}) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -1158,7 +1165,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() - const fiber = await mountWorkspaceContext(ctx, { dshHome: home }) + const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await fiber.dispose() const agent = stubAgent(root) @@ -1215,7 +1222,7 @@ describe('workspace context request injection', () => { try { await mkdir(join(root, '.git'), { recursive: true }) const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1241,7 +1248,7 @@ describe('workspace context request injection', () => { } }) - it('reuses the discovery lstat signature when reading cached content', async () => { + it('does not repeat a candidate metadata probe during one discovery and read pass', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1263,9 +1270,9 @@ describe('workspace context request injection', () => { const isolated = await import('@deepseek-ai/dsh-workspace-context') const cache: InstructionContentCache = new Map() - await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache }) + await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache }) observedStats.clear() - await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache }) + await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache }) expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1) } finally { @@ -1287,7 +1294,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -1316,7 +1323,7 @@ describe('dynamic nested workspace context injection', () => { const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange) ? firstChange.digest : undefined - expect(changeDigest).toMatch(/^[a-f0-9]{64}$/) + expect(changeDigest).toMatch(/^[a-f0-9]{40}$/) const text = blocksText(result.additionalContext?.content) expect(text).toBe([ '', @@ -1346,6 +1353,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, + maxBytes: 65536, instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'], }) @@ -1374,7 +1382,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -1406,7 +1414,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'old package rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -1446,7 +1454,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/CLAUDE.md'), 'fallback package rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -1485,7 +1493,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'package rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -1523,7 +1531,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'first package rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -1565,7 +1573,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -1594,7 +1602,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ callId: CallId('read-before-resume'), @@ -1631,7 +1639,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'old nested rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) const first = await ctx.tools.execute({ callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original, @@ -1661,7 +1669,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ callId: CallId('read-before-compact'), @@ -1709,7 +1717,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule') await write(join(root, 'pkg/sub/file.txt'), 'subtree file') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ callId: CallId('read-package'), @@ -1780,7 +1788,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) agent.session.append('context/message', { content: [ @@ -1838,7 +1846,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const rootResult = await ctx.tools.execute({ @@ -1872,7 +1880,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file') fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) - await ctx.plugin(workspaceContext, { dshHome: home }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = { callId: CallId('provider-probe-result'), @@ -1908,7 +1916,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') await chmod(nested, 0) const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ callId: CallId('read-with-unreadable-nested-instruction'), @@ -1934,7 +1942,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'downstream replacement' }], @@ -1977,7 +1985,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'blocked downstream' }], @@ -2010,7 +2018,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = { callId: CallId('manual'), @@ -2073,7 +2081,7 @@ describe('dynamic nested workspace context injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ callId: CallId('read-missing'), @@ -2098,7 +2106,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() - const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home }) + const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await fiber.dispose() const result = await ctx.tools.execute({ diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 6eaab4a168..f35aa7c5f9 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -61,8 +61,8 @@ export interface Config { tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */ - workspaceContext?: agentCore.Config['workspaceContext'] + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig } @@ -76,9 +76,9 @@ export const Config: z = z.object({ toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, persistenceRoot: z.string().default('./.sessions'), - workspaceContext: z.union([z.const(false), workspaceContext.Config]), + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, -}) as unknown as z +}) /** * Compose the spine with the ACP front door. The agent-core bundle pre-creates @@ -92,7 +92,7 @@ export function apply(ctx: Context, config: Config): void { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, - ...config.workspaceContext !== undefined ? { workspaceContext: config.workspaceContext } : {}, + workspaceContext: config.workspaceContext, ...config.skills !== undefined ? { skills: config.skills } : {}, }) ctx.plugin(UserInteractionService) diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 2c7f5db9c4..963eb96dbf 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -67,7 +67,7 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { describe('dsh-acp-agent composition', () => { it('brings up the spine + persistence + the ACP bridge', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig(), workspaceContext: false }) expect(ctx.get('agents')).toBeDefined() expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() @@ -86,7 +86,7 @@ describe('dsh-acp-agent composition', () => { // persistenceRoot, so the runtime fallback is the one that fires. const ctx = new Context() // No persona: covers the omitted-persona forwarding branch too. - acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() }) + acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('sessionPersistence')).toBeDefined() await ctx.fiber.dispose() @@ -107,7 +107,7 @@ describe('dsh-acp-agent composition', () => { it('uses default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() - acpAgent.apply(ctx, { model: 'mock' }) + acpAgent.apply(ctx, { model: 'mock', workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) @@ -116,7 +116,7 @@ describe('dsh-acp-agent composition', () => { }) it('forwards skill config into agent-core', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) }) + const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' }) expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...') await ctx.fiber.dispose() @@ -132,6 +132,7 @@ describe('dsh-acp-agent composition', () => { model: 'mock', toolOrder: ['zulu', TOOL_ORDER_REST], persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order', + workspaceContext: false, }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order. diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 596e2677b0..e45554edd2 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -84,8 +84,8 @@ export interface Config { * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */ - workspaceContext?: agentCore.Config['workspaceContext'] + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] } export const Config: z = z.object({ @@ -100,8 +100,8 @@ export const Config: z = z.object({ welcome: z.string().default('ready.'), skills: agentCore.SkillConfigSchema, resumeSessionId: z.string(), - workspaceContext: z.union([z.const(false), workspaceContext.Config]), -}) as unknown as z + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), +}) /** * Compose the spine with the stdio front door. The console logger comes first @@ -122,7 +122,7 @@ export function apply(ctx: Context, config: Config): void { cwd: process.cwd(), ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], - ...config.workspaceContext !== undefined ? { workspaceContext: config.workspaceContext } : {}, + workspaceContext: config.workspaceContext, ...config.skills !== undefined ? { skills: config.skills } : {}, }) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 7093db458d..94cda02c5a 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -74,7 +74,7 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { describe('dsh-stdio-agent app', () => { it('composes the spine + front-door cluster and pre-creates the main agent', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig(), workspaceContext: false }) // The spine services (brought up by the agent-core bundle) are all present. expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() @@ -95,7 +95,7 @@ describe('dsh-stdio-agent app', () => { // schema-bypassing direct-mount caller. const ctx = new Context() // No persona: covers the omitted-persona forwarding branch too. - stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() }) + stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() @@ -116,7 +116,7 @@ describe('dsh-stdio-agent app', () => { it('uses default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() - stdioAgent.apply(ctx, { model: 'mock' }) + stdioAgent.apply(ctx, { model: 'mock', workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) @@ -134,13 +134,14 @@ describe('dsh-stdio-agent app', () => { persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume', resumeSessionId: 'no-such-session', skills: await isolatedSkillsConfig(), + workspaceContext: false, }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() await ctx.fiber.dispose() }) it('forwards skill config into agent-core', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) }) + const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' }) expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...') await ctx.fiber.dispose() @@ -156,6 +157,7 @@ describe('dsh-stdio-agent app', () => { model: 'mock', toolOrder: ['zulu', TOOL_ORDER_REST], persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order', + workspaceContext: false, }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order.