fix(workspace-context): restore baseline after compaction
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/README.md
|
||||
README.md: fce6e21816d261171aaeaa217171580adb7c43f9
|
||||
README.zh.md: b8a4d68ca6892b51ed52479a7296513f5edcc292
|
||||
README.md: 0eb554eda5bc461cfb3b5d437209a7308f26a97a
|
||||
README.zh.md: 18d36b63f8603134689a330ebfe9b2b034c64cc8
|
||||
|
||||
@@ -9,6 +9,6 @@ Product plugins that add model-visible request context without defining a tool.
|
||||
| `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` |
|
||||
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
|
||||
| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/step`, reads `ctx.bash`) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `system-prompt/assemble` + `tools/post-execute`) |
|
||||
|
||||
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
| `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` |
|
||||
| `time-context/` | 持久化的逐步骤当前时间与已用时上下文 | (无) |
|
||||
| `tmux-context/` | 持久化的逐轮次上下文,记录本 agent 所在的 tmux pane/window 位置 | (监听 `agent/step`,读取 `ctx.bash`) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `system-prompt/assemble` + `tools/post-execute`) |
|
||||
|
||||
[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md
|
||||
README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d
|
||||
README.zh.md: e9fab4c6998f1193068389b41bdd7fa7d8c98dca
|
||||
README.md: 8201a9bb347c27da432748576151a0f6db9f3d9f
|
||||
README.zh.md: adcf50d70fe685a0cf8604405be5568fbcb409c1
|
||||
|
||||
@@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt.
|
||||
The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. If a later surface replacement such as compaction shadows that baseline, `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots its next request.
|
||||
|
||||
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
|
||||
|
||||
@@ -52,7 +52,7 @@ Model-visible text contains no hidden state markers. Each baseline or dynamic co
|
||||
|
||||
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 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.
|
||||
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. After a surface replacement removes it, prompt assembly recomposes the current baseline for that replacement generation and injects it before the first post-replacement request; a queued baseline that is discarded with its step remains eligible for the next request. A successful filesystem touch can still append later replacements or removals. 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, when prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -83,7 +83,7 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even
|
||||
|
||||
#### What the model sees
|
||||
|
||||
At the first request of each loop instance, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order.
|
||||
At the first request of each loop instance, and again on the first request after a surface replacement shadows it, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order.
|
||||
|
||||
##### Baseline instruction template
|
||||
|
||||
@@ -103,11 +103,11 @@ 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 remains in derived history until a surface replacement shadows it, then one recomposed baseline is appended before the next request. `maxBytes` bounds each complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only after the existing reusable prefix. A new or resumed instance may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position.
|
||||
Append-only after the existing reusable prefix. A new, resumed, or post-compaction request may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position.
|
||||
|
||||
### Newly discovered scope context
|
||||
|
||||
@@ -162,7 +162,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
|
||||
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop prepares its baseline.
|
||||
- **Refresh is event-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline.
|
||||
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
|
||||
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
|
||||
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 生命周期
|
||||
|
||||
基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。
|
||||
基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,`system-prompt/assemble` 会在 loop 对下一个请求创建快照之前,重新组合并注入当前指令链。
|
||||
|
||||
该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
|
||||
|
||||
@@ -50,9 +50,9 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `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 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
|
||||
路径与 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。
|
||||
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,提示词组装会为该替换代次重新组合当前基线,并在替换后的第一个请求前注入;随其步骤一起被丢弃的已排队基线仍可在下一个请求中重新准备。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface Config {
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在每个 loop 实例的第一个请求中,模型会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。
|
||||
在每个 loop 实例的第一个请求中,以及表层替换将其遮蔽后的第一个请求中,模型都会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。
|
||||
|
||||
##### 基线指令模板
|
||||
|
||||
@@ -103,11 +103,11 @@ Instructions from: AGENTS.md
|
||||
|
||||
#### Token 影响
|
||||
|
||||
渲染后基线只追加一次,并保留在派生历史中直到压缩。`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。
|
||||
渲染后基线会保留在派生历史中,直到表层替换将其遮蔽;随后会在下一个请求前追加一条重新组合的基线。`maxBytes` 会限制每条完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加,位于现有可复用前缀之后。新建或恢复的实例可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。
|
||||
仅追加,位于现有可复用前缀之后。新建实例的请求、恢复后的请求或压缩后的请求可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。
|
||||
|
||||
### 新发现的 scope 上下文
|
||||
|
||||
@@ -162,7 +162,7 @@ The previously loaded instructions from this file no longer apply.
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。
|
||||
- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在恢复 loop 准备基线时可见。
|
||||
- **刷新由事件驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。
|
||||
- **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但用户全局 `$DSH_HOME` scope 没有本地 overlay,其他自定义名称需要显式候选配置。
|
||||
- **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。
|
||||
- **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不会覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Workspace instruction loader for AGENTS.md-compatible files.
|
||||
*
|
||||
* Baseline instructions enter durable context before the first request; successful fs
|
||||
* Baseline instructions enter durable context before the first request and are
|
||||
* restored during prompt assembly when compaction removes them. Successful fs
|
||||
* tool touches reconcile nested, changed, and removed instructions through
|
||||
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
|
||||
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
|
||||
@@ -12,6 +13,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import { loadBaselineInstructionSet } from './files.ts'
|
||||
@@ -44,15 +46,21 @@ 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]
|
||||
function hasVisibleBaseline(session: Agent['session']): boolean {
|
||||
return session.surface.nodes.some((seq) => {
|
||||
const event = session.events[seq]
|
||||
return event?.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true
|
||||
})
|
||||
}
|
||||
|
||||
function hasBaselineHistory(agent: Agent): boolean {
|
||||
return agent.session.events.some(event => 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>>()
|
||||
@@ -60,6 +68,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
|
||||
const baselineLoaded = new WeakSet<object>()
|
||||
// Settled means this generation needed no new baseline; queued covers the
|
||||
// interval before an injected baseline becomes a durable surface event.
|
||||
const baselineSettledGeneration = new WeakMap<object, number>()
|
||||
const baselineQueuedGeneration = new WeakMap<object, number>()
|
||||
// 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. Resumes always re-compose the
|
||||
@@ -78,17 +90,29 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true) baselineQueuedGeneration.delete(session)
|
||||
if ((event.type === 'step/end' || event.type === 'turn/end')
|
||||
&& !hasVisibleBaseline(session)) baselineQueuedGeneration.delete(session)
|
||||
})
|
||||
|
||||
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
|
||||
if (baselineLoaded.has(agent.session)) return
|
||||
const prepareBaseline = async (
|
||||
agent: Agent,
|
||||
signal: AbortSignal | undefined,
|
||||
keepVisibleBaseline: boolean,
|
||||
): Promise<void> => {
|
||||
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
|
||||
baselineLoaded.add(agent.session)
|
||||
baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
return
|
||||
}
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) {
|
||||
baselineLoaded.add(agent.session)
|
||||
baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
@@ -101,7 +125,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
maxSourceBytes: resolved.maxSourceBytes,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
|
||||
signal,
|
||||
...signal === undefined ? {} : { signal },
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
baselineSessions.add(agent.session)
|
||||
@@ -113,15 +137,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
pendingNestedChanges,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{ includeBaselineScopes: false, signal },
|
||||
{ includeBaselineScopes: false, ...signal === undefined ? {} : { signal } },
|
||||
)
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.context)
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
|
||||
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
|
||||
baselineSettledGeneration.delete(agent.session)
|
||||
baselineQueuedGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
agent.inject(createUserMessage({
|
||||
content: baselineMessage.content,
|
||||
source: {
|
||||
@@ -130,8 +155,30 @@ export function apply(ctx: Context, config: Config): void {
|
||||
changes: [...baseline.changes.values()],
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
}
|
||||
baselineLoaded.add(agent.session)
|
||||
}
|
||||
|
||||
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
|
||||
if (baselineLoaded.has(agent.session)) return
|
||||
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent.session)
|
||||
await prepareBaseline(agent, signal, keepVisibleBaseline)
|
||||
})
|
||||
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
const assembled = await next()
|
||||
const agent = context.agent
|
||||
if (agent === undefined
|
||||
|| !baselineLoaded.has(agent.session)
|
||||
|| hasVisibleBaseline(agent.session)
|
||||
|| baselineSettledGeneration.get(agent.session) === agent.session.surface.replaceGeneration
|
||||
|| baselineQueuedGeneration.get(agent.session) === agent.session.surface.replaceGeneration
|
||||
|| !hasBaselineHistory(agent)) return assembled
|
||||
await prepareBaseline(agent, context.signal, false)
|
||||
return assembled
|
||||
})
|
||||
|
||||
ctx.on('tools/post-execute', async (
|
||||
|
||||
@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import LlmService, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
@@ -1086,6 +1086,131 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('restores a compacted baseline during prompt assembly before another filesystem touch', 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')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await mountWorkspaceContext(ctx, { 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', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||||
sourceEventSeqs: [baseline!.seq],
|
||||
})
|
||||
|
||||
await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a re-injected baseline when its queued step closes before the message becomes durable', 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')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await mountWorkspaceContext(ctx, { 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', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||||
sourceEventSeqs: [baseline!.seq],
|
||||
})
|
||||
const queued: UserMessage[] = []
|
||||
const queuedAgent: Agent = {
|
||||
...agent,
|
||||
inject(input) { queued.push(input) },
|
||||
}
|
||||
|
||||
await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal))
|
||||
await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal))
|
||||
expect(queued).toHaveLength(1)
|
||||
|
||||
ctx.emit('session/event', agent.session, {
|
||||
type: 'step/end', seq: 999, time: 0, data: { turn: 1, step: 1 },
|
||||
})
|
||||
await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal))
|
||||
|
||||
expect(queued).toHaveLength(2)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('includes a re-injected baseline in the first real request after a between-step replacement', 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'), 'first post-compaction request rule')
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/step', (subject, turn) => {
|
||||
if (turn !== 2) return
|
||||
const baseline = baselineEvents(subject).find(event => subject.session.surface.nodes.includes(event.seq))
|
||||
if (baseline === undefined) throw new Error('first turn did not retain its workspace baseline')
|
||||
subject.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq },
|
||||
sourceEventSeqs: [baseline.seq],
|
||||
})
|
||||
})
|
||||
const agent = ctx.agentLoop.create(
|
||||
SessionId('workspace-context-post-compact'),
|
||||
{ provider: 'mock', model: 'mock' },
|
||||
{ cwd: root },
|
||||
)
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(adapter.requests[1]?.messages.map(message => blocksText(message.content)).join('\n'))
|
||||
.toContain('first post-compaction request rule')
|
||||
expect(baselineEvents(agent)).toHaveLength(2)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('recomposes the baseline from current files when a resumed session edited it offline', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -1675,6 +1800,38 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans up its prompt-assembly listener when the plugin fiber is disposed', 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')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
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', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||||
sourceEventSeqs: [baseline!.seq],
|
||||
})
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.systemPrompt.assemble(assembleContextFor(agent, testToolSignal))
|
||||
|
||||
expect(baselineEvents(agent)).toHaveLength(1)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not inject anything when maxBytes is zero', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user