docs(rfc): align scoped layers with final scope design

This commit is contained in:
Tianyi Cui
2026-07-13 00:06:04 +08:00
parent 0e80bd2077
commit 84d0932e5e
3 changed files with 134 additions and 120 deletions

View File

@@ -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
2026-07-12-scoped-layers-store.md: 0673d6c63291a66c84e91f2eff8eca26798b931f
2026-07-12-scoped-layers-store.zh.md: 6460802f3cf3163f6be2ad51f2f82319a04288bc
2026-07-12-scoped-layers-store.md: c3a9ab8724191b5b1bc87f02a90d6995c247d944
2026-07-12-scoped-layers-store.zh.md: e2ec72145766a95ea1c330e3a658f7c86490e263

View File

@@ -6,72 +6,70 @@ English | [中文](2026-07-12-scoped-layers-store.zh.md)
## Problem
Agent scoping ([the agent-scope RFC](../../implemented/architecture/2026-07-08-agent-scope-contexts.md), [runtime design](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)) made "a registry with a global layer plus per-agent layers" a recurring shape, and every occurrence is hand-written. Seven registration sites exist today — `tools.register`/`tools.restrict`/`tools.guard` in `dsh-tools` and `section`/`tools`/`variable`/`protect` in `dsh-system-prompt` — each pairing a global container with its own `Map<ScopeKey, container>` and repeating the same 10-15-line effect choreography: read the calling context's tag, get-or-create the layer, validate, mutate, yield a rollback that deletes the entry, reclaims the emptied layer, and emits the change event, then emit and return the exact cordis effect disposer.
Agent scoping ([the agent-scope RFC](../../implemented/architecture/2026-07-08-agent-scope-contexts.md), [runtime design](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)) made "a registry with a global layer plus per-agent layers" a recurring shape, and every occurrence is hand-written. Six registration sites exist today — `tools.register`/`tools.restrict`/`tools.guard` in `dsh-tools` and `section`/`tools`/`variable` in `dsh-system-prompt` — each repeating the same 10-15-line effect choreography around its applicable global or scoped containers: read the calling context's tag, get or create the layer, validate, mutate, yield a rollback that deletes the entry and reclaims an emptied scoped layer, emit the applicable change event, and return the exact Cordis effect disposer.
Beyond the duplication, the risk concentrates in the choreography details:
- The rollback must be collected before the change emit (so a throwing listener unwinds the insertion instead of leaking it)
- The returned disposer must be cordis's own function (a wrapper silently breaks nested ordered teardown)
- The returned disposer must be Cordis's own function (a wrapper silently breaks nested ordered teardown)
- Emptied scoped layers must be reclaimed (a disposed agent must not leave residue keyed by its dead `ScopeKey`)
Every new consumer has to rewrite all of that correctly, and the copies have already diverged stylistically — two private `layerFor` helpers in `dsh-tools`, four inline IIFEs in `dsh-system-prompt`.
Every new consumer has to rewrite all of that correctly, and the copies have already diverged stylistically — two private layer helpers in `dsh-tools`, three inline IIFEs in `dsh-system-prompt`.
Finally, one agent's contribution to one service is scattered across several maps that know nothing of each other — there is no object that means "what this scope contributes here" — and the consumer count keeps growing: guards and prompt protections landed recently, and per-agent `fs/*` policy, `llm/*` overrides, and per-agent compaction policy are all queued on the same pattern.
Finally, one agent's contribution to one service is scattered across several maps that know nothing of each other — there is no object that means "what this scope contributes here" — and the consumer count keeps growing: scoped guards and per-agent prompt/tool composition landed recently, while per-agent `fs/*` policy, `llm/*` overrides, and compaction policy are plausible future users of the same pattern.
## Proposal
`dsh-scope` gains a store module (a new `store.ts` under its `src/`, peer-dependent on cordis only, key-agnostic) built around one division of labor: **business logic lives in a layer class; the helper only schedules layers**. One helper instance per service; the value in its map is the aggregate of everything one scope contributes to that service.
`dsh-scope` gains a key-agnostic `store.ts`, with Cordis as its only peer dependency. The module implements the smallest abstraction shared by the six current sites: **business state and validation stay in an explicit layer class; one helper owns layer selection, effect attachment, rollback, notification, and reclamation**. One helper instance belongs to one service, and one layer instance aggregates everything a scope contributes to that service.
- **`ScopedLayers<L>`** a concrete scheduler, never subclassed. It owns the global layer plus one `Map<ScopeKey, L>`, builds layers on demand as `new layerClass(scope, this)`, reclaims a layer when `isEmpty()`, and funnels every write through `effect(ctx, action, options?)`. The single `ctx` parameter decides both the visible layer (`scopeOf(ctx)`) and the owning fiber (`ctx.effect`), so "visible to X, disposed with Y" stays unrepresentable — the same shape argument the agent-scope RFC used against explicit scope parameters. Actions may produce one undo, an iterable of undos, a promise, or an async iterable — the four shapes of cordis `Effect` — and undos may be async. The helper seals collected undos (run in LIFO), empty-layer reclamation, and the change notification into one disposer, and hands cordis that disposer **before** the notification runs: a throwing change listener therefore makes cordis execute the already-collected rollback and rethrow, exactly like the hand-written yield-before-emit today. Reads are `global`/`peek` plus three selector primitives lifting the table views across the two layers — `merge` (named entries, scoped shadows global, global position preserved, optional admit predicate), `values` (concatenation including anonymous entries, deliberately no shadowing), `keys` (the pre-restriction name universe) and array-returning `forEach`/`filter`/`map` over all layers.
- **`createLayer({ name: table<V>(kind) })`** — a class factory in the `defineTool` DSL tradition. The generated base class builds every declared table in its constructor, threads the scope down, receives the sibling back-reference (`protected readonly layers: ScopedLayers<this>`, injected by the helper at construction; polymorphic `this` narrows it in subclasses), and aggregates `isEmpty()` over the declared tables. `layer.<table>` is a fully typed mapped property, so a misspelled table name is a compile error; the table names `scope`, `isEmpty`, and `layers` are reserved and throw. Business subclasses add domain methods in the class body — single-layer queries, registration validations, and cross-layer *reads* through `this.layers` (writes must still go through `effect`); a fully custom layer may instead implement the one-method `ScopeLayer` interface (`isEmpty()`).
- **`Entries<V>`** the canned table: named entries (`insert`, same-layer duplicates throw one standardized message pair pointing at `agent.ctx`) and anonymous entries (`append`, process-unique symbol keys, O(1) undo removal) share one insertion-ordered map; read views (`keys`/`entries`/`values`) return array snapshots.
- **`ScopedLayers<L>`** is a concrete scheduler, not a base class. It owns the global layer plus one `Map<ScopeKey, L>`, constructs scoped layers on demand through an explicit factory, and reclaims a layer when `isEmpty()`. Its `effect(ctx, action, options?)` accepts one synchronous action that returns one synchronous undo because that is the complete shape of all six current sites. The single `ctx` decides both the visible layer (`scopeOf(ctx)`) and the owning Cordis fiber (`ctx.effect`), so "visible to X, disposed with Y" stays unrepresentable. The helper yields the undo before notifying listeners, returns Cordis's exact disposer, and reclaims a newly created empty layer if validation or mutation throws. Reads are `global`/`peek` plus `merge` (named entries with scoped shadowing and an optional global-admission predicate), `values` (global then scoped concatenation without shadowing), `keys` (the pre-restriction name universe), and `some` (cross-layer invariant checks).
- **Explicit `ScopeLayer` classes** make each service's state visible to readers. `ToolLayer` and `PromptLayer` declare their three table properties and their `isEmpty()` aggregation directly; a small layer factory receives only the scope, while its closure may capture real constructor dependencies. Domain methods stay ordinary class methods. This costs a few repetitive declarations but avoids a mapped-type class factory, a scheduler/layer ownership cycle, reserved property names, and generated runtime structure.
- **`NamedEntries<V>` and `AnonymousEntries<V>`** are the two shared insertion-ordered tables. Named entries expose `insert`/lookup and retain the current global/scoped duplicate wording through domain `kind` and per-agent-alternative labels; anonymous entries expose only `append`, using process-unique symbol keys for O(1) undo removal. Keeping the classes separate makes meaningless mixed named/anonymous operations unrepresentable and keeps key types sound. Their iterators borrow membership and typed contribution values; they do not clone or freeze values. `ScopedLayers` materializes only the merged arrays/maps already required by the service read paths.
`dsh-tools` migrates its three tables into one `ToolLayer` (domain methods `addRestriction` — empty-filter/read-once/reserved-name/known-names validation with the reserved list passed in as data, since it reads service state — plus `admits` and `guardReason`), and `dsh-system-prompt` its four into one `PromptLayer` (`addProtection` with the global-conflict self-check via the back-reference, plus the `shadowedSections` predicate). Every facade becomes a single `effect` call carrying per-call `label`, `silent` (guards emit no change event), or `scopedOnly` (boolean, or a string carrying the domain error message) options. `assemble` stays in the facade for three hard reasons: it has no legal receiver (the subject scope's layer may not exist, and reads never create layers), shadowing forces merge-before-evaluate (per-layer rendering would evaluate shadowed providers, an observable change), and the assemble waterfall, `toolOrder`, and protection restore need service-level resources a layer must not hold.
`dsh-tools` migrates its three tables into one `ToolLayer`: tools, compiled restrictions, and guards. The layer owns restriction admission and guard evaluation; the facade retains domain validation that needs service configuration, such as the reserved `run_code` name and the current known-global-name universe. Readonly allow/deny inputs are compiled once into internal sets. `dsh-system-prompt` likewise migrates sections, tool providers, and variables into one `PromptLayer`; its facade performs owner-final cross-layer checks through `layers.some`. Every registration facade performs its public argument validation and then makes one `effect` call with a label and, for guards, `silent: true`. A generic helper does not learn domain rules such as "restrictions require a scoped context."
Migration is behavior-preserving with two declared exceptions: the three duplicate-name messages unify into one template (tests asserting the old wording update in the same change), and validations move relative to the effect boundary (restrict/protect checks move inside the action, the variable name regex moves to the facade), so the error *order* for multiply-invalid inputs can change while every single-fault path is unchanged. Two knowingly unobservable differences: an aggregate layer is reclaimed only when all its tables are empty, and read views are snapshots rather than live containers (visible only to a callback that registers during its own iteration).
`assemble` stays in the `SystemPrompt` facade for three reasons: the subject scope's layer may not exist and reads must not create it; shadowing requires merge-before-evaluate so a hidden section provider is never called; and the assembly waterfall, `toolOrder`, and owner-final restoration use service-level resources. Sections and tool providers keep their current materialized derived views. Variable providers instead iterate the global and scoped `NamedEntries` directly, preserving today's live Map behavior when a provider registers another variable during assembly. Tool guards likewise iterate their `AnonymousEntries` directly. Owner-final remains metadata on section and tool contributions, not a second protection registry.
Migration preserves public behavior and exact duplicate messages. The internal aggregate layer is reclaimed only after all three tables empty rather than when one table empties; no service API exposes layer identity. Direct live iteration retains current re-entrant variable-provider and guard behavior, while selector helpers continue to materialize the same section, tool-provider, and tool-resolution views their facades build today.
`ScopeLayer`, `EntryValues`, `ScopedLayers`, `NamedEntries`, and `AnonymousEntries` are public `dsh-scope` root exports with export JSDoc. Consumers import them from `@deepseek-ai/dsh-scope`; `store.ts` is an implementation module, not a package subpath.
## API sketch
```ts ignore-check
interface ScopeLayer {
export interface ScopeLayer {
isEmpty(): boolean
}
type LayerClass<L extends ScopeLayer> = new (scope: ScopeKey | undefined, layers: ScopedLayers<L>) => L
declare function table<V>(kind: string): TableSpec<V>
declare function createLayer<S extends Record<string, TableSpec<unknown>>>(
spec: S,
): LayerClass<ScopeLayer & { readonly [K in keyof S]: Entries<EntryTypeOf<S[K]>> }>
type Undo = () => unknown
type LayerAction<L> = (layer: L) =>
| Undo
| Iterable<Undo, void, void>
| Promise<Undo>
| AsyncIterable<Undo, void, void>
class ScopedLayers<L extends ScopeLayer> {
constructor(layerClass: LayerClass<L>, options: { label: string; onChange?: () => void })
export class ScopedLayers<L extends ScopeLayer> {
constructor(createLayer: (scope: ScopeKey | undefined) => L, options: { onChange?: () => void })
readonly global: L
peek(scope: ScopeKey | undefined): L | undefined
merge<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>, admitGlobal?: (name: string) => boolean): Map<string, T>
values<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>): T[]
keys<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>): string[]
effect(ctx: Context, action: LayerAction<L>, options?: { label?: string; silent?: boolean; scopedOnly?: boolean | string }): () => Promise<void> | void
forEach(fn: (layer: L, scope: ScopeKey | undefined) => void): void
filter(fn: (layer: L, scope: ScopeKey | undefined) => boolean): L[]
map<T>(fn: (layer: L, scope: ScopeKey | undefined) => T): T[]
merge<T>(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries<T>, admitGlobal?: (name: string) => boolean): Map<string, T>
values<T>(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues<T>): T[]
keys<T>(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries<T>): string[]
some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean
effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => Promise<void> | void
}
class Entries<V> {
constructor(kind: string, scope: ScopeKey | undefined)
export interface EntryValues<V> {
values(): IterableIterator<V>
isEmpty(): boolean
}
export class NamedEntries<V> implements EntryValues<V> {
constructor(kind: string, perAgentAlternative: string, scope: ScopeKey | undefined)
insert(name: string, value: V): () => void
append(value: V): () => void
get(name: string): V | undefined
has(name: string): boolean
keys(): string[]
entries(): ReadonlyArray<readonly [string, V]>
values(): readonly V[]
keys(): IterableIterator<string>
entries(): IterableIterator<[string, V]>
values(): IterableIterator<V>
isEmpty(): boolean
}
export class AnonymousEntries<V> implements EntryValues<V> {
append(value: V): () => void
values(): IterableIterator<V>
isEmpty(): boolean
}
```
@@ -79,21 +77,26 @@ class Entries<V> {
What a migrated consumer looks like — the heaviest current site shrinks from 30+ lines of choreography to a declaration and one-line facades:
```ts ignore-check
class ToolLayer extends createLayer({
tools: table<ToolDefinition>('tool'),
restrictions: table<ToolRestriction>('tool restriction'),
guards: table<ToolGuardRegistration>('tool guard'),
}) {
addRestriction(filter: ToolRestriction, reserved: readonly string[]): () => void { /* validate, snapshot, append */ }
class ToolLayer implements ScopeLayer {
readonly tools = new NamedEntries<ToolDefinition>('tool', 'variant', this.scope)
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuardRegistration>()
constructor(
readonly scope: ScopeKey | undefined,
) {}
isEmpty(): boolean { return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() }
addRestriction(filter: ToolRestriction): () => void { /* compile to sets, append */ }
admits(name: string): boolean { /* intersection over this.restrictions.values() */ }
guardReason(view: Readonly<ToolExecution>): string | undefined { /* first monotonic denial */ }
}
class ToolRegistry extends Service {
private readonly layers = new ScopedLayers(ToolLayer, {
label: 'tools',
onChange: () => this.ctx.emit('tools/change'),
})
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
{ onChange: () => this.ctx.emit('tools/change') },
)
register(definition: ToolDefinition): () => Promise<void> | void {
return this.layers.effect(this.ctx,
@@ -101,8 +104,9 @@ class ToolRegistry extends Service {
{ label: 'tools.register()' })
}
visible(scope?: ScopeKey): ToolDefinition[] {
return Array.from(this.layers.merge(scope, layer => layer.tools, name => this.admits(scope, name)).values())
private resolveVisible(scope?: ScopeKey): ToolDefinition[] {
const scoped = this.layers.peek(scope)
return Array.from(this.layers.merge(scope, layer => layer.tools, name => scoped?.admits(name) ?? true).values())
}
}
```
@@ -115,6 +119,10 @@ class ToolRegistry extends Service {
**Extracting only the data structure, leaving the choreography in services.** Removes the safe half of the duplication and keeps the dangerous half — the rollback-before-emit ordering, raw-disposer, and reclamation rules are exactly where the bugs live.
**Accepting the full Cordis `Effect` union as a layer action.** None of the six sites has asynchronous setup, multiple undos, or an independent settlement boundary. Normalizing promises, iterables, async iterables, LIFO sealing, and partial failure would duplicate lifecycle machinery speculatively. The store accepts one synchronous action and one undo; a future real boundary can justify widening it.
**Generating layer classes from a mapped-type table DSL.** The two consumers each declare three tables. A class factory would save a handful of lines while adding generated runtime shape, reserved names, polymorphic-`this` typing, and a second construction model. Explicit classes are easier to inspect and can still share the entry tables and `ScopedLayers`.
**A fixed-container helper with built-in view semantics.** Pins container shapes and merge policy inside the helper; business gets no freedom, and every naming or single-value variation becomes a helper feature request.
**One helper per table.** Reproduces today's scattered bookkeeping — that is the status quo being replaced, with N scope maps per service and no aggregate for an agent's contribution.
@@ -125,15 +133,14 @@ class ToolRegistry extends Service {
## Acceptance criteria
- `store.ts` ships in `dsh-scope` (peer deps unchanged: cordis only; module-graph position unchanged) with per-file 100% coverage, including: layer bookkeeping and reclamation, all four action shapes, seal ordering, the throwing-change-listener rollback (the entry is rolled back and the duplicate check re-registers), failure reclamation of freshly created layers, `label`/`silent`/`scopedOnly` options, `createLayer` construction, reserved table names, back-reference typing, and `Entries` named/anonymous semantics.
- `dsh-tools` and `dsh-system-prompt` each collapse to one `ScopedLayers`; all existing tests pass with only the declared duplicate-message assertion updates; every registration facade is a single `effect` call and keeps returning the exact cordis effect disposer.
- Behavior matches the old baseline per the equivalence statement above: two declared exceptions (unified messages; error order for multiply-invalid inputs), two unobservable differences (aggregate reclamation timing; snapshot read views), nothing else.
- `store.ts` ships in `dsh-scope` (peer dependencies unchanged: Cordis only; module-graph position unchanged) with per-file 100% coverage of layer selection and reclamation, synchronous action/undo ordering, throwing-action cleanup, throwing-change-listener rollback, exact disposer identity, `label`/`silent`, factory typing, cross-layer `some`, merge selectors, and separate named/anonymous entry semantics. Its five public symbols are re-exported from the package root and carry export JSDoc.
- `dsh-tools` and `dsh-system-prompt` each collapse to one `ScopedLayers`; every registration facade validates its domain contract and then makes one `effect` call, and all keep returning the exact Cordis effect disposer.
- Existing behavior, duplicate messages, validation order, live variable-provider re-entrancy, and live guard re-entrancy remain unchanged. Tests additionally pin aggregate reclamation timing and selector materialization.
- Documentation lands in the same change: `dsh-scope`/`dsh-tools`/`dsh-system-prompt` READMEs; on implementation this RFC moves to `implemented/` and the [runtime-design RFC](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)'s registration section is updated in place.
## Risks
- The layer/facade boundary may not fit a future consumer's shape. Mitigation: the bare `ScopeLayer` interface remains the floor, and widening `LayerClass` to accept a factory (for layers with constructor dependencies) is a recorded non-breaking extension.
- `createLayer`'s mapped-type factory is deliberate type gymnastics. Accepted: the `defineTool` schema DSL is the repo precedent, and the gymnastics stay inside `dsh-scope`.
- The two equivalence exceptions can surprise tests that assert exact duplicate messages or multi-fault error order; they are declared here so review checks them rather than discovers them.
- Snapshot read views hide entries registered by a callback during its own iteration — a pathological pattern, but a visible one; snapshots make it deterministic instead.
- The layer/facade boundary may not fit a future consumer's shape. Mitigation: `ScopeLayer` requires only `isEmpty()`, while the factory closure can capture constructor dependencies without giving a layer ownership of its scheduler.
- A future registration may genuinely need asynchronous setup or several independently owned undos. The helper deliberately does not predict that lifecycle; such a consumer must first identify its owner and settlement boundary, then widen the contract with tests.
- Explicit layer declarations repeat three property initializers and `isEmpty()` in each consumer. Accepted: the repetition keeps runtime state and types visible and avoids a second DSL for two classes.
- Two core registries migrate at once. Mitigated by the behavior comparison performed during design and by landing the store with equivalence-pinning tests before either migration commit.

View File

@@ -6,72 +6,70 @@ Status: proposed
## 问题
agent 作用域落地之后([agent-scope RFC](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)、[运行时设计篇](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)),「一张全局层加若干 per-agent 层的注册表」成为反复出现的形态,而每一处都是手写的。今天已有个登记口——`dsh-tools``tools.register`/`tools.restrict`/`tools.guard``dsh-system-prompt``section`/`tools`/`variable`/`protect`——每处都是一个全局容器配一张自己的 `Map<ScopeKey, 容器>`,并重复同一段 10-15 行的 effect 编排读调用方上下文的标签、按需建层、校验、变更、yield 一个「删条目 → 回收空层 → 发 change 事件」的回滚,然后发事件并返回 cordis effect 的原始 disposer。
agent 作用域落地之后([agent-scope RFC](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)、[运行时设计篇](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)),「一张全局层加若干 per-agent 层的注册表」成为反复出现的形态,而每一处都是手写的。今天已有个登记口——`dsh-tools``tools.register`/`tools.restrict`/`tools.guard``dsh-system-prompt``section`/`tools`/`variable`——每处都围绕适用的全局或专属容器重复同一段 10-15 行的 effect 编排读调用方上下文的标签、按需建层、校验、变更、yield 一个删除条目并回收空专属层的回滚、发适用的 change 事件,然后返回 Cordis effect 的原始 disposer。
除此之外:风险集中在编排细节上:
- 回滚必须在 change 发出之前被收集(抛错的监听器才能回卷插入而不是泄漏)
- 返回的 disposer 必须是 cordis 自己的那个函数(包装器会静默破坏嵌套的有序拆除)
- 返回的 disposer 必须是 Cordis 自己的那个函数(包装器会静默破坏嵌套的有序拆除)
- 清空的专属层必须被回收(被 dispose 的 agent 不得留下以死 `ScopeKey` 为键的残余)
每个新消费者都要把这一切重新写对一遍,而各副本的写法已经分叉——`dsh-tools` 里有两个私有 `layerFor``dsh-system-prompt` 里是处内联 IIFE。
每个新消费者都要把这一切重新写对一遍,而各副本的写法已经分叉——`dsh-tools` 里有两个私有建层 helper`dsh-system-prompt` 里是处内联 IIFE。
最后,一个 agent 在一个服务里的贡献散落在几张互不相识的 Map 里——不存在一个「这个 scope 在这里贡献了什么」的对象——而消费者还在持续增多guard 与提示词 protection 是最近落地的一批per-agent 的 `fs/*` 策略、`llm/*` 覆盖、per-agent compaction 策略都排在同一模式
最后,一个 agent 在一个服务里的贡献散落在几张互不相识的 Map 里——不存在一个「这个 scope 在这里贡献了什么」的对象——而消费者还在持续增多:专属 guard 与 per-agent 提示词/工具组合是最近落地的一批per-agent 的 `fs/*` 策略、`llm/*` 覆盖 compaction 策略则是同一模式的潜在后续用户
## 提案
`dsh-scope` 新增 store 模块(其 `src/` 下新增 `store.ts`peer 依赖仅 cordis与键类型无关核心是一条分工**业务逻辑封在层类里helper 只负责调度层**。一个服务一个 helper 实例;其 Map 的 value 就是「一个 scope 该服务的全部贡献」这一聚合对象
`dsh-scope` 新增与键类型无关的 `store.ts`peer 依赖仍只有 Cordis。模块只抽取六个现有登记口已经共同证明的最小形状**业务状态与校验留在显式层类里;一个 helper 统一负责选层、挂 effect、回滚、通知与回收**。一个 helper 实例属于一个服务;一个层实例聚合某 scope 该服务的全部贡献。
- **`ScopedLayers<L>`**——具体调度器,不作继承点。持有全局层与一张 `Map<ScopeKey, L>`按需以 `new layerClass(scope, this)` 建层,`isEmpty()` 时回收,并把所有写入收拢到 `effect(ctx, action, options?)`。单一 `ctx` 参数同时决定可见层(`scopeOf(ctx)`)与属主 fiber`ctx.effect`),「对 X 可见、随 Y 销毁」因此不可表达——与 agent-scope RFC 否决显式 scope 参数用的是同一个形状论证。action 可以产出单个撤销、撤销的可迭代、Promise 或异步可迭代——即 cordis `Effect` 的四种形态——且撤销允许异步。helper 把收集到的撤销(逆序执行)、空层回收与 change 通知合成**一个** disposer并在通知运行**之前**先把它交给 cordis因此 change 监听器抛错时cordis 会执行已收集的回滚再重抛与今天手写的「yield 在 emit 之前」逐字等价。读取`global`/`peek`外加把表视图提升到两层的三个 selector 原语——`merge`(命名条目专属遮蔽全局、保留全局位置,可选放行谓词)、`values`拼接、含匿名条目、刻意不做遮蔽)、`keys`(限制前名字全集)——以及跨全部层、返回数组的 `forEach`/`filter`/`map`
- **`createLayer({ 表名: table<V>(kind) })`**——`defineTool` DSL 传统的类工厂。生成的基类在构造器里建好每张声明的表、把 scope 传下去、接收同族回引(`protected readonly layers: ScopedLayers<this>`,由 helper 建层时注入;多态 `this` 型在子类中自动收窄),并对声明的表聚合 `isEmpty()``layer.<表名>` 是带完整类型的映射属性,写错表名是编译错误;表名 `scope``isEmpty``layers` 保留,冲突即抛。业务子类在类体里追加领域方法——单层查询、登记校验,以及经 `this.layers` 的跨层**只读**(写入仍必须走 `effect`);完全自定义的层也可以只实现单方法接口 `ScopeLayer``isEmpty()`
- **`Entries<V>`**——罐装条目表命名条目(`insert`,同层重名抛一对指向 `agent.ctx` 的标准化文案)与匿名条目(`append`,进程内唯一 symbol 键、O(1) 撤销删除)共用一张保插入序的 Map读视图`keys`/`entries`/`values`)返回数组快照
- **`ScopedLayers<L>`**具体调度器,不作基类。它持有全局层与一张 `Map<ScopeKey, L>`通过显式工厂按需构造专属层,并在`isEmpty()` 时回收`effect(ctx, action, options?)` 只接受一个同步 actionaction 只返回一个同步 undo因为六个现有登记口的完整形状就是如此。单一 `ctx` 同时决定可见层(`scopeOf(ctx)`)与属主 Cordis fiber`ctx.effect`),「对 X 可见、随 Y 销毁」因此不可表达。helper 在通知监听器前 yield undo返回 Cordis 的原始 disposer并在校验或变更抛错时回收刚建出的空层。读取接口`global`/`peek`以及 `merge`(命名条目专属遮蔽与可选全局放行谓词)、`values`不遮蔽地依次拼接全局与专属条目)、`keys`(限制前名字全集)`some`(跨层不变量检查)
- **显式 `ScopeLayer` 类**让每个服务的状态一眼可见。`ToolLayer``PromptLayer` 直接声明各自三项表属性与 `isEmpty()` 聚合;一个小工厂只向构造器传入 scope闭包仍可捕获真实构造依赖。领域方法仍是普通类方法。代价是几行重复声明收益是不用引入 mapped-type 类工厂、scheduler/layer 属主环、保留属性名和生成式运行时结构
- **`NamedEntries<V>``AnonymousEntries<V>`** 是两种共用的保插入序条目表命名表暴露 `insert`/查询,并通过领域 `kind` 与 per-agent alternative 标签保持现有全局/专属重名文案;匿名表只暴露 `append`进程内唯一 symbol 作键支持 O(1) 撤销删除。分成两类以后,无意义的命名/匿名混用不可表达key 类型也保持健全。迭代器借用表成员与带类型的贡献值,不会 clone 或 freeze 值;`ScopedLayers` 只物化服务读路径本来就需要的合并数组或 Map
`dsh-tools`三张表合并进一个 `ToolLayer`(领域方法 `addRestriction`——空过滤器/读取一次性/保留名/已知名校验,保留名单因读服务状态而以数据传入——加上 `admits``guardReason``dsh-system-prompt` 把四张表合并进一个 `PromptLayer``addProtection` 经同族回引做全局冲突自检,加上 `shadowedSections` 谓词)。每个门面都变成单次 `effect` 调用,携带 per-call 的 `label``silent`guard 不发 change 事件)或 `scopedOnly`(布尔,或携带领域报错文案的字符串)选项。`assemble` 留在门面,三条硬理由:它没有合法接收者(主体 scope 的层可能不存在,而读路径绝不建层)、遮蔽语义强制先合并后求值(逐层渲染会求值被遮蔽的 provider行为可观察地改变、组装 waterfall、`toolOrder` 与 protection 恢复需要层不应持有的服务级资源
`dsh-tools`工具、已编译 restriction 与 guard 三张表合并进一个 `ToolLayer`。restriction 放行判断与 guard 求值归层所有;`run_code` 保留名、当前已知全局名集合等依赖服务配置的领域校验仍留在门面。只读 allow/deny 输入只编译一次,成为内部 Set。`dsh-system-prompt` 同样把 section、tool provider 与 variable 合并进一个 `PromptLayer`;门面通过 `layers.some` 完成 owner-final 跨层冲突检查。每个登记门面先完成公开参数校验,再以 label 做一次 `effect` 调用guard 额外传 `silent: true`。通用 helper 不理解「restriction 必须由 scoped context 调用」之类领域规则
迁移保持行为等价,带两个声明的例外:三处重名文案统一为一个模板(断言旧文案的测试在同一变更中更新);校验相对 effect 边界发生挪动restrict/protect 的检查移入 actionvariable 的名字正则移到门面),因此多重非法输入的报错**先后**可能改变,而所有单一错误路径不变。两个已知的不可观察差异:聚合层要等全部表清空才回收;读视图是快照而非活容器(仅对「在自己的遍历回调里再注册」可见)
`assemble` 留在 `SystemPrompt` 门面,三条理由:主体 scope 的层可能不存在,读路径不得创建它;遮蔽语义要求先合并再求值,被遮蔽的 section provider 绝不能被调用;组装 waterfall、`toolOrder` 与 owner-final 恢复使用服务级资源。section 与 tool provider 保持既有的派生视图物化variable provider 则直接遍历全局与专属 `NamedEntries`,保留 provider 在组装期间登记另一 variable 时的现有活 Map 行为。tool guard 同样直接遍历其 `AnonymousEntries`。owner-final 仍是 section 与 tool 贡献上的元数据,不是第二张 protection 注册表
迁移保持公开行为与精确重名文案不变。内部聚合层会在三张表全部清空后才回收,而不是某一张表清空时回收;服务 API 不暴露层身份。直接活遍历保留现有 variable-provider 与 guard 重入行为selector helper 则继续物化门面今天已经在构造的 section、tool-provider 与工具解析视图。
`ScopeLayer``EntryValues``ScopedLayers``NamedEntries``AnonymousEntries` 都是带 export JSDoc 的 `dsh-scope` 根导出。消费者从 `@deepseek-ai/dsh-scope` 导入;`store.ts` 是实现模块,不是 package subpath。
## API 草图
```ts ignore-check
interface ScopeLayer {
export interface ScopeLayer {
isEmpty(): boolean
}
type LayerClass<L extends ScopeLayer> = new (scope: ScopeKey | undefined, layers: ScopedLayers<L>) => L
declare function table<V>(kind: string): TableSpec<V>
declare function createLayer<S extends Record<string, TableSpec<unknown>>>(
spec: S,
): LayerClass<ScopeLayer & { readonly [K in keyof S]: Entries<EntryTypeOf<S[K]>> }>
type Undo = () => unknown
type LayerAction<L> = (layer: L) =>
| Undo
| Iterable<Undo, void, void>
| Promise<Undo>
| AsyncIterable<Undo, void, void>
class ScopedLayers<L extends ScopeLayer> {
constructor(layerClass: LayerClass<L>, options: { label: string; onChange?: () => void })
export class ScopedLayers<L extends ScopeLayer> {
constructor(createLayer: (scope: ScopeKey | undefined) => L, options: { onChange?: () => void })
readonly global: L
peek(scope: ScopeKey | undefined): L | undefined
merge<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>, admitGlobal?: (name: string) => boolean): Map<string, T>
values<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>): T[]
keys<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>): string[]
effect(ctx: Context, action: LayerAction<L>, options?: { label?: string; silent?: boolean; scopedOnly?: boolean | string }): () => Promise<void> | void
forEach(fn: (layer: L, scope: ScopeKey | undefined) => void): void
filter(fn: (layer: L, scope: ScopeKey | undefined) => boolean): L[]
map<T>(fn: (layer: L, scope: ScopeKey | undefined) => T): T[]
merge<T>(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries<T>, admitGlobal?: (name: string) => boolean): Map<string, T>
values<T>(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues<T>): T[]
keys<T>(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries<T>): string[]
some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean
effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => Promise<void> | void
}
class Entries<V> {
constructor(kind: string, scope: ScopeKey | undefined)
export interface EntryValues<V> {
values(): IterableIterator<V>
isEmpty(): boolean
}
export class NamedEntries<V> implements EntryValues<V> {
constructor(kind: string, perAgentAlternative: string, scope: ScopeKey | undefined)
insert(name: string, value: V): () => void
append(value: V): () => void
get(name: string): V | undefined
has(name: string): boolean
keys(): string[]
entries(): ReadonlyArray<readonly [string, V]>
values(): readonly V[]
keys(): IterableIterator<string>
entries(): IterableIterator<[string, V]>
values(): IterableIterator<V>
isEmpty(): boolean
}
export class AnonymousEntries<V> implements EntryValues<V> {
append(value: V): () => void
values(): IterableIterator<V>
isEmpty(): boolean
}
```
@@ -79,21 +77,26 @@ class Entries<V> {
迁移后的消费者长什么样——现存最重的登记口从 30+ 行编排缩为一份声明加一行门面:
```ts ignore-check
class ToolLayer extends createLayer({
tools: table<ToolDefinition>('tool'),
restrictions: table<ToolRestriction>('tool restriction'),
guards: table<ToolGuardRegistration>('tool guard'),
}) {
addRestriction(filter: ToolRestriction, reserved: readonly string[]): () => void { /* validate, snapshot, append */ }
class ToolLayer implements ScopeLayer {
readonly tools = new NamedEntries<ToolDefinition>('tool', 'variant', this.scope)
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuardRegistration>()
constructor(
readonly scope: ScopeKey | undefined,
) {}
isEmpty(): boolean { return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() }
addRestriction(filter: ToolRestriction): () => void { /* compile to sets, append */ }
admits(name: string): boolean { /* intersection over this.restrictions.values() */ }
guardReason(view: Readonly<ToolExecution>): string | undefined { /* first monotonic denial */ }
}
class ToolRegistry extends Service {
private readonly layers = new ScopedLayers(ToolLayer, {
label: 'tools',
onChange: () => this.ctx.emit('tools/change'),
})
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
{ onChange: () => this.ctx.emit('tools/change') },
)
register(definition: ToolDefinition): () => Promise<void> | void {
return this.layers.effect(this.ctx,
@@ -101,8 +104,9 @@ class ToolRegistry extends Service {
{ label: 'tools.register()' })
}
visible(scope?: ScopeKey): ToolDefinition[] {
return Array.from(this.layers.merge(scope, layer => layer.tools, name => this.admits(scope, name)).values())
private resolveVisible(scope?: ScopeKey): ToolDefinition[] {
const scoped = this.layers.peek(scope)
return Array.from(this.layers.merge(scope, layer => layer.tools, name => scoped?.admits(name) ?? true).values())
}
}
```
@@ -115,6 +119,10 @@ class ToolRegistry extends Service {
**只抽数据结构、编排留在服务。** 消掉的是重复里安全的那一半,留下的是危险的那一半——回滚先于 emit 的顺序、原始 disposer、回收规则恰是 bug 所在。
**让 layer action 接受完整 Cordis `Effect` union。** 六个现有登记口都没有异步 setup、多份 undo 或独立 settlement 边界。现在就规范化 Promise、iterable、async iterable、LIFO 合成与部分失败,会重复一套纯属推测的生命周期 machinery。store 只接受一个同步 action 与一个 undo未来出现真实边界时再凭证据拓宽。
**由 mapped-type 表 DSL 生成层类。** 两个消费者各自只有三张表。类工厂省下几行代码,却引入生成式运行时形状、保留名、多态 `this` 类型和第二种构造模型。显式类更易检查,同时仍可复用两种条目表与 `ScopedLayers`。
**内置视图语义的固定容器 helper。** 容器形态与合并策略被钉死在 helper 里;业务没有自由度,任何命名或单值变体都变成对 helper 的功能诉求。
**每张表一个 helper。** 复刻今天的散装簿记——那正是被替换的现状:每服务 N 张 scope Mapagent 的贡献没有聚合。
@@ -125,15 +133,14 @@ class ToolRegistry extends Service {
## 验收标准
- `store.ts` 落在 `dsh-scope`peer 依赖不变:仅 cordis模块图位置不变逐文件 100% 覆盖,包括:层簿记与回收、四种 action 形态、合成顺序、change 监听器抛错回滚(条目被回卷、重名检查可再注册)、新建层的失败回收、`label`/`silent`/`scopedOnly` 选项、`createLayer` 构造、保留表名、同族回引类型、`Entries` 命名/匿名语义
- `dsh-tools` 与 `dsh-system-prompt` 各收敛为一个 `ScopedLayers`所有既有测试通过,改动仅限已声明的重名文案断言更新;每个登记门面都是单次 `effect` 调用,并继续返回 cordis effect 的原始 disposer。
- 行为按上文等价性声明与老基线一致:两个声明例外(统一文案;多重非法输入的报错先后)、两个不可观察差异(聚合回收时机;快照读视图),此外无他
- `store.ts` 落在 `dsh-scope`peer 依赖不变:仅 Cordis模块图位置不变逐文件 100% 覆盖选层与回收、同步 action/undo 顺序、action 抛错清理、change 监听器抛错回滚、原始 disposer 身份、`label`/`silent`、工厂类型、跨层 `some`、合并 selector以及分开的命名/匿名条目语义。五个公开符号从 package 根重导出并带 export JSDoc
- `dsh-tools` 与 `dsh-system-prompt` 各收敛为一个 `ScopedLayers`每个登记门面先校验领域契约再做一次 `effect` 调用,并继续返回 Cordis effect 的原始 disposer。
- 既有行为、重名文案、校验顺序、variable-provider 活重入与 guard 活重入不变。测试另行钉住聚合回收时机与 selector 物化
- 文档随同一变更落地:`dsh-scope`/`dsh-tools`/`dsh-system-prompt` 的 README实现后本 RFC 移入 `implemented/`,并就地更新[运行时设计 RFC](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) 的注册章节。
## 风险
- 层/门面边界可能不适配某个未来消费者的形状。缓解:`ScopeLayer` 接口始终是兜底;把 `LayerClass` 拓宽为可接受工厂(供有构造依赖的层)是已记录的非破坏扩展
- `createLayer` 的映射类型工厂是刻意的类型体操。接受:`defineTool` schema DSL 是仓库先例,体操圈在 `dsh-scope` 内部
- 两个等价性例外可能让断言精确重名文案或多重错误顺序的测试意外;在此声明,使评审是核对而非发现
- 快照读视图会隐藏「回调在自己的遍历中注册」的条目——病态但可见的模式;快照使其转为确定性行为。
- 层/门面边界可能不适配某个未来消费者的形状。缓解:`ScopeLayer` 只要求 `isEmpty()`;工厂闭包可捕获构造依赖,无需让层反向持有 scheduler
- 未来登记口可能真的需要异步 setup 或多份独立属主的 undo。helper 刻意不预测这种生命周期;该消费者必须先说明 owner 与 settlement 边界,再连同测试拓宽契约
- 显式层声明会在两个消费者中各重复三行属性初始化与一段 `isEmpty()`。接受:这点重复让运行时状态和类型保持可见,避免为两个类引入第二套 DSL
- 两个核心注册表同时迁移。缓解:设计期已完成逐行为对比,且 store 连同钉住等价性的测试先于任一迁移 commit 落地。