Merge commit '70396085b141370ce32de1be4e225b4384eaf46d' into HEAD
# Conflicts: # .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md # .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml # docs/config-catalog.i18n.yaml # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/tool-catalog.i18n.yaml # docs/tool-catalog.md # docs/tool-catalog.zh.md # examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json # examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json # examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json # packages/core/tools/README.i18n.yaml # packages/core/tools/README.zh.md # packages/core/tools/src/code-mode.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/plugin-inventory/tests/inventory.spec.ts # packages/mcp/mcp-client/tests/mcp-client.e2e.ts # packages/mcp/mcp-client/tests/mcp-client.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # packages/test-support/acp-snapshot/README.i18n.yaml # pnpm-lock.yaml
This commit is contained in:
6
packages/context/agent-instructions/README.i18n.yaml
Normal file
6
packages/context/agent-instructions/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/agent-instructions/README.md
|
||||
README.md: 6f9d3f4e6b93609d44be0000c75e1a257eeb29d4
|
||||
README.zh.md: e999af402ebc4e8460216c83230623c611140ae2
|
||||
169
packages/context/agent-instructions/README.md
Normal file
169
packages/context/agent-instructions/README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# @deepseek-ai/dsh-agent-instructions
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin injects the initial user-global and project instruction chain into durable history, then discovers nested files and reports later changes or removals after successful filesystem tool calls.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. A resumed session retains one compatible visible baseline and appends only current-file transitions; a changed discovery, precedence, project-root, or budget identity instead folds one explicitly superseding complete baseline into the entering batch.
|
||||
|
||||
The plugin also observes immutable `tools/result` outcomes for successful first-party `read`, `write`, and `edit` calls. Each accepted touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file queues an addition in the agent inbox; a changed file queues a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate queues a removal notice. Native calls and Code Mode sub-dispatches share this path: nested touches bubble through opaque parent execution tokens until the top-level result settles, and touches produced inside an agent-loop step do not begin their asynchronous projection until the durable `step/end`. Direct tool executions outside an open step project immediately. This preserves tool-call/result/step adjacency without depending on filesystem timing. Discovery follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
|
||||
|
||||
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
|
||||
|
||||
## Prompt Shape
|
||||
|
||||
Baseline instructions are durable user-role messages framed with the familiar system-reminder pattern:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
The 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.
|
||||
|
||||
Instructions from: ~/.dsh/AGENTS.md
|
||||
|
||||
...
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
...
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
Newly reached scopes use a durable sourced `user/message`:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
Additional instructions from: packages/app/AGENTS.md
|
||||
|
||||
These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.
|
||||
|
||||
...
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text anywhere in instruction content or model-visible path, scope, and budget metadata is escaped so repository-controlled text cannot close the plugin-owned frame.
|
||||
|
||||
The plugin owns the complete `<system-reminder>` framing, and every injected `user/message` reaches the model verbatim with no core wrapper.
|
||||
|
||||
## State And Refresh
|
||||
|
||||
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `agent-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. A baseline may still publish its budget diagnostic with an empty change list. A dynamic batch with no committed change is not injected at all, and a later touch retries it.
|
||||
|
||||
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. When compaction shadows the event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. At the first pre-step after resume or hot remount, a compatible visible baseline is retained and compared with the files retained by the current complete rendering. Unchanged and budget-omitted files append nothing; offline additions, edits, removals, and files leaving the retained budget set append `set`, `replace`, or `remove` transitions. An incompatible visible baseline is superseded by one complete current baseline, including an explicit empty baseline when no candidate remains. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a resumed session reconciles its baseline, or when an entering pre-step restores a shadowed baseline.
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
export interface Config {
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
maxBytes: number
|
||||
maxSourceBytes?: number
|
||||
instructionFileCandidates?: string[]
|
||||
localInstructionFileCandidates?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory every existing candidate loads, and candidates whose content matches an earlier one after trimming surrounding whitespace are dropped, so with the defaults an `AGENTS.md` and a `CLAUDE.md` that share content render once (as `AGENTS.md`) while genuinely distinct siblings both apply. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads its existing overlays alongside the base files of the same directory (rendered after them) under the same per-directory dedup; an empty list disables the overlay. Candidate entries in both lists 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` with no local overlay; both candidate lists only control 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 render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
|
||||
|
||||
## Budgeting And Bounded Reads
|
||||
|
||||
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
|
||||
|
||||
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in the structured message source.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Baseline context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
At the first request, derived history contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. Resume reuses that message when its visible baseline is compatible.
|
||||
|
||||
##### Baseline instruction template
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
The 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.
|
||||
|
||||
Instructions from: ~/.dsh/AGENTS.md
|
||||
|
||||
<user-global-instructions>
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
<project-instructions>
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
The rendered baseline is appended once and remains in derived history until compaction. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only after the existing reusable prefix. Resume preserves reuse when the visible baseline identity is compatible; an incompatible identity appends a complete replacement, so discovery, precedence, project-root, or budget changes affect reuse only from that history position.
|
||||
|
||||
### Newly discovered scope context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained sourced `user/message` with the newly applicable instruction file.
|
||||
|
||||
##### Additional instruction template
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
Additional instructions from: packages/app/AGENTS.md
|
||||
|
||||
These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.
|
||||
|
||||
<nested-instructions>
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result and its enclosing durable step.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Changed or removed instruction context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A changed file produces `Updated instructions from: <path>` plus its replacement content. A candidate that disappears or becomes a per-directory duplicate of an earlier candidate produces the removal notice below.
|
||||
|
||||
##### Removal notice
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
Instructions removed: packages/app/AGENTS.md
|
||||
|
||||
The previously loaded instructions from this file no longer apply.
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
|
||||
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when resume reconciles a visible baseline, or when an entering pre-step restores a shadowed baseline.
|
||||
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
|
||||
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
|
||||
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.
|
||||
- **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose.
|
||||
169
packages/context/agent-instructions/README.zh.md
Normal file
169
packages/context/agent-instructions/README.zh.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# @deepseek-ai/dsh-agent-instructions
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为每个会话加载与 `AGENTS.md` 兼容的工作区指令文件。该插件会将初始的用户全局指令与项目指令链注入持久历史,随后发现嵌套文件,并在成功的文件系统工具调用后报告后续变更或移除。
|
||||
|
||||
## 生命周期
|
||||
|
||||
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1,并共同抵达第一次请求。被拒绝或为空的第一步决策会将基线留在 agent(智能体)的 `next-step` inbox,等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。恢复后的会话会保留一条兼容的可见基线,并只追加当前文件的转换;如果发现、优先级、项目根目录或预算标识发生变化,则会将一条明确取代旧基线的完整基线折入进入步骤的批次。
|
||||
|
||||
该插件还会观察第一方 `read`、`write` 和 `edit` 调用成功后产生的不可变 `tools/result`。每个已接受的 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件会在 agent inbox 中排入一项新增;已改变文件会排入一项替换;文件消失或成为同一目录中较早候选文件的重复项时,会排入一则移除通知。原生调用与 Code Mode 子分派共享该路径:嵌套 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定;在 agent loop(智能体循环)步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影。打开的步骤之外直接执行工具时,则立即投影。这样无需依赖文件系统时序,也能保持工具调用/结果/步骤的相邻关系。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
|
||||
|
||||
指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并对解析结果执行 stat,因此会跟随路径最后一段的 symlink 到其目标:指向常规文件的链接会加载目标内容,缺失路径或非文件目标(包括指向目录的链接)则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。
|
||||
|
||||
## 提示词结构
|
||||
|
||||
基线指令是持久的 user 角色消息,使用熟悉的 system-reminder 模式框定:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
The 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.
|
||||
|
||||
Instructions from: ~/.dsh/AGENTS.md
|
||||
|
||||
...
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
...
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
新达到的 scope 使用持久的带来源 `user/message`:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
Additional instructions from: packages/app/AGENTS.md
|
||||
|
||||
These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.
|
||||
|
||||
...
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
同一文件的编辑以 `Updated instructions from: <path>` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: <path>`,后跟 `The previously loaded instructions from this file no longer apply.`。指令内容或模型可见的路径、scope 与预算元数据中出现的字面 `</system-reminder>` 文本都会转义,因此仓库控制的文本无法关闭插件控制的框架。
|
||||
|
||||
该插件控制完整的 `<system-reminder>` 框架,每个注入的 `user/message` 都不经核心包装便原样传给模型。
|
||||
|
||||
## 状态与刷新
|
||||
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `agent-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;若被拒绝,当前上下文则继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch;串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
|
||||
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。基线即使带空变更列表,仍可发布字节预算诊断。动态批次若没有可提交变更,则完全不注入,并在后续 touch 时重试。
|
||||
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。当压缩遮蔽该事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统 touch 重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。恢复或插件热重挂后的第一次 pre-step 会保留兼容的可见基线,并将它与当前完整渲染所保留的文件进行比较。未变化和被预算省略的文件不追加任何内容;agent 离线期间新增、编辑、移除或不再属于预算保留集的文件会追加 `set`、`replace` 或 `remove` 转换。不兼容的可见基线会被一条完整的当前基线取代;如果没有候选文件,这条当前基线会是显式空基线。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复后的会话对账其基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
|
||||
|
||||
## 配置
|
||||
|
||||
```ts
|
||||
export interface Config {
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
maxBytes: number
|
||||
maxSourceBytes?: number
|
||||
instructionFileCandidates?: string[]
|
||||
localInstructionFileCandidates?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
`maxBytes` 必填,因此每个部署都必须显式选择提示词预算。`maxSourceBytes` 在渲染前限制每个源指令文件,默认为 1 MiB。`projectRootMarkers` 默认为 `['.git']`,`instructionFileCandidates` 默认为 `['AGENTS.md', 'CLAUDE.md']`。每个项目目录中的所有现有候选文件都会加载,在去除周围空白后与较早候选文件内容匹配的文件会被丢弃。因此,使用默认设置时,内容相同的 `AGENTS.md` 与 `CLAUDE.md` 只渲染一次(作为 `AGENTS.md`),真正不同的同级文件则同时应用。`localInstructionFileCandidates` 默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`,会与同一目录的基础文件一起加载其现有 overlay(渲染在它们之后),并应用同一个每目录去重;空列表会禁用 overlay。两个列表中的候选项都必须是同一目录下的文件名,因此会忽略空项、`.`/`..` 以及包含 `/` 或 `\` 的项。
|
||||
|
||||
用户全局文件始终是 `$DSH_HOME/AGENTS.md`,没有本地 overlay;两个候选列表只控制项目 scope。`$DSH_HOME` 默认为 `~/.dsh`,已配置的 `~`、`~/...` 与 Windows 风格 `~\...` 前缀会基于操作系统 home 目录展开。非正数或非有限渲染预算会同时禁用基线与动态加载;已配置 `maxSourceBytes` 必须是正整数。
|
||||
|
||||
## 预算与有界读取
|
||||
|
||||
渲染会优先保留最具体的指令文件。它会先丢弃完整的较宽泛文件,再截断最具体文件,并发出可见 `Workspace instruction budget ...` 通知,其中指名已省略与已截断路径。渲染后字节数绝不超过 `maxBytes`。
|
||||
|
||||
即使提供方元数据省略大小,或文件在元数据探测后增长,指令内容仍会通过 `streamText()` 在 `maxSourceBytes` 下读取。超大文件会被忽略;在动态对账期间,它会暂时不可用,而不是被移除。该插件不保留进程级 cache,绝不缓存指令文本。其会话本地 scope cache 只将提供方版本用作快速失效信号;失效后,对有界读取计算的 SHA-1 仍是存储在结构化消息来源中的跨提供方内容标识。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 基线上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
第一次请求的派生历史中包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。可见基线兼容时,恢复会复用该消息。
|
||||
|
||||
##### 基线指令模板
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
The 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.
|
||||
|
||||
Instructions from: ~/.dsh/AGENTS.md
|
||||
|
||||
<user-global-instructions>
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
<project-instructions>
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
渲染后基线只追加一次,并保留在派生历史中直到压缩。`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加,位于现有可复用前缀之后。可见基线标识兼容时,恢复会保持复用;不兼容的标识会追加一条完整的替代基线,因此发现、优先级、项目根目录或预算变更只会从该历史位置起影响复用。
|
||||
|
||||
### 新发现的 scope 上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
成功的第一方文件系统调用达到更深目录后,下一个请求会包含一条保留的带来源 `user/message`,其中包含新适用的指令文件。
|
||||
|
||||
##### 附加指令模板
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
Additional instructions from: packages/app/AGENTS.md
|
||||
|
||||
These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.
|
||||
|
||||
<nested-instructions>
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟至外层 `run_code` 结果及其所属持久步骤之后。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 已改变或移除的指令上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
已改变文件会产生 `Updated instructions from: <path>` 加替换内容。消失或成为同一目录中较早候选文件重复项的候选文件会产生下方移除通知。
|
||||
|
||||
##### 移除通知
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
Instructions removed: packages/app/AGENTS.md
|
||||
|
||||
The previously loaded instructions from this file no longer apply.
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每项已确认变更或移除都是一条受 `maxBytes` 限制的保留历史消息。提供方失败不添加消息,预算省略的更新仍可在后续文件系统 touch 中处理。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。
|
||||
- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时、恢复过程对账可见基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
|
||||
- **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但用户全局 `$DSH_HOME` scope 没有本地 overlay,其他自定义名称需要显式候选配置。
|
||||
- **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。
|
||||
- **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不会覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。
|
||||
- **指令内容受限但不会被摘要**:超出预算的宽泛文件会被省略,最具体文件可能被截断;该插件绝不请求模型压缩指令文本。
|
||||
63
packages/context/agent-instructions/package.json
Normal file
63
packages/context/agent-instructions/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-instructions",
|
||||
"description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files",
|
||||
"version": "0.1.0-rc.6",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/context/agent-instructions"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
123
packages/context/agent-instructions/src/config.ts
Normal file
123
packages/context/agent-instructions/src/config.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Configuration normalization for workspace instruction discovery and rendering.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-instructions/config
|
||||
*/
|
||||
|
||||
import { relative } from 'node:path'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
|
||||
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
|
||||
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
|
||||
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
|
||||
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
|
||||
|
||||
/** User-facing workspace instruction loader configuration. */
|
||||
export interface Config {
|
||||
/** Harness home containing the fixed user-global `AGENTS.md`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Directory entries that identify the project root while walking upward from the session cwd. */
|
||||
projectRootMarkers?: string[]
|
||||
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
|
||||
maxBytes: number
|
||||
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
|
||||
maxSourceBytes?: number
|
||||
/**
|
||||
* Ordered same-directory project candidates; every existing file loads, with
|
||||
* per-directory trimmed-content duplicates collapsed to the earliest candidate.
|
||||
*/
|
||||
instructionFileCandidates?: string[]
|
||||
/**
|
||||
* Ordered same-directory local-overlay candidates loaded after the base files
|
||||
* under the same per-directory trimmed-content dedup; empty disables the overlay.
|
||||
*/
|
||||
localInstructionFileCandidates?: string[]
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
|
||||
maxBytes: z.number().required(),
|
||||
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
|
||||
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
|
||||
localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
|
||||
})
|
||||
|
||||
/** Normalized instruction discovery configuration. */
|
||||
export interface ResolvedDiscoveryConfig {
|
||||
dshHome: string
|
||||
projectRootMarkers: string[]
|
||||
instructionFileCandidates: string[]
|
||||
localInstructionFileCandidates: string[]
|
||||
}
|
||||
|
||||
/** Normalized configuration used by discovery and reconciliation. */
|
||||
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
|
||||
maxBytes: number
|
||||
maxSourceBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the discovery, precedence, and budget semantics of one baseline.
|
||||
* @param config - normalized plugin configuration.
|
||||
* @param cwd - absolute session working directory.
|
||||
* @param projectRoot - project root selected for the current baseline.
|
||||
* @returns stable serialized identity for compatibility checks on resume.
|
||||
*/
|
||||
export function workspaceBaselineIdentity(
|
||||
config: ResolvedConfig,
|
||||
cwd: string,
|
||||
projectRoot: string,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
projectRoot: relative(cwd, projectRoot),
|
||||
projectRootMarkers: config.projectRootMarkers,
|
||||
maxBytes: config.maxBytes,
|
||||
maxSourceBytes: config.maxSourceBytes,
|
||||
instructionFileCandidates: config.instructionFileCandidates,
|
||||
localInstructionFileCandidates: config.localInstructionFileCandidates,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the subset of configuration used before instruction content is rendered.
|
||||
* @param config - optional discovery controls.
|
||||
* @returns normalized home, root markers, and instruction candidates.
|
||||
*/
|
||||
export function resolveDiscoveryConfig(
|
||||
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates' | 'localInstructionFileCandidates'>,
|
||||
): ResolvedDiscoveryConfig {
|
||||
return {
|
||||
dshHome: resolveDshHome(config.dshHome),
|
||||
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
|
||||
instructionFileCandidates: resolveInstructionFileCandidates(
|
||||
config.instructionFileCandidates,
|
||||
DEFAULT_INSTRUCTION_FILE_CANDIDATES,
|
||||
),
|
||||
localInstructionFileCandidates: resolveInstructionFileCandidates(
|
||||
config.localInstructionFileCandidates,
|
||||
DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstructionFileCandidates(candidates: string[] | undefined, fallback: readonly string[]): string[] {
|
||||
return (candidates ?? [...fallback]).filter(candidate => (
|
||||
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
|
||||
))
|
||||
}
|
||||
28
packages/context/agent-instructions/src/digest.ts
Normal file
28
packages/context/agent-instructions/src/digest.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Content identity for workspace instruction duplicate suppression.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-instructions/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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the whitespace-insensitive identity used for per-directory duplicate
|
||||
* suppression. Leading and trailing whitespace is trimmed before hashing so a
|
||||
* symlinked or byte-copied sibling that differs only by surrounding whitespace
|
||||
* still collapses to a single rendered file.
|
||||
* @param content - exact UTF-8 instruction text.
|
||||
* @returns SHA-1 digest of the trimmed content.
|
||||
*/
|
||||
export function trimmedInstructionDigest(content: string): string {
|
||||
return instructionContentSha1(content.trim())
|
||||
}
|
||||
521
packages/context/agent-instructions/src/files.ts
Normal file
521
packages/context/agent-instructions/src/files.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Instruction-file discovery and bounded, abort-aware provider reads.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-instructions/files
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { dshHomeDisplay } from '@deepseek-ai/dsh-home-paths'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
decodeScopeKey,
|
||||
renderWorkspaceInstructionSet,
|
||||
type RenderedWorkspaceContext,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
} from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
export interface InstructionFile {
|
||||
absolutePath: string
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
/** An instruction file whose UTF-8 content was read successfully. */
|
||||
export interface LoadedInstructionFile extends InstructionFile {
|
||||
content: string
|
||||
/** Provider freshness token when the file was loaded through `ctx.fs`. */
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
interface DiscoveredInstructionFile extends InstructionFile {
|
||||
target?: FsTarget
|
||||
size?: number
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
/** Provider metadata for a probed scope candidate before its content is read. */
|
||||
export interface ProbedInstructionFile extends InstructionFile {
|
||||
target: FsTarget
|
||||
version: FsVersion
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface DiscoverOptions {
|
||||
cwd: string
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
instructionFileCandidates?: string[]
|
||||
localInstructionFileCandidates?: string[]
|
||||
projectRoot?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface LoadOptions extends DiscoverOptions {
|
||||
maxBytes: number
|
||||
maxSourceBytes?: number
|
||||
replacePreviousBaseline?: boolean
|
||||
}
|
||||
|
||||
/** Rendered baseline plus the successfully read and byte-budget-retained files. */
|
||||
export interface RenderedInstructionSet {
|
||||
rendered: RenderedWorkspaceContext
|
||||
/** Successfully read candidates before content deduplication and byte budgeting. */
|
||||
observed: LoadedInstructionFile[]
|
||||
/** Candidates retained by content deduplication and byte budgeting. */
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||
export type ScopeInstructionProbe =
|
||||
| { kind: 'present'; file: ProbedInstructionFile }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
interface StatFileInfo {
|
||||
target?: FsTarget
|
||||
size?: number
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
type StatFileProbe =
|
||||
| { kind: 'present'; info: StatFileInfo }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined {
|
||||
return signal === undefined ? undefined : { signal }
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
}
|
||||
|
||||
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
// stat (not lstat) follows a final-component symlink so a link to a regular
|
||||
// file loads; a broken link surfaces as ENOENT and is treated as absent below.
|
||||
const info = await stat(path)
|
||||
signal?.throwIfAborted()
|
||||
if (!info.isFile()) return { kind: 'absent' }
|
||||
return { kind: 'present', info: { size: info.size } }
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
return isMissingPathError(error) ? { kind: 'absent' } : { kind: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function fsStatFile(
|
||||
path: string,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StatFileProbe> {
|
||||
// resolve() follows a final-component symlink to its target's stable identity;
|
||||
// stat then classifies that target. A link to a regular file loads, while a
|
||||
// missing path or non-file target (including a link to a directory) is absent.
|
||||
try {
|
||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||
signal?.throwIfAborted()
|
||||
const info = await fileSystem.stat(target, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (info?.type !== 'file') return { kind: 'absent' }
|
||||
return {
|
||||
kind: 'present',
|
||||
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
|
||||
}
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function statFile(
|
||||
path: string,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StatFileProbe> {
|
||||
return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
|
||||
}
|
||||
|
||||
async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: AbortSignal): Promise<boolean> {
|
||||
if (fileSystem !== undefined) {
|
||||
try {
|
||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||
return await fileSystem.stat(target, signal) !== undefined
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
// TODO(root-marker-unavailable): preserve provider failure separately from
|
||||
// absence and stop discovery; continuing upward can cross into an ancestor project.
|
||||
return false
|
||||
}
|
||||
}
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
await stat(path)
|
||||
signal?.throwIfAborted()
|
||||
return true
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upward to the first directory containing a configured root marker.
|
||||
* @param cwd - absolute session working directory where the walk begins.
|
||||
* @param markers - child names that identify a project root.
|
||||
* @param fileSystem - optional provider used instead of host filesystem probes.
|
||||
* @param signal - cancellation for provider and host probes.
|
||||
* @returns the discovered project root, or `cwd` when no marker exists.
|
||||
*/
|
||||
export async function findProjectRoot(
|
||||
cwd: string,
|
||||
markers: readonly string[],
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
let current = resolve(cwd)
|
||||
for (;;) {
|
||||
for (const marker of markers) {
|
||||
if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return resolve(cwd)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the inclusive root-to-cwd directory chain.
|
||||
* @param root - root directory expected to contain or equal `cwd`.
|
||||
* @param cwd - most-specific directory in the chain.
|
||||
* @returns directories ordered from broadest to most specific.
|
||||
*/
|
||||
export function ancestorChain(root: string, cwd: string): string[] {
|
||||
const chain: string[] = []
|
||||
let current = resolve(cwd)
|
||||
const resolvedRoot = resolve(root)
|
||||
while (current !== resolvedRoot) {
|
||||
chain.push(current)
|
||||
const parent = dirname(current)
|
||||
/* v8 ignore next -- discovery always supplies cwd or an ancestor root. */
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
chain.push(resolvedRoot)
|
||||
return chain.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find descendant directories crossed between a cwd and a touched file.
|
||||
* @param root - session cwd that bounds nested discovery.
|
||||
* @param touchedPath - absolute path or path relative to `root`.
|
||||
* @returns descendant directories from shallowest through the touched file's parent.
|
||||
*/
|
||||
export function descendantDirsBetween(root: string, touchedPath: string): string[] {
|
||||
const resolvedRoot = resolve(root)
|
||||
const targetPath = isAbsolute(touchedPath) ? resolve(touchedPath) : resolve(resolvedRoot, touchedPath)
|
||||
const targetDir = dirname(targetPath)
|
||||
const rel = relative(resolvedRoot, targetDir)
|
||||
if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return []
|
||||
return ancestorChain(resolvedRoot, targetDir).slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an absolute instruction path to its project-root-relative display form.
|
||||
* @param root - project root used as the display base.
|
||||
* @param path - absolute path to display.
|
||||
* @returns the root-relative path.
|
||||
*/
|
||||
export function relativeDisplay(root: string, path: string): string {
|
||||
return relative(root, path)
|
||||
}
|
||||
|
||||
async function allExistingInstructionFiles(
|
||||
dir: string,
|
||||
root: string,
|
||||
instructionFileCandidates: readonly string[],
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DiscoveredInstructionFile[]> {
|
||||
const found: DiscoveredInstructionFile[] = []
|
||||
for (const candidate of instructionFileCandidates) {
|
||||
const path = join(dir, candidate)
|
||||
const probe = await statFile(path, fileSystem, signal)
|
||||
switch (probe.kind) {
|
||||
case 'present':
|
||||
found.push({ absolutePath: path, displayPath: relativeDisplay(root, path), ...probe.info })
|
||||
continue
|
||||
// A missing candidate is skipped; a transient provider failure skips only
|
||||
// that candidate so the remaining independent candidates still load.
|
||||
case 'absent':
|
||||
case 'unavailable':
|
||||
continue
|
||||
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
|
||||
default:
|
||||
assertNever(probe, 'StatFileProbe')
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
async function discoverInstructionFiles(
|
||||
options: DiscoverOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<DiscoveredInstructionFile[]> {
|
||||
const config = resolveDiscoveryConfig(options)
|
||||
const files: DiscoveredInstructionFile[] = []
|
||||
const seen = new Set<string>()
|
||||
const addFile = (file: DiscoveredInstructionFile): void => {
|
||||
if (seen.has(file.absolutePath)) return
|
||||
seen.add(file.absolutePath)
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
const userGlobal = join(config.dshHome, USER_GLOBAL_FILE)
|
||||
const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
|
||||
switch (userGlobalProbe.kind) {
|
||||
case 'present':
|
||||
addFile({
|
||||
absolutePath: userGlobal,
|
||||
displayPath: userGlobalDisplayPath(config.dshHome),
|
||||
...userGlobalProbe.info,
|
||||
})
|
||||
break
|
||||
case 'absent':
|
||||
case 'unavailable':
|
||||
break
|
||||
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
|
||||
default:
|
||||
assertNever(userGlobalProbe, 'StatFileProbe')
|
||||
}
|
||||
|
||||
const cwd = resolve(options.cwd)
|
||||
const projectRoot = options.projectRoot
|
||||
?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) {
|
||||
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
|
||||
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
|
||||
addFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover host-visible user-global and root-to-cwd instruction candidates.
|
||||
* All present candidates in each directory are returned; trimmed-content
|
||||
* duplicates are collapsed later, once content is read.
|
||||
* @param options - cwd, home, root marker, and candidate configuration.
|
||||
* @returns path-deduplicated instruction candidates in model precedence order.
|
||||
*/
|
||||
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
|
||||
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
|
||||
}
|
||||
|
||||
async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterable<string> {
|
||||
const stream = createReadStream(path, { encoding: 'utf8', signal })
|
||||
for await (const chunk of stream) yield String(chunk)
|
||||
}
|
||||
|
||||
async function readBounded(
|
||||
file: { absolutePath: string; target?: FsTarget; size?: number },
|
||||
maxSourceBytes: number,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
// TODO(total-instruction-read-bound): enforce an aggregate source budget
|
||||
// across a complete baseline or reconciliation batch; the render budget is
|
||||
// applied only after every accepted file has been read under this per-file cap.
|
||||
signal?.throwIfAborted()
|
||||
if (file.size !== undefined && file.size > maxSourceBytes) return undefined
|
||||
try {
|
||||
const chunks = fileSystem === undefined || file.target === undefined
|
||||
? nodeTextChunks(file.absolutePath, signal)
|
||||
: await fileSystem.streamText(file.target, signal)
|
||||
const parts: string[] = []
|
||||
let bytes = 0
|
||||
for await (const chunk of chunks) {
|
||||
signal?.throwIfAborted()
|
||||
bytes += Buffer.byteLength(chunk, 'utf8')
|
||||
if (bytes > maxSourceBytes) return undefined
|
||||
parts.push(chunk)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return parts.join('')
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
// A file may disappear or become unreadable after its metadata probe.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop later candidates whose trimmed content duplicates an earlier sibling in
|
||||
* the same directory. Different directories never collapse even when identical;
|
||||
* within one directory the earliest candidate in discovery order is kept and its
|
||||
* original bytes are rendered. A candidate that symlinks a sibling resolves to
|
||||
* the same content and collapses here like any byte-identical real file.
|
||||
* @param files - loaded files in discovery order.
|
||||
* @returns the retained files in the same order.
|
||||
*/
|
||||
export function dedupInstructionFilesByDirectory(files: LoadedInstructionFile[]): LoadedInstructionFile[] {
|
||||
const keptDigestsByDir = new Map<string, Set<string>>()
|
||||
const kept: LoadedInstructionFile[] = []
|
||||
for (const file of files) {
|
||||
const dir = dirname(file.displayPath)
|
||||
let digests = keptDigestsByDir.get(dir)
|
||||
if (digests === undefined) {
|
||||
digests = new Set()
|
||||
keptDigestsByDir.set(dir, digests)
|
||||
}
|
||||
const digest = trimmedInstructionDigest(file.content)
|
||||
if (digests.has(digest)) continue
|
||||
digests.add(digest)
|
||||
kept.push(file)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover, read, and render the baseline instruction chain.
|
||||
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
|
||||
* @param fileSystem - optional provider used instead of host filesystem reads.
|
||||
* @returns rendered baseline context, or undefined when nothing can be loaded.
|
||||
*/
|
||||
export async function loadBaselineInstructions(
|
||||
options: LoadOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<RenderedWorkspaceContext | undefined> {
|
||||
return (await loadBaselineInstructionSet(options, fileSystem))?.rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a baseline together with the files retained after rendering.
|
||||
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
|
||||
* @param fileSystem - optional provider used instead of host filesystem reads.
|
||||
* @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled.
|
||||
*/
|
||||
export async function loadBaselineInstructionSet(
|
||||
options: LoadOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<RenderedInstructionSet | undefined> {
|
||||
const config = resolveConfig(options)
|
||||
if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return undefined
|
||||
if (config.maxSourceBytes <= 0 || !Number.isFinite(config.maxSourceBytes)) return undefined
|
||||
const discovered = await discoverInstructionFiles(options, fileSystem)
|
||||
const loaded: LoadedInstructionFile[] = []
|
||||
for (const file of discovered) {
|
||||
const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal)
|
||||
if (content !== undefined) {
|
||||
loaded.push({
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
...file.version === undefined ? {} : { version: file.version },
|
||||
})
|
||||
}
|
||||
}
|
||||
const deduped = dedupInstructionFilesByDirectory(loaded)
|
||||
if (deduped.length === 0) {
|
||||
if (options.replacePreviousBaseline !== true) return undefined
|
||||
const { rendered, included } = renderWorkspaceInstructionSet([], {
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
})
|
||||
return {
|
||||
rendered,
|
||||
observed: [],
|
||||
included,
|
||||
}
|
||||
}
|
||||
const { rendered, included } = renderWorkspaceInstructionSet(deduped, {
|
||||
maxBytes: config.maxBytes,
|
||||
...options.replacePreviousBaseline === undefined
|
||||
? {}
|
||||
: { replacePreviousBaseline: options.replacePreviousBaseline },
|
||||
})
|
||||
return {
|
||||
rendered,
|
||||
observed: loaded,
|
||||
included,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the current provider metadata for one per-candidate instruction scope.
|
||||
* @param scope - a {@link candidateScopeKey} identifying a directory and candidate file.
|
||||
* @param projectRoot - project root used to resolve and display project scopes.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param fileSystem - provider used to resolve and stat scope candidates.
|
||||
* @param signal - cancellation for provider probes.
|
||||
* @returns present metadata, confirmed absence, or temporary unavailability.
|
||||
*/
|
||||
export async function probeScopeInstruction(
|
||||
scope: string,
|
||||
projectRoot: string,
|
||||
resolved: ResolvedConfig,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScopeInstructionProbe> {
|
||||
const { directory, candidateName } = decodeScopeKey(scope)
|
||||
const dir = directory === USER_GLOBAL_DIRECTORY
|
||||
? resolved.dshHome
|
||||
: directory === '.' ? projectRoot : join(projectRoot, directory)
|
||||
const absolutePath = join(dir, candidateName)
|
||||
// resolve() follows a final-component symlink; stat then classifies the target.
|
||||
// A non-file target (missing, or a link to a directory) is a confirmed absence;
|
||||
// only a provider exception is reported as unavailable.
|
||||
let target: FsTarget
|
||||
let info: FsInfo | undefined
|
||||
try {
|
||||
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
|
||||
info = await fileSystem.stat(target, signal)
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (info?.type !== 'file') return { kind: 'absent' }
|
||||
const file: ProbedInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: directory === USER_GLOBAL_DIRECTORY ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
target,
|
||||
version: info.version,
|
||||
...info.size === undefined ? {} : { size: info.size },
|
||||
}
|
||||
return { kind: 'present', file }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one already-probed scope candidate under the configured source cap.
|
||||
* @param file - winning provider candidate and its metadata snapshot.
|
||||
* @param maxSourceBytes - maximum UTF-8 bytes accepted from the source.
|
||||
* @param fileSystem - provider used for the streaming read.
|
||||
* @param signal - cancellation for provider streaming.
|
||||
* @returns loaded content with the probed version, or undefined when unavailable.
|
||||
*/
|
||||
export async function readScopeInstruction(
|
||||
file: ProbedInstructionFile,
|
||||
maxSourceBytes: number,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LoadedInstructionFile | undefined> {
|
||||
const content = await readBounded(file, maxSourceBytes, fileSystem, signal)
|
||||
if (content === undefined) return undefined
|
||||
return {
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
version: file.version,
|
||||
}
|
||||
}
|
||||
|
||||
function userGlobalDisplayPath(dshHome: string): string {
|
||||
return `${dshHomeDisplay(dshHome)}/AGENTS.md`
|
||||
}
|
||||
367
packages/context/agent-instructions/src/index.ts
Normal file
367
packages/context/agent-instructions/src/index.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Workspace instruction loader for AGENTS.md-compatible files.
|
||||
*
|
||||
* Baseline instructions enter durable context before the first request; successful fs
|
||||
* tool touches project nested, changed, and removed instructions into the inbox.
|
||||
* Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products
|
||||
* mount it as a no-op.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-instructions
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
|
||||
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
|
||||
import {
|
||||
applyInstructionVersionUpdates,
|
||||
baselineInstructionState,
|
||||
name,
|
||||
reconcileInstructionContext,
|
||||
workspaceContextMessage,
|
||||
type InstructionVersionCache,
|
||||
type AgentInstructionSource,
|
||||
} from './state.ts'
|
||||
import type { AgentInstructionChange } from './render.ts'
|
||||
|
||||
export { Config, name }
|
||||
export {
|
||||
discoverBaselineInstructionFiles,
|
||||
loadBaselineInstructions,
|
||||
} from './files.ts'
|
||||
export type {
|
||||
InstructionFile,
|
||||
LoadedInstructionFile,
|
||||
} from './files.ts'
|
||||
export { renderWorkspaceContext } from './render.ts'
|
||||
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
|
||||
|
||||
function visibleBaselineSource(
|
||||
agent: Agent,
|
||||
authorityMessages: readonly UserMessage[],
|
||||
): AgentInstructionSource | undefined {
|
||||
for (const message of authorityMessages.toReversed()) {
|
||||
if (message.source.kind === 'agent-instructions' && message.source.baseline === true) {
|
||||
return message.source
|
||||
}
|
||||
}
|
||||
for (const seq of agent.session.surface.nodes.toReversed()) {
|
||||
const event = agent.session.events[seq]
|
||||
if (event?.type === 'user/message'
|
||||
&& event.data.source.kind === 'agent-instructions'
|
||||
&& event.data.source.baseline === true) return event.data.source
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isWorkspaceContext(message: UserMessage): boolean {
|
||||
return message.source.kind === 'agent-instructions'
|
||||
}
|
||||
|
||||
function sameContextPayload(left: UserMessage, right: UserMessage): boolean {
|
||||
return isDeepStrictEqual(left.content, right.content)
|
||||
&& isDeepStrictEqual(left.source, right.source)
|
||||
}
|
||||
|
||||
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
|
||||
|
||||
function filePathFromExecution(exec: ToolExecution): string | undefined {
|
||||
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
|
||||
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
|
||||
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
|
||||
const filePath = exec.arguments.file_path.trim()
|
||||
return filePath.length > 0 ? filePath : undefined
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved: ResolvedConfig = resolveConfig(config)
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const baselinePreparations = new WeakMap<Session, {
|
||||
identity: string
|
||||
excludedScopes: ReadonlySet<string>
|
||||
}>()
|
||||
const projectionLifecycle = new AbortController()
|
||||
type ProjectionTouch = { agent: Agent; path: string }
|
||||
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
projectionLifecycle.abort(new Error('agent-instructions disposed'))
|
||||
executionTouches.clear()
|
||||
},
|
||||
'agent-instructions.projectionLifecycle',
|
||||
)
|
||||
// Emit listeners are not awaited, so each projection must compose against the
|
||||
// inbox produced by earlier file results for the same agent.
|
||||
const projectionTails = new WeakMap<Agent, Promise<void>>()
|
||||
// Execution ancestry and the enclosing durable step are the two commit
|
||||
// boundaries before an asynchronous projection may mutate the agent inbox.
|
||||
const openSteps = new WeakMap<Session, boolean>()
|
||||
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
|
||||
|
||||
const compose = async (
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
claimed: readonly UserMessage[],
|
||||
pending: readonly UserMessage[],
|
||||
touchedPaths: readonly string[] = [],
|
||||
): Promise<UserMessage | undefined> => {
|
||||
signal.throwIfAborted()
|
||||
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
|
||||
return undefined
|
||||
}
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return undefined
|
||||
if (touchedPaths.length === 0 && pending.length > 0) return pending[0]
|
||||
const content: UserMessage['content'][number][] = []
|
||||
const changes: AgentInstructionChange[] = []
|
||||
let desiredBaseline = false
|
||||
const authorityMessages = [...claimed]
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal)
|
||||
const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot)
|
||||
const visibleBaseline = visibleBaselineSource(agent, authorityMessages)
|
||||
const baselinePresent = visibleBaseline !== undefined
|
||||
const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity
|
||||
const prepared = baselinePreparations.get(agent.session)
|
||||
let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity
|
||||
? prepared.excludedScopes
|
||||
: undefined
|
||||
let nextPreparation: { identity: string; excludedScopes: ReadonlySet<string> } | undefined
|
||||
if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === undefined) {
|
||||
const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline
|
||||
const instructions = await loadBaselineInstructionSet({
|
||||
cwd,
|
||||
dshHome: resolved.dshHome,
|
||||
projectRootMarkers: resolved.projectRootMarkers,
|
||||
maxBytes: resolved.maxBytes,
|
||||
maxSourceBytes: resolved.maxSourceBytes,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
|
||||
projectRoot,
|
||||
replacePreviousBaseline,
|
||||
signal,
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
const observedBaseline = baselineInstructionState(instructions?.observed ?? [])
|
||||
const excludedScopes = new Set(observedBaseline.changes.keys())
|
||||
for (const scope of baseline.changes.keys()) excludedScopes.delete(scope)
|
||||
excludedBaselineScopes = excludedScopes
|
||||
nextPreparation = { identity, excludedScopes }
|
||||
let versionStates = instructionVersions.get(agent.session)
|
||||
if (versionStates === undefined && baseline.versions.size > 0) {
|
||||
versionStates = new Map()
|
||||
instructionVersions.set(agent.session, versionStates)
|
||||
}
|
||||
for (const [scope, state] of baseline.versions) versionStates?.set(scope, state)
|
||||
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const baselineContent = workspaceContextMessage(instructions.rendered.text).content
|
||||
content.push(...baselineContent)
|
||||
const replacementScopes = new Set(baseline.changes.keys())
|
||||
const replacementRemovals = replacePreviousBaseline
|
||||
? visibleBaseline.changes.flatMap(change => (
|
||||
change.action === 'remove' || replacementScopes.has(change.scope)
|
||||
? []
|
||||
: [{ action: 'remove' as const, scope: change.scope, path: change.path }]
|
||||
))
|
||||
: []
|
||||
const baselineChanges = [...replacementRemovals, ...baseline.changes.values()]
|
||||
changes.push(...baselineChanges)
|
||||
authorityMessages.push(createUserMessage({
|
||||
content: baselineContent,
|
||||
source: {
|
||||
kind: 'agent-instructions',
|
||||
form: 'instructions',
|
||||
baseline: true,
|
||||
baselineIdentity: identity,
|
||||
changes: baselineChanges,
|
||||
},
|
||||
}))
|
||||
desiredBaseline = true
|
||||
}
|
||||
}
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{
|
||||
authorityMessages,
|
||||
scopeMessages: pending,
|
||||
includeBaselineScopes: keepVisibleBaseline,
|
||||
...keepVisibleBaseline ? { excludedBaselineScopes } : {},
|
||||
touchedPaths,
|
||||
projectRoot,
|
||||
signal,
|
||||
},
|
||||
)
|
||||
if (update !== undefined) {
|
||||
content.push(...update.context.content)
|
||||
/* v8 ignore next -- reconciliation constructs only agent-instructions contexts. */
|
||||
if (update.context.source.kind === 'agent-instructions') {
|
||||
changes.push(...update.context.source.changes)
|
||||
}
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (nextPreparation !== undefined) baselinePreparations.set(agent.session, nextPreparation)
|
||||
if (content.length === 0) return undefined
|
||||
return createUserMessage({
|
||||
content,
|
||||
source: {
|
||||
kind: 'agent-instructions',
|
||||
form: 'instructions',
|
||||
...desiredBaseline ? { baseline: true } : {},
|
||||
...desiredBaseline ? { baselineIdentity: identity } : {},
|
||||
changes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => {
|
||||
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
|
||||
const alreadySupplied = desired !== undefined && (
|
||||
claimed.some(message => sameContextPayload(message, desired))
|
||||
|| agent.session.surface.nodes.some((seq) => {
|
||||
const event = agent.session.events[seq]
|
||||
return event?.type === 'user/message' && sameContextPayload(event.data, desired)
|
||||
})
|
||||
)
|
||||
if (desired === undefined || alreadySupplied) {
|
||||
for (const message of pending) agent.inbox.remove(message.id)
|
||||
return
|
||||
}
|
||||
const reusable = pending.find(message => sameContextPayload(message, desired))
|
||||
if (reusable !== undefined) {
|
||||
for (const message of pending) {
|
||||
if (message !== reusable) agent.inbox.remove(message.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const replaced = pending[0]
|
||||
if (replaced === undefined) agent.inbox.prepend('next-step', desired)
|
||||
else agent.inbox.replace(replaced.id, desired)
|
||||
for (const message of pending.slice(1)) agent.inbox.remove(message.id)
|
||||
}
|
||||
|
||||
const composeAndSync = async (
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
claimed: readonly UserMessage[],
|
||||
touchedPaths: readonly string[] = [],
|
||||
): Promise<void> => {
|
||||
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
|
||||
const desired = await compose(agent, signal, claimed, pending, touchedPaths)
|
||||
signal.throwIfAborted()
|
||||
syncInbox(agent, claimed, desired)
|
||||
}
|
||||
|
||||
const queueProjection = (
|
||||
agent: Agent,
|
||||
touchedPath: string,
|
||||
): void => {
|
||||
const previous = projectionTails.get(agent) ?? Promise.resolve()
|
||||
const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath]))
|
||||
.catch((error: unknown) => {
|
||||
if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)
|
||||
})
|
||||
projectionTails.set(agent, current)
|
||||
void current.then(() => {
|
||||
if (projectionTails.get(agent) === current) projectionTails.delete(agent)
|
||||
})
|
||||
}
|
||||
|
||||
const waitForProjections = async (agent: Agent): Promise<void> => {
|
||||
let projection: Promise<void> | undefined
|
||||
while ((projection = projectionTails.get(agent)) !== undefined) await projection
|
||||
}
|
||||
|
||||
const stepIsOpen = (session: Session): boolean => {
|
||||
const known = openSteps.get(session)
|
||||
if (known !== undefined) return known
|
||||
let open = false
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'step/start') open = true
|
||||
else if (event.type === 'step/end' || event.type === 'turn/end') open = false
|
||||
}
|
||||
openSteps.set(session, open)
|
||||
return open
|
||||
}
|
||||
|
||||
const projectTouch = (touch: ProjectionTouch): void => {
|
||||
const session = touch.agent.session
|
||||
if (!stepIsOpen(session)) {
|
||||
queueProjection(touch.agent, touch.path)
|
||||
return
|
||||
}
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) stepTouches.set(session, [touch])
|
||||
else pending.push(touch)
|
||||
}
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'step/start') {
|
||||
openSteps.set(session, true)
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
openSteps.set(session, false)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'step/end') return
|
||||
openSteps.set(session, false)
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) return
|
||||
stepTouches.delete(session)
|
||||
for (const touch of pending) queueProjection(touch.agent, touch.path)
|
||||
})
|
||||
|
||||
ctx.on('agent/pre-step', async (
|
||||
{ agent, messages, step, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
await waitForProjections(agent)
|
||||
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
|
||||
const desired = await compose(agent, signal, messages, pending)
|
||||
signal.throwIfAborted()
|
||||
// An empty first entry owns a no-step turn; keep context pending instead
|
||||
// of turning it into a standalone request. Later entries may be tool continuations.
|
||||
if (decision.kind === 'reject' || (step === 1 && decision.messages.length === 0)) {
|
||||
syncInbox(agent, messages, desired)
|
||||
return decision
|
||||
}
|
||||
// A proceeding step settles the pending context: it either enters below as
|
||||
// `desired`, or its payload is already covered by the batch, so nothing stays pending.
|
||||
for (const message of pending) agent.inbox.remove(message.id)
|
||||
if (desired === undefined || decision.messages.some(message => sameContextPayload(message, desired))) {
|
||||
return decision
|
||||
}
|
||||
// Fold the context right after the claimed batch, so the direct prompt
|
||||
// precedes it and the driver-appended runtime context follows it.
|
||||
const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message))
|
||||
const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired)
|
||||
return { kind: 'enter', messages: entered }
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
const touches = executionTouches.get(exec.token) ?? []
|
||||
executionTouches.delete(exec.token)
|
||||
if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) {
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath })
|
||||
}
|
||||
if (exec.parent !== undefined) {
|
||||
if (touches.length > 0) {
|
||||
const parentTouches = executionTouches.get(exec.parent)
|
||||
if (parentTouches === undefined) executionTouches.set(exec.parent, touches)
|
||||
else parentTouches.push(...touches)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const touch of touches) projectTouch(touch)
|
||||
})
|
||||
}
|
||||
30
packages/context/agent-instructions/src/invariant.ts
Normal file
30
packages/context/agent-instructions/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-agent-instructions`.
|
||||
* @module @deepseek-ai/dsh-agent-instructions/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-instructions'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workspace-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources,
|
||||
* while focused pipeline tests own its private pending/cache state transitions.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
361
packages/context/agent-instructions/src/render.ts
Normal file
361
packages/context/agent-instructions/src/render.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Model-facing workspace instruction rendering within an explicit byte budget.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-instructions/render
|
||||
*/
|
||||
|
||||
import { basename, dirname } from 'node:path'
|
||||
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
|
||||
|
||||
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
|
||||
const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
|
||||
const WORKSPACE_CONTEXT_INTRO = 'The 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.'
|
||||
const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
|
||||
+ WORKSPACE_CONTEXT_INTRO
|
||||
const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
|
||||
+ 'No workspace instructions are currently active.'
|
||||
const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
|
||||
|
||||
/** Byte-accounting record for one truncated instruction file. */
|
||||
export interface TruncatedInstruction {
|
||||
displayPath: string
|
||||
originalBytes: number
|
||||
includedBytes: number
|
||||
}
|
||||
|
||||
/** Model-facing text plus omitted and truncated source records. */
|
||||
export interface RenderedWorkspaceContext {
|
||||
text: string
|
||||
omitted: InstructionFile[]
|
||||
truncated: TruncatedInstruction[]
|
||||
}
|
||||
|
||||
interface RenderedInstructionContext extends RenderedWorkspaceContext {
|
||||
/**
|
||||
* Original files semantically represented by rendered section text. This is
|
||||
* not the complement of `omitted`: a truncated file may be represented here
|
||||
* and in `truncated`, while a notice-only file appears in neither. A genuinely
|
||||
* empty file counts when its heading survives because that heading conveys
|
||||
* that the instruction exists and has no content.
|
||||
*/
|
||||
represented: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||
export interface AgentInstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
scope: string
|
||||
path: string
|
||||
digest?: string
|
||||
}
|
||||
|
||||
/** One state transition paired with the content used to render it. */
|
||||
export interface ChangeRenderItem {
|
||||
change: AgentInstructionChange
|
||||
file: LoadedInstructionFile
|
||||
}
|
||||
|
||||
interface RenderStyle {
|
||||
intro: string
|
||||
section(file: LoadedInstructionFile): string
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return Buffer.byteLength(value, 'utf8')
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const bytes = Buffer.from(value, 'utf8')
|
||||
if (bytes.length <= maxBytes) return value
|
||||
let end = Math.max(0, Math.trunc(maxBytes))
|
||||
// If the first excluded byte is a UTF-8 continuation byte, the budget cut
|
||||
// through that code point. Back up to its lead byte and exclude it too.
|
||||
while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) {
|
||||
end -= 1
|
||||
}
|
||||
return bytes.subarray(0, end).toString('utf8')
|
||||
}
|
||||
|
||||
function escapeInstructionFrameBody(body: string): string {
|
||||
return body.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
|
||||
}
|
||||
|
||||
function sectionText(file: LoadedInstructionFile): string {
|
||||
return `Instructions from: ${file.displayPath}\n\n${file.content}`
|
||||
}
|
||||
|
||||
/** Directory component that identifies the single user-global instruction scope. */
|
||||
export const USER_GLOBAL_DIRECTORY = 'user-global'
|
||||
|
||||
/**
|
||||
* File name of the single user-global instruction file under `$DSH_HOME`.
|
||||
* Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
|
||||
* candidate component) both key on this name, so it lives in one place: were the
|
||||
* two to disagree, the user-global instruction would load but never reconcile.
|
||||
*/
|
||||
export const USER_GLOBAL_FILE = 'AGENTS.md'
|
||||
|
||||
/**
|
||||
* Derive the logical instruction scope from a model-facing path.
|
||||
* @param displayPath - project-relative or user-global instruction path.
|
||||
* @returns `user-global`, `.`, or the containing project-relative directory.
|
||||
*/
|
||||
export function scopeForDisplayPath(displayPath: string): string {
|
||||
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return USER_GLOBAL_DIRECTORY
|
||||
return dirname(displayPath)
|
||||
}
|
||||
|
||||
const SCOPE_SEPARATOR = '\u0000'
|
||||
|
||||
/**
|
||||
* Compose the reconciliation key for one instruction candidate file.
|
||||
* Each loaded candidate is tracked independently, so the key pairs the logical
|
||||
* directory with the exact candidate file name behind a NUL separator that no
|
||||
* directory path or file name can contain. Distinct candidates in one directory
|
||||
* (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
|
||||
* never collide in the scope-keyed state maps.
|
||||
* @param directory - `user-global`, `.`, or a project-relative directory.
|
||||
* @param candidateName - instruction file name within that directory.
|
||||
* @returns the per-candidate logical scope key.
|
||||
*/
|
||||
export function candidateScopeKey(directory: string, candidateName: string): string {
|
||||
return `${directory}${SCOPE_SEPARATOR}${candidateName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the per-candidate scope key for a loaded instruction file.
|
||||
* @param displayPath - project-relative or user-global instruction path.
|
||||
* @returns the scope key pairing the file's directory with its name.
|
||||
*/
|
||||
export function instructionScopeKey(displayPath: string): string {
|
||||
return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the directory and candidate name that {@link candidateScopeKey} encoded.
|
||||
* @param scope - a per-candidate scope key.
|
||||
* @returns the directory scope and the candidate file name within it.
|
||||
*/
|
||||
export function decodeScopeKey(scope: string): { directory: string; candidateName: string } {
|
||||
const separator = scope.indexOf(SCOPE_SEPARATOR)
|
||||
/* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
|
||||
if (separator < 0) return { directory: scope, candidateName: '' }
|
||||
return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) }
|
||||
}
|
||||
|
||||
function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
const scope = scopeForDisplayPath(file.displayPath)
|
||||
return [
|
||||
`Additional instructions from: ${file.displayPath}`,
|
||||
'',
|
||||
`These instructions apply to work under \`${scope}\`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.`,
|
||||
'',
|
||||
file.content,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
|
||||
|
||||
function baselineRenderStyle(files: LoadedInstructionFile[], replacePreviousBaseline: boolean | undefined): RenderStyle {
|
||||
if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE
|
||||
return {
|
||||
...BASELINE_RENDER_STYLE,
|
||||
intro: files.length === 0
|
||||
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
|
||||
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
|
||||
}
|
||||
}
|
||||
|
||||
function changedSectionText(item: ChangeRenderItem): string {
|
||||
const { change, file } = item
|
||||
if (change.action === 'set') return additionalSectionText(file)
|
||||
if (change.action === 'remove') {
|
||||
return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
|
||||
}
|
||||
return [
|
||||
`Updated instructions from: ${change.path}`,
|
||||
'',
|
||||
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
|
||||
'',
|
||||
file.content,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one reconciliation batch and retain only transitions that fit.
|
||||
* @param items - ordered state transitions and current file contents.
|
||||
* @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch.
|
||||
* @returns bounded prompt text and the transitions actually represented by it.
|
||||
*/
|
||||
export function renderInstructionChanges(
|
||||
items: ChangeRenderItem[],
|
||||
maxBytes: number,
|
||||
): { text: string; changes: AgentInstructionChange[] } {
|
||||
const byAbsolutePath = new Map(items.map(item => [item.file.absolutePath, item]))
|
||||
const style: RenderStyle = {
|
||||
intro: '',
|
||||
section(file) {
|
||||
const item = byAbsolutePath.get(file.absolutePath)
|
||||
/* v8 ignore next -- the renderer receives exactly the files used to construct this map. */
|
||||
return item === undefined ? '' : changedSectionText({ ...item, file })
|
||||
},
|
||||
}
|
||||
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
|
||||
const represented = new Set(rendered.represented.map(file => file.absolutePath))
|
||||
return {
|
||||
text: rendered.text,
|
||||
changes: items
|
||||
.filter(item => represented.has(item.file.absolutePath))
|
||||
.map(item => item.change),
|
||||
}
|
||||
}
|
||||
|
||||
function markerText(maxBytes: number, omitted: InstructionFile[], truncated: TruncatedInstruction[]): string {
|
||||
if (omitted.length === 0 && truncated.length === 0) return ''
|
||||
const parts: string[] = []
|
||||
if (omitted.length > 0) {
|
||||
parts.push(`omitted ${omitted.map(file => file.displayPath).join(', ')}`)
|
||||
}
|
||||
if (truncated.length > 0) {
|
||||
parts.push(`truncated ${truncated.map(item => `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes`).join(', ')}`)
|
||||
}
|
||||
return `Workspace instruction budget ${maxBytes} bytes: ${parts.join('; ')}`
|
||||
}
|
||||
|
||||
function buildInstructionText(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
omitted: InstructionFile[],
|
||||
truncated: TruncatedInstruction[],
|
||||
style: RenderStyle,
|
||||
): string {
|
||||
const marker = markerText(maxBytes, omitted, truncated)
|
||||
const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0)
|
||||
// Caller-owned framing: the plugin bakes the complete `<system-reminder>`
|
||||
// frame into the message content. The session surface projects context
|
||||
// verbatim and does not wrap it, so any framing must live here in the
|
||||
// producer's content (the pattern a future `meta`-driven renderer would
|
||||
// generalize — see the deferred note in
|
||||
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md).
|
||||
return [SYSTEM_REMINDER_OPEN, escapeInstructionFrameBody(body.join('\n\n')), SYSTEM_REMINDER_CLOSE].join('\n')
|
||||
}
|
||||
|
||||
function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile {
|
||||
return { ...file, content: truncateUtf8(file.content, includedBytes) }
|
||||
}
|
||||
|
||||
function truncateToFit(
|
||||
file: LoadedInstructionFile,
|
||||
includedFiles: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
omitted: InstructionFile[],
|
||||
style: RenderStyle,
|
||||
): LoadedInstructionFile {
|
||||
const originalBytes = byteLength(file.content)
|
||||
let low = 0
|
||||
let high = originalBytes
|
||||
let best = withTruncatedContent(file, 0)
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
const candidate = withTruncatedContent(file, mid)
|
||||
const truncated = [{ displayPath: file.displayPath, originalBytes, includedBytes: byteLength(candidate.content) }]
|
||||
const text = buildInstructionText([...includedFiles, candidate], maxBytes, omitted, truncated, style)
|
||||
if (byteLength(text) <= maxBytes) {
|
||||
best = candidate
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function renderInstructionContext(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
style: RenderStyle,
|
||||
): RenderedInstructionContext {
|
||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
|
||||
return { text: '', omitted: files, truncated: [], represented: [] }
|
||||
}
|
||||
|
||||
const fullText = buildInstructionText(files, maxBytes, [], [], style)
|
||||
if (byteLength(fullText) <= maxBytes) {
|
||||
return { text: fullText, omitted: [], truncated: [], represented: files }
|
||||
}
|
||||
|
||||
for (let start = 1; start < files.length; start += 1) {
|
||||
const included = files.slice(start)
|
||||
const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
|
||||
if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], represented: included }
|
||||
}
|
||||
|
||||
const mostSpecific = files.at(-1)
|
||||
/* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
|
||||
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] }
|
||||
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
const originalBytes = byteLength(mostSpecific.content)
|
||||
|
||||
for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
|
||||
const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
|
||||
const includedBytes = byteLength(truncatedFile.content)
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes,
|
||||
includedBytes,
|
||||
}]
|
||||
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
|
||||
if (byteLength(text) <= maxBytes) {
|
||||
const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text, omitted, truncated, represented }
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes,
|
||||
includedBytes: 0,
|
||||
}]
|
||||
const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated))
|
||||
const compactWithHeading = escapeInstructionFrameBody(
|
||||
[compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
|
||||
)
|
||||
if (byteLength(compactWithHeading) <= maxBytes) {
|
||||
const represented = originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text: compactWithHeading, omitted, truncated, represented }
|
||||
}
|
||||
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
|
||||
return { text, omitted, truncated, represented: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a baseline together with the exact source files semantically represented in it.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @returns bounded public rendering plus files with surviving content, including genuinely empty files.
|
||||
* @internal
|
||||
*/
|
||||
export function renderWorkspaceInstructionSet(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } {
|
||||
const style = baselineRenderStyle(files, options.replacePreviousBaseline)
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style)
|
||||
return { rendered, included: represented }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the baseline instruction chain with deterministic precedence budgeting.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @returns bounded baseline prompt text and budget diagnostics.
|
||||
*/
|
||||
export function renderWorkspaceContext(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): RenderedWorkspaceContext {
|
||||
return renderWorkspaceInstructionSet(files, options).rendered
|
||||
}
|
||||
433
packages/context/agent-instructions/src/state.ts
Normal file
433
packages/context/agent-instructions/src/state.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Session-visible workspace instruction state and dynamic reconciliation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-instructions/state
|
||||
*/
|
||||
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
findProjectRoot,
|
||||
probeScopeInstruction,
|
||||
readScopeInstruction,
|
||||
relativeDisplay,
|
||||
type LoadedInstructionFile,
|
||||
} from './files.ts'
|
||||
import {
|
||||
candidateScopeKey,
|
||||
decodeScopeKey,
|
||||
instructionScopeKey,
|
||||
renderInstructionChanges,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
type ChangeRenderItem,
|
||||
type AgentInstructionChange,
|
||||
} from './render.ts'
|
||||
|
||||
export const name = 'agent-instructions'
|
||||
|
||||
/** Durable producer, file, and reconciliation facts for one workspace context. */
|
||||
export interface AgentInstructionSource {
|
||||
kind: 'agent-instructions'
|
||||
/** Every workspace context carries instructions read out of a file (the `instructions` context form). */
|
||||
form: 'instructions'
|
||||
/** Marks the complete startup/resume baseline rather than a later delta. */
|
||||
baseline?: true
|
||||
/** Discovery, precedence, and budget identity used to validate a resumed baseline. */
|
||||
baselineIdentity?: string
|
||||
changes: AgentInstructionChange[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
'agent-instructions': AgentInstructionSource
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
|
||||
export interface InstructionVersionState {
|
||||
path: string
|
||||
version: FsVersion
|
||||
digest: string
|
||||
/**
|
||||
* Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
|
||||
* per-directory duplicates on the metadata fast path without re-reading a sibling.
|
||||
*/
|
||||
trimmedDigest: string
|
||||
}
|
||||
|
||||
/** Session-isolated fast-path state keyed by logical instruction scope. */
|
||||
export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
|
||||
|
||||
/** A metadata-cache transition associated with one rendered instruction change. */
|
||||
export interface InstructionVersionUpdate {
|
||||
change: AgentInstructionChange
|
||||
state?: InstructionVersionState
|
||||
}
|
||||
|
||||
/** Rendered reconciliation plus its metadata-cache transitions. */
|
||||
export interface ReconciledInstructionContext {
|
||||
context: UserMessage
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}
|
||||
|
||||
function workspaceContextHook(text: string, changes: AgentInstructionChange[]): UserMessage {
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'agent-instructions', form: 'instructions', changes },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user-role message for a rendered baseline.
|
||||
* @param text - complete plugin-owned system-reminder text.
|
||||
* @returns a user-role prefix message.
|
||||
*/
|
||||
export function workspaceContextMessage(text: string): Message {
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: name },
|
||||
})
|
||||
}
|
||||
|
||||
function isWorkspaceContextSource(
|
||||
source: unknown,
|
||||
): source is { kind: 'agent-instructions'; changes: unknown[] } {
|
||||
return typeof source === 'object' && source !== null
|
||||
&& 'kind' in source && source.kind === 'agent-instructions'
|
||||
&& 'changes' in source && Array.isArray(source.changes)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function workspaceInstructionChanges(source: { changes: unknown[] }): AgentInstructionChange[] {
|
||||
const changes: AgentInstructionChange[] = []
|
||||
for (const value of source.changes) {
|
||||
if (!isRecord(value)) continue
|
||||
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
|
||||
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
|
||||
if (value.digest !== undefined && typeof value.digest !== 'string') continue
|
||||
changes.push({
|
||||
action: value.action,
|
||||
scope: value.scope,
|
||||
path: value.path,
|
||||
...value.digest !== undefined ? { digest: value.digest } : {},
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
function sameInstructionChange(a: AgentInstructionChange, b: AgentInstructionChange): boolean {
|
||||
return a.action === b.action
|
||||
&& a.scope === b.scope
|
||||
&& a.path === b.path
|
||||
&& a.digest === b.digest
|
||||
}
|
||||
|
||||
function visibleInstructionChanges(
|
||||
agent: Agent,
|
||||
authorityMessages: readonly UserMessage[],
|
||||
): Map<string, AgentInstructionChange> {
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes)
|
||||
const visible = new Map<string, AgentInstructionChange>()
|
||||
for (const [seq, event] of agent.session.events.entries()) {
|
||||
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
|
||||
const changes = workspaceInstructionChanges(event.data.source)
|
||||
for (const change of changes) {
|
||||
if (visibleSeqs.has(seq)) visible.set(change.scope, change)
|
||||
}
|
||||
}
|
||||
for (const message of authorityMessages) {
|
||||
if (!isWorkspaceContextSource(message.source)) continue
|
||||
for (const change of workspaceInstructionChanges(message.source)) {
|
||||
visible.set(change.scope, change)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert retained baseline files into comparison and metadata-cache state.
|
||||
* @param files - baseline files that survived rendering.
|
||||
* @returns latest baseline changes and provider versions keyed by logical scope.
|
||||
*/
|
||||
export function baselineInstructionState(files: LoadedInstructionFile[]): {
|
||||
changes: Map<string, AgentInstructionChange>
|
||||
versions: Map<string, InstructionVersionState>
|
||||
} {
|
||||
const changes = new Map<string, AgentInstructionChange>()
|
||||
const versions = new Map<string, InstructionVersionState>()
|
||||
for (const file of files) {
|
||||
const digest = instructionContentSha1(file.content)
|
||||
const change: AgentInstructionChange = {
|
||||
action: 'set',
|
||||
scope: instructionScopeKey(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest,
|
||||
}
|
||||
changes.set(change.scope, change)
|
||||
if (file.version !== undefined) {
|
||||
versions.set(change.scope, {
|
||||
path: file.displayPath,
|
||||
version: file.version,
|
||||
digest,
|
||||
trimmedDigest: trimmedInstructionDigest(file.content),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { changes, versions }
|
||||
}
|
||||
|
||||
function versionStatesFor(session: Session, cache: InstructionVersionCache): Map<string, InstructionVersionState> {
|
||||
let states = cache.get(session)
|
||||
if (states === undefined) {
|
||||
states = new Map()
|
||||
cache.set(session, states)
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only cache updates represented by rendered changes.
|
||||
* @param updates - proposed updates from one or more reconciliations.
|
||||
* @param renderedChanges - transitions retained by the renderer.
|
||||
* @returns updates represented by an exact retained transition.
|
||||
*/
|
||||
export function retainedInstructionVersionUpdates(
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
renderedChanges: readonly AgentInstructionChange[],
|
||||
): InstructionVersionUpdate[] {
|
||||
return updates.filter(update => renderedChanges.some(change => sameInstructionChange(update.change, change)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply metadata-cache transitions without retaining instruction prose.
|
||||
* @param session - owning session.
|
||||
* @param updates - ordered set/delete transitions.
|
||||
* @param cache - session-isolated metadata cache.
|
||||
*/
|
||||
export function applyInstructionVersionUpdates(
|
||||
session: Session,
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
cache: InstructionVersionCache,
|
||||
): void {
|
||||
if (updates.length === 0) return
|
||||
const states = versionStatesFor(session, cache)
|
||||
for (const update of updates) {
|
||||
if (update.state === undefined) states.delete(update.change.scope)
|
||||
else states.set(update.change.scope, update.state)
|
||||
}
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
function relativeScope(projectRoot: string, dir: string): string {
|
||||
const scope = relativeDisplay(projectRoot, dir)
|
||||
return scope.length === 0 ? '.' : scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare visible state with provider-visible files and render transitions.
|
||||
* @param agent - session owner whose visible surface supplies durable state.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
|
||||
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
|
||||
*/
|
||||
export async function reconcileInstructionContext(
|
||||
agent: Agent,
|
||||
resolved: ResolvedConfig,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
options: {
|
||||
authorityMessages: readonly UserMessage[]
|
||||
scopeMessages: readonly UserMessage[]
|
||||
touchedPaths: readonly string[]
|
||||
includeBaselineScopes: boolean
|
||||
excludedBaselineScopes?: ReadonlySet<string>
|
||||
projectRoot?: string
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
const session = agent.session
|
||||
const effective = visibleInstructionChanges(agent, options.authorityMessages)
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = session.header.cwd ?? process.cwd()
|
||||
// TODO(frozen-project-root): retain the baseline root for the loop instance;
|
||||
// recomputing it after marker edits reinterprets the existing relative scope keys.
|
||||
const projectRoot = options.projectRoot
|
||||
?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const scopes = new Set<string>()
|
||||
const baselineScopes = new Set<string>()
|
||||
const addDirScopes = (target: Set<string>, directory: string): void => {
|
||||
for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
|
||||
for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
|
||||
}
|
||||
const addProjectScopes = (target: Set<string>, dir: string): void => {
|
||||
addDirScopes(target, relativeScope(projectRoot, dir))
|
||||
}
|
||||
baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir)
|
||||
if (options.includeBaselineScopes) {
|
||||
for (const scope of baselineScopes) scopes.add(scope)
|
||||
}
|
||||
for (const message of options.scopeMessages) {
|
||||
/* v8 ignore next -- the plugin passes its workspace-only pending projection. */
|
||||
if (!isWorkspaceContextSource(message.source)) continue
|
||||
for (const change of workspaceInstructionChanges(message.source)) {
|
||||
if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue
|
||||
scopes.add(change.scope)
|
||||
}
|
||||
}
|
||||
for (const scope of effective.keys()) {
|
||||
if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
else addDirScopes(scopes, directory)
|
||||
}
|
||||
for (const touchedPath of options.touchedPaths) {
|
||||
for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir)
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
const seenAbsolutePaths = new Set<string>()
|
||||
// Per-directory trimmed-content identities kept so far this pass, iterated in
|
||||
// candidate order (base before local); a later sibling matching an earlier one
|
||||
// is a duplicate and is dropped or removed rather than rendered twice.
|
||||
const keptTrimmedByDir = new Map<string, Set<string>>()
|
||||
const registerKeptTrimmed = (directory: string, digest: string): boolean => {
|
||||
let digests = keptTrimmedByDir.get(directory)
|
||||
if (digests === undefined) {
|
||||
digests = new Set()
|
||||
keptTrimmedByDir.set(directory, digests)
|
||||
}
|
||||
if (digests.has(digest)) return true
|
||||
digests.add(digest)
|
||||
return false
|
||||
}
|
||||
const items: ChangeRenderItem[] = []
|
||||
const versionUpdates: InstructionVersionUpdate[] = []
|
||||
const pushRemoval = (scope: string, path: string): void => {
|
||||
const change: AgentInstructionChange = { action: 'remove', scope, path }
|
||||
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
|
||||
versionUpdates.push({ change })
|
||||
}
|
||||
const scopesByDirectory = new Map<string, string[]>()
|
||||
for (const scope of scopes) {
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
const directoryScopes = scopesByDirectory.get(directory)
|
||||
if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope])
|
||||
else directoryScopes.push(scope)
|
||||
}
|
||||
for (const [directory, directoryScopes] of scopesByDirectory) {
|
||||
const probedScopes: string[] = []
|
||||
for (const scope of directoryScopes) {
|
||||
if (options.excludedBaselineScopes !== undefined
|
||||
&& baselineScopes.has(scope)
|
||||
&& options.excludedBaselineScopes.has(scope)) {
|
||||
const previous = effective.get(scope)
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
} else {
|
||||
probedScopes.push(scope)
|
||||
}
|
||||
}
|
||||
const itemStart = items.length
|
||||
const versionUpdateStart = versionUpdates.length
|
||||
const addedAbsolutePaths: string[] = []
|
||||
const priorVersions = new Map(probedScopes.map(scope => [scope, versions.get(scope)]))
|
||||
for (const scope of probedScopes) {
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') {
|
||||
if (previous === undefined || previous.action === 'remove') continue
|
||||
// Same-directory candidates form one deduplicated authority group. If an
|
||||
// active member cannot be observed, preserve the entire last-good group;
|
||||
// cache warmth must never decide whether a sibling transition is emitted.
|
||||
items.splice(itemStart)
|
||||
versionUpdates.splice(versionUpdateStart)
|
||||
for (const [candidateScope, prior] of priorVersions) {
|
||||
if (prior === undefined) versions.delete(candidateScope)
|
||||
else versions.set(candidateScope, prior)
|
||||
}
|
||||
for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath)
|
||||
keptTrimmedByDir.delete(directory)
|
||||
break
|
||||
}
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
|
||||
seenAbsolutePaths.add(probedFile.absolutePath)
|
||||
addedAbsolutePaths.push(probedFile.absolutePath)
|
||||
const cached = versions.get(scope)
|
||||
if (
|
||||
cached !== undefined
|
||||
&& cached.path === probedFile.displayPath
|
||||
&& cached.version === probedFile.version
|
||||
&& previous !== undefined
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) {
|
||||
// Unchanged and previously rendered: keep it, but an earlier sibling that
|
||||
// now matches its trimmed content makes this the duplicate to remove.
|
||||
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const trimmedDigest = trimmedInstructionDigest(file.content)
|
||||
if (registerKeptTrimmed(directory, trimmedDigest)) {
|
||||
// A distinct file whose trimmed content already appeared earlier in this
|
||||
// directory: drop it, removing any copy that was previously rendered.
|
||||
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
|
||||
else versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
trimmedDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const change: AgentInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
versionUpdates.push({ change, state: nextVersion })
|
||||
}
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
// When no transition survived rendering (tiny budgets render notice-only
|
||||
// text), emit nothing and commit nothing — the uncommitted versions make the
|
||||
// next pass retry instead of spamming notice-only contexts.
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
return {
|
||||
context: workspaceContextHook(rendered.text, rendered.changes),
|
||||
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions'
|
||||
import { candidateScopeKey } from '../src/render.ts'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PROBE = 'banana-271828'
|
||||
const NESTED_PROBE = 'papaya-314159'
|
||||
const UPDATED_PROBE = 'guava-161803'
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; agent: Agent }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-workspace-context-e2e-'))
|
||||
await mkdir(join(workdir, '.git'), { recursive: true })
|
||||
await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'Answer the user exactly and concisely.' })
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('workspace-context-e2e-session'),
|
||||
meta: { cwd: workdir },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
return { ctx, agent: handle.agent }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function finalText(events: SessionEvent[]): string {
|
||||
const message = events.findLast(event => event.type === 'assistant/message')
|
||||
if (message?.type !== 'assistant/message') return ''
|
||||
return message.data.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real model sees AGENTS.md baseline', () => {
|
||||
it('obeys a probe instruction loaded from the workspace', async () => {
|
||||
const live = await harness()
|
||||
|
||||
live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
expect(finalText([...live.agent.session.events])).toContain(PROBE)
|
||||
}, 120_000)
|
||||
|
||||
it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => {
|
||||
const live = await harness()
|
||||
await mkdir(join(workdir!, 'pkg/deep'), { recursive: true })
|
||||
await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`)
|
||||
await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n')
|
||||
|
||||
live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
|
||||
}, 120_000)
|
||||
|
||||
it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
|
||||
const live = await harness()
|
||||
await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n')
|
||||
live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`)
|
||||
|
||||
live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
const events = [...live.agent.session.events]
|
||||
const update = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'agent-instructions'
|
||||
&& event.data.source.baseline !== true)
|
||||
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||||
})
|
||||
const updateText = update?.type === 'user/message'
|
||||
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
: ''
|
||||
expect(updateText).toContain('Updated instructions from: AGENTS.md')
|
||||
expect(finalText(events)).toContain(UPDATED_PROBE)
|
||||
}, 120_000)
|
||||
})
|
||||
4648
packages/context/agent-instructions/tests/agent-instructions.spec.ts
Normal file
4648
packages/context/agent-instructions/tests/agent-instructions.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
39
packages/context/agent-instructions/tsconfig.json
Normal file
39
packages/context/agent-instructions/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../util/home-paths"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user