mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(workspace-context): rearm compacted baseline
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-24-workspace-context.md: 9591e1ab169f1883e1831b50308c86e55599296b
|
||||
2026-06-24-workspace-context.zh.md: 6317ad6e2e6c85768e74b5b2657903723ddf72d3
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md
|
||||
2026-06-24-workspace-context.md: f86e227be615c9b54e2a9013d3c7dca75d3975f0
|
||||
2026-06-24-workspace-context.zh.md: 154b5260955570e2de3c88d98286c5ea6afaa3b5
|
||||
|
||||
@@ -30,9 +30,9 @@ The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by eithe
|
||||
|
||||
At the first `agent/step` of an agent-loop instance, the plugin injects one sourced user-role message before the request is derived. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root.
|
||||
|
||||
The injection becomes a durable `user/message` with plugin provenance. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request.
|
||||
The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request.
|
||||
|
||||
A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that already holds a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. Only a remount (no witnessed lifecycle start, baseline already logged) keeps the existing baseline and skips; a resume re-composes.
|
||||
A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes.
|
||||
|
||||
The baseline is a user-role `<system-reminder>` with `Instructions from: <path>` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. A literal `</system-reminder>` inside file content is escaped. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape).
|
||||
|
||||
@@ -42,19 +42,19 @@ After a successful first-party `read`, `write`, or `edit` call, the `tools/post-
|
||||
|
||||
A content edit appends `Updated instructions from: <path>`, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: <path>` and states that the previously loaded instructions no longer apply.
|
||||
|
||||
Dynamic messages carry their complete system-reminder framing in `content`, and every sourced `user/message` reaches the model verbatim (there is no core wrapper to opt out of). The typed `workspace-instructions` source carries persisted state that is never rendered to the model.
|
||||
Baseline and dynamic messages carry their complete system-reminder framing in `content`, and every sourced `user/message` reaches the model verbatim (there is no core wrapper to opt out of). The typed `workspace-instructions` source carries persisted state that is never rendered to the model.
|
||||
|
||||
Shell commands are not discovery triggers. Local bash calls start fresh shells, and inferring reached paths from arbitrary command strings would require shell semantics the prompt plugin does not own.
|
||||
|
||||
### Duplicate Suppression And Change Detection
|
||||
|
||||
Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
|
||||
Every workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. Baselines additionally carry `baseline: true`. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
|
||||
|
||||
At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy.
|
||||
|
||||
An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch.
|
||||
|
||||
The initial baseline keeps an in-memory path/digest map for comparison. A later successful filesystem touch appends baseline edits or removals as dynamic messages; it never rewrites the original event. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request.
|
||||
The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request.
|
||||
|
||||
There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully.
|
||||
|
||||
@@ -68,7 +68,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc
|
||||
|
||||
**Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content.
|
||||
|
||||
**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-session guard gives one durable baseline event while dynamic append-only messages handle changes.
|
||||
**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; dynamic append-only messages handle changes and compaction re-arming.
|
||||
|
||||
**Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable.
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ Status: implemented
|
||||
|
||||
在 agent loop(智能体循环)实例的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。
|
||||
|
||||
该注入成为一条带插件来源的持久 `user/message`。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。
|
||||
该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。
|
||||
|
||||
恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有热重挂(未见证生命周期开始、且日志已有基线)才保留既有基线并跳过;恢复则重新组合。
|
||||
恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。
|
||||
|
||||
基线是一条 user 角色的 `<system-reminder>`,包含 `Instructions from: <path>` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `</system-reminder>` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。
|
||||
|
||||
@@ -42,19 +42,19 @@ Status: implemented
|
||||
|
||||
内容编辑会追加 `Updated instructions from: <path>`,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: <path>`,并说明先前加载的指令不再适用。
|
||||
|
||||
动态消息在 `content` 中携带完整的 system-reminder 框架;每条带来源的 `user/message` 都逐字抵达模型,核心层不会再添加可选择退出的包装。带类型的 `workspace-instructions` 来源携带持久化状态,该状态绝不会渲染给模型。
|
||||
基线消息和动态消息都在 `content` 中携带完整的 system-reminder 框架;每条带来源的 `user/message` 都逐字抵达模型,核心层不会再添加可选择退出的包装。带类型的 `workspace-instructions` 来源携带持久化状态,该状态绝不会渲染给模型。
|
||||
|
||||
shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,而从任意命令字符串推断已到达路径,需要实现提示词插件并不拥有的 shell 语义。
|
||||
|
||||
### 重复抑制与变更检测
|
||||
|
||||
每个动态工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。
|
||||
每个工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。基线还会额外携带 `baseline: true`。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。
|
||||
|
||||
协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。
|
||||
|
||||
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩(compaction)从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
|
||||
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
|
||||
|
||||
初始基线会保留一个内存中的 path/digest map 以供比较。后续成功的文件系统触碰会把基线编辑或移除操作追加为动态消息,绝不重写原始事件。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。
|
||||
只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。
|
||||
|
||||
系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。
|
||||
|
||||
@@ -68,7 +68,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,
|
||||
|
||||
**使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。
|
||||
|
||||
**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐会话防护提供一条持久的基线事件,动态仅追加消息则负责变更。
|
||||
**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;动态仅追加消息负责处理变更和压缩后的重新启用。
|
||||
|
||||
**在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: bf0180f191395f6af81ce6fe5b6497810596fc29
|
||||
README.zh.md: d544d1c8373de0064b87f466829b523308991741
|
||||
# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md
|
||||
README.md: df75b29dd3e8dbb504aac9e9885c32a809cbf70f
|
||||
README.zh.md: 8bd926302f09ecdf453c7832b3a15b0e7fcc1b2a
|
||||
|
||||
@@ -48,11 +48,11 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
|
||||
|
||||
## State And Refresh
|
||||
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
|
||||
|
||||
The initial baseline event itself is not rewritten. Its path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline.
|
||||
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -48,11 +48,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
## 状态与刷新
|
||||
|
||||
模型可见文本不含隐藏状态标记。每个动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
|
||||
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
|
||||
|
||||
初始基线事件自身不会被改写。其路径/digest map 保留为比较状态;下一次成功文件系统 touch 会追加任何基线替换或移除。恢复的 loop 会重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -43,19 +43,27 @@ export type {
|
||||
export { renderWorkspaceContext } from './render.ts'
|
||||
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
|
||||
|
||||
function hasVisibleBaseline(agent: Agent): boolean {
|
||||
return agent.session.surface.nodes.some((seq) => {
|
||||
const event = agent.session.events[seq]
|
||||
return event?.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true
|
||||
})
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved: ResolvedConfig = resolveConfig(config)
|
||||
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
|
||||
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
|
||||
const baselineSessions = new WeakSet<object>()
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
|
||||
const baselineLoaded = new WeakSet<object>()
|
||||
// Sessions whose lifecycle start this mount witnessed. A startup or resume
|
||||
// emits agent/session-start before the first step; a hot remount attaches to
|
||||
// an already-live session and never sees it. That difference is the only
|
||||
// reliable way to tell a resumed session (re-compose the baseline from
|
||||
// current files) from a remount over a live one (keep the single baseline
|
||||
// already in the log) — the durable log looks identical in both cases.
|
||||
// an already-live session and never sees it. Resumes always re-compose the
|
||||
// baseline from current files. Hot remounts retain a baseline only while its
|
||||
// typed event remains model-visible.
|
||||
const lifecycleWitnessed = new WeakSet<object>()
|
||||
const pendingByParent = new Map<ToolExecutionToken, {
|
||||
agent: Agent
|
||||
@@ -73,15 +81,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
|
||||
if (baselineLoaded.has(agent.session)) return
|
||||
// A baseline already in the log with no witnessed lifecycle start is a hot
|
||||
// remount over a live session: keep that single baseline and skip. A
|
||||
// resumed session witnessed its start, so it falls through and re-composes.
|
||||
if (!lifecycleWitnessed.has(agent.session)
|
||||
&& agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'workspace-context')) {
|
||||
baselineLoaded.add(agent.session)
|
||||
return
|
||||
}
|
||||
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
|
||||
baselineLoaded.add(agent.session)
|
||||
return
|
||||
@@ -104,14 +103,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
signal,
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
baselineInstructionStates.set(agent.session, baseline.changes)
|
||||
baselineSessions.add(agent.session)
|
||||
instructionVersions.set(agent.session, baseline.versions)
|
||||
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{ includeBaselineScopes: false, signal },
|
||||
@@ -120,9 +118,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
agent.inject({ content: update.context.content, source: update.context.source })
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
|
||||
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
|
||||
agent.inject({ content: baselineMessage.content, source: { kind: 'plugin', plugin: 'workspace-context' } })
|
||||
agent.inject({
|
||||
content: baselineMessage.content,
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
baseline: true,
|
||||
changes: [...baseline.changes.values()],
|
||||
},
|
||||
})
|
||||
}
|
||||
baselineLoaded.add(agent.session)
|
||||
})
|
||||
@@ -148,7 +154,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
result,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
baselineSessions,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,8 @@ const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
|
||||
/** Durable provenance and reconciliation facts for one workspace context. */
|
||||
export interface WorkspaceInstructionSource {
|
||||
kind: 'workspace-instructions'
|
||||
/** Marks the complete startup/resume baseline rather than a later delta. */
|
||||
baseline?: true
|
||||
changes: WorkspaceInstructionChange[]
|
||||
}
|
||||
|
||||
@@ -89,7 +91,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request-prefix message for a rendered baseline.
|
||||
* Build the user-role message for a rendered baseline.
|
||||
* @param text - complete plugin-owned system-reminder text.
|
||||
* @returns a user-role prefix message.
|
||||
*/
|
||||
@@ -378,50 +380,49 @@ function relativeScope(projectRoot: string, dir: string): string {
|
||||
* @param agent - session owner whose visible surface supplies durable state.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingBySession - short pending window before returned context is logged.
|
||||
* @param baselineBySession - frozen baseline comparison state per session.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @param options - touched path and whether baseline scopes should be checked.
|
||||
* @param options - touched path and whether baseline scopes should participate.
|
||||
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
|
||||
*/
|
||||
export async function reconcileInstructionContext(
|
||||
agent: Agent,
|
||||
resolved: ResolvedConfig,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
const session = agent.session
|
||||
const pending = pendingChangesFor(session, pendingBySession)
|
||||
const visible = visibleInstructionChanges(agent, pending)
|
||||
const effective = new Map(baselineBySession.get(session) ?? [])
|
||||
for (const [scope, change] of visible) effective.set(scope, change)
|
||||
const effective = visibleInstructionChanges(agent, pending)
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = session.header.cwd ?? process.cwd()
|
||||
// TODO(frozen-project-root): retain the baseline root for the loop instance;
|
||||
// recomputing it after marker edits reinterprets the existing relative scope keys.
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const scopes = new Set<string>()
|
||||
const addDirScopes = (directory: string): void => {
|
||||
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
|
||||
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
|
||||
const baselineScopes = new Set<string>()
|
||||
const addDirScopes = (target: Set<string>, directory: string): void => {
|
||||
for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
|
||||
for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
|
||||
}
|
||||
const addProjectScopes = (dir: string): void => {
|
||||
addDirScopes(relativeScope(projectRoot, dir))
|
||||
const addProjectScopes = (target: Set<string>, dir: string): void => {
|
||||
addDirScopes(target, relativeScope(projectRoot, dir))
|
||||
}
|
||||
baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir)
|
||||
if (options.includeBaselineScopes) {
|
||||
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
|
||||
for (const scope of baselineScopes) scopes.add(scope)
|
||||
}
|
||||
for (const scope of effective.keys()) {
|
||||
if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
else addDirScopes(directory)
|
||||
else addDirScopes(scopes, directory)
|
||||
}
|
||||
if (options.touchedPath !== undefined) {
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir)
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
@@ -533,7 +534,7 @@ export async function reconcileInstructionContext(
|
||||
* @param result - original tool result before post-execute decisions.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @param baselineInstructionStates - retained baseline comparison state.
|
||||
* @param baselineSessions - sessions whose configured baseline scopes should be probed.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
|
||||
@@ -544,7 +545,7 @@ export async function dynamicInstructionContext(
|
||||
result: ToolExecutionResult,
|
||||
resolved: ResolvedConfig,
|
||||
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||
baselineSessions: WeakSet<object>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
@@ -552,10 +553,10 @@ export async function dynamicInstructionContext(
|
||||
const touchedPath = filePathFromExecution(exec)
|
||||
if (touchedPath === undefined) return undefined
|
||||
return reconcileInstructionContext(
|
||||
agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
|
||||
agent, resolved, pendingNestedChanges, versionCache, fileSystem,
|
||||
{
|
||||
touchedPath,
|
||||
includeBaselineScopes: baselineInstructionStates.has(agent.session),
|
||||
includeBaselineScopes: baselineSessions.has(agent.session),
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -207,6 +207,13 @@ function workspaceContextOf(result: { additionalContexts?: UserMessageData[] }):
|
||||
context.source.kind === 'workspace-instructions')
|
||||
}
|
||||
|
||||
function baselineEvents(agent: Agent): SessionEvent[] {
|
||||
return agent.session.events.filter(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true)
|
||||
}
|
||||
|
||||
function workspaceChangeContext(scope: string, digest: string): UserMessageData {
|
||||
return {
|
||||
content: [{ type: 'text', text: `instructions for ${scope}` }],
|
||||
@@ -943,9 +950,17 @@ describe('workspace context request injection', () => {
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind !== 'user',
|
||||
)).toHaveLength(1)
|
||||
expect(baselineEvents(agent)).toHaveLength(1)
|
||||
expect(baselineEvents(agent)[0]).toMatchObject({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
baseline: true,
|
||||
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(composedPrefixes.get(agent)).toHaveLength(1)
|
||||
expect(derivedText(agent)).toContain('<system-reminder>')
|
||||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
|
||||
@@ -980,7 +995,44 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('folds an already-appended baseline from the log after a plugin remount', async () => {
|
||||
it('retains a visible baseline after a plugin remount', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
await write(join(root, 'file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
// Hot remount over the live session: the durable baseline remains
|
||||
// visible, so the fresh mount does not append a duplicate.
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(baselineEvents(agent)).toHaveLength(1)
|
||||
|
||||
await write(join(root, 'AGENTS.md'), 'updated repo rule')
|
||||
const update = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-remount'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'file.txt' },
|
||||
agent,
|
||||
})
|
||||
expect(workspaceContextOf(update)?.source).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||||
})
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('restores a compacted baseline on a hot plugin remount', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -991,15 +1043,23 @@ describe('workspace context request injection', () => {
|
||||
const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
const baseline = baselineEvents(agent)[0]
|
||||
expect(baseline).toBeDefined()
|
||||
|
||||
agent.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||||
sourceEventSeqs: [baseline!.seq],
|
||||
})
|
||||
|
||||
// Hot remount over the live session: the durable baseline survived, so
|
||||
// the fresh mount must fold it from the log instead of appending a
|
||||
// duplicate on its next step.
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
|
||||
expect(baselineEvents(agent)).toHaveLength(2)
|
||||
expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('repo rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1031,14 +1091,12 @@ describe('workspace context request injection', () => {
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
|
||||
await composeBaselinePrefix(ctx, resumed)
|
||||
|
||||
const pluginMessages = resumed.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'workspace-context')
|
||||
expect(pluginMessages).toHaveLength(2)
|
||||
const latest = pluginMessages.at(-1)
|
||||
const baselines = baselineEvents(resumed)
|
||||
expect(baselines).toHaveLength(2)
|
||||
const latest = baselines.at(-1)
|
||||
expect(latest?.type === 'user/message' && blocksText(latest.data.content))
|
||||
.toContain('new root rule after offline edit')
|
||||
const original0 = pluginMessages[0]
|
||||
const original0 = baselines[0]
|
||||
expect(original0?.type === 'user/message' && blocksText(original0.data.content))
|
||||
.toContain('old root rule')
|
||||
} finally {
|
||||
@@ -2657,6 +2715,63 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('re-arms an unchanged baseline after compaction removes it from the surface', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'root rule')
|
||||
await write(join(root, 'file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
const baseline = baselineEvents(agent)[0]
|
||||
expect(baseline).toBeDefined()
|
||||
|
||||
const whileVisible = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-visible-baseline'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'file.txt' },
|
||||
agent,
|
||||
})
|
||||
agent.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||||
sourceEventSeqs: [baseline!.seq],
|
||||
})
|
||||
|
||||
const rearmed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-compacted-baseline'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'file.txt' },
|
||||
agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, rearmed)
|
||||
const afterRearm = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-rearmed-baseline'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'file.txt' },
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(whileVisible.additionalContexts).toBeUndefined()
|
||||
expect(workspaceContextOf(rearmed)?.source).toMatchObject({
|
||||
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(rearmed)?.content)).toContain('root rule')
|
||||
expect(afterRearm.additionalContexts).toBeUndefined()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat markdown headings inside instruction content as loaded instruction metadata', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
|
||||
Reference in New Issue
Block a user