From eff7b758b1a0781acf66d176c108ac0e49f42544 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 3 Aug 2026 21:28:38 +0800 Subject: [PATCH 1/2] fix(workspace-context): restore baseline after compaction --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 16 +- .../2026-06-24-workspace-context.zh.md | 16 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 3 +- .../fixtures/workspace-context-compaction.ts | 26 +++ .../snapshots/workspace-context/session.jsonl | 54 +++--- .../workspace-context.cordis.snapshot.yml | 2 + examples/package.json | 1 + knip.json | 1 + packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 2 +- packages/context/README.zh.md | 2 +- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 12 +- .../context/workspace-context/README.zh.md | 14 +- .../context/workspace-context/package.json | 1 + .../context/workspace-context/src/index.ts | 65 ++++++- .../tests/workspace-context.spec.ts | 159 +++++++++++++++++- .../context/workspace-context/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 24 files changed, 325 insertions(+), 77 deletions(-) create mode 100644 examples/acp-agent/tests/fixtures/workspace-context-compaction.ts diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 073aa9fa4b..0cbf595b7a 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e -2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 +2026-06-24-workspace-context.md: b224eedb03cd1e48842c883637b2097ee83fd4b4 +2026-06-24-workspace-context.zh.md: f6c410b9467091b85a3be43c69dd3aecae5ea51a diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 8baced0143..b224eedb03 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -10,11 +10,11 @@ Repository guidance such as `AGENTS.md` belongs in a coding session's effective Neighboring products establish useful conventions but differ in details. Codex treats `AGENTS.md` as native, Claude Code uses `CLAUDE.md` and familiar system-reminder-style user context, and opencode supports both names with one winner per directory plus lazy nested discovery. The harness needs cross-tool compatibility without loading duplicate or contradictory files from the same scope. -The lifecycle has two distinct classes of content. The initial applicable chain is injected once before the first request. Nested files, edits, candidate switches, and removals happen later and join the same durable append-only history. +The lifecycle has two distinct classes of content. The applicable baseline chain is injected before the first request and restored before the first request after a surface replacement shadows it. Nested files, edits, candidate switches, and removals happen later and join the same durable append-only history. ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `system-prompt/assemble`, `tools/post-execute`, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -34,6 +34,8 @@ The injection becomes a durable `user/message` with a typed `workspace-instructi A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. +Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, then checks the final visible surface. When a prior typed baseline exists but none remains visible, it recomposes and injects the current chain before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker prevents duplicate assembly before outbox drain and clears when a step or turn closes without a durable baseline, so a cancelled delivery remains eligible for the next request. + The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. The final rendering boundary escapes a literal `` anywhere in instruction content or model-visible path, scope, and budget metadata before byte accounting completes. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). ### Dynamic Discovery And Refresh @@ -52,11 +54,11 @@ Every workspace context event stores versioned metadata with `{ action, scope, p At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. -An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. +An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. -The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request; a queued baseline discarded with its step can be prepared again. Later successful filesystem touches can append edits or removals as dynamic messages. It never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. -There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. +There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch, post-replacement prompt assembly, or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. ### Byte Budget And Bounded Reads @@ -68,7 +70,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc **Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content. -**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; dynamic append-only messages handle changes and compaction re-arming. +**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; prompt assembly performs the narrow post-replacement recovery, and dynamic append-only messages handle later changes. **Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable. @@ -82,7 +84,7 @@ Workspace guidance is isolated per session and shared by the demo front doors, W Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). -The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed files are noticed on the next successful structured touch or resume. This keeps the design deterministic and provider-neutral. +The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed baseline files are also noticed when a surface replacement or resume triggers recomposition. This keeps the design deterministic and provider-neutral. ## Deferred diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 392d57f344..f6c410b946 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -10,11 +10,11 @@ Status: implemented 相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。 -生命周期中有两类截然不同的内容。初始适用文件链在第一次请求前一次性注入。嵌套文件、编辑、候选项切换和移除发生在其后,进入同一份持久的仅追加历史。 +生命周期中有两类截然不同的内容。适用的基线文件链会在第一次请求前注入,并在表层替换将其遮蔽后的第一次请求前恢复。嵌套文件、编辑、候选项切换和移除发生在其后,进入同一份持久的仅追加历史。 ## 决策 -该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`system-prompt/assemble`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 @@ -34,6 +34,8 @@ Status: implemented 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 +在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,再检查最终可见表层。如果此前存在带类型的基线、但已无基线可见,它会在 loop 排空 outbox 并对派生请求历史创建快照之前,重新组合并注入当前文件链。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记会在 outbox 排空前避免重复组装,并在步骤或轮次关闭且未产生持久基线时清除,因此已取消的投递仍可在下一个请求中重试。 + 基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。最终渲染边界会在完成字节核算前,转义指令内容或模型可见的路径、scope 与预算元数据中出现的字面量 ``。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 ### 动态发现与刷新 @@ -52,11 +54,11 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它;随其步骤一起被丢弃的已排队基线可以再次准备。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 -系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 +系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰、替换后的提示词组装或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 ### 字节预算与有界读取 @@ -68,7 +70,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, **使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 -**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;动态仅追加消息负责处理变更和压缩后的重新启用。 +**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;提示词组装负责狭窄的替换后恢复,动态仅追加消息则处理之后的变更。 **在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 @@ -82,7 +84,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 -系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。 +系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;表层替换或恢复触发重新组合时,也会发现外部变更的基线文件。这使设计保持确定性并且与提供方无关。 ## 延后事项 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index bded8b85c8..8a8fa2efa2 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: b11ab9bc3060aea668d142139e0a25f491a777d1 -architecture.zh.md: 5eb1cab7e453dc0423cbb42348e918de798f1f9b +architecture.md: cbd118a1259a6cc681ec52443459b021b62eee40 +architecture.zh.md: 707d56f374fd1dc689ad449090e6e1a1b9f7da4d diff --git a/docs/architecture.md b/docs/architecture.md index b11ab9bc30..cbd118a125 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,7 +169,7 @@ A swappable capability usually has **interface / implementation / consumer** lay Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` injects baseline at the first `agent/step`, restores a compacted baseline during `system-prompt/assemble` before the next request snapshot, and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 5eb1cab7e4..707d56f374 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -169,7 +169,7 @@ idle inject: 例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/step` 注入基线,在下一次请求创建快照前于 `system-prompt/assemble` 期间恢复因压缩而被遮蔽的基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 ### 组合包与应用 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b59b8733d5..3938d256f1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`workspace-context`](../packages/context/workspace-context) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 8419646dca..9c5e32b3ec 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -846,6 +846,7 @@ flowchart TD pkg_workspace_context --> pkg_llm pkg_workspace_context --> pkg_paths pkg_workspace_context --> pkg_session + pkg_workspace_context --> pkg_system_prompt pkg_workspace_context --> pkg_tools pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_invariants @@ -1222,7 +1223,7 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | -| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | diff --git a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts new file mode 100644 index 0000000000..465b16acc7 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts @@ -0,0 +1,26 @@ +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-agent' +import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' +import { createUserMessage } from '@deepseek-ai/dsh-llm' + +export const name = 'workspace-context-compaction' + +/** Replace the visible workspace baseline before the snapshot's second step. */ +export function apply(ctx: Context): void { + ctx.on('agent/step', (agent, turn, step) => { + if (turn !== 1 || step !== 2) return + const baseline = agent.session.surface.nodes + .map(seq => agent.session.events[seq]) + .find(event => event?.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline === true) + if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction') + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Earlier context was compacted for this snapshot.' }], + source: COMPACT_CHECKPOINT_SOURCE, + }), { + surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq }, + sourceEventSeqs: [baseline.seq], + }) + }) +} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 0a568d3460..41faaef25f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,9 +1,9 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7cb62d32-ef8e-4d45-9b5e-d2a1fbdbabbd"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"950c77c7-6a48-43aa-8e72-b6068d4e876b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903339799,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"77cda160-cca0-495b-b283-ac39eac5da7d"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"95aaf126-946e-4ada-985a-943b490b6f2f"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -12,28 +12,30 @@ {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785464650866,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":12,"time":1785487608779,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785487608779,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6908b415-6aca-462d-9d91-0b27a73ba08c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785487608779,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d0d49aa4-74cf-4af4-9256-c8531d6f597f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785487608779,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":15,"time":1785487608790,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1144df09-c5e0-4781-8734-55acf6f2d4d0"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"ed901b04-9258-4f36-a094-c24127021158"},"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785487608790,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"b63d472c-84f3-40d3-9c3b-f291f6d466f3"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"24e6c34c-3fea-462b-8399-5d8b8c14eb9c"},"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785487608790,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":18,"time":1785487608799,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} -{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":22,"time":1785464650886,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":1785487608800,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785487608800,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"268c94fb-859c-4ed9-aa98-3a1ccfa31a6f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"tool/call","seq":25,"time":1785487608801,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} -{"type":"tool/result","seq":26,"time":1785487608810,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"228be9fc-eacf-4a2e-a475-9d4f46b2606d"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[25],"surfaceOp":"append"} -{"type":"user/message","seq":27,"time":1785487608811,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"f955bfc9-0679-478e-84d8-77e266114c44"},"surfaceOp":"append"} -{"type":"step/end","seq":28,"time":1785487608811,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":29,"time":1785487608818,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":30,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":31,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":32,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":33,"time":1785464650905,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":34,"time":1785487608819,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785487608819,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e5f5ae5d-e7fc-47e8-a1af-92fc859e3612"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785487608819,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":37,"time":1785487608819,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":18,"time":1785762637747,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"5413be2d-cb6c-490c-9fa3-64b95c20b72b"},"sourceEventSeqs":[4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","seq":19,"time":1785762637756,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"5417d355-11a7-4d9a-b724-f63acf215392"},"surfaceOp":"append"} +{"type":"step/start","seq":20,"time":1785762637756,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":22,"time":1785464650886,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} +{"type":"assistant/chunk","seq":23,"time":1785487608800,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":24,"time":1785762637757,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":25,"time":1785762637757,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":26,"time":1785762637757,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"12b61b11-c415-41c1-8e67-15b097112399"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"tool/call","seq":27,"time":1785762637757,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} +{"type":"tool/result","seq":28,"time":1785762637766,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"621ff2e6-6cb7-4465-bf57-65bfff611a82"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[27],"surfaceOp":"append"} +{"type":"user/message","seq":29,"time":1785762637767,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"b790daa3-c5f6-4954-ab0f-0362f1b47487"},"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1785762637767,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":31,"time":1785762637773,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":32,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":33,"time":1785464650905,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":34,"time":1785487608819,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":35,"time":1785762637774,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":36,"time":1785762637774,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785762637775,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"80c9c0f4-8bf0-4a39-83ed-247f8ef62881"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785762637775,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":39,"time":1785762637775,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml index 70d726c838..6abaaa3f13 100644 --- a/examples/acp-agent/workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -28,3 +28,5 @@ - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' + - id: workspace-context-compaction + name: './tests/fixtures/workspace-context-compaction.ts' diff --git a/examples/package.json b/examples/package.json index b1a32fc381..43771a4834 100644 --- a/examples/package.json +++ b/examples/package.json @@ -20,6 +20,7 @@ "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-command-goal": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compact": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", diff --git a/knip.json b/knip.json index f1a6efef71..e73b619226 100644 --- a/knip.json +++ b/knip.json @@ -43,6 +43,7 @@ "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", + "acp-agent/tests/fixtures/workspace-context-compaction.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 7836a2a03c..c77abeeeeb 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -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 diff --git a/packages/context/README.md b/packages/context/README.md index fce6e21816..0eb554eda5 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -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. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index b8a4d68ca6..18d36b63f8 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -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(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。 diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 102c991391..27e5adca83 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -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 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 2669422ec1..8201a9bb34 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -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. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index e9fab4c699..adcf50d70f 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -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`。 diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 0c50b8cc17..bac4522dcc 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -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" }, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 52dc76070c..7d8b6d1cc6 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -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>() @@ -60,6 +68,10 @@ export function apply(ctx: Context, config: Config): void { const instructionVersions: InstructionVersionCache = new WeakMap() const pendingVersionUpdates = new Map() const baselineLoaded = new WeakSet() + // 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() + const baselineQueuedGeneration = new WeakMap() // 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 => { - if (baselineLoaded.has(agent.session)) return + const prepareBaseline = async ( + agent: Agent, + signal: AbortSignal | undefined, + keepVisibleBaseline: boolean, + ): Promise => { 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 => { + 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 ( diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 158d96f24d..fb099fbc85 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -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() diff --git a/packages/context/workspace-context/tsconfig.json b/packages/context/workspace-context/tsconfig.json index b5aca1dfc8..838059d342 100644 --- a/packages/context/workspace-context/tsconfig.json +++ b/packages/context/workspace-context/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../core/system-prompt" + }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e27760b0d..daf02246fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -608,6 +608,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:* version: link:../packages/ui/commands + '@deepseek-ai/dsh-compact': + specifier: workspace:* + version: link:../packages/compact/compact '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic From 19be29cd0bc21d8e58a41c9d150c173684078549 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 3 Aug 2026 22:05:17 +0800 Subject: [PATCH 2/2] fix(workspace-context): guard baseline restoration --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 4 +- .../2026-06-24-workspace-context.zh.md | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 32 ++--- .../system-prompt.i18n.yaml | 4 +- docs/core-data-structures/system-prompt.md | 2 +- docs/core-data-structures/system-prompt.zh.md | 2 +- docs/event-producer-consumer.md | 32 ++--- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 6 +- .../context/workspace-context/README.zh.md | 6 +- .../context/workspace-context/src/index.ts | 46 +++--- .../tests/workspace-context.spec.ts | 132 +++++++++++++++--- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 4 +- .../agent-loop/tests/agent-initiator.spec.ts | 5 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/dispatch.ts | 12 ++ packages/core/agent/src/index.ts | 2 +- packages/core/agent/src/types.ts | 2 + packages/core/agent/tests/agent.spec.ts | 19 +++ packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 2 +- packages/core/system-prompt/README.zh.md | 2 +- 31 files changed, 249 insertions(+), 105 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 0cbf595b7a..359a4aac1c 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: b224eedb03cd1e48842c883637b2097ee83fd4b4 -2026-06-24-workspace-context.zh.md: f6c410b9467091b85a3be43c69dd3aecae5ea51a +2026-06-24-workspace-context.md: 19b227c56c6c50f802f1d9cf32ca6f21b3a24180 +2026-06-24-workspace-context.zh.md: 86137db7296cce99eeb06e5cc023f20ded0f77c4 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index b224eedb03..19b227c56c 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -34,7 +34,7 @@ The injection becomes a durable `user/message` with a typed `workspace-instructi A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. -Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, then checks the final visible surface. When a prior typed baseline exists but none remains visible, it recomposes and injects the current chain before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker prevents duplicate assembly before outbox drain and clears when a step or turn closes without a durable baseline, so a cancelled delivery remains eligible for the next request. +Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, but restores only for an assembly explicitly marked for the loop's next model request; diagnostic assemblies such as TUI `/status` remain read-only. When a prior typed baseline exists but none remains visible, the listener recomposes the current chain, rechecks cancellation and the current surface generation after every asynchronous probe, and injects before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker plus the synchronous commit-time recheck lets concurrent preparations scan without queuing duplicate baselines. The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. The final rendering boundary escapes a literal `` anywhere in instruction content or model-visible path, scope, and budget metadata before byte accounting completes. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). @@ -56,7 +56,7 @@ At reconciliation time the plugin scans workspace-sourced `user/message` events An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. -The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request; a queued baseline discarded with its step can be prepared again. Later successful filesystem touches can append edits or removals as dynamic messages. It never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Model-request prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request. It rechecks the caller's signal before injection, so an aborted preparation publishes no pending baseline; a queued marker remains until the corresponding durable event confirms delivery. Later successful filesystem touches can append edits or removals as dynamic messages. The plugin never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch, post-replacement prompt assembly, or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index f6c410b946..86137db729 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -34,7 +34,7 @@ Status: implemented 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 -在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,再检查最终可见表层。如果此前存在带类型的基线、但已无基线可见,它会在 loop 排空 outbox 并对派生请求历史创建快照之前,重新组合并注入当前文件链。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记会在 outbox 排空前避免重复组装,并在步骤或轮次关闭且未产生持久基线时清除,因此已取消的投递仍可在下一个请求中重试。 +在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,但只有当组装被明确标记为供 loop 的下一个模型请求使用时才恢复;TUI `/status` 等诊断组装保持只读。如果此前存在带类型的基线、但已无基线可见,该监听器会重新组合当前文件链,在每次异步探测后重新检查取消状态和当前表层代次,并在 loop 排空 outbox 和对派生请求历史创建快照之前注入。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记加上提交时同步复查,使并发准备可以扫描而不会排入重复基线。 基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。最终渲染边界会在完成字节核算前,转义指令内容或模型可见的路径、scope 与预算元数据中出现的字面量 ``。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 @@ -56,7 +56,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它;随其步骤一起被丢弃的已排队基线可以再次准备。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。面向模型请求的提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它。它会在注入前重新检查调用方的 signal,因此已中止的准备不会发布待处理基线;排队标记会保留,直到相应的持久事件确认投递。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。插件绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰、替换后的提示词组装或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8a8fa2efa2..2c1022ccbf 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: cbd118a1259a6cc681ec52443459b021b62eee40 -architecture.zh.md: 707d56f374fd1dc689ad449090e6e1a1b9f7da4d +architecture.md: f9a0856d270c239d45ecca0965787a443ce184ae +architecture.zh.md: 9b5b1877f7a5afa6924e5c13674804c4eb889699 diff --git a/docs/architecture.md b/docs/architecture.md index cbd118a125..f9a0856d27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,7 +169,7 @@ A swappable capability usually has **interface / implementation / consumer** lay Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` injects baseline at the first `agent/step`, restores a compacted baseline during `system-prompt/assemble` before the next request snapshot, and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` injects baseline at the first `agent/step`, restores a compacted baseline during the loop's model-request `system-prompt/assemble` before the request snapshot, and appends `ctx.fs`-discovered changes through `tools/post-execute`; inspection-only assemblies stay read-only. Its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 707d56f374..9b5b1877f7 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -169,7 +169,7 @@ idle inject: 例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在第一次 `agent/step` 注入基线,在下一次请求创建快照前于 `system-prompt/assemble` 期间恢复因压缩而被遮蔽的基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/step` 注入基线,在请求创建快照前于 loop 面向模型请求的 `system-prompt/assemble` 期间恢复因压缩而被遮蔽的基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;仅检查组装保持只读。其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 ### 组合包与应用 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 8f4385938c..7ece38cbc0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:467`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:469`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:380`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:406`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:408`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:454`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:456`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:393`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:442`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml index 2984c73425..9b29344827 100644 --- a/docs/core-data-structures/system-prompt.i18n.yaml +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -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 docs/core-data-structures/system-prompt.md -system-prompt.md: 5abb8f46c13045c7d37bbe12ecf6c3744ee063b5 -system-prompt.zh.md: 1088b20ba4289ad5912a193eead39d069c1a6e17 +system-prompt.md: d585dd2604e83a808beefd8fb22685ab6b88878a +system-prompt.zh.md: ade05e151cb70af98e47c18cf63dac89fecf35f2 diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index 5abb8f46c1..d585dd2604 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -8,7 +8,7 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system- ## Assembly context -`AssembleContext` identifies the scope layer one assembly resolves and may carry the explicit control signal for that request. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent, signal)` sets the explicit fields together. A bare assembly has neither scope nor signal. +`AssembleContext` identifies the scope layer one assembly resolves and may carry the explicit control signal for that request. It is merge-extensible: `dsh-agent` adds the optional live `agent` field and `modelRequest?: true` marker. `assembleContextFor(agent, signal)` builds an agent-scoped inspection context; `assembleRequestContextFor(agent, signal)` marks a result that the caller will materialize into the next model request. A bare assembly has neither scope nor signal. ```ts type-equiv /** Merge-extensible context for one prompt assembly. */ diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md index 1088b20ba4..ade05e151c 100644 --- a/docs/core-data-structures/system-prompt.zh.md +++ b/docs/core-data-structures/system-prompt.zh.md @@ -8,7 +8,7 @@ ## 组装上下文 -`AssembleContext` 标识一次组装所解析的作用域 layer,并可携带该请求的显式控制 signal。它可合并扩展:`dsh-agent` 添加可选的 live `agent` 字段,`assembleContextFor(agent, signal)` 则一起设置这些显式字段。裸组装既没有 scope,也没有 signal。 +`AssembleContext` 标识一次组装所解析的作用域 layer,并可携带该请求的显式控制 signal。它可合并扩展:`dsh-agent` 添加可选的 live `agent` 字段和 `modelRequest?: true` 标记。`assembleContextFor(agent, signal)` 构建带 agent 作用域的检查上下文;`assembleRequestContextFor(agent, signal)` 将结果标记为调用方会把它物化为下一个模型请求。裸组装既没有 scope,也没有 signal。 ```ts type-equiv /** Merge-extensible context for one prompt assembly. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3938d256f1..aefb1baed8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:467`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:380`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:454`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:393`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:469`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 27e5adca83..9dd438c1ba 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -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: 8201a9bb347c27da432748576151a0f6db9f3d9f -README.zh.md: adcf50d70fe685a0cf8604405be5568fbcb409c1 +README.md: c79f208705c827c609a4b482f0057a18ac0d8fc0 +README.zh.md: aacdcc9b2b12aca8394fb87099af0c382677bef1 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 8201a9bb34..c79f208705 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -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, `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots its next request. +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 such as TUI `/status` do not mutate the session. 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. 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. +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. ## Configuration @@ -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 prompt assembly restores a shadowed baseline, 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 model-request 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. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index adcf50d70f..aacdcc9b2b 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -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/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;TUI `/status` 等仅检查组装不会改变会话。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 @@ -52,7 +52,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when 路径与 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 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,面向模型请求的提示词组装会重新组合当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 ## 配置 @@ -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 准备基线时可见。 +- **刷新由事件驱动**:没有 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`。 diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 7d8b6d1cc6..7ca9189a4f 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -2,7 +2,7 @@ * Workspace instruction loader for AGENTS.md-compatible files. * * Baseline instructions enter durable context before the first request and are - * restored during prompt assembly when compaction removes them. Successful fs + * 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. @@ -55,8 +55,8 @@ function hasVisibleBaseline(session: Agent['session']): boolean { }) } -function hasBaselineHistory(agent: Agent): boolean { - return agent.session.events.some(event => event.type === 'user/message' +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) } @@ -93,14 +93,13 @@ export function apply(ctx: Context, config: Config): void { 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) }) const prepareBaseline = async ( agent: Agent, signal: AbortSignal | undefined, keepVisibleBaseline: boolean, + deduplicateRestore = false, ): Promise => { if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { baselineLoaded.add(agent.session) @@ -139,6 +138,13 @@ export function apply(ctx: Context, config: Config): void { fileSystem, { includeBaselineScopes: false, ...signal === undefined ? {} : { 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) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) @@ -146,15 +152,20 @@ export function apply(ctx: Context, config: Config): void { 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: { - kind: 'workspace-instructions', - baseline: true, - changes: [...baseline.changes.values()], - }, - })) + 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) @@ -171,13 +182,14 @@ export function apply(ctx: Context, config: Config): void { ctx.on('system-prompt/assemble', async (_assembly, context, next) => { const assembled = await next() const agent = context.agent - if (agent === undefined + 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)) return assembled - await prepareBaseline(agent, context.signal, false) + || !hasBaselineHistory(agent.session)) return assembled + await prepareBaseline(agent, context.signal, false, true) return assembled }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index fb099fbc85..4d46e1393e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -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, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, assembleRequestContextFor, 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 { @@ -155,6 +155,26 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } +class OverlappingReadFileSystem extends RecordingFileSystem { + readonly paired = Promise.withResolvers() + readonly release = Promise.withResolvers() + private armed = false + private started = 0 + + arm(): void { + this.armed = true + } + + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { + if (this.armed) { + this.started += 1 + if (this.started === 2) this.paired.resolve(undefined) + await this.release.promise + } + return super.streamText(target, signal) + } +} + async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) return ctx.plugin(workspaceContext, config) @@ -1108,7 +1128,7 @@ describe('workspace context request injection', () => { sourceEventSeqs: [baseline!.seq], }) - await ctx.systemPrompt.assemble(assembleContextFor(agent)) + await ctx.systemPrompt.assemble(assembleRequestContextFor(agent)) expect(baselineEvents(agent)).toHaveLength(2) expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('repo rule') @@ -1118,7 +1138,7 @@ describe('workspace context request injection', () => { } }) - it('retries a re-injected baseline when its queued step closes before the message becomes durable', async () => { + it('does not restore a compacted baseline for an inspection-only assembly', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1138,28 +1158,104 @@ describe('workspace context request injection', () => { 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(agent, testToolSignal)) - 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) + expect(baselineEvents(agent)).toHaveLength(1) + expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('compacted summary') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } }) + it('queues one baseline when two request assemblies finish preparation concurrently', async () => { + const root = resolve('/virtual/concurrent-assembly-repo') + const home = resolve('/virtual/concurrent-assembly-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(OverlappingReadFileSystem) + const fs = ctx.fs as OverlappingReadFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) + 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], + }) + const queued: UserMessage[] = [] + const queuedAgent: Agent = { + ...agent, + acceptsNextStep: true, + inject(input) { queued.push(input) }, + } + fs.arm() + + const first = ctx.systemPrompt.assemble(assembleRequestContextFor(queuedAgent, testToolSignal)) + const second = ctx.systemPrompt.assemble(assembleRequestContextFor(queuedAgent, testToolSignal)) + await fs.paired.promise + fs.release.resolve(undefined) + await Promise.all([first, second]) + + expect(queued).toHaveLength(1) + expect(blocksText(queued[0]?.content)).toContain('repo rule') + } finally { + await ctx.fiber.dispose() + } + }) + + it('retries restoration after synchronous baseline injection failure', async () => { + const root = resolve('/virtual/injection-failure-repo') + const home = resolve('/virtual/injection-failure-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) + 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], + }) + const throwingAgent: Agent = { + ...agent, + inject() { throw new Error('injection failed') }, + } + + await expect(ctx.systemPrompt.assemble( + assembleRequestContextFor(throwingAgent, testToolSignal), + )).rejects.toThrow('injection failed') + + const queued: UserMessage[] = [] + const retryingAgent: Agent = { + ...agent, + inject(input) { queued.push(input) }, + } + await ctx.systemPrompt.assemble(assembleRequestContextFor(retryingAgent, testToolSignal)) + + expect(queued).toHaveLength(1) + expect(blocksText(queued[0]?.content)).toContain('repo rule') + } finally { + await ctx.fiber.dispose() + } + }) + 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() diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 81f5271097..e030d8da9c 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -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/core/agent-loop/README.md -README.md: 2ce85071c4b7408adb4ee05291c499ec642be114 -README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced +README.md: 5f3da35f818569fa78d19b517e0fc7759c2bd793 +README.zh.md: 54d3409e413be40d40ccea8ca3df13b2d78ed8c0 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2ce85071c4..5f3da35f81 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -14,7 +14,7 @@ Creation and resume are one rollback-covered transaction: construct a private se The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. -Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step assembly goes through `assembleContextFor(agent)`. +Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step request assembly goes through `assembleRequestContextFor(agent)`. - `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index bc78c02fc0..54d3409e41 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -14,7 +14,7 @@ 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 -每个 agent 与其会话共享一个由调用方选择的 `SessionId`,并假设它在全局唯一;意外的 UUID 冲突不属于受支持模型。两个使用同一 id 的并发操作都可以进行准备,但最终的 `enter()` 调用会裁决发布,所有失败方都会回滚各自的私有资源。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。在同步创建通知期间请求的 detach 会等待该次分发退栈,从而保留 created/disposed 配对。Teardown 顺序为停止并 drain → 撤销作用域 → detach agent → detach 会话;私有作用域清理完成后,该 id 即可复用。普通、不可 veto 的 `agent/*` 通知通过 `agentEvents(ctx, agent)` 发出;逐步骤组装通过 `assembleContextFor(agent)` 完成。 +每个 agent 与其会话共享一个由调用方选择的 `SessionId`,并假设它在全局唯一;意外的 UUID 冲突不属于受支持模型。两个使用同一 id 的并发操作都可以进行准备,但最终的 `enter()` 调用会裁决发布,所有失败方都会回滚各自的私有资源。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。在同步创建通知期间请求的 detach 会等待该次分发退栈,从而保留 created/disposed 配对。Teardown 顺序为停止并 drain → 撤销作用域 → detach agent → detach 会话;私有作用域清理完成后,该 id 即可复用。普通、不可 veto 的 `agent/*` 通知通过 `agentEvents(ctx, agent)` 发出;逐步骤请求组装通过 `assembleRequestContextFor(agent)` 完成。 - `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent`:在确切共享的 agent/会话 id 下同步创建,不运行 setup,并随调用 fiber dispose。声明式配置把 `agents[].id` 视为稳定 label,通常会先生成 `${label}-session-`,再调用此边界。应用也可以提供稳定且确切的 `sessionId`:首次使用时创建;重新挂载且持久化内容已存在时,则恢复已经实体化的历史。`resumeSessionId` 要求并加载现有的持久化 id,且与 `sessionId` 互斥。这样,默认的全新重启不会冲突,也无需保留第二个实时路由身份。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 7954d31cf3..379a6e05d8 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -10,7 +10,7 @@ import type { Context } from 'cordis' import { randomUUID } from 'node:crypto' -import { agentCarrier, assembleContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent' +import { agentCarrier, assembleRequestContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { @@ -688,7 +688,7 @@ export class ReactLoopAgent implements Agent { // Assemble request-owned prompt inputs fresh each step. Dynamic context is // committed at the tail before deriving history once, preserving the stable // system/history cache prefix while keeping every model-visible byte logged. - const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) + const assembly = await this.loopCtx.systemPrompt.assemble(assembleRequestContextFor(this, signal)) signal.throwIfAborted() const system = renderPrompt(assembly) materializeRuntimeContext(session, renderContextSnapshot(assembly)) diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index f3d784679f..12b992bd92 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -161,7 +161,10 @@ describe('AgentLoop initiator scope', () => { } ctx.on('system-prompt/assemble', async (_assembly, context, next) => { - if (context.agent === agent) capture(context.signal) + if (context.agent === agent) { + expect(context.modelRequest).toBe(true) + capture(context.signal) + } return next() }) ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 8673595322..917978340e 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -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/core/agent/README.md -README.md: 98421aa6de3d6778702665854ed723507e933028 -README.zh.md: bfc8d68a9656a29a809de0848986e4ee9eb3fe7c +README.md: 5af048065fc36ef9a571e7baa40ba51227bd9480 +README.zh.md: 8575f63dfbeecd77bd9a0348663dd91de0c164e9 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 98421aa6de..5af048065f 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds an inspection-capable per-agent assembly context (`agent` + `scope` together), while `assembleRequestContextFor(agent)` additionally marks an assembly that the caller will materialize into the next model request. `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves. `AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index bfc8d68a96..8575f63dfb 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建可用于检查的逐 agent 组装上下文(同时包含 `agent` + `scope`),而 `assembleRequestContextFor(agent)` 还会将组装标记为其结果将由调用方物化为下一个模型请求。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 `AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index b28586b6b8..54a57843c8 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -146,3 +146,15 @@ export function emitAgentEvent( export function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext { return { agent, scope: agent, ...signal === undefined ? {} : { signal } } } + +/** + * Build the prompt assembly context for the agent loop's next model request. + * Inspection callers use {@link assembleContextFor} so listeners cannot mistake + * a diagnostic assembly for an imminent request commit. + * @param agent - the agent the request assembly is for. + * @param signal - the current turn's explicit control signal, when available. + * @returns the agent-scoped context marked for request materialization. + */ +export function assembleRequestContextFor(agent: Agent, signal?: AbortSignal): AssembleContext { + return { ...assembleContextFor(agent, signal), modelRequest: true } +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 66dee4efb7..d98594eb48 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,7 +17,7 @@ import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export * from './brand.ts' export * from './llm-target.ts' -export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' +export { agentCarrier, agentEvents, assembleContextFor, assembleRequestContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' declare module 'cordis' { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 80d289c9f0..4bf2809677 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -15,6 +15,8 @@ declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ agent?: Agent + /** Present only when the caller will materialize this assembly into the agent's next model request. */ + modelRequest?: true } } diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 09f3af6cff..fc7b0a09e1 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -4,6 +4,8 @@ import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, + assembleContextFor, + assembleRequestContextFor, } from '@deepseek-ai/dsh-agent' import type { @@ -187,6 +189,23 @@ describe('agentEvents()', () => { }) }) +describe('agent prompt assembly context', () => { + it('marks only request-owned assemblies for model materialization', () => { + const agent = stubAgent('assembly') + const signal = new AbortController().signal + + expect(assembleContextFor(agent, signal)).toEqual({ agent, scope: agent, signal }) + expect(assembleRequestContextFor(agent, signal)).toEqual({ + agent, + scope: agent, + signal, + modelRequest: true, + }) + expect(assembleContextFor(agent)).toEqual({ agent, scope: agent }) + expect(assembleRequestContextFor(agent)).toEqual({ agent, scope: agent, modelRequest: true }) + }) +}) + describe('explicit cancellation contract', () => { it('exposes the closed typed cancellation cause at the Agent seam', () => { expectTypeOf[0]>().toEqualTypeOf() diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index a3937c24ec..72b4202062 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -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/core/system-prompt/README.md -README.md: d4e0f69323b7326fc7575834bf48a5aeeec0777e -README.zh.md: 47290335d725083fc46ef4f2ee09b09263276788 +README.md: a521baf05f46d82c63058e1ebbbffb1e46e3f312 +README.zh.md: 199f790faadff265005d0654c4cded45f94173a5 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d4e0f69323..a521baf05f 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -28,7 +28,7 @@ Model-input assembly registry. Plugins contribute ordered stable system sections ### Key types -- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. +- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` and `modelRequest?: true`. Use `assembleContextFor(agent, signal)` for agent-scoped inspection and `assembleRequestContextFor(agent, signal)` only when the caller will materialize the result into the next model request. Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. - `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. - `PromptContext` — `{ name, order, text }`. Contexts carry changing current facts that must not rewrite the cached system/history prefix; they use the same per-assembly provider and strict-variable contracts as sections. - `PromptAssembly` — `{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record }`. Section and context texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 47290335d7..199f790faa 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -28,7 +28,7 @@ ### 关键类型 -- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 +- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent` 和 `modelRequest?: true`。使用 `assembleContextFor(agent, signal)` 进行 agent 作用域检查;只有当调用方会将结果物化为下一个模型请求时,才使用 `assembleRequestContextFor(agent, signal)`。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 - `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 - `PromptContext`:`{ name, order, text }`。上下文承载不断变化的当前事实,这些事实不能改写已缓存的系统/历史前缀;上下文与段使用相同的逐组装提供方契约和严格变量契约。 - `PromptAssembly`:`{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record }`。段与上下文文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。