mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/fix-compact-agents-reinjection
# Conflicts: # .agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml # .agents/notes/implemented/feature/2026-06-24-workspace-context.md # .agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/cordis-catalog/events.md # docs/core-data-structures/system-prompt.i18n.yaml # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # packages/context/workspace-context/README.i18n.yaml # packages/context/workspace-context/README.md # packages/context/workspace-context/README.zh.md # packages/context/workspace-context/src/index.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/tests/agent.spec.ts # packages/core/system-prompt/README.i18n.yaml
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/workspace-context/README.md
|
||||
README.md: c4a3fae337cca70c98026bbdce7b252ba33bac0c
|
||||
README.zh.md: 35b7fe6bfc7d247ff155d19053e3d9e4551838ae
|
||||
README.md: b9751f2d0f64027d9c28c67971e6e5aa4e1278a1
|
||||
README.zh.md: 0daabf0cd2b541a16b697e5bbe2a5a8582598f49
|
||||
|
||||
@@ -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. If a later surface replacement such as compaction shadows that baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session.
|
||||
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 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.
|
||||
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`. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. 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. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that 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 typed changes remain authoritative only while that event is in the visible session surface. After a surface replacement removes it, model-request prompt assembly recomposes the current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. 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 a model request restores a shadowed baseline, 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. When compaction shadows the event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it queues a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes at its first pre-step; an entering first request records that context in the same step. 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
|
||||
|
||||
@@ -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, 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.
|
||||
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.
|
||||
|
||||
##### Baseline instruction template
|
||||
|
||||
@@ -103,11 +103,11 @@ Instructions from: AGENTS.md
|
||||
|
||||
#### Token effect
|
||||
|
||||
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.
|
||||
The rendered baseline is appended once and remains in derived history until compaction. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only after the existing reusable prefix. 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.
|
||||
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.
|
||||
|
||||
### 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 event-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when model-request prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline.
|
||||
- **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.
|
||||
- **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` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。
|
||||
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1,并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox,等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。
|
||||
|
||||
该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
|
||||
|
||||
@@ -48,11 +48,11 @@ 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 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `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。
|
||||
路径与 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。
|
||||
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,面向模型请求的提示词组装会重新组合当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。当压缩遮蔽该事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统 touch 重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会排队当前基线。恢复的 loop 始终在第一次 pre-step 重新组合当前基线,并对账仍可见的动态 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。
|
||||
- **刷新由事件驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在面向模型请求的提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。
|
||||
- **刷新由 touch 驱动**:没有 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`。
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"@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,36 +1,29 @@
|
||||
/**
|
||||
* Workspace instruction loader for AGENTS.md-compatible files.
|
||||
*
|
||||
* Baseline instructions enter durable context before the first request and are
|
||||
* restored during model-request 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.
|
||||
* Baseline instructions enter durable context before the first request; successful fs
|
||||
* tool touches project nested, changed, and removed instructions into the inbox.
|
||||
* Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products
|
||||
* mount it as a no-op.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Agent, PreStepDecision } 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 type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import { loadBaselineInstructionSet } from './files.ts'
|
||||
import {
|
||||
applyInstructionVersionUpdates,
|
||||
baselineInstructionState,
|
||||
commitPendingInstructionContexts,
|
||||
dynamicInstructionContext,
|
||||
name,
|
||||
observeInstructionSessionEvent,
|
||||
reconcileInstructionContext,
|
||||
retainedInstructionVersionUpdates,
|
||||
rollbackPendingInstructionChanges,
|
||||
workspaceContextMessage,
|
||||
type InstructionVersionCache,
|
||||
type InstructionVersionUpdate,
|
||||
type PendingInstructionChange,
|
||||
} from './state.ts'
|
||||
import type { WorkspaceInstructionChange } from './render.ts'
|
||||
|
||||
@@ -46,219 +39,212 @@ export type {
|
||||
export { renderWorkspaceContext } from './render.ts'
|
||||
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
|
||||
|
||||
function hasVisibleBaseline(session: Agent['session']): boolean {
|
||||
return session.surface.nodes.some((seq) => {
|
||||
const event = session.events[seq]
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
function hasBaselineHistory(session: Agent['session']): boolean {
|
||||
return session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true)
|
||||
function isWorkspaceContext(message: UserMessage): boolean {
|
||||
return message.source.kind === 'workspace-instructions'
|
||||
}
|
||||
|
||||
function sameContextPayload(left: UserMessage, right: UserMessage): boolean {
|
||||
return isDeepStrictEqual(left.content, right.content)
|
||||
&& isDeepStrictEqual(left.source, right.source)
|
||||
}
|
||||
|
||||
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
|
||||
|
||||
function filePathFromExecution(exec: ToolExecution): string | undefined {
|
||||
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
|
||||
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
|
||||
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
|
||||
const filePath = exec.arguments.file_path.trim()
|
||||
return filePath.length > 0 ? filePath : undefined
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved: ResolvedConfig = resolveConfig(config)
|
||||
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
|
||||
const baselineSessions = new WeakSet<object>()
|
||||
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
|
||||
// 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
|
||||
changes: WorkspaceInstructionChange[]
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}>()
|
||||
const projectionLifecycle = new AbortController()
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
projectionLifecycle.abort(new Error('workspace-context disposed'))
|
||||
},
|
||||
'workspace-context.projectionLifecycle',
|
||||
)
|
||||
// Emit listeners are not awaited, so each projection must compose against the
|
||||
// inbox produced by earlier file results for the same agent.
|
||||
const projectionTails = new WeakMap<Agent, Promise<void>>()
|
||||
|
||||
ctx.on('agent/session-start', (agent: Agent) => {
|
||||
lifecycleWitnessed.add(agent.session)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
const prepareBaseline = async (
|
||||
const compose = async (
|
||||
agent: Agent,
|
||||
signal: AbortSignal | undefined,
|
||||
keepVisibleBaseline: boolean,
|
||||
deduplicateRestore = false,
|
||||
): Promise<void> => {
|
||||
signal: AbortSignal,
|
||||
claimed: readonly UserMessage[],
|
||||
pending: readonly UserMessage[],
|
||||
touchedPaths: readonly string[] = [],
|
||||
): Promise<UserMessage | undefined> => {
|
||||
signal.throwIfAborted()
|
||||
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
|
||||
return undefined
|
||||
}
|
||||
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
|
||||
if (fileSystem === undefined) return undefined
|
||||
if (touchedPaths.length === 0 && pending.length > 0) return pending[0]
|
||||
const content: UserMessage['content'][number][] = []
|
||||
const changes: WorkspaceInstructionChange[] = []
|
||||
let desiredBaseline = false
|
||||
const authorityMessages = [...claimed]
|
||||
const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message =>
|
||||
message.source.kind === 'workspace-instructions' && message.source.baseline === true)
|
||||
if (!baselinePresent) {
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const instructions = await loadBaselineInstructionSet({
|
||||
cwd,
|
||||
dshHome: resolved.dshHome,
|
||||
projectRootMarkers: resolved.projectRootMarkers,
|
||||
maxBytes: resolved.maxBytes,
|
||||
maxSourceBytes: resolved.maxSourceBytes,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
|
||||
signal,
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
let versionStates = instructionVersions.get(agent.session)
|
||||
if (versionStates === undefined && baseline.versions.size > 0) {
|
||||
versionStates = new Map()
|
||||
instructionVersions.set(agent.session, versionStates)
|
||||
}
|
||||
for (const [scope, state] of baseline.versions) versionStates?.set(scope, state)
|
||||
if (instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
content.push(...workspaceContextMessage(instructions.rendered.text).content)
|
||||
changes.push(...baseline.changes.values())
|
||||
desiredBaseline = true
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const instructions = await loadBaselineInstructionSet({
|
||||
cwd,
|
||||
dshHome: resolved.dshHome,
|
||||
projectRootMarkers: resolved.projectRootMarkers,
|
||||
maxBytes: resolved.maxBytes,
|
||||
maxSourceBytes: resolved.maxSourceBytes,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
|
||||
...signal === undefined ? {} : { signal },
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
baselineSessions.add(agent.session)
|
||||
instructionVersions.set(agent.session, baseline.versions)
|
||||
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{ includeBaselineScopes: false, ...signal === undefined ? {} : { signal } },
|
||||
{ authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal },
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const generation = agent.session.surface.replaceGeneration
|
||||
if (deduplicateRestore && (
|
||||
hasVisibleBaseline(agent.session)
|
||||
|| baselineSettledGeneration.get(agent.session) === generation
|
||||
|| baselineQueuedGeneration.get(agent.session) === generation
|
||||
)) return
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.context)
|
||||
content.push(...update.context.content)
|
||||
/* v8 ignore next -- reconciliation constructs only workspace-instructions contexts. */
|
||||
if (update.context.source.kind === 'workspace-instructions') {
|
||||
changes.push(...update.context.source.changes)
|
||||
}
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
|
||||
baselineSettledGeneration.delete(agent.session)
|
||||
baselineQueuedGeneration.set(agent.session, generation)
|
||||
try {
|
||||
agent.inject(createUserMessage({
|
||||
content: baselineMessage.content,
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
baseline: true,
|
||||
changes: [...baseline.changes.values()],
|
||||
},
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration)
|
||||
baselineQueuedGeneration.delete(agent.session)
|
||||
}
|
||||
baselineLoaded.add(agent.session)
|
||||
if (content.length === 0) return undefined
|
||||
return createUserMessage({
|
||||
content,
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
...desiredBaseline ? { baseline: true } : {},
|
||||
changes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 (context.modelRequest !== true
|
||||
|| 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.session)) return assembled
|
||||
await prepareBaseline(agent, context.signal, false, true)
|
||||
return assembled
|
||||
})
|
||||
|
||||
ctx.on('tools/post-execute', async (
|
||||
exec: ToolExecution,
|
||||
result: ToolExecutionResult,
|
||||
next,
|
||||
): Promise<PostToolDecision> => {
|
||||
const downstream = await next()
|
||||
// A downstream listener/policy blocked this call: the registry turns it
|
||||
// into a final `isError` result, so treat it like a failed fs touch and
|
||||
// load nothing. Reconciling here would surface workspace instructions from
|
||||
// a call the pipeline rejected, violating the "successful fs tool touches"
|
||||
// contract, and would advance the nested/baseline tracking state off a
|
||||
// touch that never really happened.
|
||||
if (downstream.kind === 'block') return downstream
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return downstream
|
||||
const update = await dynamicInstructionContext(
|
||||
exec.agent,
|
||||
exec,
|
||||
result,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineSessions,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => {
|
||||
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
|
||||
const alreadySupplied = desired !== undefined && (
|
||||
claimed.some(message => sameContextPayload(message, desired))
|
||||
|| agent.session.surface.nodes.some((seq) => {
|
||||
const event = agent.session.events[seq]
|
||||
return event?.type === 'user/message' && sameContextPayload(event.data, desired)
|
||||
})
|
||||
)
|
||||
if (update === undefined) return downstream
|
||||
pendingVersionUpdates.set(exec.token, update.versionUpdates)
|
||||
return {
|
||||
...downstream,
|
||||
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
|
||||
if (desired === undefined || alreadySupplied) {
|
||||
for (const message of pending) agent.inbox.remove(message.id)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
|
||||
pendingVersionUpdates.delete(exec.token)
|
||||
if (exec.parent !== undefined) {
|
||||
if (exec.agent === undefined) return
|
||||
// Child contexts participate in duplicate suppression within one composite
|
||||
// run, but remain provisional until the parent reaches its final policy.
|
||||
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
if (changes.length === 0) return
|
||||
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
|
||||
const staged = pendingByParent.get(exec.parent)
|
||||
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
|
||||
else {
|
||||
staged.changes.push(...changes)
|
||||
staged.versionUpdates.push(...versionUpdates)
|
||||
const reusable = pending.find(message => sameContextPayload(message, desired))
|
||||
if (reusable !== undefined) {
|
||||
for (const message of pending) {
|
||||
if (message !== reusable) agent.inbox.remove(message.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const replaced = pending[0]
|
||||
if (replaced === undefined) agent.inbox.prepend('next-step', desired)
|
||||
else agent.inbox.replace(replaced.id, desired)
|
||||
for (const message of pending.slice(1)) agent.inbox.remove(message.id)
|
||||
}
|
||||
|
||||
// The parent result is authoritative: remove every provisional child change,
|
||||
// then commit only contexts that survived outer post-execute policy.
|
||||
const staged = pendingByParent.get(exec.token)
|
||||
if (staged !== undefined) {
|
||||
pendingByParent.delete(exec.token)
|
||||
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
|
||||
const composeAndSync = async (
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
claimed: readonly UserMessage[],
|
||||
touchedPaths: readonly string[] = [],
|
||||
): Promise<void> => {
|
||||
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
|
||||
const desired = await compose(agent, signal, claimed, pending, touchedPaths)
|
||||
signal.throwIfAborted()
|
||||
syncInbox(agent, claimed, desired)
|
||||
}
|
||||
|
||||
const queueProjection = (
|
||||
agent: Agent,
|
||||
touchedPath: string,
|
||||
): void => {
|
||||
const previous = projectionTails.get(agent) ?? Promise.resolve()
|
||||
const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath]))
|
||||
.catch((error: unknown) => {
|
||||
if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)
|
||||
})
|
||||
projectionTails.set(agent, current)
|
||||
void current.then(() => {
|
||||
if (projectionTails.get(agent) === current) projectionTails.delete(agent)
|
||||
})
|
||||
}
|
||||
|
||||
const waitForProjections = async (agent: Agent): Promise<void> => {
|
||||
let projection: Promise<void> | undefined
|
||||
while ((projection = projectionTails.get(agent)) !== undefined) await projection
|
||||
}
|
||||
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent: Agent,
|
||||
messages,
|
||||
{ step, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
await waitForProjections(agent)
|
||||
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
|
||||
const desired = await compose(agent, signal, messages, pending)
|
||||
signal.throwIfAborted()
|
||||
// An empty first entry owns a no-step turn; keep context pending instead
|
||||
// of turning it into a standalone request. Later entries may be tool continuations.
|
||||
if (decision.kind === 'reject' || (step === 1 && decision.messages.length === 0)) {
|
||||
syncInbox(agent, messages, desired)
|
||||
return decision
|
||||
}
|
||||
if (exec.agent === undefined) return
|
||||
const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
const stagedVersionUpdates = staged?.versionUpdates ?? []
|
||||
const versionUpdates = retainedInstructionVersionUpdates(
|
||||
[...stagedVersionUpdates, ...ownVersionUpdates],
|
||||
committed,
|
||||
)
|
||||
applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
|
||||
// A proceeding step settles the pending context: it either enters below as
|
||||
// `desired`, or its payload is already covered by the batch, so nothing stays pending.
|
||||
for (const message of pending) agent.inbox.remove(message.id)
|
||||
if (desired === undefined || decision.messages.some(message => sameContextPayload(message, desired))) {
|
||||
return decision
|
||||
}
|
||||
// Fold the context right after the claimed batch, so the direct prompt
|
||||
// precedes it and the driver-appended runtime context follows it.
|
||||
const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message))
|
||||
const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired)
|
||||
return { kind: 'enter', messages: entered }
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath === undefined) return
|
||||
queueProjection(exec.agent, ownPath)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
@@ -34,8 +33,6 @@ import {
|
||||
|
||||
export const name = 'workspace-context'
|
||||
|
||||
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'
|
||||
@@ -50,13 +47,6 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dynamic state waiting for the loop to append its returned context event. */
|
||||
export interface PendingInstructionChange {
|
||||
change: WorkspaceInstructionChange
|
||||
afterSeq: number
|
||||
step?: { turn: number; step: number }
|
||||
}
|
||||
|
||||
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
|
||||
export interface InstructionVersionState {
|
||||
path: string
|
||||
@@ -72,13 +62,13 @@ export interface InstructionVersionState {
|
||||
/** Session-isolated fast-path state keyed by logical instruction scope. */
|
||||
export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
|
||||
|
||||
/** A cache transition coupled to the model-visible change that authorizes it. */
|
||||
/** A metadata-cache transition associated with one rendered instruction change. */
|
||||
export interface InstructionVersionUpdate {
|
||||
change: WorkspaceInstructionChange
|
||||
state?: InstructionVersionState
|
||||
}
|
||||
|
||||
/** Rendered reconciliation plus cache transitions awaiting final policy. */
|
||||
/** Rendered reconciliation plus its metadata-cache transitions. */
|
||||
export interface ReconciledInstructionContext {
|
||||
context: UserMessage
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
@@ -103,14 +93,6 @@ export function workspaceContextMessage(text: string): Message {
|
||||
})
|
||||
}
|
||||
|
||||
function filePathFromExecution(exec: ToolExecution): string | undefined {
|
||||
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
|
||||
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
|
||||
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
|
||||
const filePath = exec.arguments.file_path.trim()
|
||||
return filePath.length > 0 ? filePath : undefined
|
||||
}
|
||||
|
||||
function isWorkspaceContextSource(
|
||||
source: unknown,
|
||||
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
|
||||
@@ -149,7 +131,7 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
|
||||
|
||||
function visibleInstructionChanges(
|
||||
agent: Agent,
|
||||
pending: Map<string, PendingInstructionChange>,
|
||||
authorityMessages: readonly UserMessage[],
|
||||
): Map<string, WorkspaceInstructionChange> {
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes)
|
||||
const visible = new Map<string, WorkspaceInstructionChange>()
|
||||
@@ -157,14 +139,15 @@ function visibleInstructionChanges(
|
||||
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
|
||||
const changes = workspaceInstructionChanges(event.data.source)
|
||||
for (const change of changes) {
|
||||
const waiting = pending.get(change.scope)
|
||||
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
|
||||
pending.delete(change.scope)
|
||||
}
|
||||
if (visibleSeqs.has(seq)) visible.set(change.scope, change)
|
||||
}
|
||||
}
|
||||
for (const { change } of pending.values()) visible.set(change.scope, change)
|
||||
for (const message of authorityMessages) {
|
||||
if (!isWorkspaceContextSource(message.source)) continue
|
||||
for (const change of workspaceInstructionChanges(message.source)) {
|
||||
visible.set(change.scope, change)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
@@ -210,20 +193,20 @@ function versionStatesFor(session: Session, cache: InstructionVersionCache): Map
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only cache updates whose model-visible changes survived final policy.
|
||||
* Keep only cache updates represented by rendered changes.
|
||||
* @param updates - proposed updates from one or more reconciliations.
|
||||
* @param committedChanges - transitions retained on the authoritative result.
|
||||
* @returns updates authorized by an exact retained transition.
|
||||
* @param renderedChanges - transitions retained by the renderer.
|
||||
* @returns updates represented by an exact retained transition.
|
||||
*/
|
||||
export function retainedInstructionVersionUpdates(
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
committedChanges: readonly WorkspaceInstructionChange[],
|
||||
renderedChanges: readonly WorkspaceInstructionChange[],
|
||||
): InstructionVersionUpdate[] {
|
||||
return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change)))
|
||||
return updates.filter(update => renderedChanges.some(change => sameInstructionChange(update.change, change)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply authorized metadata-cache transitions without retaining instruction prose.
|
||||
* Apply metadata-cache transitions without retaining instruction prose.
|
||||
* @param session - owning session.
|
||||
* @param updates - ordered set/delete transitions.
|
||||
* @param cache - session-isolated metadata cache.
|
||||
@@ -242,164 +225,35 @@ export function applyInstructionVersionUpdates(
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
function pendingChangesFor(
|
||||
session: object,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): Map<string, PendingInstructionChange> {
|
||||
let pending = pendingBySession.get(session)
|
||||
if (pending === undefined) {
|
||||
pending = new Map()
|
||||
pendingBySession.set(session, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function openStep(session: Session): { turn: number; step: number } | undefined {
|
||||
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
|
||||
return boundary?.type === 'step/start' ? boundary.data : undefined
|
||||
}
|
||||
|
||||
function invalidateInstructionVersions(
|
||||
session: Session,
|
||||
scopes: readonly string[],
|
||||
cache: InstructionVersionCache,
|
||||
): void {
|
||||
const states = cache.get(session)
|
||||
if (states === undefined) return
|
||||
for (const scope of scopes) states.delete(scope)
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle provisional tool-result state against durable session events.
|
||||
* A matching context event confirms the transition. If its owning step closes
|
||||
* first, both duplicate suppression and the metadata fast path are re-armed for
|
||||
* the next successful touch.
|
||||
* @param session - session whose append-only log emitted `event`.
|
||||
* @param event - newly committed session event.
|
||||
* @param pendingBySession - provisional transitions awaiting log confirmation.
|
||||
* @param versionCache - metadata fast path coupled to those transitions.
|
||||
*/
|
||||
export function observeInstructionSessionEvent(
|
||||
session: Session,
|
||||
event: SessionEvent,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
): void {
|
||||
const pending = pendingBySession.get(session)
|
||||
if (pending === undefined) return
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
if (!isWorkspaceContextSource(event.data.source)) return
|
||||
for (const change of workspaceInstructionChanges(event.data.source)) {
|
||||
const waiting = pending.get(change.scope)
|
||||
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
|
||||
pending.delete(change.scope)
|
||||
}
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(session)
|
||||
return
|
||||
}
|
||||
case 'step/end': {
|
||||
const discardedScopes: string[] = []
|
||||
for (const [scope, waiting] of pending) {
|
||||
const step = waiting.step
|
||||
if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
|
||||
pending.delete(scope)
|
||||
discardedScopes.push(scope)
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(session)
|
||||
invalidateInstructionVersions(session, discardedScopes, versionCache)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit only workspace contexts that survived the complete tool pipeline.
|
||||
* The observe-only `tools/result` notification calls this before the loop can
|
||||
* append the returned contexts, closing that short pending window without
|
||||
* trusting an intermediate post-execute decision.
|
||||
* @param agent - session that will receive the final result contexts.
|
||||
* @param contexts - immutable contexts on the authoritative top-level result.
|
||||
* @param pendingBySession - per-session pending transition maps.
|
||||
* @returns transitions committed into the short pending window.
|
||||
*/
|
||||
export function commitPendingInstructionContexts(
|
||||
agent: Agent,
|
||||
contexts: readonly UserMessage[] | undefined,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): WorkspaceInstructionChange[] {
|
||||
const committed: WorkspaceInstructionChange[] = []
|
||||
const step = openStep(agent.session)
|
||||
for (const context of contexts ?? []) {
|
||||
if (!isWorkspaceContextSource(context.source)) continue
|
||||
const changes = workspaceInstructionChanges(context.source)
|
||||
if (changes.length === 0) continue
|
||||
const pending = pendingChangesFor(agent.session, pendingBySession)
|
||||
for (const change of changes) {
|
||||
pending.set(change.scope, {
|
||||
change,
|
||||
afterSeq: agent.session.seq,
|
||||
...step === undefined ? {} : { step },
|
||||
})
|
||||
committed.push(change)
|
||||
}
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back parent-token state when an enclosing tool result discards deferred
|
||||
* contexts. A newer transition for the same scope is left intact.
|
||||
* @param agent - session whose pending state was staged.
|
||||
* @param changes - exact staged transitions to remove when still current.
|
||||
* @param pendingBySession - per-session pending transition maps.
|
||||
*/
|
||||
export function rollbackPendingInstructionChanges(
|
||||
agent: Agent,
|
||||
changes: readonly WorkspaceInstructionChange[],
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): void {
|
||||
const pending = pendingBySession.get(agent.session)
|
||||
if (pending === undefined) return
|
||||
for (const change of changes) {
|
||||
const current = pending.get(change.scope)
|
||||
if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(agent.session)
|
||||
}
|
||||
|
||||
function relativeScope(projectRoot: string, dir: string): string {
|
||||
const scope = relativeDisplay(projectRoot, dir)
|
||||
return scope.length === 0 ? '.' : scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare visible/pending state with provider-visible files and render transitions.
|
||||
* Compare visible state with provider-visible files and render transitions.
|
||||
* @param agent - session owner whose visible surface supplies durable state.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingBySession - short pending window before returned context is logged.
|
||||
* @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 participate.
|
||||
* @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
|
||||
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
|
||||
*/
|
||||
export async function reconcileInstructionContext(
|
||||
agent: Agent,
|
||||
resolved: ResolvedConfig,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
|
||||
options: {
|
||||
authorityMessages: readonly UserMessage[]
|
||||
scopeMessages: readonly UserMessage[]
|
||||
touchedPaths: readonly string[]
|
||||
includeBaselineScopes: boolean
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
const session = agent.session
|
||||
const pending = pendingChangesFor(session, pendingBySession)
|
||||
const effective = visibleInstructionChanges(agent, pending)
|
||||
const effective = visibleInstructionChanges(agent, options.authorityMessages)
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = session.header.cwd ?? process.cwd()
|
||||
// TODO(frozen-project-root): retain the baseline root for the loop instance;
|
||||
@@ -419,14 +273,22 @@ export async function reconcileInstructionContext(
|
||||
if (options.includeBaselineScopes) {
|
||||
for (const scope of baselineScopes) scopes.add(scope)
|
||||
}
|
||||
for (const message of options.scopeMessages) {
|
||||
/* v8 ignore next -- the plugin passes its workspace-only pending projection. */
|
||||
if (!isWorkspaceContextSource(message.source)) continue
|
||||
for (const change of workspaceInstructionChanges(message.source)) {
|
||||
if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue
|
||||
scopes.add(change.scope)
|
||||
}
|
||||
}
|
||||
for (const scope of effective.keys()) {
|
||||
if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
else addDirScopes(scopes, directory)
|
||||
}
|
||||
if (options.touchedPath !== undefined) {
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir)
|
||||
for (const touchedPath of options.touchedPaths) {
|
||||
for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir)
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
@@ -452,116 +314,97 @@ export async function reconcileInstructionContext(
|
||||
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
|
||||
versionUpdates.push({ change })
|
||||
}
|
||||
const scopesByDirectory = new Map<string, string[]>()
|
||||
for (const scope of scopes) {
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') {
|
||||
// Last-good-state: the candidate stays effective, so its cached trimmed
|
||||
// digest must keep occupying the directory's dedup slot — otherwise an
|
||||
// identical later sibling would be emitted as a duplicate `set` until the
|
||||
// next successful reconciliation removed it again.
|
||||
const cached = versions.get(scope)
|
||||
if (cached !== undefined && previous !== undefined && previous.action !== 'remove') {
|
||||
registerKeptTrimmed(directory, cached.trimmedDigest)
|
||||
const directoryScopes = scopesByDirectory.get(directory)
|
||||
if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope])
|
||||
else directoryScopes.push(scope)
|
||||
}
|
||||
for (const [directory, directoryScopes] of scopesByDirectory) {
|
||||
const itemStart = items.length
|
||||
const versionUpdateStart = versionUpdates.length
|
||||
const addedAbsolutePaths: string[] = []
|
||||
const priorVersions = new Map(directoryScopes.map(scope => [scope, versions.get(scope)]))
|
||||
for (const scope of directoryScopes) {
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') {
|
||||
if (previous === undefined || previous.action === 'remove') continue
|
||||
// Same-directory candidates form one deduplicated authority group. If an
|
||||
// active member cannot be observed, preserve the entire last-good group;
|
||||
// cache warmth must never decide whether a sibling transition is emitted.
|
||||
items.splice(itemStart)
|
||||
versionUpdates.splice(versionUpdateStart)
|
||||
for (const [candidateScope, prior] of priorVersions) {
|
||||
if (prior === undefined) versions.delete(candidateScope)
|
||||
else versions.set(candidateScope, prior)
|
||||
}
|
||||
for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath)
|
||||
keptTrimmedByDir.delete(directory)
|
||||
break
|
||||
}
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
|
||||
seenAbsolutePaths.add(probedFile.absolutePath)
|
||||
addedAbsolutePaths.push(probedFile.absolutePath)
|
||||
const cached = versions.get(scope)
|
||||
if (
|
||||
cached !== undefined
|
||||
&& cached.path === probedFile.displayPath
|
||||
&& cached.version === probedFile.version
|
||||
&& previous !== undefined
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) {
|
||||
// Unchanged and previously rendered: keep it, but an earlier sibling that
|
||||
// now matches its trimmed content makes this the duplicate to remove.
|
||||
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
|
||||
seenAbsolutePaths.add(probedFile.absolutePath)
|
||||
const cached = versions.get(scope)
|
||||
if (
|
||||
cached !== undefined
|
||||
&& cached.path === probedFile.displayPath
|
||||
&& cached.version === probedFile.version
|
||||
&& previous !== undefined
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) {
|
||||
// Unchanged and previously rendered: keep it, but an earlier sibling that
|
||||
// now matches its trimmed content makes this the duplicate to remove.
|
||||
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const trimmedDigest = trimmedInstructionDigest(file.content)
|
||||
if (registerKeptTrimmed(directory, trimmedDigest)) {
|
||||
// A distinct file whose trimmed content already appeared earlier in this
|
||||
// directory: drop it, removing any copy that was previously rendered.
|
||||
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
|
||||
else versions.delete(scope)
|
||||
continue
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const trimmedDigest = trimmedInstructionDigest(file.content)
|
||||
if (registerKeptTrimmed(directory, trimmedDigest)) {
|
||||
// A distinct file whose trimmed content already appeared earlier in this
|
||||
// directory: drop it, removing any copy that was previously rendered.
|
||||
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
|
||||
else versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
trimmedDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
versionUpdates.push({ change, state: nextVersion })
|
||||
}
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
trimmedDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
versionUpdates.push({ change, state: nextVersion })
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
return {
|
||||
context: workspaceContextHook(rendered.text, rendered.changes),
|
||||
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a successful structured file touch and reconcile its applicable scopes.
|
||||
* @param agent - optional agent attached to the tool execution.
|
||||
* @param exec - completed tool execution descriptor.
|
||||
* @param result - original tool result before post-execute decisions.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @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.
|
||||
*/
|
||||
export async function dynamicInstructionContext(
|
||||
agent: Agent | undefined,
|
||||
exec: ToolExecution,
|
||||
result: ToolExecutionResult,
|
||||
resolved: ResolvedConfig,
|
||||
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineSessions: WeakSet<object>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
if (agent === undefined || result.isError) return undefined
|
||||
const touchedPath = filePathFromExecution(exec)
|
||||
if (touchedPath === undefined) return undefined
|
||||
return reconcileInstructionContext(
|
||||
agent, resolved, pendingNestedChanges, versionCache, fileSystem,
|
||||
{
|
||||
touchedPath,
|
||||
includeBaselineScopes: baselineSessions.has(agent.session),
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,9 +23,6 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user