Shared scoped-layer storage
共享作用域分层存储
Agent scoping ([decision](2026-07-08-agent-scope-contexts.md), [runtime design](2026-07-12-agent-scope-runtime-design.md)) gives scope-aware registries the same recurring shape: one global registration layer plus one exact agent layer. Seven registration facades use that shape: `tools.register`, `tools.restrict`, and `tools.guard` in `dsh-tools`; `SystemPrompt.section`, `SystemPrompt.tools`, and `SystemPrompt.variabl
English
Problem
Agent scoping (decision, runtime design) gives scope-aware registries the same recurring shape: one global registration layer plus one exact agent layer. Seven registration facades use that shape: tools.register, tools.restrict, and tools.guard in dsh-tools; SystemPrompt.section, SystemPrompt.tools, and SystemPrompt.variable in dsh-system-prompt; and CommandRuntime.register in dsh-commands.
Without a shared primitive, each facade repeats the lifecycle choreography around its domain state: derive visibility from the calling context, create a scoped container on demand, attach ownership to the same Cordis fiber, install undo before notifying observers, return Cordis's exact disposer, and reclaim empty scoped state. Separate maps and collection types also leave a service without one object representing a scope's complete contribution.
The duplicated code carries three non-obvious requirements:
- Visibility and ownership must come from the same context; accepting them separately permits a registration visible in one scope but disposed with another.
- Undo must be collected before a change callback runs, so a throwing callback rolls the mutation back.
- The public disposer must be the exact function returned by
ctx.effect(); wrapping it breaks Cordis's identity-based ordered teardown.
The shared part is lifecycle and insertion-ordered storage, not registry policy. Tool restrictions, reserved transport handling, prompt evaluation timing, command normalization, exact diagnostics, and callback containment remain different domain contracts.
Decision
@deepseek-ai/dsh-scope provides a key-agnostic store.ts implementation module. The package continues to peer on Cordis and @deepseek-ai/dsh-invariants, and its invariant companion remains unchanged. The package root exports four storage symbols: ScopeLayer, ScopedLayers, NamedEntries, and AnonymousEntries. EntryValues remains internal, and store.ts is not a package subpath.
ScopeLayer keeps the aggregate concept explicit while requiring only whole-layer emptiness. A service defines one concrete layer whose tables and domain helpers fit that service; ScopedLayers owns construction, selection, lifecycle attachment, notification, and aggregate reclamation.
Public interface
export interface ScopeLayer {
isEmpty(): boolean
}
export class ScopedLayers<L extends ScopeLayer> {
constructor(
createLayer: (scope: ScopeKey | undefined) => L,
onChange: () => void,
)
readonly global: L
peek(scope: ScopeKey | undefined): L | undefined
merge<V>(
scope: ScopeKey | undefined,
pick: (layer: L) => NamedEntries<V>,
): Map<string, V>
effect(
ctx: Context,
action: (layer: L) => () => void,
options: { label: string; notify?: boolean },
): () => void
}
export class NamedEntries<V> {
constructor(duplicateError: (name: string) => Error)
insert(name: string, value: V): () => void
get(name: string): V | undefined
has(name: string): boolean
keys(): IterableIterator<string>
entries(): IterableIterator<[string, V]>
values(): IterableIterator<V>
isEmpty(): boolean
}
export class AnonymousEntries<V> {
append(value: V): () => void
values(): IterableIterator<V>
isEmpty(): boolean
}
Storage contract
- The constructor creates
globalonce withcreateLayer(undefined). A scoped layer is created only byeffect();peek()andmerge()never create one, andpeek(undefined)returnsundefinedbecause the global layer is already explicit. merge()is the only materialized generic read. It copies named global entries in insertion order, then applies matching scoped entries in their insertion order so same-name entries shadow without moving unrelated names.NamedEntries.insert()checks and inserts atomically, returns an idempotent exact-entry undo, and obtains the registry's exact duplicate diagnostic from the caller-supplied factory. Lookup and iterators retain nativeMaporder and stay live within one nonempty table generation; draining the table starts a new generation so an in-flight iterator cannot observe a self-replacement.AnonymousEntries.append()assigns a unique internal key per registration, so equal callbacks or values remain independent. Its iterator is insertion-ordered and uses the same live-generation boundary.effect()derives the key withscopeOf(ctx)and attaches the action to that samectx.effect(). It accepts one synchronous action returning one synchronous undo; actions must either return their undo or throw before retaining a contribution. The helper does not normalize the wider CordisEffectunion.effect()collects the action's undo before callingonChangeand returns the exactctx.effect()disposer. Disposal runs the action undo before notification, is idempotent through Cordis, and removes a scoped layer only after its completeScopeLayer.isEmpty()becomes true.options.notifydefaults totrue. The callback's own policy stays authoritative: tool and prompt change callbacks may throw and trigger registration rollback;CommandRuntime.notifyChange()contains observer failures; tool guards passnotify: false.
Registry migrations
dsh-tools defines one ToolLayer containing named tools plus anonymous compiled restrictions and guard registrations. ToolRuntime retains its private domain resolver for visible definitions, pre-restriction known names, restrictable global names, scoped shadowing, restrictions, and reserved run_code insertion. Guard evaluation live-iterates global then scoped registrations: additions to a nonempty generation can run in the current dispatch, while a self-replacement after draining the guard table begins with the next dispatch.
dsh-system-prompt defines one PromptLayer containing named sections and variables plus anonymous tool providers. Assembly merges sections before evaluating them, so a shadowed provider is never called. Tool-provider membership is materialized once per assembly. Variable providers live-iterate global then scoped tables: additions to a nonempty generation can run in the current assembly, while a self-replacement after draining the variable table begins with the next assembly.
dsh-commands defines a one-table layer containing NamedEntries<RegisteredCommand>. Effective views use merge(), while CommandRuntime retains definition normalization and freezing, exact duplicate diagnostics, sorted immutable descriptors, direct execution, HMR cleanup, and independently contained commands/change observers.
All seven facades keep validation and diagnostics in their owning registry and continue to return the exact Cordis disposer. The migration changes neither public registry behavior nor model-, human-, wire-, persistence-, or configuration-visible output.
Alternatives considered
Keep the independent implementations. This avoids a new library interface but leaves lifecycle ordering, disposer identity, and scope reclamation duplicated across seven facades.
One helper per table. This removes some local code but preserves multiple per-scope maps and cannot reclaim one scope's aggregate contribution correctly.
Per-scope registry instances. Child registries would need delegation for global-plus-scoped views, special subtraction for restrictions, and observer discovery across instances. They would move complexity rather than remove it.
Explicit scope parameters on registration methods. Separate visibility and ownership inputs make mismatched lifetimes representable, while an omitted scope silently becomes global.
Accept the complete Cordis Effect union. None of the seven registrations has asynchronous setup, multiple undos, or an independent settlement boundary. General normalization would duplicate Cordis lifecycle machinery without a current consumer.
Expose ScopedLayers.values(), ScopedLayers.keys(), or a global-admission predicate. Those operations encode consumer-specific live/materialized and filtering policies. Direct table iteration preserves explicit live semantics, merge() covers the shared named shadowing operation, and ToolRuntime keeps its richer private resolver.
Put values() on ScopeLayer or export EntryValues. A layer aggregates heterogeneous tables and has no coherent value type or iteration policy. EntryValues is useful only to share implementation details between the two table classes; making it public would enlarge the interface without giving callers a meaningful layer-wide read.
Generate layers from a mapped-type table description. Three-table and one-table concrete layers are short, inspectable, and free to hold domain helpers. A class generator would add a second construction model and generated runtime shape for little leverage.
Consequences
- Scope-aware registries express one aggregate layer and reuse the same construction, ownership, rollback, notification, and reclamation choreography. Domain-specific validation, diagnostics, filtering, evaluation, and observer policy remain in each registry.
- The public read API stays narrow: direct table iteration preserves explicitly live behavior, while
merge()is the one shared materialized shadowing operation. A heterogeneousScopeLayerhas no layer-widevalues()contract. - The helper is deliberately synchronous. A future registration that needs asynchronous setup or several independently owned undos must identify its ownership and settlement boundaries before widening this contract.
- An action must throw before retaining a contribution or return an undo for everything it retained; the helper cannot repair mutation outside that contract. The provided entry operations are atomic, and migrated registries perform fallible validation before insertion.
- A scoped layer remains allocated until every table in its aggregate is empty. Disposing one facade therefore cannot discard sibling contributions owned by the same scope.
- The four public symbols become a reusable package contract. Keeping
EntryValuesinternal and consumer policy outside the helper limits the compatibility API. - The migration changes no public registry behavior and no model-, human-, wire-, persistence-, configuration-, or dependency-graph output.
Verification
dsh-scopeunit tests cover global construction, lazy scoped construction, non-creating reads, named merge order and shadowing, aggregate reclamation, factory and action failure cleanup, notification ordering and rollback,notify: false, effect labels, exact disposer identity, idempotent teardown, caller-owned duplicate errors, independent anonymous duplicates, live iterators, and drained-generation detachment.- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy and self-replacement, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy and self-replacement, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal.
- The scoped core-data type-equivalence check ties
ScopeLayerdocumentation to its source declaration. Repository documentation, module-graph, build, hygiene, coverage, and built-artifact gates exercise the root export and package boundary. - Existing ACP, headless, and TUI keyless snapshots remain the regression boundary for tool schemas and prompt assembly; TUI coverage owns human commands. The implementation does not update any expected transcript.
中文
问题
agent(智能体)作用域机制(决策、运行时设计)让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:tools.register、tools.restrict 和 tools.guard(位于 dsh-tools);SystemPrompt.section、SystemPrompt.tools 和 SystemPrompt.variable(位于 dsh-system-prompt);以及 CommandRuntime.register(位于 dsh-commands)。
如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。
重复代码承载着三项不明显的要求:
- 可见性与属主必须来自同一个上下文;若分开接受二者,就能登记出对一个 scope 可见、却随另一个 scope 销毁的贡献。
- change 回调运行前必须收集 undo,抛错的回调才能回滚变更。
- 公开 disposer 必须就是
ctx.effect()返回的那个函数;包装它会破坏 Cordis 基于身份的有序拆除。
共享的是生命周期与保持插入顺序的存储,而不是注册表策略。工具限制、保留传输项处理、提示词求值时机、命令规范化、精确诊断和回调异常隔离,仍分别属于不同的领域约定。
决策
@deepseek-ai/dsh-scope 提供与键类型无关的 store.ts 实现模块。该包继续将 Cordis 和 @deepseek-ai/dsh-invariants 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:ScopeLayer、ScopedLayers、NamedEntries 和 AnonymousEntries。EntryValues 仍是内部接口,store.ts 不是包子路径。
ScopeLayer 保留显式的聚合概念,同时只要求判断整个层是否为空。服务定义一个具体层,使其表结构与领域 helper 适合该服务;ScopedLayers 负责构造、选择、生命周期挂接、通知和聚合回收。
公开接口
export interface ScopeLayer {
isEmpty(): boolean
}
export class ScopedLayers<L extends ScopeLayer> {
constructor(
createLayer: (scope: ScopeKey | undefined) => L,
onChange: () => void,
)
readonly global: L
peek(scope: ScopeKey | undefined): L | undefined
merge<V>(
scope: ScopeKey | undefined,
pick: (layer: L) => NamedEntries<V>,
): Map<string, V>
effect(
ctx: Context,
action: (layer: L) => () => void,
options: { label: string; notify?: boolean },
): () => void
}
export class NamedEntries<V> {
constructor(duplicateError: (name: string) => Error)
insert(name: string, value: V): () => void
get(name: string): V | undefined
has(name: string): boolean
keys(): IterableIterator<string>
entries(): IterableIterator<[string, V]>
values(): IterableIterator<V>
isEmpty(): boolean
}
export class AnonymousEntries<V> {
append(value: V): () => void
values(): IterableIterator<V>
isEmpty(): boolean
}
存储约定
- 构造器只创建一次
global,调用的是createLayer(undefined)。只有effect()会创建专属层;peek()和merge()从不创建专属层,而peek(undefined)返回undefined,因为全局层已经显式存在。 merge()是唯一会物化结果的通用读取接口。它按插入顺序复制全局命名条目,再按专属条目的插入顺序应用这些条目;同名条目完成遮蔽,但不会移动无关名称。NamedEntries.insert()以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留Map的原生顺序,并在同一个非空表 generation 内保持活遍历;清空表会开启新的 generation,因此尚未结束的迭代器无法观察到自我替换。AnonymousEntries.append()为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器保留插入顺序,并采用同样的 generation 活遍历边界。effect()通过scopeOf(ctx)导出键,并把 action 挂到同一个ctx.effect()上。它只接受一个同步 action,且该 action 只返回一个同步 undo;action 要么返回其 undo,要么必须在保留任何贡献之前抛错。helper 不会规范化更宽泛的 CordisEffectunion。effect()在调用onChange前收集 action 的 undo,并原样返回ctx.effect()的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有在整个层的ScopeLayer.isEmpty()返回 true 后,helper 才会删除专属层。options.notify默认为true。回调自身的策略仍具最终效力:工具与提示词的 change 回调可以抛错并触发登记回滚;CommandRuntime.notifyChange()会隔离观察者失败;工具 guard 传入notify: false。
注册表迁移
dsh-tools 定义一个 ToolLayer,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。ToolRuntime 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 run_code 插入。guard 求值会先活遍历全局登记,再活遍历专属登记:向非空 generation 新增的登记可以在当前分发中运行,而 guard 表清空后的自我替换则从下一次分发开始运行。
dsh-system-prompt 定义一个 PromptLayer,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方会先活遍历全局表,再活遍历专属表:向非空 generation 新增的提供方可以在当前组装中运行,而变量表清空后的自我替换则从下一次组装开始运行。
dsh-commands 定义一个单表层,其中包含 NamedEntries<RegisteredCommand>。生效视图使用 merge();CommandRuntime 则保留对定义的规范化与冻结处理、精确重名诊断、经过排序的不可变描述符、直接执行、HMR(热模块替换)清理,以及对各个 commands/change 观察者分别隔离失败的行为。
七个门面都把校验与诊断留在所属注册表中,并继续返回 Cordis 的原始 disposer。迁移既不改变公开注册表行为,也不改变模型可见或人类可见的输出,以及协议、持久化或配置层面的可见输出。
备选方案
保留彼此独立的实现。 这样不必新增库接口,但七个门面仍会重复生命周期顺序、disposer 身份和 scope 回收。
每张表一个 helper。 这能减少一部分局部代码,但会保留多张按 scope 划分的映射,而且无法正确回收某个 scope 的聚合贡献。
每 scope 一个注册表实例。 子注册表需要通过委托获得全局加专属的视图,对 restriction 进行特殊的减法处理,并跨实例发现观察者。这只会转移复杂度,而不会消除复杂度。
注册方法上的显式 scope 参数。 分开的可见性与属主输入让不匹配的生命周期成为可表达状态,而遗漏 scope 则会静默变成全局登记。
接受完整的 Cordis Effect union。 七个登记口都不涉及异步 setup、多份 undo 或独立结算边界。若没有现有消费方需要,通用规范化只会重复实现 Cordis 的生命周期机制。
暴露 ScopedLayers.values()、ScopedLayers.keys() 或全局放行谓词。 这些操作会编码消费方特有的活遍历或物化策略,以及过滤策略。直接遍历条目表可保留显式的活语义,merge() 覆盖共享的命名遮蔽操作,而 ToolRuntime 继续保有功能更丰富的私有解析器。
把 values() 放在 ScopeLayer 上,或导出 EntryValues。 一个层会聚合异构表,因而没有一致的值类型或迭代策略。EntryValues 只适合在两个表类之间共享实现细节;将其公开只会扩大接口,却不能为调用方提供有意义的整层读取方式。
通过 mapped-type 表描述生成层。 三表与单表具体层都很短、易于检查,并可自由持有领域 helper。类生成器会增加第二种构造模型和生成式运行时形状,收益却很小。
后果
- 支持作用域的注册表各自通过一个聚合层表达状态,并复用相同的构造、属主、回滚、通知和回收编排。各注册表仍各自保有领域特有的校验、诊断、过滤、求值和观察者策略。
- 公开读取接口保持狭窄:直接遍历条目表可保留显式的活语义,
merge()是唯一共享的物化遮蔽操作。异构的ScopeLayer不具备整层values()约定。 - helper 刻意保持同步。未来的登记若需要异步 setup 或多份分别拥有属主的 undo,必须先明确属主与 settlement 边界,再拓宽这项约定。
- action 必须在保留贡献前抛错,或者为自己保留的一切返回 undo;helper 无法修复超出这项约定的变更。提供的条目操作是原子的,迁移后的注册表会在插入前执行可能失败的校验。
- 专属层会一直保持已分配状态,直到其聚合内的所有表都为空。因此,销毁一个门面不会丢弃同一 scope 拥有的其他贡献。
- 四个公开符号构成一项可复用的包约定。将
EntryValues保持为内部接口,并把消费方策略留在 helper 之外,可以限制兼容性范围。 - 迁移不改变任何公开注册表行为,也不改变模型、人类、协议、持久化、配置或依赖图层面的任何输出。
验证
dsh-scope单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、notify: false、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记、活迭代器,以及表清空后的 generation 脱离。- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输项处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且经过排序的视图、直接执行和生命周期销毁。
- 作用域核心数据的类型等价性检查将
ScopeLayer文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。 - 现有 ACP(Agent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema 与提示词组装的回归边界;人类命令由 TUI 覆盖。实现不会更新任何预期 transcript(文本记录)。