mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
fix(agent-instructions): budget the user-global and project chains separately (#6400)
* fix(agent-instructions): budget the user-global and project chains separately The user-global and project instruction files shared one maxBytes and the user-global files were the broadest, so a large project chain omitted them first. Each chain now renders under maxBytes on its own and the results share one system-reminder frame: unchanged output when everything fits or one chain is empty, at most twice maxBytes otherwise. The project chain keeps its omit-broadest-first, truncate-last rule; reconciliation batches follow the same split; the maxBytes key and the baseline identity are unchanged. Tests cover the fast paths, both chains fitting only separately, each chain over its own budget, the 2 × maxBytes boundary at exact and tiny limits, multibyte content, reconciliation batches, and a resumed single-budget baseline receiving the user-global file as one transition. The agent-instructions-budget snapshot is refreshed and agent-instructions-budget-chains is added. README, Config.maxBytes JSDoc, the configuration catalog, a new Agent Note, and an upgrade guide state the per-chain bound. * fix(agent-instructions): keep loaded order in merged frames and note the unshared chain budget A merged frame now lists sections, omissions and truncations in the loaded order, so a reconciliation batch renders its items in one order whether or not it fits a single budget. The guard comment states only the invariant and points to the Agent Note; the intro selection states its rule. The README names the unshared budget between chains. --------- Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
ZiyaZhang
parent
d2552daf45
commit
114da79e42
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-16-separate-instruction-chain-budgets.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-16-separate-instruction-chain-budgets.md
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains:
|
||||
en: 8ad05eb6bd0511b9
|
||||
zh: bd0658fa58edc70d
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/problem:
|
||||
en: 77f3bb05053ce59f
|
||||
zh: 4a1759900b71cc44
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/decision:
|
||||
en: 5dd811039ee2fef3
|
||||
zh: c9c1dc27890ff204
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/alternatives-considered:
|
||||
en: 157837c9fbc377cb
|
||||
zh: 32fb2d8427529772
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/consequences:
|
||||
en: 186092d64376de0c
|
||||
zh: 92fe504ff77cba71
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Agent Note: Separate byte budgets for the user-global and project instruction chains
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-16-separate-instruction-chain-budgets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-agent-instructions` rendered the user-global and project files as one chain under one `maxBytes`, the rule of the [original workspace-context decision](../../archived/feature/2026-06-24-workspace-context.md). Rendering omits the broadest files first, and user-global files are the broadest, so a large project chain removed them: under the shipped 65,536-byte budget, 20 KiB user-global, 30 KiB project root, and 20 KiB nested files rendered without the user-global file.
|
||||
|
||||
## Decision
|
||||
|
||||
The renderer splits the loaded files into the user-global and project chains, keeping their order, and renders each under the same `maxBytes`: omit the broadest files, truncate the last file, then fall back to the compact intro, to the notice with an empty heading, and to the notice alone. The project chain keeps the result it has when rendered alone; user-global files are omitted or truncated only beyond their own budget, except in the fallback below.
|
||||
|
||||
When all files fit `maxBytes` together, or only one chain has files, the output is unchanged byte for byte. Otherwise one `<system-reminder>` frame of at most twice `maxBytes` holds the merged notice when a file was omitted or truncated, one intro when a chain kept its frame, and the rendered sections in loaded order, with user-global files first in a baseline. If a chain renders without a frame and the merged frame would exceed that bound, all files render as one chain under one `maxBytes`, which can omit a user-global file that fit its own budget; this needs a budget of a few hundred bytes or a notice that nearly fills or exceeds `maxBytes`. Reconciliation batches apply the same split.
|
||||
|
||||
The `maxBytes` key and the baseline identity are unchanged, so a resumed session keeps its visible baseline and receives a user-global file that baseline omitted as one additional-instructions transition.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One budget that gives user-global files priority.** A large user-global file then truncates or omits the project root file, and bytes near the limit decide which chain loses content.
|
||||
|
||||
**One budget with a user-global share.** The project chain still shrinks, and no measurement supports a share size.
|
||||
|
||||
**A configuration field for the second chain.** No deployment needs different sizes, and the field would join the baseline identity, so resumed sessions would resend their baselines.
|
||||
|
||||
**The Codex layout: an unbounded user-global file and a root-first project chain.** Output has no bound, and the project chain keeps its broadest files, not its most specific ones.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A baseline or batch holding both chains can reach twice `maxBytes`; deployments that sized `maxBytes` as a total budget halve it, per the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.1/agent-instructions-chain-budgets/guide.md).
|
||||
- User-global instructions survive a large project chain.
|
||||
- The `agent-instructions-budget` snapshot shows a truncated user-global file beside the whole project file; `agent-instructions-budget-chains` shows two chains that fit only separately.
|
||||
- The package README, `Config.maxBytes` JSDoc, and configuration catalog state the per-chain bound.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Agent Note: 用户全局指令链与项目指令链分别使用字节预算
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-16-separate-instruction-chain-budgets.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh-agent-instructions` 曾把用户全局文件与项目文件作为一条指令链,在一个 `maxBytes` 内渲染,这是[最初的工作区上下文决策](../../archived/feature/2026-06-24-workspace-context.md)定下的规则。渲染会最先省略最宽泛的文件,而用户全局文件最宽泛,因此较大的项目指令链会移除它们:在默认的 65,536 字节预算下,20 KiB 的用户全局文件、30 KiB 的项目根文件与 20 KiB 的嵌套文件一起渲染时,用户全局文件被省略。
|
||||
|
||||
## 决策
|
||||
|
||||
渲染器把已加载的文件分成用户全局指令链与项目指令链,各自保持原有顺序,并分别在同一个 `maxBytes` 内渲染:先省略最宽泛的文件,再截断最后一个文件,然后依次退回到精简引言、带空标题的通知和仅含通知的文本。项目指令链的结果与它单独渲染时相同;用户全局文件只有超出自身预算时才会被省略或截断,下文的回退情形除外。
|
||||
|
||||
所有文件合起来能放进 `maxBytes`,或只有一条链有文件时,输出逐字节不变。其他情况下,一个至多为 `maxBytes` 两倍的 `<system-reminder>` 框架包含合并后的通知(仅当有文件被省略或截断时)、一段引言(仅当某条链保留了框架时),以及按加载顺序排列的渲染段落,因此基线中用户全局文件在前。若某条链在没有框架的情况下渲染,且合并后的框架会超过该上限,则所有文件作为一条链在一个 `maxBytes` 内渲染,这可能省略一个本可放进自身预算的用户全局文件;这只会在预算只有几百字节,或通知接近或超过 `maxBytes` 时发生。对账批次采用同样的拆分。
|
||||
|
||||
`maxBytes` 配置键与基线标识都不变,因此恢复的会话保留其可见基线;该基线曾省略的用户全局文件会以一次附加指令变更的形式加入。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**一个预算,并优先保留用户全局文件。** 较大的用户全局文件会截断或省略项目根文件,而且临界值附近的字节差异就决定哪条链丢失内容。
|
||||
|
||||
**一个预算,并为用户全局文件划出份额。** 项目指令链仍会缩小,而且没有测量数据支撑份额的大小。
|
||||
|
||||
**为第二条链增加配置字段。** 没有部署需要为两条链设定不同大小,而且该字段会进入基线标识,使恢复的会话重新发送基线。
|
||||
|
||||
**采用 Codex 的布局:不设上限的用户全局文件,加上从根目录开始保留的项目指令链。** 输出没有上限,项目指令链会保留最宽泛的文件,而不是最具体的文件。
|
||||
|
||||
## 影响
|
||||
|
||||
- 同时包含两条链的基线或批次最多可达 `maxBytes` 的两倍;把 `maxBytes` 当作总预算设定的部署应将其减半,见[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.1/agent-instructions-chain-budgets/guide.zh.md)。
|
||||
- 用户全局指令不会因项目指令链较大而被移除。
|
||||
- `agent-instructions-budget` snapshot 显示被截断的用户全局文件与完整的项目文件;`agent-instructions-budget-chains` 显示两条只能各自放下的链。
|
||||
- 包 README、`Config.maxBytes` 的 JSDoc 与配置目录说明了按链计算的上限。
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 8d2cfff9bab7327f
|
||||
zh: 21515ab6e6798504
|
||||
/dsh-cli-behavior-reference/web-profile:
|
||||
en: d768b6213e75a890
|
||||
zh: 731791b496ce14ef
|
||||
en: 850434e8ca162221
|
||||
zh: 4893613f97a21eb8
|
||||
/dsh-cli-behavior-reference/shared-deployment-behavior:
|
||||
en: 6a7abc9339e8f37f
|
||||
zh: 133adb834c8e195d
|
||||
|
||||
@@ -126,7 +126,7 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
|
||||
|
||||
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
|
||||
The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root and includes `read_image`, but omits text filesystem tools, instruction discovery, and SQLite. An enabled HMR plugin watches both `cordis.patch.yml` layers (profile and home) and reapplies valid edits transactionally; without HMR, the process applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
|
||||
The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget for each instruction chain, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root and includes `read_image`, but omits text filesystem tools, instruction discovery, and SQLite. An enabled HMR plugin watches both `cordis.patch.yml` layers (profile and home) and reapplies valid edits transactionally; without HMR, the process applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
|
||||
|
||||
New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ dsh web --help
|
||||
|
||||
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
|
||||
|
||||
基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录并包含 `read_image`,但省略文本文件系统工具、指令发现与 SQLite。启用的 HMR 插件会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;未启用 HMR 时只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。
|
||||
基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以每条指令链 65,536 字节的渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录并包含 `read_image`,但省略文本文件系统工具、指令发现与 SQLite。启用的 HMR 插件会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;未启用 HMR 时只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。
|
||||
|
||||
基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
|
||||
|
||||
|
||||
@@ -67,7 +67,11 @@ export interface Config {
|
||||
export interface Config {
|
||||
/** Directory entries that identify the project root while walking upward from the current working directory. */
|
||||
projectRootMarkers?: string[]
|
||||
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
|
||||
/**
|
||||
* UTF-8 byte cap for each instruction chain (user-global, project) within one
|
||||
* rendered baseline or dynamic batch, so a baseline or batch holding both
|
||||
* chains is at most twice this size; non-positive or non-finite disables loading.
|
||||
*/
|
||||
maxBytes: number
|
||||
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
|
||||
maxSourceBytes?: number
|
||||
|
||||
@@ -69,7 +69,11 @@ export interface Config {
|
||||
export interface Config {
|
||||
/** Directory entries that identify the project root while walking upward from the current working directory. */
|
||||
projectRootMarkers?: string[]
|
||||
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
|
||||
/**
|
||||
* UTF-8 byte cap for each instruction chain (user-global, project) within one
|
||||
* rendered baseline or dynamic batch, so a baseline or batch holding both
|
||||
* chains is at most twice this size; non-positive or non-finite disables loading.
|
||||
*/
|
||||
maxBytes: number
|
||||
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
|
||||
maxSourceBytes?: number
|
||||
|
||||
@@ -954,8 +954,8 @@
|
||||
en: 9ec6b8bd755f3e33
|
||||
zh: 9ec6b8bd755f3e33
|
||||
/session-persistence-event-catalog/resolved-persistence-types/agentinstructionchange:
|
||||
en: 98710438ecb65dd6
|
||||
zh: 82c06bda2056f000
|
||||
en: abb6739b8e18f43d
|
||||
zh: d8b2390d1099e586
|
||||
/session-persistence-event-catalog/resolved-persistence-types/agentinstructionchange~2:
|
||||
en: dba6b1babdde0cfe
|
||||
zh: 99b209824b99b693
|
||||
|
||||
@@ -3075,7 +3075,7 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0`
|
||||
|
||||
SHA-256: `83da72c6857601c75da11212ef847f57d457b9c99f0a82246dcdff3e26700239`
|
||||
|
||||
Sources: [`packages/context/agent-instructions/src/render.ts:53`](../packages/context/agent-instructions/src/render.ts)
|
||||
Sources: [`packages/context/agent-instructions/src/render.ts:62`](../packages/context/agent-instructions/src/render.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -3077,7 +3077,7 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0`
|
||||
|
||||
SHA-256: `83da72c6857601c75da11212ef847f57d457b9c99f0a82246dcdff3e26700239`
|
||||
|
||||
来源:[`packages/context/agent-instructions/src/render.ts:53`](../packages/context/agent-instructions/src/render.ts)
|
||||
来源:[`packages/context/agent-instructions/src/render.ts:62`](../packages/context/agent-instructions/src/render.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+1
-1
@@ -24413,7 +24413,7 @@
|
||||
"packages/context/agent-instructions/src/render.ts#AgentInstructionChange"
|
||||
],
|
||||
"sources": [
|
||||
"packages/context/agent-instructions/src/render.ts:53"
|
||||
"packages/context/agent-instructions/src/render.ts:62"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.1/agent-instructions-chain-budgets/guide.md
|
||||
/:
|
||||
en: a4f7c0355dfc799f
|
||||
zh: 7fba904f2c771d26
|
||||
/size-maxbytes-of-deepseek-ai-dsh-agent-instructions-per-instruction-chain:
|
||||
en: 32a1e5663b3ddc28
|
||||
zh: 2f2bdb9dbc72449a
|
||||
/size-maxbytes-of-deepseek-ai-dsh-agent-instructions-per-instruction-chain/change:
|
||||
en: 852972af8ae3b10b
|
||||
zh: 984774781a0233bc
|
||||
/size-maxbytes-of-deepseek-ai-dsh-agent-instructions-per-instruction-chain/migration:
|
||||
en: 45737c7f5e7534ea
|
||||
zh: 46ac35baa76b850a
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The maxBytes budget of @deepseek-ai/dsh-agent-instructions caps each instruction chain separately, so a baseline holding both chains can reach twice the configured size."
|
||||
---
|
||||
|
||||
# Size `maxBytes` of `@deepseek-ai/dsh-agent-instructions` per instruction chain
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`maxBytes` of `@deepseek-ai/dsh-agent-instructions` bounded the complete workspace-instruction baseline and each reconciliation message. It now bounds the user-global chain (`$DSH_HOME/AGENTS.md` and the shared agents root) and the project chain separately, so a message holding both chains can reach twice the configured size. The exported `loadBaselineInstructions` and `renderAgentInstructions` follow the same bound. Deployments that sized `maxBytes` as a total prompt budget observe the larger maximum.
|
||||
|
||||
## Migration
|
||||
|
||||
1. When the old total must hold, halve `maxBytes` in the `@deepseek-ai/dsh-agent-instructions` row of the deployment's `cordis.yml` or patch; `dsh-base` keeps `65536`. `maxBytes` is part of the baseline identity, so every session resumed after the change receives one complete replacement baseline.
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-agent-instructions'
|
||||
config:
|
||||
maxBytes: 32768
|
||||
```
|
||||
|
||||
2. Start a new session and read the first `user/message` whose `source.kind` is `agent-instructions` in its session log: its text stays within twice `maxBytes`, and a `Workspace instruction budget ...` line names every omitted or truncated file.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "@deepseek-ai/dsh-agent-instructions 的 maxBytes 分别限制每条指令链,因此同时包含两条链的基线最多可达配置值的两倍。"
|
||||
---
|
||||
|
||||
# 按指令链设定 `@deepseek-ai/dsh-agent-instructions` 的 `maxBytes`
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-agent-instructions` 的 `maxBytes` 原先限制完整的工作区指令基线和每条对账消息。现在它分别限制用户全局指令链(`$DSH_HOME/AGENTS.md` 与共享 agents 根目录)和项目指令链,因此同时包含两条链的消息最多可达配置值的两倍。导出的 `loadBaselineInstructions` 与 `renderAgentInstructions` 遵循同一上限。把 `maxBytes` 当作总提示词预算设定的部署会看到更大的上限。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 若仍须遵守原来的总量,在部署的 `cordis.yml` 或 patch 中 `@deepseek-ai/dsh-agent-instructions` 配置行里把 `maxBytes` 减半;`dsh-base` 保持 `65536`。`maxBytes` 属于基线标识,因此修改后恢复的每个会话都会收到一次完整的替代基线。
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-agent-instructions'
|
||||
config:
|
||||
maxBytes: 32768
|
||||
```
|
||||
|
||||
2. 启动新会话,在其会话日志中读取第一条 `source.kind` 为 `agent-instructions` 的 `user/message`:其文本不超过 `maxBytes` 的两倍,且一行 `Workspace instruction budget ...` 会指名所有被省略或截断的文件。
|
||||
@@ -9,23 +9,23 @@
|
||||
en: e88163f21938a7e6
|
||||
zh: 17acc4af39bd7df0
|
||||
/deepseek-ai-dsh-agent-instructions/summary:
|
||||
en: 9d849b3c7bc5e3b7
|
||||
zh: aee26ccc12e73fe3
|
||||
en: b1a2f5d9e76029fe
|
||||
zh: 1455076337921cec
|
||||
/deepseek-ai-dsh-agent-instructions/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-agent-instructions/use-this-package:
|
||||
en: 76eedbd3c6ae5174
|
||||
zh: ed3fdcd6f2b3f522
|
||||
en: a6f5c0d19634d0e9
|
||||
zh: 74c88e1f76381a30
|
||||
/deepseek-ai-dsh-agent-instructions/use-this-package/what-the-agent-gets:
|
||||
en: 1435be3e04948c50
|
||||
zh: a3946e05a9a221dd
|
||||
/deepseek-ai-dsh-agent-instructions/use-this-package/configuration:
|
||||
en: eb827036480985b8
|
||||
zh: 2f237f46eb4b455d
|
||||
en: 3feef2ec59ebd308
|
||||
zh: 2f4c6346ce96f811
|
||||
/deepseek-ai-dsh-agent-instructions/use-this-package/observing-the-budget:
|
||||
en: 9797082b1e69b72c
|
||||
zh: d26c00b1b90fe4ea
|
||||
en: e9e2b819a799517e
|
||||
zh: 12bc2a2339eb5b4c
|
||||
/deepseek-ai-dsh-agent-instructions/understand-the-implementation:
|
||||
en: 724df7ff6d1d9fea
|
||||
zh: 91fb1013eb9178ec
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 4708b51bf8fe86f7
|
||||
zh: 869fc4b1f78383ee
|
||||
/deepseek-ai-dsh-agent-instructions/understand-the-implementation/source-map:
|
||||
en: cb4c1f929ef5a835
|
||||
zh: 3f0fcb7f568a0a5c
|
||||
en: b54bdb371640d989
|
||||
zh: fdf82b545c1a0c6e
|
||||
/deepseek-ai-dsh-agent-instructions/understand-the-implementation/main-flow:
|
||||
en: d881b9055f823929
|
||||
zh: 87b25f0bf8e204e9
|
||||
@@ -57,8 +57,8 @@
|
||||
en: 0ced1c177da6d120
|
||||
zh: 6c61efb987e227fe
|
||||
/deepseek-ai-dsh-agent-instructions/model-experience/baseline-context/token-effect:
|
||||
en: e7c0cc34065a1113
|
||||
zh: a973ba9fac7a918d
|
||||
en: 0a005863a59a9de1
|
||||
zh: 4e29ade86023db3e
|
||||
/deepseek-ai-dsh-agent-instructions/model-experience/baseline-context/kv-cache-effect:
|
||||
en: c611902b9f369d86
|
||||
zh: d7da1dbe6c1ac7f1
|
||||
@@ -87,14 +87,14 @@
|
||||
en: c78113708e7d7665
|
||||
zh: b10dcce1569febae
|
||||
/deepseek-ai-dsh-agent-instructions/model-experience/changed-or-removed-instruction-context/token-effect:
|
||||
en: 436f2fb373e7822d
|
||||
zh: 0a3173b4398530bd
|
||||
en: 76b2419177a5de96
|
||||
zh: 2d8ec672911aa125
|
||||
/deepseek-ai-dsh-agent-instructions/model-experience/changed-or-removed-instruction-context/kv-cache-effect:
|
||||
en: 2fbf2175168e8244
|
||||
zh: f681f89596f500f5
|
||||
/deepseek-ai-dsh-agent-instructions/known-limitations-and-deferred-work:
|
||||
en: 9b50e4414fac4a79
|
||||
zh: 5957bfd23f79b336
|
||||
en: 4cb0c690ac47f7b7
|
||||
zh: 53dd149f37f4afe2
|
||||
/deepseek-ai-dsh-agent-instructions/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-agent-instructions` gives agents workspace guidance from user-global and project-level `AGENTS.md`-compatible files. It loads the applicable chain for the first request. It does not watch external edits continuously: successful filesystem operations discover newly relevant nested files and make later changes or removals visible, while session resume reconciles the baseline. `dsh-base` enables this behavior by default, while profiles can disable it. A byte budget bounds the injected context: broader files are omitted before the most specific file is truncated, and an empty chain adds nothing.
|
||||
`dsh-agent-instructions` gives agents workspace guidance from user-global and project-level `AGENTS.md`-compatible files. It loads the applicable chain for the first request. It does not watch external edits continuously: successful filesystem operations discover newly relevant nested files and make later changes or removals visible, while session resume reconciles the baseline. `dsh-base` enables this behavior by default, while profiles can disable it. A byte budget bounds the user-global chain and the project chain separately: within each chain, broader files are omitted before the most specific file is truncated, and an empty chain adds nothing.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Changing the Session's working directory reloads the applicable project instruction chain before the next request.
|
||||
|
||||
Mount this plugin when agents should work from the workspace's own instruction files. `dsh-base` already includes it with a 65,536-byte budget, so base-backed profiles only need to replace the row when they want another `maxBytes`; providerless trees load nothing until a filesystem provider is present.
|
||||
Mount this plugin when agents should work from the workspace's own instruction files. `dsh-base` already includes it with a 65,536-byte budget for each instruction chain, so base-backed profiles only need to replace the row when they want another `maxBytes`; providerless trees load nothing until a filesystem provider is present.
|
||||
|
||||
### What the agent gets
|
||||
|
||||
@@ -35,7 +35,7 @@ The first request includes one durable baseline message with the user-global cha
|
||||
|
||||
### Configuration
|
||||
|
||||
The defaults suit a typical checkout: `.git` marks the project root, `AGENTS.md` and `CLAUDE.md` are the base candidates, and `AGENTS.local.md` and `CLAUDE.local.md` are additive local overlays. Only `maxBytes` is required — it caps the complete rendered baseline so each deployment chooses its prompt budget explicitly.
|
||||
The defaults suit a typical checkout: `.git` marks the project root, `AGENTS.md` and `CLAUDE.md` are the base candidates, and `AGENTS.local.md` and `CLAUDE.local.md` are additive local overlays. Only `maxBytes` is required — it caps the user-global chain and the project chain separately within each rendered message, so each deployment chooses its prompt budget explicitly; a message that holds both chains is at most twice `maxBytes`.
|
||||
|
||||
The harness home holding the user-global `AGENTS.md` is process policy rather than row configuration: the plugin resolves `$DSH_HOME`, or `~/.dsh`, through `@deepseek-ai/dsh-home-paths`, so instruction loading always reads the process home. The shared agents root resolves the same way, from `$DSH_AGENTS_HOME` or `~/.agents`. Stray `dshHome` and `agentsHome` row keys are ignored.
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface Config {
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `maxBytes` | required | Cap on the complete rendered baseline message, in bytes |
|
||||
| `maxBytes` | required | Cap on each instruction chain (user-global, project) in one rendered message, in bytes; a message holding both chains is at most twice this size |
|
||||
| `maxSourceBytes` | `1048576` | Cap on one source instruction file before rendering |
|
||||
| `projectRootMarkers` | `['.git']` | Directory names that mark the project root |
|
||||
| `instructionFileCandidates` | `['AGENTS.md', 'CLAUDE.md']` | Base file names loaded in each project directory |
|
||||
@@ -71,7 +71,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
### Observing the budget
|
||||
|
||||
Rendering keeps the most specific files first: it drops whole broader files before truncating the most-specific file, and emits a visible `Workspace instruction budget ...` notice naming the omitted and truncated paths. The rendered bytes never exceed `maxBytes`. An over-budget broad file is ignored; during refresh it is treated as temporarily unavailable rather than removed.
|
||||
Rendering budgets the user-global chain and the project chain separately, each under `maxBytes`. Within a chain it keeps the most specific files first: it drops whole broader files, truncates the last remaining file, and then falls back to a compact intro, to the budget notice with that file's empty heading, and finally to the notice alone. User-global files are therefore omitted or truncated only when they exceed their own budget, with the harness-home file omitted before the shared-root file, except in the fallback below. A visible `Workspace instruction budget ...` notice names the omitted and truncated paths of both chains. An over-budget broad file is ignored; during refresh it is treated as temporarily unavailable rather than removed.
|
||||
|
||||
When all files fit within `maxBytes` together, the message holds them with no budget notice; when only one chain has files, that chain renders alone under `maxBytes`. Otherwise one `<system-reminder>` frame holds the merged budget notice when a file was omitted or truncated, one intro when a chain kept its frame, and the rendered sections of both chains in their loaded order, which puts user-global files first in a baseline; this message can exceed `maxBytes` and is at most twice that size. When a chain renders without a frame and the merged frame would exceed twice `maxBytes`, all files render as one chain under one `maxBytes` instead, which can omit a user-global file that fit its own budget; this needs a budget of a few hundred bytes or a budget notice that nearly fills or exceeds `maxBytes`. The [per-chain budget decision](../../../.agents/notes/implemented/architecture/2026-10-16-separate-instruction-chain-budgets.md) records why the two chains are budgeted separately.
|
||||
|
||||
-----
|
||||
|
||||
@@ -94,7 +96,7 @@ The plugin is built on one principle: workspace instructions are durable convers
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: pre-step listener, `tools/result` touch tracking, inbox composition |
|
||||
| [`src/config.ts`](src/config.ts) | `Config` schema, budget resolution, baseline identity |
|
||||
| [`src/files.ts`](src/files.ts) | Candidate discovery, project-root search, bounded streaming reads |
|
||||
| [`src/render.ts`](src/render.ts) | Instruction rendering, budget truncation, change records |
|
||||
| [`src/render.ts`](src/render.ts) | Instruction rendering, per-chain budget truncation, change records |
|
||||
| [`src/state.ts`](src/state.ts) | Durable message sources, version/digest cache, reconciliation |
|
||||
| [`src/digest.ts`](src/digest.ts) | SHA-1 content identity and per-candidate-group duplicate keys |
|
||||
|
||||
@@ -153,7 +155,7 @@ Instructions from: AGENTS.md
|
||||
|
||||
#### 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.
|
||||
The rendered baseline is appended once and remains in derived history until compaction. `maxBytes` bounds the user-global chain and the project chain separately, so a baseline that holds both is at most twice `maxBytes`; within each chain, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -203,7 +205,7 @@ The previously loaded instructions from this file no longer apply.
|
||||
|
||||
#### 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.
|
||||
Each batch of confirmed changes or removals is one retained history message. `maxBytes` bounds each instruction chain in it, so a batch with user-global and project changes is at most twice `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -221,9 +223,10 @@ These limits define when instruction loading is a poor fit or needs operational
|
||||
- **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 neither user-global root has a local overlay and other custom names require explicit candidate configuration. A project directory named like a user-global scope directory renders as `./<name>/...`, so it keeps its own scope instead of resolving to the global root.
|
||||
- **Deduplication is content-based within one candidate group** — candidates collapse only when byte-identical after trimming leading and trailing whitespace. The two user-global roots share one group, so the shared-root file collapses behind a matching harness-home file; sibling project candidates collapse the same way, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it. A duplicate of budget-retained content stays reconciled: when the retained candidate changes or disappears, the duplicate becomes visible again. Unchanged hidden duplicates, including files removed from visible state as duplicates, use cached metadata instead of content reads.
|
||||
- **One candidate group shares failure fate** — when metadata or content cannot be read for any candidate, including a hidden duplicate, the group keeps its last-good state for that pass. An unreadable shared agents root therefore also postpones a harness-home change notice until a later pass observes both roots.
|
||||
- **A byte-budget omission does not re-enter reconciliation** — a candidate the baseline dropped for budget reasons, including any content duplicate of that candidate, is not probed again while that visible baseline holds; it becomes visible only after the baseline is rebuilt.
|
||||
- **A byte-budget omission does not re-enter reconciliation** — a candidate that its chain's budget dropped from the baseline, including any content duplicate of that candidate, is not probed again while that visible baseline holds; it becomes visible only after the baseline is rebuilt.
|
||||
- **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.
|
||||
- **Instruction content is bounded, not summarized** — within each chain, over-budget broad files are omitted and the chain's most-specific file may be truncated; the plugin never asks a model to compress instruction prose.
|
||||
- **Budget is not shared across chains** — bytes one chain leaves unused do not extend the other, so a 70 KiB user-global file is truncated to fit `maxBytes` even when the project chain uses 1 KiB.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-agent-instructions` 向 agent(智能体)提供来自用户全局文件和项目级文件的工作区指引;这些文件均与 `AGENTS.md` 兼容。它为第一次请求加载适用的指令链。它不会持续监视外部编辑:成功的文件系统操作会发现新适用的嵌套文件,并让后续变更或移除可见;恢复会话也会对账基线。`dsh-base` 默认启用此行为,profile 可以禁用。字节预算限制注入的上下文:较宽泛的文件先被省略,最具体的文件最后被截断,空指令链不添加任何内容。
|
||||
`dsh-agent-instructions` 向 agent(智能体)提供来自用户全局文件和项目级文件的工作区指引;这些文件均与 `AGENTS.md` 兼容。它为第一次请求加载适用的指令链。它不会持续监视外部编辑:成功的文件系统操作会发现新适用的嵌套文件,并让后续变更或移除可见;恢复会话也会对账基线。`dsh-base` 默认启用此行为,profile 可以禁用。字节预算分别限制用户全局指令链与项目指令链:每条链内较宽泛的文件先被省略,最具体的文件最后被截断,空指令链不添加任何内容。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
切换 Session 工作目录后,下一次请求前会重新加载适用的项目指令链。
|
||||
|
||||
当 agent 需要依据工作区自身的指令文件工作时,挂载此插件。`dsh-base` 已包含它并给予 65,536 字节预算,因此基于 base 的 profile 仅在需要其他 `maxBytes` 时替换该配置行;没有文件系统提供方的树加载不到任何内容,直到提供方出现。
|
||||
当 agent 需要依据工作区自身的指令文件工作时,挂载此插件。`dsh-base` 已包含它,并为每条指令链给予 65,536 字节预算,因此基于 base 的 profile 仅在需要其他 `maxBytes` 时替换该配置行;没有文件系统提供方的树加载不到任何内容,直到提供方出现。
|
||||
|
||||
### agent 获得的内容
|
||||
|
||||
@@ -35,7 +35,7 @@ kind: "package-reference"
|
||||
|
||||
### 配置
|
||||
|
||||
默认设置适合典型检出:`.git` 标记项目根目录,`AGENTS.md` 与 `CLAUDE.md` 是基础候选,`AGENTS.local.md` 与 `CLAUDE.local.md` 是叠加的本地 overlay。只有 `maxBytes` 必填——它限制完整渲染后的基线,让每个部署显式选择自己的提示词预算。
|
||||
默认设置适合典型检出:`.git` 标记项目根目录,`AGENTS.md` 与 `CLAUDE.md` 是基础候选,`AGENTS.local.md` 与 `CLAUDE.local.md` 是叠加的本地 overlay。只有 `maxBytes` 必填——它在每条渲染消息内分别限制用户全局指令链与项目指令链,让每个部署显式选择自己的提示词预算;同时包含两条链的消息最多为 `maxBytes` 的两倍。
|
||||
|
||||
存放用户全局 `AGENTS.md` 的 harness home 属于进程策略,而不是配置行:该插件通过 `@deepseek-ai/dsh-home-paths` 解析 `$DSH_HOME` 或 `~/.dsh`,因此指令加载始终读取进程 home。共享 agents 根目录同样如此解析:`$DSH_AGENTS_HOME` 或 `~/.agents`。残留的 `dshHome` 与 `agentsHome` 配置行字段会被忽略。
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface Config {
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `maxBytes` | 必填 | 完整渲染基线消息的上限,单位为字节 |
|
||||
| `maxBytes` | 必填 | 一条渲染消息中每条指令链(用户全局、项目)的上限,单位为字节;同时包含两条链的消息最多为该值的两倍 |
|
||||
| `maxSourceBytes` | `1048576` | 渲染前单个源指令文件的上限 |
|
||||
| `projectRootMarkers` | `['.git']` | 标记项目根目录的目录名 |
|
||||
| `instructionFileCandidates` | `['AGENTS.md', 'CLAUDE.md']` | 每个项目目录中加载的基础文件名 |
|
||||
@@ -71,7 +71,9 @@ export interface Config {
|
||||
|
||||
### 观察预算
|
||||
|
||||
渲染会优先保留最具体的文件:先丢弃完整的较宽泛文件,再截断最具体的文件,并发出可见的 `Workspace instruction budget ...` 通知,指名被省略与被截断的路径。渲染后的字节数绝不超过 `maxBytes`。超出预算的宽泛文件会被忽略;刷新期间它被视为暂时不可用,而非被移除。
|
||||
渲染分别为用户全局指令链与项目指令链做预算,每条链各受 `maxBytes` 限制。在一条链内,渲染优先保留最具体的文件:先丢弃完整的较宽泛文件,再截断剩下的最后一个文件,然后依次退回到精简引言、带该文件空标题的预算通知,最后是仅含预算通知的文本。因此用户全局文件只有超出自身预算时才会被省略或截断,且 harness home 文件先于共享根目录文件被省略;下文的回退情形除外。可见的 `Workspace instruction budget ...` 通知会指名两条链中被省略与被截断的路径。超出预算的宽泛文件会被忽略;刷新期间它被视为暂时不可用,而非被移除。
|
||||
|
||||
所有文件合起来能放进 `maxBytes` 时,消息包含全部文件且没有预算通知;只有一条链有文件时,该链单独在 `maxBytes` 内渲染。其他情况下,一个 `<system-reminder>` 框架包含合并后的预算通知(仅当有文件被省略或截断时)、一段引言(仅当某条链保留了框架时),以及按加载顺序排列的两条链渲染段落,因此基线中用户全局文件在前;这条消息可以超过 `maxBytes`,但至多为其两倍。若某条链在没有框架的情况下渲染,且合并后的框架会超过 `maxBytes` 的两倍,则所有文件改为作为一条链在一个 `maxBytes` 内渲染,这可能省略一个本可放进自身预算的用户全局文件;这只会在预算只有几百字节,或预算通知接近或超过 `maxBytes` 时发生。[按指令链分别预算的决策](../../../.agents/notes/implemented/architecture/2026-10-16-separate-instruction-chain-budgets.zh.md)记录了两条链分别预算的原因。
|
||||
|
||||
-----
|
||||
|
||||
@@ -94,7 +96,7 @@ export interface Config {
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:pre-step 监听器、`tools/result` touch 跟踪、inbox 组合 |
|
||||
| [`src/config.ts`](src/config.ts) | `Config` schema、预算解析、基线标识 |
|
||||
| [`src/files.ts`](src/files.ts) | 候选发现、项目根搜索、有界流式读取 |
|
||||
| [`src/render.ts`](src/render.ts) | 指令渲染、预算截断、变更记录 |
|
||||
| [`src/render.ts`](src/render.ts) | 指令渲染、按指令链的预算截断、变更记录 |
|
||||
| [`src/state.ts`](src/state.ts) | 持久消息来源、版本/digest 缓存、对账 |
|
||||
| [`src/digest.ts`](src/digest.ts) | SHA-1 内容标识与每候选组重复键 |
|
||||
|
||||
@@ -153,7 +155,7 @@ Instructions from: AGENTS.md
|
||||
|
||||
#### Token 影响
|
||||
|
||||
渲染后基线只追加一次,并保留在派生历史中直到压缩。`maxBytes` 限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。
|
||||
渲染后基线只追加一次,并保留在派生历史中直到压缩。`maxBytes` 分别限制用户全局指令链与项目指令链,因此同时包含两条链的基线最多为 `maxBytes` 的两倍;每条链内较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -203,7 +205,7 @@ The previously loaded instructions from this file no longer apply.
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每项已确认变更或移除都是一条受 `maxBytes` 限制的保留历史消息。提供方失败不添加消息,预算省略的更新仍可在后续文件系统 touch 中处理。
|
||||
每批已确认的变更或移除是一条保留历史消息。`maxBytes` 限制其中每条指令链,因此同时包含用户全局与项目变更的批次最多为 `maxBytes` 的两倍。提供方失败不添加消息,预算省略的更新仍可在后续文件系统 touch 中处理。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -221,9 +223,10 @@ The previously loaded instructions from this file no longer apply.
|
||||
- **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但两个用户全局根目录都没有本地 overlay,其他自定义名称需要显式候选配置。与用户全局 scope 目录同名的项目目录会渲染为 `./<name>/...`,从而保留自己的 scope,而不会解析到全局根目录。
|
||||
- **去重在同一候选组内基于内容**:候选只有在去除首尾空白后字节完全一致时才折叠。两个用户全局根目录共享同一个组,因此共享根文件会折叠在内容一致的 harness home 文件之后;同级项目候选同样折叠,而从 `AGENTS.md` 漂移的独立副本则会与它一起完整加载。预算保留内容的重复候选仍参与对账:保留的候选一旦改变或消失,它就会重新可见。未改变的隐藏重复项(包括因重复而从可见状态移除的文件)使用缓存元数据,不重新读取内容。
|
||||
- **同一候选组共享失败命运**:组内任一候选(包括隐藏的重复项)的元数据或内容无法读取时,整个组在该轮保留最后一次成功状态。因此共享 agents 根目录不可读时,harness home 的变更通知也会推迟到之后某一轮同时观测到两个根目录时。
|
||||
- **字节预算省略的候选不重新进入对账**:基线因预算丢弃的候选及其内容重复项在可见基线存续期间不再被探测,只有基线重建后才会重新可见。
|
||||
- **字节预算省略的候选不重新进入对账**:所在指令链的预算从基线中丢弃的候选及其内容重复项在可见基线存续期间不再被探测,只有基线重建后才会重新可见。
|
||||
- **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。
|
||||
- **指令内容受限但不会被摘要**:超出预算的宽泛文件会被省略,最具体文件可能被截断;该插件绝不请求模型压缩指令文本。
|
||||
- **指令内容受限但不会被摘要**:每条指令链内超出预算的宽泛文件会被省略,该链最具体的文件可能被截断;该插件绝不请求模型压缩指令文本。
|
||||
- **预算不在指令链之间共享**:一条链未用完的字节不会让给另一条链,因此即使项目指令链只用了 1 KiB,70 KiB 的用户全局文件也会被截断到 `maxBytes` 以内。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -18,7 +18,11 @@ const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
|
||||
export interface Config {
|
||||
/** Directory entries that identify the project root while walking upward from the current working directory. */
|
||||
projectRootMarkers?: string[]
|
||||
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
|
||||
/**
|
||||
* UTF-8 byte cap for each instruction chain (user-global, project) within one
|
||||
* rendered baseline or dynamic batch, so a baseline or batch holding both
|
||||
* chains is at most twice this size; non-positive or non-finite disables loading.
|
||||
*/
|
||||
maxBytes: number
|
||||
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
|
||||
maxSourceBytes?: number
|
||||
|
||||
@@ -47,6 +47,15 @@ interface RenderedInstructionContext extends RenderedAgentInstructions {
|
||||
* that the instruction exists and has no content.
|
||||
*/
|
||||
represented: LoadedInstructionFile[]
|
||||
/** Files whose sections the text holds, in text order, each with the content the text holds. */
|
||||
sections: LoadedInstructionFile[]
|
||||
/**
|
||||
* Intro block of the `<system-reminder>` frame: undefined when the text is
|
||||
* empty or a notice without a frame, and the empty string when the frame has
|
||||
* no intro, as in a change batch or a merged frame whose chains both rendered
|
||||
* without a frame.
|
||||
*/
|
||||
intro: string | undefined
|
||||
}
|
||||
|
||||
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||
@@ -262,8 +271,10 @@ function changedSectionText(item: ChangeRenderItem): string {
|
||||
|
||||
/**
|
||||
* Render one reconciliation batch and retain only transitions that fit.
|
||||
* User-global and project transitions are budgeted as separate chains, so a
|
||||
* batch holding both can exceed `maxBytes` and is at most twice that size.
|
||||
* @param items - ordered state transitions and current file contents.
|
||||
* @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch.
|
||||
* @param maxBytes - maximum UTF-8 bytes for each instruction chain in the rendered batch.
|
||||
* @returns bounded prompt text and the transitions actually represented by it.
|
||||
*/
|
||||
export function renderInstructionChanges(
|
||||
@@ -279,7 +290,7 @@ export function renderInstructionChanges(
|
||||
return item === undefined ? '' : changedSectionText({ ...item, file })
|
||||
},
|
||||
}
|
||||
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
|
||||
const rendered = renderInstructionChains(items.map(item => item.file), maxBytes, style)
|
||||
const represented = new Set(rendered.represented.map(file => file.absolutePath))
|
||||
return {
|
||||
text: rendered.text,
|
||||
@@ -345,30 +356,36 @@ function truncateToFit(
|
||||
return best
|
||||
}
|
||||
|
||||
function isDisabledBudget(maxBytes: number): boolean {
|
||||
return maxBytes <= 0 || !Number.isFinite(maxBytes)
|
||||
}
|
||||
|
||||
function renderInstructionContext(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
style: RenderStyle,
|
||||
): RenderedInstructionContext {
|
||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
|
||||
return { text: '', omitted: files, truncated: [], represented: [] }
|
||||
if (isDisabledBudget(maxBytes)) {
|
||||
return { text: '', omitted: files, truncated: [], represented: [], sections: [], intro: undefined }
|
||||
}
|
||||
|
||||
const fullText = buildInstructionText(files, maxBytes, [], [], style)
|
||||
if (byteLength(fullText) <= maxBytes) {
|
||||
return { text: fullText, omitted: [], truncated: [], represented: files }
|
||||
return { text: fullText, omitted: [], truncated: [], represented: files, sections: files, intro: style.intro }
|
||||
}
|
||||
|
||||
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 }
|
||||
if (byteLength(suffixText) <= maxBytes) {
|
||||
return { text: suffixText, omitted, truncated: [], represented: included, sections: included, intro: style.intro }
|
||||
}
|
||||
}
|
||||
|
||||
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: [] }
|
||||
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [], sections: [], intro: undefined }
|
||||
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
const originalBytes = byteLength(mostSpecific.content)
|
||||
|
||||
@@ -383,7 +400,7 @@ function renderInstructionContext(
|
||||
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
|
||||
if (byteLength(text) <= maxBytes) {
|
||||
const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text, omitted, truncated, represented }
|
||||
return { text, omitted, truncated, represented, sections: [truncatedFile], intro: candidateStyle.intro }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,21 +410,81 @@ function renderInstructionContext(
|
||||
includedBytes: 0,
|
||||
}]
|
||||
const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated))
|
||||
const compactWithHeading = escapeInstructionFrameBody(
|
||||
[compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
|
||||
)
|
||||
const heading = withTruncatedContent(mostSpecific, 0)
|
||||
const compactWithHeading = escapeInstructionFrameBody([compactNotice, style.section(heading)].join('\n\n'))
|
||||
if (byteLength(compactWithHeading) <= maxBytes) {
|
||||
const represented = originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text: compactWithHeading, omitted, truncated, represented }
|
||||
return { text: compactWithHeading, omitted, truncated, represented, sections: [heading], intro: undefined }
|
||||
}
|
||||
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
|
||||
return { text, omitted, truncated, represented: [] }
|
||||
return { text, omitted, truncated, represented: [], sections: [], intro: undefined }
|
||||
}
|
||||
|
||||
function isUserGlobalInstruction(file: InstructionFile): boolean {
|
||||
return isUserGlobalDirectory(scopeForDisplayPath(file.displayPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the user-global chain and the project chain under one `maxBytes` each.
|
||||
*
|
||||
* A disabled budget, files from only one chain, or files that fit within
|
||||
* `maxBytes` together return the single-budget rendering unchanged. Otherwise
|
||||
* each chain renders alone (omit the broadest files, truncate the last file,
|
||||
* then fall back to the compact intro, to the marker with an empty heading,
|
||||
* and to the marker alone), and one frame holds the merged marker when a chain
|
||||
* omitted or truncated a file, one intro when a chain kept its frame, and the
|
||||
* sections of both chains as rendered, in their loaded order, which puts
|
||||
* user-global files first in a baseline. The single-budget rendering replaces a
|
||||
* merged frame longer than twice `maxBytes`.
|
||||
*/
|
||||
function renderInstructionChains(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
style: RenderStyle,
|
||||
): RenderedInstructionContext {
|
||||
const whole = renderInstructionContext(files, maxBytes, style)
|
||||
const userGlobal = files.filter(isUserGlobalInstruction)
|
||||
const project = files.filter(file => !isUserGlobalInstruction(file))
|
||||
if (isDisabledBudget(maxBytes) || userGlobal.length === 0 || project.length === 0
|
||||
|| (whole.omitted.length === 0 && whole.truncated.length === 0)) return whole
|
||||
|
||||
const chains = [
|
||||
renderInstructionContext(userGlobal, maxBytes, style),
|
||||
renderInstructionContext(project, maxBytes, style),
|
||||
]
|
||||
// Merged records and sections keep the loaded order, so a reconciliation batch
|
||||
// renders its items in one order whether or not it fits a single budget.
|
||||
const inLoadedOrder = <T extends InstructionFile>(records: T[]): T[] => {
|
||||
const byPath = new Map(records.map(record => [record.absolutePath, record]))
|
||||
return files.flatMap((file) => {
|
||||
const record = byPath.get(file.absolutePath)
|
||||
return record === undefined ? [] : [record]
|
||||
})
|
||||
}
|
||||
const omitted = inLoadedOrder(chains.flatMap(chain => chain.omitted))
|
||||
const sections = inLoadedOrder(chains.flatMap(chain => chain.sections))
|
||||
const represented = inLoadedOrder(chains.flatMap(chain => chain.represented))
|
||||
// A chain truncates only its last file, so the chain whose last file loads
|
||||
// first lists its truncation first.
|
||||
const userGlobalLast = files.findLastIndex(isUserGlobalInstruction)
|
||||
const projectLast = files.findLastIndex(file => !isUserGlobalInstruction(file))
|
||||
const truncated = (userGlobalLast < projectLast ? chains : chains.toReversed()).flatMap(chain => chain.truncated)
|
||||
const intros = chains.flatMap(chain => chain.intro === undefined ? [] : [chain.intro])
|
||||
// A chain that kept the full frame decides the intro; otherwise the first
|
||||
// surviving (compact) intro applies; otherwise there is none.
|
||||
const intro = intros.includes(style.intro) ? style.intro : intros[0] ?? ''
|
||||
const text = buildInstructionText(sections, maxBytes, omitted, truncated, { ...style, intro })
|
||||
// Each chain text fits maxBytes; the merged frame exceeds 2 * maxBytes only by the join beside a
|
||||
// frameless chain or by a chain marker cut to fit, and the single-budget rendering then replaces it
|
||||
// (.agents/notes/implemented/architecture/2026-10-16-separate-instruction-chain-budgets.md).
|
||||
if (byteLength(text) > 2 * maxBytes) return whole
|
||||
return { text, omitted, truncated, represented, sections, intro }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param options - per-chain 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
|
||||
*/
|
||||
@@ -416,14 +493,16 @@ export function renderAgentInstructionSet(
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): { rendered: RenderedAgentInstructions; included: LoadedInstructionFile[] } {
|
||||
const style = baselineRenderStyle(files, options.replacePreviousBaseline)
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style)
|
||||
return { rendered, included: represented }
|
||||
const { text, omitted, truncated, represented } = renderInstructionChains(files, options.maxBytes, style)
|
||||
return { rendered: { text, omitted, truncated }, included: represented }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the baseline instruction chain with deterministic precedence budgeting.
|
||||
* Render the baseline instruction chains with deterministic precedence budgeting.
|
||||
* The user-global and project chains are each bounded by `maxBytes`, so a
|
||||
* baseline holding both can exceed `maxBytes` and is at most twice that size.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @param options - per-chain rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @returns bounded baseline prompt text and budget diagnostics.
|
||||
*/
|
||||
export function renderAgentInstructions(
|
||||
|
||||
@@ -43,7 +43,15 @@ import {
|
||||
type InstructionVersionCache,
|
||||
} from '../src/state.ts'
|
||||
import { resolveConfig } from '../src/config.ts'
|
||||
import { AGENTS_GLOBAL_DIRECTORY, candidateScopeKey, renderInstructionChanges, renderAgentInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts'
|
||||
import {
|
||||
AGENTS_GLOBAL_DIRECTORY,
|
||||
candidateScopeKey,
|
||||
renderInstructionChanges,
|
||||
renderAgentInstructionSet,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
type ChangeRenderItem,
|
||||
} from '../src/render.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import {
|
||||
mountAgentLoopTestDependencies,
|
||||
@@ -68,6 +76,40 @@ function checkpointSource(compactionId: string): CheckpointSource {
|
||||
/** Per-candidate reconciliation scope key: directory paired with the file name. */
|
||||
const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName)
|
||||
|
||||
/** Intro of a baseline rendered with the full workspace-instruction guidance. */
|
||||
const baselineIntro = '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.'
|
||||
|
||||
/** Guidance that follows the applicability sentence of every additional-instructions section. */
|
||||
const additionalGuidance = 'Use them as guidance when relevant; more specific instructions take precedence. '
|
||||
+ 'They do not override system, developer, or direct user instructions.'
|
||||
|
||||
/** Five 5,000-byte project files from the root down four 10-character directories, with a long budget marker. */
|
||||
const deepProjectFiles = [
|
||||
'AGENTS.md',
|
||||
'aaaaaaaaaa/AGENTS.md',
|
||||
'aaaaaaaaaa/bbbbbbbbbb/AGENTS.md',
|
||||
'aaaaaaaaaa/bbbbbbbbbb/cccccccccc/AGENTS.md',
|
||||
'aaaaaaaaaa/bbbbbbbbbb/cccccccccc/dddddddddd/AGENTS.md',
|
||||
].map(displayPath => ({ absolutePath: `/repo/${displayPath}`, displayPath, content: 'p'.repeat(5000) }))
|
||||
|
||||
/** Budget marker of {@link deepProjectFiles} rendered alone under a `maxBytes` too small to keep any of their content. */
|
||||
function deepProjectMarker(maxBytes: number): string {
|
||||
return `Workspace instruction budget ${String(maxBytes)} bytes: omitted AGENTS.md, aaaaaaaaaa/AGENTS.md, `
|
||||
+ 'aaaaaaaaaa/bbbbbbbbbb/AGENTS.md, aaaaaaaaaa/bbbbbbbbbb/cccccccccc/AGENTS.md; '
|
||||
+ 'truncated aaaaaaaaaa/bbbbbbbbbb/cccccccccc/dddddddddd/AGENTS.md from 5000 to 0 bytes'
|
||||
}
|
||||
|
||||
/** A plain ASCII instruction file of `kib` KiB at a `~/` user-global or project display path. */
|
||||
function kibInstruction(displayPath: string, kib: number): { absolutePath: string; displayPath: string; content: string } {
|
||||
return {
|
||||
absolutePath: displayPath.startsWith('~/') ? `/home/user/${displayPath.slice(2)}` : `/repo/${displayPath}`,
|
||||
displayPath,
|
||||
content: 'x'.repeat(kib * 1024),
|
||||
}
|
||||
}
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
const isolatedInboxCtx = new Context()
|
||||
provideWorkingDirectoryFixture(isolatedInboxCtx)
|
||||
@@ -1189,6 +1231,566 @@ describe('workspace context rendering', () => {
|
||||
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(51)
|
||||
})
|
||||
|
||||
it('renders a user-global file and project files that fit one budget as one unmarked chain', () => {
|
||||
const rendered = renderAgentInstructions([
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'Prefer pnpm.' },
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rules' },
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'package rules' },
|
||||
], { maxBytes: 65536 })
|
||||
|
||||
expect(rendered).toEqual({
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'Prefer pnpm.',
|
||||
'',
|
||||
'Instructions from: AGENTS.md',
|
||||
'',
|
||||
'root rules',
|
||||
'',
|
||||
'Instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'package rules',
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
omitted: [],
|
||||
truncated: [],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
layout: '20/30/20 KiB',
|
||||
files: [kibInstruction('~/.dsh/AGENTS.md', 20), kibInstruction('AGENTS.md', 30), kibInstruction('pkg/AGENTS.md', 20)],
|
||||
},
|
||||
{ layout: '40/30 KiB', files: [kibInstruction('~/.dsh/AGENTS.md', 40), kibInstruction('AGENTS.md', 30)] },
|
||||
{ layout: '63.5/8 KiB', files: [kibInstruction('~/.dsh/AGENTS.md', 63.5), kibInstruction('AGENTS.md', 8)] },
|
||||
])('keeps every file of the $layout layout when each chain fits its own budget', ({ files }) => {
|
||||
const { rendered, included } = renderAgentInstructionSet(files, { maxBytes: 65536 })
|
||||
|
||||
expect(rendered.omitted).toEqual([])
|
||||
expect(rendered.truncated).toEqual([])
|
||||
expect(included).toEqual(files)
|
||||
expect(rendered.text).toBe(renderAgentInstructions(files, { maxBytes: 1_048_576 }).text)
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeGreaterThan(65536)
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 65536)
|
||||
})
|
||||
|
||||
it('omits the harness-home file before the shared-root file when only the user-global chain exceeds its budget', () => {
|
||||
const files = [
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'home '.repeat(100) },
|
||||
{ absolutePath: '/home/user/.agents/AGENTS.md', displayPath: '~/.agents/AGENTS.md', content: 'shared rule' },
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(40) },
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'package rule' },
|
||||
]
|
||||
|
||||
const { rendered, included } = renderAgentInstructionSet(files, { maxBytes: 600 })
|
||||
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 600 bytes: omitted ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: ~/.agents/AGENTS.md',
|
||||
'',
|
||||
'shared rule',
|
||||
'',
|
||||
'Instructions from: AGENTS.md',
|
||||
'',
|
||||
'root '.repeat(40),
|
||||
'',
|
||||
'Instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'package rule',
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(rendered.omitted).toEqual([{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md' }])
|
||||
expect(rendered.truncated).toEqual([])
|
||||
expect(included).toEqual(files.slice(1))
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeGreaterThan(600)
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 600)
|
||||
})
|
||||
|
||||
it('truncates an oversized user-global file within its own budget while the project chain renders whole', () => {
|
||||
const userGlobal = kibInstruction('~/.dsh/AGENTS.md', 70)
|
||||
const files = [
|
||||
userGlobal,
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rule' },
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'package rule' },
|
||||
]
|
||||
const truncated = [{ displayPath: '~/.dsh/AGENTS.md', originalBytes: 71680, includedBytes: 65129 }]
|
||||
|
||||
const { rendered, included } = renderAgentInstructionSet(files, { maxBytes: 65536 })
|
||||
|
||||
expect(rendered).toEqual({
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 65536 bytes: truncated ~/.dsh/AGENTS.md from 71680 to 65129 bytes',
|
||||
'',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
userGlobal.content.slice(0, 65129),
|
||||
'',
|
||||
'Instructions from: AGENTS.md',
|
||||
'',
|
||||
'root rule',
|
||||
'',
|
||||
'Instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'package rule',
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
omitted: [],
|
||||
truncated,
|
||||
})
|
||||
expect(included).toEqual(files)
|
||||
expect(renderAgentInstructions([userGlobal], { maxBytes: 65536 }).truncated).toEqual(truncated)
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 65536)
|
||||
})
|
||||
|
||||
it('records the same omission and truncation for an over-budget project chain as rendering it alone', () => {
|
||||
const project = [
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(200) },
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'leaf '.repeat(100) },
|
||||
]
|
||||
const projectAlone = renderAgentInstructions(project, { maxBytes: 600 })
|
||||
|
||||
const rendered = renderAgentInstructions([
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'Prefer pnpm.' },
|
||||
...project,
|
||||
], { maxBytes: 600 })
|
||||
|
||||
expect(projectAlone.omitted.map(file => file.displayPath)).toEqual(['AGENTS.md'])
|
||||
expect(rendered.omitted).toEqual(projectAlone.omitted)
|
||||
expect(rendered.truncated).toEqual(projectAlone.truncated)
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 600 bytes: omitted AGENTS.md; truncated pkg/AGENTS.md from 500 to 186 bytes',
|
||||
'',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'Prefer pnpm.',
|
||||
'',
|
||||
'Instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'leaf '.repeat(100).slice(0, 186),
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 600)
|
||||
})
|
||||
|
||||
it('keeps a user-global file that fills its budget exactly while the project chain fits only its notice and empty heading', () => {
|
||||
const userGlobal = { absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'Use pnpm.' }
|
||||
const project = [
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'r'.repeat(1000) },
|
||||
{ absolutePath: '/repo/packages/app/AGENTS.md', displayPath: 'packages/app/AGENTS.md', content: 'a'.repeat(1000) },
|
||||
{
|
||||
absolutePath: '/repo/packages/app/src/feature/AGENTS.md',
|
||||
displayPath: 'packages/app/src/feature/AGENTS.md',
|
||||
content: 'f'.repeat(1000),
|
||||
},
|
||||
]
|
||||
expect(Buffer.byteLength(renderAgentInstructions([userGlobal], { maxBytes: 320 }).text, 'utf8')).toBe(320)
|
||||
expect(renderAgentInstructions(project, { maxBytes: 320 }).text).toMatch(/^Workspace instruction budget 320 bytes: /)
|
||||
|
||||
const { rendered, included } = renderAgentInstructionSet([userGlobal, ...project], { maxBytes: 320 })
|
||||
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 320 bytes: omitted AGENTS.md, packages/app/AGENTS.md; '
|
||||
+ 'truncated packages/app/src/feature/AGENTS.md from 1000 to 0 bytes',
|
||||
'',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'Use pnpm.',
|
||||
'',
|
||||
'Instructions from: packages/app/src/feature/AGENTS.md',
|
||||
'',
|
||||
'',
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(rendered.text.split(baselineIntro)).toHaveLength(2)
|
||||
expect(included).toEqual([userGlobal])
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 320)
|
||||
})
|
||||
|
||||
it('keeps a user-global file that fills its budget exactly while the project chain fits only its bare notice', () => {
|
||||
const userGlobal = { absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(5) }
|
||||
expect(Buffer.byteLength(renderAgentInstructions([userGlobal], { maxBytes: 316 }).text, 'utf8')).toBe(316)
|
||||
expect(renderAgentInstructions(deepProjectFiles, { maxBytes: 316 }).text).toBe(deepProjectMarker(316))
|
||||
|
||||
const { rendered, included } = renderAgentInstructionSet([userGlobal, ...deepProjectFiles], { maxBytes: 316 })
|
||||
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
deepProjectMarker(316),
|
||||
'',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'g'.repeat(5),
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(included).toEqual([userGlobal])
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 316)
|
||||
})
|
||||
|
||||
it('merges a frameless project chain at exactly twice the budget and falls back to one budget one byte above', () => {
|
||||
const userGlobal = (bytes: number): { absolutePath: string; displayPath: string; content: string } => (
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(bytes) }
|
||||
)
|
||||
const deepestHeading = 'Instructions from: aaaaaaaaaa/bbbbbbbbbb/cccccccccc/dddddddddd/AGENTS.md'
|
||||
expect(renderAgentInstructions(deepProjectFiles, { maxBytes: 318 }).text).toBe(`${deepProjectMarker(318)}\n\n${deepestHeading}\n\n`)
|
||||
expect(renderAgentInstructions([userGlobal(6)], { maxBytes: 318 })).toMatchObject({ omitted: [], truncated: [] })
|
||||
|
||||
const atBound = renderAgentInstructionSet([userGlobal(5), ...deepProjectFiles], { maxBytes: 318 })
|
||||
const overBound = renderAgentInstructionSet([userGlobal(6), ...deepProjectFiles], { maxBytes: 318 })
|
||||
|
||||
expect(atBound.rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
deepProjectMarker(318),
|
||||
'',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'g'.repeat(5),
|
||||
'',
|
||||
deepestHeading,
|
||||
'',
|
||||
'',
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(Buffer.byteLength(atBound.rendered.text, 'utf8')).toBe(2 * 318)
|
||||
expect(atBound.included).toEqual([userGlobal(5)])
|
||||
expect(overBound.rendered.text).toBe('Workspace instruction budget 318 bytes: omitted ~/.dsh/AGENTS.md, AGENTS.md, aaaaaaaaaa/AGENTS.md, '
|
||||
+ 'aaaaaaaaaa/bbbbbbbbbb/AGENTS.md, aaaaaaaaaa/bbbbbbbbbb/cccccccccc/AGENTS.md; '
|
||||
+ 'truncated aaaaaaaaaa/bbbbbbbbbb/cccccccccc/dddddddddd/AGENTS.md from 5000 to 0 bytes')
|
||||
expect(overBound.rendered.omitted[0]).toEqual({ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md' })
|
||||
expect(overBound.included).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps both chains whole when each fills its budget exactly and truncates both one byte lower', () => {
|
||||
const files = [
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(289) },
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'p'.repeat(296) },
|
||||
]
|
||||
for (const file of files) {
|
||||
expect(Buffer.byteLength(renderAgentInstructions([file], { maxBytes: 600 }).text, 'utf8')).toBe(600)
|
||||
}
|
||||
|
||||
const exact = renderAgentInstructionSet(files, { maxBytes: 600 })
|
||||
const below = renderAgentInstructionSet(files, { maxBytes: 599 })
|
||||
|
||||
expect(exact.rendered).toEqual({ text: renderAgentInstructions(files, { maxBytes: 928 }).text, omitted: [], truncated: [] })
|
||||
expect(Buffer.byteLength(exact.rendered.text, 'utf8')).toBe(928)
|
||||
expect(exact.included).toEqual(files)
|
||||
expect(below.rendered.text.split('\n')[1]).toBe(
|
||||
'Workspace instruction budget 599 bytes: truncated ~/.dsh/AGENTS.md from 289 to 198 bytes, AGENTS.md from 296 to 212 bytes',
|
||||
)
|
||||
expect(below.rendered.omitted).toEqual([])
|
||||
expect(below.included).toEqual(files)
|
||||
expect(Buffer.byteLength(below.rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 599)
|
||||
})
|
||||
|
||||
it('frames both chains under the compact intro when neither chain fits the full intro', () => {
|
||||
const rendered = renderAgentInstructions([
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(1000) },
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'p'.repeat(1000) },
|
||||
], { maxBytes: 300 })
|
||||
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 300 bytes: truncated ~/.dsh/AGENTS.md from 1000 to 51 bytes, pkg/AGENTS.md from 1000 to 57 bytes',
|
||||
'',
|
||||
'Workspace instructions were omitted or truncated to fit the configured byte budget.',
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'g'.repeat(51),
|
||||
'',
|
||||
'Instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'p'.repeat(57),
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 300)
|
||||
})
|
||||
|
||||
it('frames the budget notices of both chains without an intro when neither chain fits a frame of its own', () => {
|
||||
const { rendered, included } = renderAgentInstructionSet([
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(1000) },
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'p'.repeat(1000) },
|
||||
], { maxBytes: 150 })
|
||||
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 150 bytes: truncated ~/.dsh/AGENTS.md from 1000 to 0 bytes, pkg/AGENTS.md from 1000 to 0 bytes',
|
||||
'',
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'Instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'',
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(included).toEqual([])
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 150)
|
||||
})
|
||||
|
||||
it('falls back to the single-budget notice when the merged chain notices would exceed twice the budget', () => {
|
||||
const rendered = renderAgentInstructions([
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(1000) },
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'p'.repeat(1000) },
|
||||
], { maxBytes: 50 })
|
||||
|
||||
expect(rendered).toEqual({
|
||||
text: 'Workspace instruction budget 50 bytes: omitted ~/.',
|
||||
omitted: [{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md' }],
|
||||
truncated: [{ displayPath: 'pkg/AGENTS.md', originalBytes: 1000, includedBytes: 0 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps every two-chain baseline and change batch within twice the budget at every limit', () => {
|
||||
const files = [
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'home '.repeat(200) },
|
||||
{ absolutePath: '/home/user/.agents/AGENTS.md', displayPath: '~/.agents/AGENTS.md', content: 'shared '.repeat(30) },
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(200) },
|
||||
{ absolutePath: '/repo/packages/app/AGENTS.md', displayPath: 'packages/app/AGENTS.md', content: 'app '.repeat(100) },
|
||||
]
|
||||
const actions = ['set', 'replace', 'set', 'remove'] as const
|
||||
const items = files.map((file, index) => ({
|
||||
change: { action: actions[index]!, scope: file.displayPath, path: file.displayPath, digest: 'digest' },
|
||||
file,
|
||||
}))
|
||||
|
||||
for (let maxBytes = 1; maxBytes <= 3000; maxBytes += 1) {
|
||||
expect(Buffer.byteLength(renderAgentInstructions(files, { maxBytes }).text, 'utf8')).toBeLessThanOrEqual(2 * maxBytes)
|
||||
expect(Buffer.byteLength(renderInstructionChanges(items, maxBytes).text, 'utf8')).toBeLessThanOrEqual(2 * maxBytes)
|
||||
}
|
||||
})
|
||||
|
||||
it('truncates multibyte content in both chains on code point boundaries near the limit', () => {
|
||||
const files = [
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: '规'.repeat(400) },
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: '😀'.repeat(300) },
|
||||
]
|
||||
|
||||
const rendered = renderAgentInstructions(files, { maxBytes: 700 })
|
||||
|
||||
expect(rendered.truncated).toEqual([
|
||||
{ displayPath: '~/.dsh/AGENTS.md', originalBytes: 1200, includedBytes: 297 },
|
||||
{ displayPath: 'AGENTS.md', originalBytes: 1200, includedBytes: 312 },
|
||||
])
|
||||
expect(rendered.text).toContain([
|
||||
'Instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
'规'.repeat(99),
|
||||
'',
|
||||
'Instructions from: AGENTS.md',
|
||||
'',
|
||||
'😀'.repeat(78),
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
for (let maxBytes = 640; maxBytes <= 720; maxBytes += 1) {
|
||||
const text = renderAgentInstructions(files, { maxBytes }).text
|
||||
expect(text).not.toContain('�')
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(2 * maxBytes)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])('renders nothing from either chain when the budget %s disables loading', (maxBytes) => {
|
||||
const files = [
|
||||
{ absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'global rule' },
|
||||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rule' },
|
||||
]
|
||||
const items = files.map(file => ({
|
||||
change: { action: 'set' as const, scope: file.displayPath, path: file.displayPath, digest: 'digest' },
|
||||
file,
|
||||
}))
|
||||
|
||||
expect(renderAgentInstructions(files, { maxBytes })).toEqual({ text: '', omitted: files, truncated: [] })
|
||||
expect(renderInstructionChanges(items, maxBytes)).toEqual({ text: '', changes: [] })
|
||||
})
|
||||
|
||||
it('retains a user-global change beside project changes when each chain fits its own budget', () => {
|
||||
const items = [
|
||||
{
|
||||
change: { action: 'set' as const, scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE), path: '~/.dsh/AGENTS.md', digest: 'global' },
|
||||
file: { absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'Prefer pnpm.' },
|
||||
},
|
||||
{
|
||||
change: { action: 'set' as const, scope: sk('a', 'AGENTS.md'), path: 'a/AGENTS.md', digest: 'a' },
|
||||
file: { absolutePath: '/repo/a/AGENTS.md', displayPath: 'a/AGENTS.md', content: 'Rule a.' },
|
||||
},
|
||||
{
|
||||
change: { action: 'set' as const, scope: sk('b', 'AGENTS.md'), path: 'b/AGENTS.md', digest: 'b' },
|
||||
file: { absolutePath: '/repo/b/AGENTS.md', displayPath: 'b/AGENTS.md', content: 'Rule b.' },
|
||||
},
|
||||
]
|
||||
const rendered = renderInstructionChanges(items, 560)
|
||||
|
||||
expect(rendered.changes).toEqual(items.map(item => item.change))
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Additional instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
`These user-global instructions apply to all work. ${additionalGuidance}`,
|
||||
'',
|
||||
'Prefer pnpm.',
|
||||
'',
|
||||
'Additional instructions from: a/AGENTS.md',
|
||||
'',
|
||||
`These instructions apply to work under \`a\`. ${additionalGuidance}`,
|
||||
'',
|
||||
'Rule a.',
|
||||
'',
|
||||
'Additional instructions from: b/AGENTS.md',
|
||||
'',
|
||||
`These instructions apply to work under \`b\`. ${additionalGuidance}`,
|
||||
'',
|
||||
'Rule b.',
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeGreaterThan(560)
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 560)
|
||||
})
|
||||
|
||||
it('drops a user-global change that cannot fit its own budget while retaining the project changes', () => {
|
||||
const items = [
|
||||
{
|
||||
change: { action: 'set' as const, scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE), path: '~/.dsh/AGENTS.md', digest: 'global' },
|
||||
file: { absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(1000) },
|
||||
},
|
||||
{
|
||||
change: { action: 'replace' as const, scope: sk('a', 'AGENTS.md'), path: 'a/AGENTS.md', digest: 'a' },
|
||||
file: { absolutePath: '/repo/a/AGENTS.md', displayPath: 'a/AGENTS.md', content: 'Rule a.' },
|
||||
},
|
||||
{
|
||||
change: { action: 'remove' as const, scope: sk('b', 'AGENTS.md'), path: 'b/AGENTS.md' },
|
||||
file: { absolutePath: 'removed:b/AGENTS.md', displayPath: 'b/AGENTS.md', content: '' },
|
||||
},
|
||||
]
|
||||
|
||||
const rendered = renderInstructionChanges(items, 330)
|
||||
|
||||
expect(rendered.changes).toEqual([items[1]!.change, items[2]!.change])
|
||||
expect(rendered.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 330 bytes: truncated ~/.dsh/AGENTS.md from 1000 to 0 bytes',
|
||||
'',
|
||||
'Updated instructions from: a/AGENTS.md',
|
||||
'',
|
||||
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
|
||||
'',
|
||||
'Rule a.',
|
||||
'',
|
||||
'Instructions removed: b/AGENTS.md',
|
||||
'',
|
||||
'The previously loaded instructions from this file no longer apply.',
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(2 * 330)
|
||||
})
|
||||
|
||||
it('keeps the loaded order of a change batch whose project item precedes its user-global item', () => {
|
||||
const items = (bytes: number): ChangeRenderItem[] => [
|
||||
{
|
||||
change: { action: 'set', scope: sk('a', 'AGENTS.md'), path: 'a/AGENTS.md', digest: 'a' },
|
||||
file: { absolutePath: '/repo/a/AGENTS.md', displayPath: 'a/AGENTS.md', content: 'a'.repeat(bytes) },
|
||||
},
|
||||
{
|
||||
change: { action: 'set', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE), path: '~/.dsh/AGENTS.md', digest: 'global' },
|
||||
file: { absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(bytes) },
|
||||
},
|
||||
]
|
||||
const batchText = (marker: string[], projectBytes: number, userGlobalBytes: number): string => [
|
||||
'<system-reminder>',
|
||||
...marker,
|
||||
'Additional instructions from: a/AGENTS.md',
|
||||
'',
|
||||
`These instructions apply to work under \`a\`. ${additionalGuidance}`,
|
||||
'',
|
||||
'a'.repeat(projectBytes),
|
||||
'',
|
||||
'Additional instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
`These user-global instructions apply to all work. ${additionalGuidance}`,
|
||||
'',
|
||||
'g'.repeat(userGlobalBytes),
|
||||
'</system-reminder>',
|
||||
].join('\n')
|
||||
const small = items(10)
|
||||
const large = items(1000)
|
||||
|
||||
const fits = renderInstructionChanges(small, 65536)
|
||||
const split = renderInstructionChanges(small, 400)
|
||||
const truncated = renderInstructionChanges(large, 400)
|
||||
|
||||
expect(fits).toEqual({ text: batchText([], 10, 10), changes: small.map(item => item.change) })
|
||||
expect(split).toEqual(fits)
|
||||
expect(Buffer.byteLength(split.text, 'utf8')).toBeGreaterThan(400)
|
||||
expect(truncated).toEqual({
|
||||
text: batchText(['Workspace instruction budget 400 bytes: truncated a/AGENTS.md from 1000 to 41 bytes, ~/.dsh/AGENTS.md from 1000 to 25 bytes', ''], 41, 25),
|
||||
changes: large.map(item => item.change),
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a user-global change beside a frameless project batch at exactly twice the budget and drops it one byte above', () => {
|
||||
const items = (globalBytes: number): ChangeRenderItem[] => [
|
||||
{
|
||||
change: { action: 'set', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE), path: '~/.dsh/AGENTS.md', digest: 'global' },
|
||||
file: { absolutePath: '/home/user/.dsh/AGENTS.md', displayPath: '~/.dsh/AGENTS.md', content: 'g'.repeat(globalBytes) },
|
||||
},
|
||||
{
|
||||
change: { action: 'set', scope: sk('a', 'AGENTS.md'), path: 'a/AGENTS.md', digest: 'a' },
|
||||
file: { absolutePath: '/repo/a/AGENTS.md', displayPath: 'a/AGENTS.md', content: 'p'.repeat(5000) },
|
||||
},
|
||||
]
|
||||
const atBoundItems = items(34)
|
||||
expect(renderInstructionChanges(items(35).slice(0, 1), 321).changes).toHaveLength(1)
|
||||
|
||||
const atBound = renderInstructionChanges(atBoundItems, 321)
|
||||
const overBound = renderInstructionChanges(items(35), 321)
|
||||
|
||||
expect(atBound.changes).toEqual([atBoundItems[0]!.change])
|
||||
expect(atBound.text).toBe([
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 321 bytes: truncated a/AGENTS.md from 5000 to 0 bytes',
|
||||
'',
|
||||
'Additional instructions from: ~/.dsh/AGENTS.md',
|
||||
'',
|
||||
`These user-global instructions apply to all work. ${additionalGuidance}`,
|
||||
'',
|
||||
'g'.repeat(34),
|
||||
'',
|
||||
'Additional instructions from: a/AGENTS.md',
|
||||
'',
|
||||
`These instructions apply to work under \`a\`. ${additionalGuidance}`,
|
||||
'',
|
||||
'',
|
||||
'</system-reminder>',
|
||||
].join('\n'))
|
||||
expect(Buffer.byteLength(atBound.text, 'utf8')).toBe(2 * 321)
|
||||
expect(overBound).toEqual({
|
||||
text: 'Workspace instruction budget 321 bytes: omitted ~/.dsh/AGENTS.md; truncated a/AGENTS.md from 5000 to 0 bytes',
|
||||
changes: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace context request injection', () => {
|
||||
@@ -1446,7 +2048,7 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps duplicate budget-omitted global instructions excluded across steps and resumes', async () => {
|
||||
it('keeps the shared-root duplicate of a user-global file truncated to 0 bytes excluded across steps and resumes', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const agents = await tempRepo()
|
||||
@@ -1458,15 +2060,20 @@ describe('workspace context request injection', () => {
|
||||
await write(join(agents, 'AGENTS.md'), `\n${'global instruction '.repeat(100)}\n`)
|
||||
pinHarnessHome(home)
|
||||
pinAgentsHome(agents)
|
||||
await mountAgentInstructions(ctx, { maxBytes: 700 })
|
||||
// A user-global file is budgeted alone, so it loses all content only at a
|
||||
// budget this small: here its chain keeps only the heading, while the
|
||||
// project chain keeps part of its file under the compact intro.
|
||||
await mountAgentInstructions(ctx, { maxBytes: 244 })
|
||||
const original = await stubAgent(root)
|
||||
await composeBaselinePrefix(ctx, original)
|
||||
|
||||
const contexts = original.session.snapshotEvents().filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'agent-instructions')
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(derivedText(original)).toContain('omitted $DSH_HOME/AGENTS.md')
|
||||
expect(derivedText(original)).toContain('truncated $DSH_HOME/AGENTS.md from 1900 to 0 bytes')
|
||||
expect(derivedText(original)).toContain('Instructions from: AGENTS.md\n\nproject')
|
||||
expect(derivedText(original)).not.toContain('global instruction')
|
||||
expect(derivedText(original)).not.toContain('$DSH_AGENTS_HOME')
|
||||
await syncAgentInstructions(ctx, original)
|
||||
await syncAgentInstructions(ctx, original)
|
||||
expect(original.inbox.nextStep.filter(message => message.source.kind === 'agent-instructions')).toEqual([])
|
||||
@@ -1484,6 +2091,65 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('adds the user-global file that a resumed single-budget baseline omitted as one additional transition', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'project rule '.repeat(20))
|
||||
await write(join(home, 'AGENTS.md'), 'global rule '.repeat(20))
|
||||
pinHarnessHome(home)
|
||||
await mountAgentInstructions(ctx, { maxBytes: 700 })
|
||||
const original = await stubAgent(root)
|
||||
await composeBaselinePrefix(ctx, original)
|
||||
expect(derivedText(original)).toContain('Instructions from: $DSH_HOME/AGENTS.md')
|
||||
const userGlobalScope = sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE)
|
||||
// The fixture models a log recorded under one shared budget: the same
|
||||
// identity, a baseline with only the project file, and its omission notice.
|
||||
const recorded = original.session.snapshotEvents().map((event) => {
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'agent-instructions') return event
|
||||
const text = [
|
||||
'<system-reminder>',
|
||||
'Workspace instruction budget 700 bytes: omitted $DSH_HOME/AGENTS.md',
|
||||
'',
|
||||
baselineIntro,
|
||||
'',
|
||||
'Instructions from: AGENTS.md',
|
||||
'',
|
||||
'project rule '.repeat(20),
|
||||
'</system-reminder>',
|
||||
].join('\n')
|
||||
const changes = event.data.source.changes.filter(change => change.scope !== userGlobalScope)
|
||||
return { ...event, data: { ...event.data, content: [{ type: 'text' as const, text }], source: { ...event.data.source, changes } } }
|
||||
})
|
||||
|
||||
const resumed = await stubAgent(root, recorded)
|
||||
await composeBaselinePrefix(ctx, resumed)
|
||||
|
||||
expect(baselineEvents(resumed)).toHaveLength(1)
|
||||
const transitions = resumed.session.snapshotEvents().filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'agent-instructions'
|
||||
&& event.data.source.baseline !== true)
|
||||
expect(transitions).toHaveLength(1)
|
||||
const transition = transitions[0]
|
||||
expect(transition?.type === 'user/message' ? transition.data.source : undefined).toMatchObject({
|
||||
changes: [{ action: 'set', scope: userGlobalScope, path: '$DSH_HOME/AGENTS.md' }],
|
||||
})
|
||||
expect(transition?.type === 'user/message' ? blocksText(transition.data.content) : '').toContain([
|
||||
'Additional instructions from: $DSH_HOME/AGENTS.md',
|
||||
'',
|
||||
`These user-global instructions apply to all work. ${additionalGuidance}`,
|
||||
'',
|
||||
'global rule '.repeat(20),
|
||||
].join('\n'))
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
await ctx.fiber.dispose()
|
||||
for (const path of [root, home]) await rm(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a duplicate excluded when only a different digest fits the same group budget', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{"type":"session","version":4,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: $DSH_HOME/AGENTS.md\n\nGlobal user preferences for every project.\nReply in short, plain sentences and state the result first.\nRun the narrowest test that covers a change before reporting it as done.\nNever commit, push, or delete branches unless the user asks for it.\n\n\nInstructions from: AGENTS.md\n\nProject instructions for the budget snapshot workspace.\nKeep task.txt unchanged; the snapshot reads it as input.\nUse the read tool for files in this workspace instead of shell commands.\nAnswer with DONE after the requested file has been read.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":700,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":"user-global\u0000AGENTS.md","path":"$DSH_HOME/AGENTS.md","digest":"d01601db44abb324601d4916104bc601756fa0aa"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"e0a5e527c1b3d9682968d7d2f90d171e38197775"}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"Read task.txt with the read","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699346103,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699346103,"index":0,"dt":[],"id":"call_workspace_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":1788699346103,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":1788699346103,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699346103,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_workspace_read"},"toolCallId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/task.txt</path>\n<type>file</type>\n<content>\n1: budget snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false,"id":"{{message:6}}"},"meta":{"path":"{{cwd}}/task.txt","offset":1,"lines":[{"number":1,"text":"budget snapshot task"}],"totalLines":1}},"sourceEventSeqs":[15],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1788699346146,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699346146,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699346146,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699346146,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1788699346146,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,7 @@
|
||||
version: 1
|
||||
scenario: agent-instructions-budget-chains
|
||||
profile: headless
|
||||
composition: agent-instructions-budget
|
||||
recording: authored
|
||||
header:
|
||||
class: agent-instructions
|
||||
@@ -0,0 +1,4 @@
|
||||
Global user preferences for every project.
|
||||
Reply in short, plain sentences and state the result first.
|
||||
Run the narrowest test that covers a change before reporting it as done.
|
||||
Never commit, push, or delete branches unless the user asks for it.
|
||||
@@ -0,0 +1,4 @@
|
||||
Project instructions for the budget snapshot workspace.
|
||||
Keep task.txt unchanged; the snapshot reads it as input.
|
||||
Use the read tool for files in this workspace instead of shell commands.
|
||||
Answer with DONE after the requested file has been read.
|
||||
@@ -0,0 +1 @@
|
||||
budget snapshot task
|
||||
@@ -8,7 +8,7 @@
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nWorkspace instruction budget 700 bytes: omitted $DSH_HOME/AGENTS.md\n\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nProject budget snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":700,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"c503c5940e5a8a6c1a27887c07ad9155c1738e5b"}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nWorkspace instruction budget 700 bytes: truncated $DSH_HOME/AGENTS.md from 1900 to 292 bytes\n\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: $DSH_HOME/AGENTS.md\n\nglobal instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global instruction global \n\nInstructions from: AGENTS.md\n\nProject budget snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":700,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":"user-global\u0000AGENTS.md","path":"$DSH_HOME/AGENTS.md","digest":"bc824d144c2a72d318c8d1b96093c64bfb58630c"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"c503c5940e5a8a6c1a27887c07ad9155c1738e5b"}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
|
||||
Reference in New Issue
Block a user