From f7b990bd5c711a29c560cd567c91e50c5cb7f310 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:09:53 +0800 Subject: [PATCH 01/10] docs(rfc): propose the scoped-layers store --- docs/rfc/INDEX.md | 1 + .../2026-07-12-scoped-layers-store.i18n.yaml | 6 + .../2026-07-12-scoped-layers-store.md | 139 ++++++++++++++++++ .../2026-07-12-scoped-layers-store.zh.md | 139 ++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml create mode 100644 docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md create mode 100644 docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 5e508344fa..9596aecd78 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -24,6 +24,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | +| [Scoped-layers store — one aggregate layer per scope behind a scheduling helper](proposed/architecture/2026-07-12-scoped-layers-store.md) | 2026-07-12 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml new file mode 100644 index 0000000000..f868cfc669 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md new file mode 100644 index 0000000000..0673d6c632 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md @@ -0,0 +1,139 @@ +# RFC: Scoped-layers store — one aggregate layer per scope behind a scheduling helper + +Status: proposed + +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` 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. + +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) +- 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`. + +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. + +## 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. + +- **`ScopedLayers`** — a concrete scheduler, never subclassed. It owns the global layer plus one `Map`, 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(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`, injected by the helper at construction; polymorphic `this` narrows it in subclasses), and aggregates `isEmpty()` over the declared tables. `layer.` 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`** — 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. + +`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. + +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). + +## API sketch + +```ts ignore-check +interface ScopeLayer { + isEmpty(): boolean +} + +type LayerClass = new (scope: ScopeKey | undefined, layers: ScopedLayers) => L + +declare function table(kind: string): TableSpec +declare function createLayer>>( + spec: S, +): LayerClass> }> + +type Undo = () => unknown +type LayerAction = (layer: L) => + | Undo + | Iterable + | Promise + | AsyncIterable + +class ScopedLayers { + constructor(layerClass: LayerClass, options: { label: string; onChange?: () => void }) + readonly global: L + peek(scope: ScopeKey | undefined): L | undefined + merge(scope: ScopeKey | undefined, pick: (layer: L) => Entries, admitGlobal?: (name: string) => boolean): Map + values(scope: ScopeKey | undefined, pick: (layer: L) => Entries): T[] + keys(scope: ScopeKey | undefined, pick: (layer: L) => Entries): string[] + effect(ctx: Context, action: LayerAction, options?: { label?: string; silent?: boolean; scopedOnly?: boolean | string }): () => Promise | void + forEach(fn: (layer: L, scope: ScopeKey | undefined) => void): void + filter(fn: (layer: L, scope: ScopeKey | undefined) => boolean): L[] + map(fn: (layer: L, scope: ScopeKey | undefined) => T): T[] +} + +class Entries { + constructor(kind: 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 + values(): readonly V[] + isEmpty(): boolean +} +``` + +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('tool'), + restrictions: table('tool restriction'), + guards: table('tool guard'), +}) { + addRestriction(filter: ToolRestriction, reserved: readonly string[]): () => void { /* validate, snapshot, append */ } + admits(name: string): boolean { /* intersection over this.restrictions.values() */ } + guardReason(view: Readonly): string | undefined { /* first monotonic denial */ } +} + +class ToolRegistry extends Service { + private readonly layers = new ScopedLayers(ToolLayer, { + label: 'tools', + onChange: () => this.ctx.emit('tools/change'), + }) + + register(definition: ToolDefinition): () => Promise | void { + return this.layers.effect(this.ctx, + layer => layer.tools.insert(definition.name, definition), + { label: 'tools.register()' }) + } + + visible(scope?: ScopeKey): ToolDefinition[] { + return Array.from(this.layers.merge(scope, layer => layer.tools, name => this.admits(scope, name)).values()) + } +} +``` + +## Alternatives considered + +**Per-scope registry instances behind a parent/child delegation chain.** Instance explosion; the "deployment tools plus my tools" merged view needs a hand-built delegating registry per service; single-subscription observers (persistence, the ACP bridge) would have to discover and subscribe per instance; and a delegation chain cannot express subtraction (restrictions). A child registry would also have to reach back into a parent context, widening the exposure surface. + +**Explicit scope parameters on registration APIs.** Already rejected by the agent-scope RFC: omitting the parameter silently registers globally, and the shape can express visible-to-X-disposed-with-Y, which is almost always a bug. + +**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. + +**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. + +**`helper.get(ctx).effect(...)` two-step registration.** Splits layer creation from lifecycle attachment; a throw between the steps strands an empty layer, and the returned handle is an extra allocation per call. + +**Layers holding a ctx and registering their own effects.** Turns data objects into lifecycle managers and reinstates the choreography once per business class. + +## 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. +- 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. +- 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. diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md new file mode 100644 index 0000000000..6460802f3c --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md @@ -0,0 +1,139 @@ +# RFC: 作用域分层存储——每 scope 一个聚合层与统一调度 helper + +Status: proposed + +[English](2026-07-12-scoped-layers-store.md) | 中文 + +## 问题 + +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`,并重复同一段 10-15 行的 effect 编排:读调用方上下文的标签、按需建层、校验、变更、yield 一个「删条目 → 回收空层 → 发 change 事件」的回滚,然后发事件并返回 cordis effect 的原始 disposer。 + +除此之外:风险集中在编排细节上: +- 回滚必须在 change 发出之前被收集(抛错的监听器才能回卷插入而不是泄漏) +- 返回的 disposer 必须是 cordis 自己的那个函数(包装器会静默破坏嵌套的有序拆除) +- 清空的专属层必须被回收(被 dispose 的 agent 不得留下以死 `ScopeKey` 为键的残余) + +每个新消费者都要把这一切重新写对一遍,而各副本的写法已经分叉——`dsh-tools` 里有两个私有 `layerFor`,`dsh-system-prompt` 里是四处内联 IIFE。 + +最后,一个 agent 在一个服务里的贡献散落在几张互不相识的 Map 里——不存在一个「这个 scope 在这里贡献了什么」的对象——而消费者还在持续增多:guard 与提示词 protection 是最近落地的一批,per-agent 的 `fs/*` 策略、`llm/*` 覆盖、per-agent compaction 策略都排在同一个模式上。 + +## 提案 + +`dsh-scope` 新增 store 模块(其 `src/` 下新增 `store.ts`,peer 依赖仅 cordis,与键类型无关),核心是一条分工:**业务逻辑封在层类里,helper 只负责调度层**。一个服务一个 helper 实例;其 Map 的 value 就是「一个 scope 在该服务的全部贡献」这一聚合对象。 + +- **`ScopedLayers`**——具体的调度器,不作继承点。持有全局层与一张 `Map`,按需以 `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(kind) })`**——`defineTool` DSL 传统的类工厂。生成的基类在构造器里建好每张声明的表、把 scope 传下去、接收同族回引(`protected readonly layers: ScopedLayers`,由 helper 建层时注入;多态 `this` 型在子类中自动收窄),并对声明的表聚合 `isEmpty()`。`layer.<表名>` 是带完整类型的映射属性,写错表名是编译错误;表名 `scope`、`isEmpty`、`layers` 保留,冲突即抛。业务子类在类体里追加领域方法——单层查询、登记校验,以及经 `this.layers` 的跨层**只读**(写入仍必须走 `effect`);完全自定义的层也可以只实现单方法接口 `ScopeLayer`(`isEmpty()`)。 +- **`Entries`**——罐装条目表:命名条目(`insert`,同层重名抛一对指向 `agent.ctx` 的标准化文案)与匿名条目(`append`,进程内唯一 symbol 键、O(1) 撤销删除)共用一张保插入序的 Map;读视图(`keys`/`entries`/`values`)返回数组快照。 + +`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 恢复需要层不应持有的服务级资源。 + +迁移保持行为等价,带两个声明的例外:三处重名文案统一为一个模板(断言旧文案的测试在同一变更中更新);校验相对 effect 边界发生挪动(restrict/protect 的检查移入 action,variable 的名字正则移到门面),因此多重非法输入的报错**先后**可能改变,而所有单一错误路径不变。两个已知的不可观察差异:聚合层要等全部表清空才回收;读视图是快照而非活容器(仅对「在自己的遍历回调里再注册」可见)。 + +## API 草图 + +```ts ignore-check +interface ScopeLayer { + isEmpty(): boolean +} + +type LayerClass = new (scope: ScopeKey | undefined, layers: ScopedLayers) => L + +declare function table(kind: string): TableSpec +declare function createLayer>>( + spec: S, +): LayerClass> }> + +type Undo = () => unknown +type LayerAction = (layer: L) => + | Undo + | Iterable + | Promise + | AsyncIterable + +class ScopedLayers { + constructor(layerClass: LayerClass, options: { label: string; onChange?: () => void }) + readonly global: L + peek(scope: ScopeKey | undefined): L | undefined + merge(scope: ScopeKey | undefined, pick: (layer: L) => Entries, admitGlobal?: (name: string) => boolean): Map + values(scope: ScopeKey | undefined, pick: (layer: L) => Entries): T[] + keys(scope: ScopeKey | undefined, pick: (layer: L) => Entries): string[] + effect(ctx: Context, action: LayerAction, options?: { label?: string; silent?: boolean; scopedOnly?: boolean | string }): () => Promise | void + forEach(fn: (layer: L, scope: ScopeKey | undefined) => void): void + filter(fn: (layer: L, scope: ScopeKey | undefined) => boolean): L[] + map(fn: (layer: L, scope: ScopeKey | undefined) => T): T[] +} + +class Entries { + constructor(kind: 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 + values(): readonly V[] + isEmpty(): boolean +} +``` + +迁移后的消费者长什么样——现存最重的登记口从 30+ 行编排缩为一份声明加一行门面: + +```ts ignore-check +class ToolLayer extends createLayer({ + tools: table('tool'), + restrictions: table('tool restriction'), + guards: table('tool guard'), +}) { + addRestriction(filter: ToolRestriction, reserved: readonly string[]): () => void { /* validate, snapshot, append */ } + admits(name: string): boolean { /* intersection over this.restrictions.values() */ } + guardReason(view: Readonly): string | undefined { /* first monotonic denial */ } +} + +class ToolRegistry extends Service { + private readonly layers = new ScopedLayers(ToolLayer, { + label: 'tools', + onChange: () => this.ctx.emit('tools/change'), + }) + + register(definition: ToolDefinition): () => Promise | void { + return this.layers.effect(this.ctx, + layer => layer.tools.insert(definition.name, definition), + { label: 'tools.register()' }) + } + + visible(scope?: ScopeKey): ToolDefinition[] { + return Array.from(this.layers.merge(scope, layer => layer.tools, name => this.admits(scope, name)).values()) + } +} +``` + +## 备选方案 + +**每 scope 一个注册表实例,父子委托链。** 实例爆炸;「部署工具加我的工具」的合并视图要每个服务手写一个委托注册表;单订阅观察者(持久化、ACP bridge)必须逐实例发现并订阅;委托链表达不了减法(restriction)。子注册表还得反向触及父上下文,扩大暴露面。 + +**注册 API 上的显式 scope 参数。** agent-scope RFC 已否决:漏传参数即静默注册为全局,且该形状能表达「对 X 可见、随 Y 销毁」——几乎必然是 bug。 + +**只抽数据结构、编排留在服务。** 消掉的是重复里安全的那一半,留下的是危险的那一半——回滚先于 emit 的顺序、原始 disposer、回收规则,恰是 bug 所在。 + +**内置视图语义的固定容器 helper。** 容器形态与合并策略被钉死在 helper 里;业务没有自由度,任何命名或单值变体都变成对 helper 的功能诉求。 + +**每张表一个 helper。** 复刻今天的散装簿记——那正是被替换的现状:每服务 N 张 scope Map,agent 的贡献没有聚合。 + +**`helper.get(ctx).effect(...)` 两步式登记。** 把建层与挂生命周期拆成两步;两步之间抛错会搁浅一个空层,返回的 handle 还是每次调用一笔额外分配。 + +**层持有 ctx、自己注册 effect。** 把数据对象变成生命周期管理者,编排在每个业务类里重演一遍。 + +## 验收标准 + +- `store.ts` 落在 `dsh-scope`(peer 依赖不变:仅 cordis;模块图位置不变),逐文件 100% 覆盖,包括:层簿记与回收、四种 action 形态、合成顺序、change 监听器抛错回滚(条目被回卷、重名检查可再注册)、新建层的失败回收、`label`/`silent`/`scopedOnly` 选项、`createLayer` 构造、保留表名、同族回引类型、`Entries` 命名/匿名语义。 +- `dsh-tools` 与 `dsh-system-prompt` 各收敛为一个 `ScopedLayers`;所有既有测试通过,改动仅限已声明的重名文案断言更新;每个登记门面都是单次 `effect` 调用,并继续返回 cordis effect 的原始 disposer。 +- 行为按上文等价性声明与老基线一致:两个声明例外(统一文案;多重非法输入的报错先后)、两个不可观察差异(聚合回收时机;快照读视图),此外无他。 +- 文档随同一变更落地:`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` 内部。 +- 两个等价性例外可能让断言精确重名文案或多重错误顺序的测试意外;在此声明,使评审是核对而非发现。 +- 快照读视图会隐藏「回调在自己的遍历中注册」的条目——病态但可见的模式;快照使其转为确定性行为。 +- 两个核心注册表同时迁移。缓解:设计期已完成逐行为对比,且 store 连同钉住等价性的测试先于任一迁移 commit 落地。 From 84d0932e5e7e117b753b34c123db9dbd1c102743 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:06:04 +0800 Subject: [PATCH 02/10] docs(rfc): align scoped layers with final scope design --- .../2026-07-12-scoped-layers-store.i18n.yaml | 4 +- .../2026-07-12-scoped-layers-store.md | 125 +++++++++--------- .../2026-07-12-scoped-layers-store.zh.md | 125 +++++++++--------- 3 files changed, 134 insertions(+), 120 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml index f868cfc669..be26cfa552 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.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 -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 diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md index 0673d6c632..c3a9ab8724 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md @@ -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` 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`** — a concrete scheduler, never subclassed. It owns the global layer plus one `Map`, 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(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`, injected by the helper at construction; polymorphic `this` narrows it in subclasses), and aggregates `isEmpty()` over the declared tables. `layer.
` 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`** — 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`** is a concrete scheduler, not a base class. It owns the global layer plus one `Map`, 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` and `AnonymousEntries`** 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 = new (scope: ScopeKey | undefined, layers: ScopedLayers) => L - -declare function table(kind: string): TableSpec -declare function createLayer>>( - spec: S, -): LayerClass> }> - -type Undo = () => unknown -type LayerAction = (layer: L) => - | Undo - | Iterable - | Promise - | AsyncIterable - -class ScopedLayers { - constructor(layerClass: LayerClass, options: { label: string; onChange?: () => void }) +export class ScopedLayers { + constructor(createLayer: (scope: ScopeKey | undefined) => L, options: { onChange?: () => void }) readonly global: L peek(scope: ScopeKey | undefined): L | undefined - merge(scope: ScopeKey | undefined, pick: (layer: L) => Entries, admitGlobal?: (name: string) => boolean): Map - values(scope: ScopeKey | undefined, pick: (layer: L) => Entries): T[] - keys(scope: ScopeKey | undefined, pick: (layer: L) => Entries): string[] - effect(ctx: Context, action: LayerAction, options?: { label?: string; silent?: boolean; scopedOnly?: boolean | string }): () => Promise | void - forEach(fn: (layer: L, scope: ScopeKey | undefined) => void): void - filter(fn: (layer: L, scope: ScopeKey | undefined) => boolean): L[] - map(fn: (layer: L, scope: ScopeKey | undefined) => T): T[] + merge(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries, admitGlobal?: (name: string) => boolean): Map + values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] + keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] + some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean + effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => Promise | void } -class Entries { - constructor(kind: string, scope: ScopeKey | undefined) +export interface EntryValues { + values(): IterableIterator + isEmpty(): boolean +} + +export class NamedEntries implements EntryValues { + 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 - values(): readonly V[] + keys(): IterableIterator + entries(): IterableIterator<[string, V]> + values(): IterableIterator + isEmpty(): boolean +} + +export class AnonymousEntries implements EntryValues { + append(value: V): () => void + values(): IterableIterator isEmpty(): boolean } ``` @@ -79,21 +77,26 @@ class Entries { 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('tool'), - restrictions: table('tool restriction'), - guards: table('tool guard'), -}) { - addRestriction(filter: ToolRestriction, reserved: readonly string[]): () => void { /* validate, snapshot, append */ } +class ToolLayer implements ScopeLayer { + readonly tools = new NamedEntries('tool', 'variant', this.scope) + readonly restrictions = new AnonymousEntries() + readonly guards = new AnonymousEntries() + + 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): 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 { 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. diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md index 6460802f3c..e2ec721457 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md @@ -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`,并重复同一段 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`**——具体的调度器,不作继承点。持有全局层与一张 `Map`,按需以 `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(kind) })`**——`defineTool` DSL 传统的类工厂。生成的基类在构造器里建好每张声明的表、把 scope 传下去、接收同族回引(`protected readonly layers: ScopedLayers`,由 helper 建层时注入;多态 `this` 型在子类中自动收窄),并对声明的表聚合 `isEmpty()`。`layer.<表名>` 是带完整类型的映射属性,写错表名是编译错误;表名 `scope`、`isEmpty`、`layers` 保留,冲突即抛。业务子类在类体里追加领域方法——单层查询、登记校验,以及经 `this.layers` 的跨层**只读**(写入仍必须走 `effect`);完全自定义的层也可以只实现单方法接口 `ScopeLayer`(`isEmpty()`)。 -- **`Entries`**——罐装条目表:命名条目(`insert`,同层重名抛一对指向 `agent.ctx` 的标准化文案)与匿名条目(`append`,进程内唯一 symbol 键、O(1) 撤销删除)共用一张保插入序的 Map;读视图(`keys`/`entries`/`values`)返回数组快照。 +- **`ScopedLayers`** 是具体调度器,不作基类。它持有全局层与一张 `Map`,通过显式工厂按需构造专属层,并在层 `isEmpty()` 时回收。`effect(ctx, action, options?)` 只接受一个同步 action,action 只返回一个同步 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` 与 `AnonymousEntries`** 是两种共用的保插入序条目表。命名表暴露 `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 的检查移入 action,variable 的名字正则移到门面),因此多重非法输入的报错**先后**可能改变,而所有单一错误路径不变。两个已知的不可观察差异:聚合层要等全部表清空才回收;读视图是快照而非活容器(仅对「在自己的遍历回调里再注册」可见)。 +`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 = new (scope: ScopeKey | undefined, layers: ScopedLayers) => L - -declare function table(kind: string): TableSpec -declare function createLayer>>( - spec: S, -): LayerClass> }> - -type Undo = () => unknown -type LayerAction = (layer: L) => - | Undo - | Iterable - | Promise - | AsyncIterable - -class ScopedLayers { - constructor(layerClass: LayerClass, options: { label: string; onChange?: () => void }) +export class ScopedLayers { + constructor(createLayer: (scope: ScopeKey | undefined) => L, options: { onChange?: () => void }) readonly global: L peek(scope: ScopeKey | undefined): L | undefined - merge(scope: ScopeKey | undefined, pick: (layer: L) => Entries, admitGlobal?: (name: string) => boolean): Map - values(scope: ScopeKey | undefined, pick: (layer: L) => Entries): T[] - keys(scope: ScopeKey | undefined, pick: (layer: L) => Entries): string[] - effect(ctx: Context, action: LayerAction, options?: { label?: string; silent?: boolean; scopedOnly?: boolean | string }): () => Promise | void - forEach(fn: (layer: L, scope: ScopeKey | undefined) => void): void - filter(fn: (layer: L, scope: ScopeKey | undefined) => boolean): L[] - map(fn: (layer: L, scope: ScopeKey | undefined) => T): T[] + merge(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries, admitGlobal?: (name: string) => boolean): Map + values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] + keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] + some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean + effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => Promise | void } -class Entries { - constructor(kind: string, scope: ScopeKey | undefined) +export interface EntryValues { + values(): IterableIterator + isEmpty(): boolean +} + +export class NamedEntries implements EntryValues { + 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 - values(): readonly V[] + keys(): IterableIterator + entries(): IterableIterator<[string, V]> + values(): IterableIterator + isEmpty(): boolean +} + +export class AnonymousEntries implements EntryValues { + append(value: V): () => void + values(): IterableIterator isEmpty(): boolean } ``` @@ -79,21 +77,26 @@ class Entries { 迁移后的消费者长什么样——现存最重的登记口从 30+ 行编排缩为一份声明加一行门面: ```ts ignore-check -class ToolLayer extends createLayer({ - tools: table('tool'), - restrictions: table('tool restriction'), - guards: table('tool guard'), -}) { - addRestriction(filter: ToolRestriction, reserved: readonly string[]): () => void { /* validate, snapshot, append */ } +class ToolLayer implements ScopeLayer { + readonly tools = new NamedEntries('tool', 'variant', this.scope) + readonly restrictions = new AnonymousEntries() + readonly guards = new AnonymousEntries() + + 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): 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 { 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 Map,agent 的贡献没有聚合。 @@ -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 落地。 From 0289e69ee99f63cbb2149137c78d08fbd314e0fb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:08:41 +0800 Subject: [PATCH 03/10] docs(rfc): narrow scoped-layer disposers --- .../architecture/2026-07-12-scoped-layers-store.i18n.yaml | 4 ++-- .../proposed/architecture/2026-07-12-scoped-layers-store.md | 4 ++-- .../architecture/2026-07-12-scoped-layers-store.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml index be26cfa552..bf5027e5ec 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.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 -2026-07-12-scoped-layers-store.md: c3a9ab8724191b5b1bc87f02a90d6995c247d944 -2026-07-12-scoped-layers-store.zh.md: e2ec72145766a95ea1c330e3a658f7c86490e263 +2026-07-12-scoped-layers-store.md: 510f462344b25b72bb8604d88f125cc92c6510b1 +2026-07-12-scoped-layers-store.zh.md: abc2aad140cc10325392ed8800e33e38684511a8 diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md index c3a9ab8724..510f462344 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md @@ -48,7 +48,7 @@ export class ScopedLayers { values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean - effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => Promise | void + effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => void } export interface EntryValues { @@ -98,7 +98,7 @@ class ToolRegistry extends Service { { onChange: () => this.ctx.emit('tools/change') }, ) - register(definition: ToolDefinition): () => Promise | void { + register(definition: ToolDefinition): () => void { return this.layers.effect(this.ctx, layer => layer.tools.insert(definition.name, definition), { label: 'tools.register()' }) diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md index e2ec721457..abc2aad140 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md @@ -48,7 +48,7 @@ export class ScopedLayers { values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean - effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => Promise | void + effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => void } export interface EntryValues { @@ -98,7 +98,7 @@ class ToolRegistry extends Service { { onChange: () => this.ctx.emit('tools/change') }, ) - register(definition: ToolDefinition): () => Promise | void { + register(definition: ToolDefinition): () => void { return this.layers.effect(this.ctx, layer => layer.tools.insert(definition.name, definition), { label: 'tools.register()' }) From 0646bae562ffb3c9f50152703e74bcc051c90cb4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:49:25 +0800 Subject: [PATCH 04/10] docs(rfc): narrow scoped-layer abstraction --- .../2026-07-12-scoped-layers-store.i18n.yaml | 4 ++-- .../architecture/2026-07-12-scoped-layers-store.md | 9 ++++----- .../architecture/2026-07-12-scoped-layers-store.zh.md | 9 ++++----- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml index bf5027e5ec..c12497f016 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.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 -2026-07-12-scoped-layers-store.md: 510f462344b25b72bb8604d88f125cc92c6510b1 -2026-07-12-scoped-layers-store.zh.md: abc2aad140cc10325392ed8800e33e38684511a8 +2026-07-12-scoped-layers-store.md: f2056446abb3b8d0793b6ef7898c01001ccf0460 +2026-07-12-scoped-layers-store.zh.md: 9726eb3de953af1086d8bcdd779fc02b3c324596 diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md index 510f462344..f2056446ab 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.md @@ -21,13 +21,13 @@ Finally, one agent's contribution to one service is scattered across several map `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`** is a concrete scheduler, not a base class. It owns the global layer plus one `Map`, 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). +- **`ScopedLayers`** is a concrete scheduler, not a base class. It owns the global layer plus one `Map`, 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), and `keys` (the pre-restriction name universe). - **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` and `AnonymousEntries`** 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`: 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." +`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`. 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." -`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. +`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 and `toolOrder` 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. 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. @@ -47,7 +47,6 @@ export class ScopedLayers { merge(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries, admitGlobal?: (name: string) => boolean): Map values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] - some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => void } @@ -133,7 +132,7 @@ class ToolRegistry extends Service { ## Acceptance criteria -- `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. +- `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, 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. diff --git a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md index abc2aad140..9726eb3de9 100644 --- a/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/docs/rfc/proposed/architecture/2026-07-12-scoped-layers-store.zh.md @@ -21,13 +21,13 @@ agent 作用域落地之后([agent-scope RFC](../../implemented/architecture/2 `dsh-scope` 新增与键类型无关的 `store.ts`,peer 依赖仍只有 Cordis。模块只抽取六个现有登记口已经共同证明的最小形状:**业务状态与校验留在显式层类里;一个 helper 统一负责选层、挂 effect、回滚、通知与回收**。一个 helper 实例属于一个服务;一个层实例聚合某 scope 对该服务的全部贡献。 -- **`ScopedLayers`** 是具体调度器,不作基类。它持有全局层与一张 `Map`,通过显式工厂按需构造专属层,并在层 `isEmpty()` 时回收。`effect(ctx, action, options?)` 只接受一个同步 action,action 只返回一个同步 undo,因为六个现有登记口的完整形状就是如此。单一 `ctx` 同时决定可见层(`scopeOf(ctx)`)与属主 Cordis fiber(`ctx.effect`),「对 X 可见、随 Y 销毁」因此不可表达。helper 在通知监听器前 yield undo,返回 Cordis 的原始 disposer,并在校验或变更抛错时回收刚建出的空层。读取接口是 `global`/`peek`,以及 `merge`(命名条目的专属遮蔽与可选全局放行谓词)、`values`(不遮蔽地依次拼接全局与专属条目)、`keys`(限制前名字全集)和 `some`(跨层不变量检查)。 +- **`ScopedLayers`** 是具体调度器,不作基类。它持有全局层与一张 `Map`,通过显式工厂按需构造专属层,并在层 `isEmpty()` 时回收。`effect(ctx, action, options?)` 只接受一个同步 action,action 只返回一个同步 undo,因为六个现有登记口的完整形状就是如此。单一 `ctx` 同时决定可见层(`scopeOf(ctx)`)与属主 Cordis fiber(`ctx.effect`),「对 X 可见、随 Y 销毁」因此不可表达。helper 在通知监听器前 yield undo,返回 Cordis 的原始 disposer,并在校验或变更抛错时回收刚建出的空层。读取接口是 `global`/`peek`,以及 `merge`(命名条目的专属遮蔽与可选全局放行谓词)、`values`(不遮蔽地依次拼接全局与专属条目)和 `keys`(限制前名字全集)。 - **显式 `ScopeLayer` 类**让每个服务的状态一眼可见。`ToolLayer` 与 `PromptLayer` 直接声明各自三项表属性与 `isEmpty()` 聚合;一个小工厂只向构造器传入 scope,闭包仍可捕获真实构造依赖。领域方法仍是普通类方法。代价是几行重复声明,收益是不用引入 mapped-type 类工厂、scheduler/layer 属主环、保留属性名和生成式运行时结构。 - **`NamedEntries` 与 `AnonymousEntries`** 是两种共用的保插入序条目表。命名表暴露 `insert`/查询,并通过领域 `kind` 与 per-agent alternative 标签保持现有全局/专属重名文案;匿名表只暴露 `append`,以进程内唯一 symbol 作键支持 O(1) 撤销删除。分成两类以后,无意义的命名/匿名混用不可表达,key 类型也保持健全。迭代器借用表成员与带类型的贡献值,不会 clone 或 freeze 值;`ScopedLayers` 只物化服务读路径本来就需要的合并数组或 Map。 -`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 调用」之类领域规则。 +`dsh-tools` 把工具、已编译 restriction 与 guard 三张表合并进一个 `ToolLayer`。restriction 放行判断与 guard 求值归层所有;`run_code` 保留名、当前已知全局名集合等依赖服务配置的领域校验仍留在门面。只读 allow/deny 输入只编译一次,成为内部 Set。`dsh-system-prompt` 同样把 section、tool provider 与 variable 合并进一个 `PromptLayer`。每个登记门面先完成公开参数校验,再以 label 做一次 `effect` 调用;guard 额外传 `silent: true`。通用 helper 不理解「restriction 必须由 scoped context 调用」之类领域规则。 -`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 注册表。 +`assemble` 留在 `SystemPrompt` 门面,三条理由:主体 scope 的层可能不存在,读路径不得创建它;遮蔽语义要求先合并再求值,被遮蔽的 section provider 绝不能被调用;组装 waterfall 与 `toolOrder` 使用服务级资源。section 与 tool provider 保持既有的派生视图物化;variable provider 则直接遍历全局与专属 `NamedEntries`,保留 provider 在组装期间登记另一 variable 时的现有活 Map 行为。tool guard 同样直接遍历其 `AnonymousEntries`。 迁移保持公开行为与精确重名文案不变。内部聚合层会在三张表全部清空后才回收,而不是某一张表清空时回收;服务 API 不暴露层身份。直接活遍历保留现有 variable-provider 与 guard 重入行为,selector helper 则继续物化门面今天已经在构造的 section、tool-provider 与工具解析视图。 @@ -47,7 +47,6 @@ export class ScopedLayers { merge(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries, admitGlobal?: (name: string) => boolean): Map values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] - some(fn: (layer: L, scope: ScopeKey | undefined) => boolean): boolean effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => void } @@ -133,7 +132,7 @@ class ToolRegistry extends Service { ## 验收标准 -- `store.ts` 落在 `dsh-scope`(peer 依赖不变:仅 Cordis;模块图位置不变),逐文件 100% 覆盖选层与回收、同步 action/undo 顺序、action 抛错清理、change 监听器抛错回滚、原始 disposer 身份、`label`/`silent`、工厂类型、跨层 `some`、合并 selector,以及分开的命名/匿名条目语义。五个公开符号从 package 根重导出并带 export JSDoc。 +- `store.ts` 落在 `dsh-scope`(peer 依赖不变:仅 Cordis;模块图位置不变),逐文件 100% 覆盖选层与回收、同步 action/undo 顺序、action 抛错清理、change 监听器抛错回滚、原始 disposer 身份、`label`/`silent`、工厂类型、合并 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) 的注册章节。 From d80dc2138742d3b80ae86f84109527a88d965cac Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:54:37 +0800 Subject: [PATCH 05/10] docs(agent-note): refresh shared scoped-layer proposal --- .../2026-07-12-scoped-layers-store.i18n.yaml | 4 +- .../2026-07-12-scoped-layers-store.md | 142 ++++++++---------- .../2026-07-12-scoped-layers-store.zh.md | 142 ++++++++---------- 3 files changed, 122 insertions(+), 166 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml index c12497f016..40694cc6d2 100644 --- a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.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 -2026-07-12-scoped-layers-store.md: f2056446abb3b8d0793b6ef7898c01001ccf0460 -2026-07-12-scoped-layers-store.zh.md: 9726eb3de953af1086d8bcdd779fc02b3c324596 +2026-07-12-scoped-layers-store.md: 64d565542e39a5ffcea98bc3343504760f31a416 +2026-07-12-scoped-layers-store.zh.md: ebe823b980864f4f24d4888312a73d2a5ee34fb5 diff --git a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.md b/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.md index c89850b4ca..64d565542e 100644 --- a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.md +++ b/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.md @@ -1,4 +1,4 @@ -# Agent Note: Scoped-layers store — one aggregate layer per scope behind a scheduling helper +# Agent Note: Shared scoped-layer storage Status: proposed @@ -6,34 +6,25 @@ English | [中文](2026-07-12-scoped-layers-store.zh.md) ## Problem -Agent scoping ([the agent-scope Agent Note](../../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. +Agent scoping ([decision](../../implemented/architecture/2026-07-08-agent-scope-contexts.md), [runtime design](../../implemented/architecture/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 implement that shape independently: `tools.register`, `tools.restrict`, and `tools.guard` in `dsh-tools`; `SystemPrompt.section`, `SystemPrompt.tools`, and `SystemPrompt.variable` in `dsh-system-prompt`; and `CommandService.register` in `dsh-commands`. -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) -- Emptied scoped layers must be reclaimed (a disposed agent must not leave residue keyed by its dead `ScopeKey`) +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. The copies use separate maps and collection types, so a service has no object representing one scope's complete contribution and must reproduce cleanup for every table. -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`. +The duplicated code carries three non-obvious requirements: -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. +- 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. ## Proposal -`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. +`@deepseek-ai/dsh-scope` gains 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. -- **`ScopedLayers`** is a concrete scheduler, not a base class. It owns the global layer plus one `Map`, 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), and `keys` (the pre-restriction name universe). -- **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` and `AnonymousEntries`** 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. +`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. -`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`. 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." - -`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 and `toolOrder` 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. - -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 +## Public interface ```ts ignore-check export interface ScopeLayer { @@ -41,22 +32,28 @@ export interface ScopeLayer { } export class ScopedLayers { - constructor(createLayer: (scope: ScopeKey | undefined) => L, options: { onChange?: () => void }) + constructor( + createLayer: (scope: ScopeKey | undefined) => L, + onChange: () => void, + ) + readonly global: L peek(scope: ScopeKey | undefined): L | undefined - merge(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries, admitGlobal?: (name: string) => boolean): Map - values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] - keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] - effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => void + + merge( + scope: ScopeKey | undefined, + pick: (layer: L) => NamedEntries, + ): Map + + effect( + ctx: Context, + action: (layer: L) => () => void, + options: { label: string; notify?: boolean }, + ): () => void } -export interface EntryValues { - values(): IterableIterator - isEmpty(): boolean -} - -export class NamedEntries implements EntryValues { - constructor(kind: string, perAgentAlternative: string, scope: ScopeKey | undefined) +export class NamedEntries { + constructor(duplicateError: (name: string) => Error) insert(name: string, value: V): () => void get(name: string): V | undefined has(name: string): boolean @@ -66,80 +63,61 @@ export class NamedEntries implements EntryValues { isEmpty(): boolean } -export class AnonymousEntries implements EntryValues { +export class AnonymousEntries { append(value: V): () => void values(): IterableIterator isEmpty(): boolean } ``` -What a migrated consumer looks like — the heaviest current site shrinks from 30+ lines of choreography to a declaration and one-line facades: +## Storage contract -```ts ignore-check -class ToolLayer implements ScopeLayer { - readonly tools = new NamedEntries('tool', 'variant', this.scope) - readonly restrictions = new AnonymousEntries() - readonly guards = new AnonymousEntries() +- The constructor creates `global` once with `createLayer(undefined)`. A scoped layer is created only by `effect()`; `peek()` and `merge()` never create one, and `peek(undefined)` returns `undefined` because 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 native `Map` order and live iteration. +- `AnonymousEntries.append()` assigns a unique internal key per registration, so equal callbacks or values remain independent. Its iterator is live and insertion-ordered. +- `effect()` derives the key with `scopeOf(ctx)` and attaches the action to that same `ctx.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 Cordis `Effect` union. +- `effect()` collects the action's undo before calling `onChange` and returns the exact `ctx.effect()` disposer. Disposal runs the action undo before notification, is idempotent through Cordis, and removes a scoped layer only after its complete `ScopeLayer.isEmpty()` becomes true. +- `options.notify` defaults to `true`. The callback's own policy stays authoritative: tool and prompt change callbacks may throw and trigger registration rollback; `CommandService.notifyChange()` contains observer failures; tool guards pass `notify: false`. - constructor( - readonly scope: ScopeKey | undefined, - ) {} +## Registry migrations - 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): string | undefined { /* first monotonic denial */ } -} +`dsh-tools` defines one `ToolLayer` containing named tools plus anonymous compiled restrictions and guard registrations. `ToolRegistry` 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 continues to live-iterate global then scoped registrations so re-entrant additions retain current behavior. -class ToolRegistry extends Service { - private readonly layers = new ScopedLayers( - scope => new ToolLayer(scope), - { onChange: () => this.ctx.emit('tools/change') }, - ) +`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 continue to live-iterate global then scoped tables, preserving re-entrant registration behavior. - register(definition: ToolDefinition): () => void { - return this.layers.effect(this.ctx, - layer => layer.tools.insert(definition.name, definition), - { label: 'tools.register()' }) - } +`dsh-commands` defines a one-table layer containing `NamedEntries`. Effective views use `merge()`, while `CommandService` retains definition normalization and freezing, exact duplicate diagnostics, sorted immutable descriptors, direct execution, HMR cleanup, and independently contained `commands/change` observers. - 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()) - } -} -``` +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 -**Per-scope registry instances behind a parent/child delegation chain.** Instance explosion; the "deployment tools plus my tools" merged view needs a hand-built delegating registry per service; single-subscription observers (persistence, the ACP bridge) would have to discover and subscribe per instance; and a delegation chain cannot express subtraction (restrictions). A child registry would also have to reach back into a parent context, widening the exposure surface. +**Keep the independent implementations.** This avoids a new library interface but leaves lifecycle ordering, disposer identity, and scope reclamation duplicated across seven facades. -**Explicit scope parameters on registration APIs.** Already rejected by the agent-scope Agent Note: omitting the parameter silently registers globally, and the shape can express visible-to-X-disposed-with-Y, which is almost always a bug. +**One helper per table.** This removes some local code but preserves multiple per-scope maps and cannot reclaim one scope's aggregate contribution correctly. -**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. +**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. -**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. +**Explicit scope parameters on registration methods.** Separate visibility and ownership inputs make mismatched lifetimes representable, while an omitted scope silently becomes global. -**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`. +**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. -**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. +**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 `ToolRegistry` keeps its richer private resolver. -**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. +**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. -**`helper.get(ctx).effect(...)` two-step registration.** Splits layer creation from lifecycle attachment; a throw between the steps strands an empty layer, and the returned handle is an extra allocation per call. - -**Layers holding a ctx and registering their own effects.** Turns data objects into lifecycle managers and reinstates the choreography once per business class. +**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. ## Acceptance criteria -- `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, 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 Agent Note moves to `implemented/` and the [runtime-design Agent Note](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)'s registration section is updated in place. +- `dsh-scope` exports exactly the four proposed storage symbols from its root and covers global construction, lazy scoped construction, non-creating reads, named shadowing, aggregate reclamation, failure cleanup, notification ordering, exact disposer identity, caller-owned duplicate errors, independent anonymous duplicates, and live iterators. +- `dsh-tools`, `dsh-system-prompt`, and `dsh-commands` migrate all seven registration facades while preserving validation order, exact diagnostics, views, notification policy, re-entrancy, and HMR disposal. +- The `dsh-scope` README and scoped core-data documentation describe the public contract; architecture and runtime-design references identify the shared store without duplicating it. Consumer READMEs remain focused on their unchanged public behavior. +- This pair moves to `implemented/architecture` in the implementation PR, changes `Proposal` to present-tense `Decision`, and records shipped consequences and verification. Existing keyless snapshots remain byte-identical. ## Risks -- 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. +- A future registration may need asynchronous setup or several independently owned undos. That consumer must identify its ownership and settlement boundary before widening this deliberately synchronous interface. +- A throwing action that mutates outside the returned undo contract cannot be repaired generically. Entry operations are atomic, migrations perform fallible validation before insertion, and tests pin cleanup for factory and pre-retention action failures. +- Aggregate reclamation keeps a scoped layer alive until every table empties. This is intentional and observable only as internal storage lifetime; tests pin that one table's disposal does not discard sibling contributions. +- The public classes add a reusable package contract. Keeping reads narrow and domain policy in consumers limits how much future code must preserve. diff --git a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.zh.md b/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.zh.md index 0f766b822a..ebe823b980 100644 --- a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 作用域分层存储——每 scope 一个聚合层与统一调度 helper +# Agent Note: 共享作用域分层存储 Status: proposed @@ -6,34 +6,25 @@ Status: proposed ## 问题 -agent 作用域落地之后([agent-scope Agent Note](../../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。 +agent(智能体)作用域机制([决策](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)、[运行时设计](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面各自独立实现这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandService.register`(位于 `dsh-commands`)。 -除此之外:风险集中在编排细节上: -- 回滚必须在 change 发出之前被收集(抛错的监听器才能回卷插入而不是泄漏) -- 返回的 disposer 必须是 Cordis 自己的那个函数(包装器会静默破坏嵌套的有序拆除) -- 清空的专属层必须被回收(被 dispose 的 agent 不得留下以死 `ScopeKey` 为键的残余) +每个门面都围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各份实现采用不同的映射与集合类型,因此服务内没有一个对象能表示某个 scope 的完整贡献,而且每张表都必须重复清理逻辑。 -每个新消费者都要把这一切重新写对一遍,而各副本的写法已经分叉——`dsh-tools` 里有两个私有建层 helper,`dsh-system-prompt` 里是三处内联 IIFE。 +重复代码承载着三项不明显的要求: -最后,一个 agent 在一个服务里的贡献散落在几张互不相识的 Map 里——不存在一个「这个 scope 在这里贡献了什么」的对象——而消费者还在持续增多:专属 guard 与 per-agent 提示词/工具组合是最近落地的一批,per-agent 的 `fs/*` 策略、`llm/*` 覆盖与 compaction 策略则是同一模式的潜在后续用户。 +- 可见性与属主必须来自同一个上下文;若分开接受二者,就能登记出对一个 scope 可见、却随另一个 scope 销毁的贡献。 +- change 回调运行前必须收集 undo,抛错的回调才能回滚变更。 +- 公开 disposer 必须就是 `ctx.effect()` 返回的那个函数;包装它会破坏 Cordis 基于身份的有序拆除。 + +共享的是生命周期与保持插入顺序的存储,而不是注册表策略。工具限制、保留传输处理、提示词求值时机、命令规范化、精确诊断和回调异常隔离,仍分别属于不同的领域契约。 ## 提案 -`dsh-scope` 新增与键类型无关的 `store.ts`,peer 依赖仍只有 Cordis。模块只抽取六个现有登记口已经共同证明的最小形状:**业务状态与校验留在显式层类里;一个 helper 统一负责选层、挂 effect、回滚、通知与回收**。一个 helper 实例属于一个服务;一个层实例聚合某 scope 对该服务的全部贡献。 +`@deepseek-ai/dsh-scope` 新增与键类型无关的 `store.ts` 实现模块。该包(package)继续将 Cordis 和 `@deepseek-ai/dsh-invariants` 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:`ScopeLayer`、`ScopedLayers`、`NamedEntries` 和 `AnonymousEntries`。`EntryValues` 仍是内部接口,`store.ts` 不是包子路径。 -- **`ScopedLayers`** 是具体调度器,不作基类。它持有全局层与一张 `Map`,通过显式工厂按需构造专属层,并在层 `isEmpty()` 时回收。`effect(ctx, action, options?)` 只接受一个同步 action,action 只返回一个同步 undo,因为六个现有登记口的完整形状就是如此。单一 `ctx` 同时决定可见层(`scopeOf(ctx)`)与属主 Cordis fiber(`ctx.effect`),「对 X 可见、随 Y 销毁」因此不可表达。helper 在通知监听器前 yield undo,返回 Cordis 的原始 disposer,并在校验或变更抛错时回收刚建出的空层。读取接口是 `global`/`peek`,以及 `merge`(命名条目的专属遮蔽与可选全局放行谓词)、`values`(不遮蔽地依次拼接全局与专属条目)和 `keys`(限制前名字全集)。 -- **显式 `ScopeLayer` 类**让每个服务的状态一眼可见。`ToolLayer` 与 `PromptLayer` 直接声明各自三项表属性与 `isEmpty()` 聚合;一个小工厂只向构造器传入 scope,闭包仍可捕获真实构造依赖。领域方法仍是普通类方法。代价是几行重复声明,收益是不用引入 mapped-type 类工厂、scheduler/layer 属主环、保留属性名和生成式运行时结构。 -- **`NamedEntries` 与 `AnonymousEntries`** 是两种共用的保插入序条目表。命名表暴露 `insert`/查询,并通过领域 `kind` 与 per-agent alternative 标签保持现有全局/专属重名文案;匿名表只暴露 `append`,以进程内唯一 symbol 作键支持 O(1) 撤销删除。分成两类以后,无意义的命名/匿名混用不可表达,key 类型也保持健全。迭代器借用表成员与带类型的贡献值,不会 clone 或 freeze 值;`ScopedLayers` 只物化服务读路径本来就需要的合并数组或 Map。 +`ScopeLayer` 保留显式的聚合概念,同时只要求判断整个层是否为空。服务定义一个具体层,使其表结构与领域 helper 适合该服务;`ScopedLayers` 负责构造、选择、生命周期挂接、通知和聚合回收。 -`dsh-tools` 把工具、已编译 restriction 与 guard 三张表合并进一个 `ToolLayer`。restriction 放行判断与 guard 求值归层所有;`run_code` 保留名、当前已知全局名集合等依赖服务配置的领域校验仍留在门面。只读 allow/deny 输入只编译一次,成为内部 Set。`dsh-system-prompt` 同样把 section、tool provider 与 variable 合并进一个 `PromptLayer`。每个登记门面先完成公开参数校验,再以 label 做一次 `effect` 调用;guard 额外传 `silent: true`。通用 helper 不理解「restriction 必须由 scoped context 调用」之类领域规则。 - -`assemble` 留在 `SystemPrompt` 门面,三条理由:主体 scope 的层可能不存在,读路径不得创建它;遮蔽语义要求先合并再求值,被遮蔽的 section provider 绝不能被调用;组装 waterfall 与 `toolOrder` 使用服务级资源。section 与 tool provider 保持既有的派生视图物化;variable provider 则直接遍历全局与专属 `NamedEntries`,保留 provider 在组装期间登记另一 variable 时的现有活 Map 行为。tool guard 同样直接遍历其 `AnonymousEntries`。 - -迁移保持公开行为与精确重名文案不变。内部聚合层会在三张表全部清空后才回收,而不是某一张表清空时回收;服务 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 export interface ScopeLayer { @@ -41,22 +32,28 @@ export interface ScopeLayer { } export class ScopedLayers { - constructor(createLayer: (scope: ScopeKey | undefined) => L, options: { onChange?: () => void }) + constructor( + createLayer: (scope: ScopeKey | undefined) => L, + onChange: () => void, + ) + readonly global: L peek(scope: ScopeKey | undefined): L | undefined - merge(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries, admitGlobal?: (name: string) => boolean): Map - values(scope: ScopeKey | undefined, pick: (layer: L) => EntryValues): T[] - keys(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries): string[] - effect(ctx: Context, action: (layer: L) => () => void, options: { label: string; silent?: boolean }): () => void + + merge( + scope: ScopeKey | undefined, + pick: (layer: L) => NamedEntries, + ): Map + + effect( + ctx: Context, + action: (layer: L) => () => void, + options: { label: string; notify?: boolean }, + ): () => void } -export interface EntryValues { - values(): IterableIterator - isEmpty(): boolean -} - -export class NamedEntries implements EntryValues { - constructor(kind: string, perAgentAlternative: string, scope: ScopeKey | undefined) +export class NamedEntries { + constructor(duplicateError: (name: string) => Error) insert(name: string, value: V): () => void get(name: string): V | undefined has(name: string): boolean @@ -66,80 +63,61 @@ export class NamedEntries implements EntryValues { isEmpty(): boolean } -export class AnonymousEntries implements EntryValues { +export class AnonymousEntries { append(value: V): () => void values(): IterableIterator isEmpty(): boolean } ``` -迁移后的消费者长什么样——现存最重的登记口从 30+ 行编排缩为一份声明加一行门面: +## 存储契约 -```ts ignore-check -class ToolLayer implements ScopeLayer { - readonly tools = new NamedEntries('tool', 'variant', this.scope) - readonly restrictions = new AnonymousEntries() - readonly guards = new AnonymousEntries() +- 构造器只创建一次 `global`,调用的是 `createLayer(undefined)`。只有 `effect()` 会创建专属层;`peek()` 和 `merge()` 从不创建专属层,而 `peek(undefined)` 返回 `undefined`,因为全局层已经显式存在。 +- `merge()` 是唯一会物化结果的通用读取接口。它按插入顺序复制全局命名条目,再按专属条目的插入顺序应用这些条目;同名条目完成遮蔽,但不会移动无关名称。 +- `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序和活遍历语义。 +- `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器是保留插入顺序的活迭代器。 +- `effect()` 通过 `scopeOf(ctx)` 导出键,并把 action 挂到同一个 `ctx.effect()` 上。它只接受一个同步 action,且该 action 只返回一个同步 undo;action 要么返回其 undo,要么必须在保留任何贡献之前抛错。helper 不会规范化更宽泛的 Cordis `Effect` union。 +- `effect()` 在调用 `onChange` 前收集 action 的 undo,并原样返回 `ctx.effect()` 的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有整个层的 `ScopeLayer.isEmpty()` 变为 true 后,helper 才删除专属层。 +- `options.notify` 默认为 `true`。回调自身的策略仍具最终效力:工具与提示词的 change 回调可以抛错并触发登记回滚;`CommandService.notifyChange()` 会隔离观察者失败;工具 guard 传入 `notify: false`。 - 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): string | undefined { /* first monotonic denial */ } -} +`dsh-tools` 定义一个 `ToolLayer`,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。`ToolRegistry` 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 `run_code` 插入。guard 求值继续先活遍历全局登记,再活遍历专属登记,因此重入时新增的登记保持现有行为。 -class ToolRegistry extends Service { - private readonly layers = new ScopedLayers( - scope => new ToolLayer(scope), - { onChange: () => this.ctx.emit('tools/change') }, - ) +`dsh-system-prompt` 定义一个 `PromptLayer`,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方继续先活遍历全局表,再活遍历专属表,从而保留重入登记行为。 - register(definition: ToolDefinition): () => void { - return this.layers.effect(this.ctx, - layer => layer.tools.insert(definition.name, definition), - { label: 'tools.register()' }) - } +`dsh-commands` 定义一个单表层,其中包含 `NamedEntries`。生效视图使用 `merge()`;`CommandService` 则保留对定义的规范化与冻结处理、精确重名诊断、经过排序的不可变描述符、直接执行、HMR(热模块替换)清理,以及对各个 `commands/change` 观察者分别隔离失败的行为。 - 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()) - } -} -``` +七个门面都把校验与诊断留在所属注册表中,并继续返回 Cordis 的原始 disposer。迁移既不改变公开注册表行为,也不改变模型可见或人类可见的输出,以及协议、持久化或配置层面的可见输出。 ## 备选方案 -**每 scope 一个注册表实例,父子委托链。** 实例爆炸;「部署工具加我的工具」的合并视图要每个服务手写一个委托注册表;单订阅观察者(持久化、ACP bridge)必须逐实例发现并订阅;委托链表达不了减法(restriction)。子注册表还得反向触及父上下文,扩大暴露面。 +**保留彼此独立的实现。** 这样不必新增库接口,但七个门面仍会重复生命周期顺序、disposer 身份和 scope 回收。 -**注册 API 上的显式 scope 参数。** agent-scope Agent Note 已否决:漏传参数即静默注册为全局,且该形状能表达「对 X 可见、随 Y 销毁」——几乎必然是 bug。 +**每张表一个 helper。** 这能减少一部分局部代码,但会保留多张按 scope 划分的映射,而且无法正确回收某个 scope 的聚合贡献。 -**只抽数据结构、编排留在服务。** 消掉的是重复里安全的那一半,留下的是危险的那一半——回滚先于 emit 的顺序、原始 disposer、回收规则,恰是 bug 所在。 +**每 scope 一个注册表实例。** 子注册表需要通过委托获得全局加专属的视图,对 restriction 进行特殊的减法处理,并跨实例发现观察者。这只会转移复杂度,而不会消除复杂度。 -**让 layer action 接受完整 Cordis `Effect` union。** 六个现有登记口都没有异步 setup、多份 undo 或独立 settlement 边界。现在就规范化 Promise、iterable、async iterable、LIFO 合成与部分失败,会重复一套纯属推测的生命周期 machinery。store 只接受一个同步 action 与一个 undo;未来出现真实边界时再凭证据拓宽。 +**注册方法上的显式 scope 参数。** 分开的可见性与属主输入让不匹配的生命周期成为可表达状态,而遗漏 scope 则会静默变成全局登记。 -**由 mapped-type 表 DSL 生成层类。** 两个消费者各自只有三张表。类工厂省下几行代码,却引入生成式运行时形状、保留名、多态 `this` 类型和第二种构造模型。显式类更易检查,同时仍可复用两种条目表与 `ScopedLayers`。 +**接受完整的 Cordis `Effect` union。** 七个登记口都没有异步 setup、多份 undo 或独立 settlement 边界。通用规范化会在没有现有消费者需要它时重复 Cordis 的生命周期 machinery。 -**内置视图语义的固定容器 helper。** 容器形态与合并策略被钉死在 helper 里;业务没有自由度,任何命名或单值变体都变成对 helper 的功能诉求。 +**暴露 `ScopedLayers.values()`、`ScopedLayers.keys()` 或全局放行谓词。** 这些操作会编码消费方特有的活遍历或物化策略,以及过滤策略。直接遍历条目表可保留显式的活语义,`merge()` 覆盖共享的命名遮蔽操作,而 `ToolRegistry` 继续保有功能更丰富的私有解析器。 -**每张表一个 helper。** 复刻今天的散装簿记——那正是被替换的现状:每服务 N 张 scope Map,agent 的贡献没有聚合。 +**把 `values()` 放在 `ScopeLayer` 上,或导出 `EntryValues`。** 一个层会聚合异构表,因而没有一致的值类型或迭代策略。`EntryValues` 只适合在两个表类之间共享实现细节;将其公开只会扩大接口,却不能为调用方提供有意义的整层读取方式。 -**`helper.get(ctx).effect(...)` 两步式登记。** 把建层与挂生命周期拆成两步;两步之间抛错会搁浅一个空层,返回的 handle 还是每次调用一笔额外分配。 - -**层持有 ctx、自己注册 effect。** 把数据对象变成生命周期管理者,编排在每个业务类里重演一遍。 +**通过 mapped-type 表描述生成层。** 三表与单表具体层都很短、易于检查,并可自由持有领域 helper。类生成器会增加第二种构造模型和生成式运行时形状,收益却很小。 ## 验收标准 -- `store.ts` 落在 `dsh-scope`(peer 依赖不变:仅 Cordis;模块图位置不变),逐文件 100% 覆盖选层与回收、同步 action/undo 顺序、action 抛错清理、change 监听器抛错回滚、原始 disposer 身份、`label`/`silent`、工厂类型、合并 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;实现后本 Agent Note 移入 `implemented/`,并就地更新[运行时设计 Agent Note](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) 的注册章节。 +- `dsh-scope` 从包根恰好导出拟议的四个存储符号,并覆盖全局构造、专属层延迟构造、非创建式读取、命名遮蔽、聚合回收、失败清理、通知顺序、原始 disposer 身份、调用方拥有的重名错误、相同匿名值的独立登记和活迭代器。 +- `dsh-tools`、`dsh-system-prompt` 与 `dsh-commands` 迁移全部七个注册门面,同时保留校验顺序、精确诊断、视图、通知策略、重入行为和 HMR 清理。 +- `dsh-scope` README 与作用域核心数据文档描述公开契约;架构和运行时设计引用标识共享 store,但不重复其内容。各消费方 README 继续聚焦其未改变的公开行为。 +- 实现 PR 将本组文件移入 `implemented/architecture`,把 `Proposal` 改为以现在时书写的 `Decision`,并记录已落地的后果与验证。现有无密钥快照保持逐字节一致。 ## 风险 -- 层/门面边界可能不适配某个未来消费者的形状。缓解:`ScopeLayer` 只要求 `isEmpty()`;工厂闭包可捕获构造依赖,无需让层反向持有 scheduler。 -- 未来登记口可能真的需要异步 setup 或多份独立属主的 undo。helper 刻意不预测这种生命周期;该消费者必须先说明 owner 与 settlement 边界,再连同测试拓宽契约。 -- 显式层声明会在两个消费者中各重复三行属性初始化与一段 `isEmpty()`。接受:这点重复让运行时状态和类型保持可见,避免为两个类引入第二套 DSL。 -- 两个核心注册表同时迁移。缓解:设计期已完成逐行为对比,且 store 连同钉住等价性的测试先于任一迁移 commit 落地。 +- 未来的登记可能需要异步 setup 或多份分别拥有属主的 undo。该消费方必须先明确其属主与 settlement 边界,再拓宽这个刻意保持同步的接口。 +- 抛错的 action 若在返回的 undo 契约之外产生变更,通用 helper 无法修复。条目操作是原子的;迁移会在插入前执行可能失败的校验;测试会钉住工厂失败和保留贡献前的 action 失败清理。 +- 聚合回收会让专属层一直存活到所有表都清空。这是有意行为,并且只能通过内部存储生命周期观察到;测试会钉住销毁一张表时不会丢弃同层的其他贡献。 +- 公开类新增了一项可复用的包契约。保持读取接口狭窄并把领域策略留在消费方,可以减少未来代码必须维持的契约范围。 From 8351cdbe65d51677d96360d415dcae322c72e755 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:09:19 +0800 Subject: [PATCH 06/10] feat(scope): add scoped-layer storage --- packages/core/scope/src/index.ts | 3 + packages/core/scope/src/store.ts | 241 ++++++++++++++++++++++ packages/core/scope/tests/store.spec.ts | 263 ++++++++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 packages/core/scope/src/store.ts create mode 100644 packages/core/scope/tests/store.spec.ts diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index f09844e9ab..fc5b1fa5fa 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -8,6 +8,9 @@ import type { Context, Fiber } from 'cordis' import { Context as CordisContext } from 'cordis' +export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts' +export type { ScopeLayer } from './store.ts' + /** An opaque, identity-compared scope key. */ export type ScopeKey = object diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts new file mode 100644 index 0000000000..5f016df929 --- /dev/null +++ b/packages/core/scope/src/store.ts @@ -0,0 +1,241 @@ +/** + * Shared insertion-ordered storage and effect ownership for scope-aware registries. + * + * @module @deepseek-ai/dsh-scope + */ + +import type { Context } from 'cordis' +import { scopeOf } from './index.ts' +import type { ScopeKey } from './index.ts' + +/** One scope's aggregate contribution to a registry. */ +export interface ScopeLayer { + /** Whether every table in this layer is empty. */ + isEmpty(): boolean +} + +/** Internal common read contract for the two entry-table implementations. */ +interface EntryValues { + values(): IterableIterator + isEmpty(): boolean +} + +/** + * Insertion-ordered named entries with caller-owned duplicate diagnostics. + * + * Values are borrowed. Iterators are live native `Map` iterators, and each + * successful insertion returns an idempotent undo for that exact entry. + */ +export class NamedEntries implements EntryValues { + private readonly data = new Map() + + constructor( + private readonly duplicateError: (name: string) => Error, + ) {} + + /** + * Insert one unique name. + * @param name - name unique within this table. + * @param value - borrowed value to retain. + * @returns an idempotent undo that removes only this insertion. + */ + insert(name: string, value: V): () => void { + if (this.data.has(name)) throw this.duplicateError(name) + this.data.set(name, value) + let active = true + return () => { + if (!active) return + active = false + this.data.delete(name) + } + } + + /** + * Read one named value. + * @param name - name to resolve. + * @returns the retained value, or `undefined` when absent. + */ + get(name: string): V | undefined { + return this.data.get(name) + } + + /** + * Test one name for membership. + * @param name - name to test. + * @returns whether the table contains that name. + */ + has(name: string): boolean { + return this.data.has(name) + } + + /** + * Iterate live names in insertion order. + * @returns the native live key iterator. + */ + keys(): IterableIterator { + return this.data.keys() + } + + /** + * Iterate live entries in insertion order. + * @returns the native live entry iterator. + */ + entries(): IterableIterator<[string, V]> { + return this.data.entries() + } + + /** + * Iterate live values in insertion order. + * @returns the native live value iterator. + */ + values(): IterableIterator { + return this.data.values() + } + + /** + * Test whether this table has no entries. + * @returns whether the table is empty. + */ + isEmpty(): boolean { + return this.data.size === 0 + } +} + +/** + * Insertion-ordered anonymous entries with independent registration identity. + * + * Equal values remain separate registrations. Values are borrowed and the + * returned iterator retains native live `Map` semantics. + */ +export class AnonymousEntries implements EntryValues { + private readonly data = new Map() + + /** + * Append one independently owned value. + * @param value - borrowed value to retain. + * @returns an idempotent undo for this exact append. + */ + append(value: V): () => void { + const key = Symbol() + this.data.set(key, value) + let active = true + return () => { + if (!active) return + active = false + this.data.delete(key) + } + } + + /** + * Iterate live values in insertion order. + * @returns the native live value iterator. + */ + values(): IterableIterator { + return this.data.values() + } + + /** + * Test whether this table has no entries. + * @returns whether the table is empty. + */ + isEmpty(): boolean { + return this.data.size === 0 + } +} + +/** + * Own the global and exact-scope layers for one registry. + * + * Reads never create scoped layers. Registrations derive both visibility and + * effect ownership from the supplied Cordis context, collect undo before + * notification, and reclaim only a completely empty aggregate layer. + */ +export class ScopedLayers { + /** The eagerly constructed context-global layer. */ + readonly global: L + + private readonly scoped = new Map() + + constructor( + private readonly createLayer: (scope: ScopeKey | undefined) => L, + private readonly onChange: () => void, + ) { + this.global = createLayer(undefined) + } + + /** + * Read an existing exact-scope overlay. + * @param scope - exact scope key; `undefined` denotes no overlay. + * @returns the existing scoped layer, or `undefined` without creating one. + */ + peek(scope: ScopeKey | undefined): L | undefined { + if (scope === undefined) return undefined + return this.scoped.get(scope) + } + + /** + * Materialize global named entries followed by exact-scope shadows. + * @param scope - exact viewing scope, or `undefined` for the global view. + * @param pick - select the named table from a layer. + * @returns an insertion-ordered effective map. + */ + merge( + scope: ScopeKey | undefined, + pick: (layer: L) => NamedEntries, + ): Map { + const merged = new Map(pick(this.global).entries()) + const layer = this.peek(scope) + if (layer === undefined) return merged + for (const [name, value] of pick(layer).entries()) merged.set(name, value) + return merged + } + + /** + * Attach one synchronous layer mutation to its registration context. + * @param ctx - context that determines both scope visibility and effect ownership. + * @param action - atomic mutation returning its synchronous undo. + * @param options - Cordis effect label and optional change notification. + * @returns the exact disposer returned by `ctx.effect()`. + */ + effect( + ctx: Context, + action: (layer: L) => () => void, + options: { label: string; notify?: boolean }, + ): () => void { + const scope = scopeOf(ctx) + const notify = options.notify ?? true + const dispose = ctx.effect(function* (this: ScopedLayers) { + let layer: L + let created = false + if (scope === undefined) { + layer = this.global + } else { + const existing = this.scoped.get(scope) + if (existing === undefined) { + layer = this.createLayer(scope) + this.scoped.set(scope, layer) + created = true + } else { + layer = existing + } + } + + let undo: () => void + try { + undo = action(layer) + } catch (error) { + if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope) + throw error + } + + yield () => { + undo() + if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope) + if (notify) this.onChange() + } + if (notify) this.onChange() + }.bind(this), options.label) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity + return dispose + } +} diff --git a/packages/core/scope/tests/store.spec.ts b/packages/core/scope/tests/store.spec.ts new file mode 100644 index 0000000000..9f70d2b527 --- /dev/null +++ b/packages/core/scope/tests/store.spec.ts @@ -0,0 +1,263 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { + AnonymousEntries, + createScope, + NamedEntries, + ScopedLayers, + type Scope, + type ScopeKey, + type ScopeLayer, +} from '@deepseek-ai/dsh-scope' + +class TestLayer implements ScopeLayer { + readonly named: NamedEntries + readonly anonymous = new AnonymousEntries() + + constructor(scope: ScopeKey | undefined) { + this.named = new NamedEntries(name => + new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`)) + } + + isEmpty(): boolean { + return this.named.isEmpty() && this.anonymous.isEmpty() + } +} + +/** Mint one active scope for lifecycle tests. */ +async function mintScope(ctx: Context, key: ScopeKey): Promise { + let scope!: Scope + await ctx.plugin((inner: Context) => { scope = createScope(inner, key) }) + return scope +} + +describe('NamedEntries', () => { + it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => { + const duplicate = new Error('caller duplicate') + const duplicateError = vi.fn(() => duplicate) + const entries = new NamedEntries(duplicateError) + const undoA = entries.insert('a', 1) + const values = entries.values() + expect(values.next()).toEqual({ value: 1, done: false }) + const undoB = entries.insert('b', 2) + + expect([...values]).toEqual([2]) + expect([...entries.keys()]).toEqual(['a', 'b']) + expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]]) + expect(entries.get('a')).toBe(1) + expect(entries.get('missing')).toBeUndefined() + expect(entries.has('b')).toBe(true) + expect(entries.has('missing')).toBe(false) + expect(entries.isEmpty()).toBe(false) + expect(() => entries.insert('a', 3)).toThrow(duplicate) + expect(duplicateError).toHaveBeenCalledWith('a') + + undoA() + entries.insert('a', 3) + undoA() + expect(entries.get('a')).toBe(3) + undoB() + expect([...entries.entries()]).toEqual([['a', 3]]) + }) +}) + +describe('AnonymousEntries', () => { + it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => { + const entries = new AnonymousEntries() + const value = {} + const undoFirst = entries.append(value) + const values = entries.values() + expect(values.next()).toEqual({ value, done: false }) + const undoSecond = entries.append(value) + + expect([...values]).toEqual([value]) + expect([...entries.values()]).toEqual([value, value]) + undoFirst() + undoFirst() + expect([...entries.values()]).toEqual([value]) + undoSecond() + expect(entries.isEmpty()).toBe(true) + }) +}) + +describe('ScopedLayers', () => { + it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => { + const created: Array = [] + const layers = new ScopedLayers( + (scope) => { + created.push(scope) + return new TestLayer(scope) + }, + vi.fn(), + ) + const key = {} + layers.global.named.insert('a', 1) + layers.global.named.insert('shared', 2) + + expect(created).toEqual([undefined]) + expect(layers.peek(undefined)).toBeUndefined() + expect(layers.peek(key)).toBeUndefined() + expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]]) + expect(created).toEqual([undefined]) + }) + + it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => { + const ctx = new Context() + const key = {} + const scope = await mintScope(ctx, key) + const changed = vi.fn() + const created: Array = [] + const layers = new ScopedLayers( + (selected) => { + created.push(selected) + return new TestLayer(selected) + }, + changed, + ) + layers.global.named.insert('a', 1) + layers.global.named.insert('shared', 1) + const removeNamed = layers.effect( + scope.ctx, + layer => layer.named.insert('shared', 2), + { label: 'test.named', notify: false }, + ) + const removeTail = layers.effect( + scope.ctx, + layer => layer.named.insert('c', 3), + { label: 'test.tail', notify: false }, + ) + const removeAnonymous = layers.effect( + scope.ctx, + layer => layer.anonymous.append('kept'), + { label: 'test.anonymous', notify: false }, + ) + + expect(created).toEqual([undefined, key]) + expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]]) + expect(changed).not.toHaveBeenCalled() + removeNamed() + expect(layers.peek(key)).toBeDefined() + expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]]) + removeTail() + expect(layers.peek(key)).toBeDefined() + removeAnonymous() + expect(layers.peek(key)).toBeUndefined() + await scope.dispose() + }) + + it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => { + const ctx = new Context() + const events: string[] = [] + const layers = new ScopedLayers( + scope => new TestLayer(scope), + () => void events.push('notify'), + ) + const dispose = layers.effect( + ctx, + (layer) => { + events.push('action') + const undo = layer.named.insert('x', 1) + return () => { + events.push('undo') + undo() + } + }, + { label: 'store.order' }, + ) + + expect(events).toEqual(['action', 'notify']) + expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order') + dispose() + dispose() + expect(events).toEqual(['action', 'notify', 'undo', 'notify']) + expect(layers.global.isEmpty()).toBe(true) + }) + + it('returns the exact context effect disposer', () => { + const rawDispose = vi.fn() + const effect = vi.fn(() => rawDispose) + const ctx = { effect } as unknown as Context + const action = vi.fn(() => vi.fn()) + const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn()) + + const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false }) + + expect(returned).toBe(rawDispose) + expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity') + expect(action).not.toHaveBeenCalled() + }) + + it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => { + const ctx = new Context() + const key = {} + const scope = await mintScope(ctx, key) + let failFactory = true + const layers = new ScopedLayers( + (selected) => { + if (selected !== undefined && failFactory) throw new Error('factory failed') + return new TestLayer(selected) + }, + vi.fn(), + ) + + expect(() => layers.effect( + scope.ctx, + layer => layer.named.insert('never', 1), + { label: 'store.factory', notify: false }, + )).toThrow('factory failed') + expect(layers.peek(key)).toBeUndefined() + + failFactory = false + expect(() => layers.effect( + scope.ctx, + () => { throw new Error('action failed') }, + { label: 'store.action', notify: false }, + )).toThrow('action failed') + expect(layers.peek(key)).toBeUndefined() + + const dispose = layers.effect( + scope.ctx, + layer => layer.named.insert('kept', 1), + { label: 'store.kept', notify: false }, + ) + expect(() => layers.effect( + scope.ctx, + () => { throw new Error('second action failed') }, + { label: 'store.existing-action', notify: false }, + )).toThrow('second action failed') + expect(layers.peek(key)?.named.get('kept')).toBe(1) + dispose() + await scope.dispose() + }) + + it('rolls back a scoped insertion when notification throws', async () => { + const ctx = new Context() + const key = {} + const scope = await mintScope(ctx, key) + const events: string[] = [] + let notifications = 0 + const layers = new ScopedLayers( + selected => new TestLayer(selected), + () => { + events.push('notify') + if (++notifications === 1) throw new Error('change failed') + }, + ) + + expect(() => layers.effect( + scope.ctx, + (layer) => { + const undo = layer.named.insert('rollback', 1) + return () => { + events.push('undo') + undo() + } + }, + { label: 'store.rollback' }, + )).toThrow('change failed') + + expect(events).toEqual(['notify', 'undo', 'notify']) + expect(layers.peek(key)).toBeUndefined() + await scope.dispose() + }) +}) From 1d209932d6e13ffd2c592090c0a43ff9ecea7747 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:14:49 +0800 Subject: [PATCH 07/10] refactor(scope): migrate tool and prompt layers --- packages/core/system-prompt/src/index.ts | 156 +++++++--------- .../core/system-prompt/tests/scoped.spec.ts | 17 +- .../system-prompt/tests/system-prompt.spec.ts | 36 ++++ packages/core/tools/src/index.ts | 168 +++++++----------- packages/core/tools/tests/scoped.spec.ts | 21 +++ 5 files changed, 195 insertions(+), 203 deletions(-) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 12171f1b2e..c46c38515c 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -6,8 +6,8 @@ import { Context, Service } from 'cordis' import z from 'schemastery' -import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' -import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' +import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { ToolSchema } from '@deepseek-ai/dsh-llm' declare module 'cordis' { @@ -209,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record ToolProviderResult + +/** One prompt-variable provider stored in a prompt layer. */ +type VariableProvider = (context: AssembleContext) => string | undefined + +/** All prompt registrations owned by one global or scoped layer. */ +class PromptLayer implements ScopeLayer { + readonly sections: NamedEntries + readonly toolProviders = new AnonymousEntries() + readonly variables: NamedEntries + + /** + * Create one prompt layer with diagnostics specific to its ownership scope. + * @param scope - the scoped owner, or `undefined` for global registrations. + */ + constructor(scope: ScopeKey | undefined) { + this.sections = new NamedEntries(name => new Error(scope === undefined + ? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)` + : `prompt section "${name}" is already registered in this scope`)) + this.variables = new NamedEntries(name => new Error(scope === undefined + ? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)` + : `prompt variable "${name}" is already registered in this scope`)) + } + + /** @returns whether this layer owns no prompt registrations. */ + isEmpty(): boolean { + return this.sections.isEmpty() + && this.toolProviders.isEmpty() + && this.variables.isEmpty() + } +} + /** Registry service for the prompt inputs assembled before each model step. */ export class SystemPrompt extends Service { static Config: z = z.object({ @@ -217,13 +250,10 @@ export class SystemPrompt extends Service { toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) - private sections: PromptSection[] = [] - private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = [] - private variableProviders = new Map string | undefined>() - /** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */ - private scopedSections = new Map() - private scopedToolProviders = new Map ToolProviderResult)[]>() - private scopedVariableProviders = new Map string | undefined>>() + private readonly layers = new ScopedLayers( + scope => new PromptLayer(scope), + () => { this.ctx.emit('system-prompt/change') }, + ) private readonly toolOrder: string[] | undefined constructor(ctx: Context, config: Config) { @@ -255,34 +285,11 @@ export class SystemPrompt extends Service { if (!Number.isFinite(section.order)) { throw new TypeError(`prompt section "${section.name}" order must be a finite number`) } - const scope = scopeOf(this.ctx) - const dispose = this.ctx.effect(function* (this: SystemPrompt) { - const layer = scope === undefined - ? this.sections - : this.scopedSections.get(scope) ?? (() => { - const created: PromptSection[] = [] - this.scopedSections.set(scope, created) - return created - })() - if (layer.some(existing => existing.name === section.name)) { - throw new Error(scope === undefined - ? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)` - : `prompt section "${section.name}" is already registered in this scope`) - } - layer.push(section) - // Install rollback before notifying listeners that may throw. - yield () => { - const index = layer.indexOf(section) - /* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */ - if (index >= 0) layer.splice(index, 1) - if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope) - this.ctx.emit('system-prompt/change') - } - this.ctx.emit('system-prompt/change') - }.bind(this), 'systemPrompt.section()') - // Return the exact disposer so composite effects preserve teardown order. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose + return this.layers.effect( + this.ctx, + layer => layer.sections.insert(section.name, section), + { label: 'systemPrompt.section()' }, + ) } /** @@ -293,29 +300,11 @@ export class SystemPrompt extends Service { * @returns the exact Cordis effect disposer. */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { - const scope = scopeOf(this.ctx) - const dispose = this.ctx.effect(function* (this: SystemPrompt) { - const layer = scope === undefined - ? this.toolProviders - : this.scopedToolProviders.get(scope) ?? (() => { - const created: ((context: AssembleContext) => ToolProviderResult)[] = [] - this.scopedToolProviders.set(scope, created) - return created - })() - layer.push(provider) - // Install rollback before notifying listeners that may throw. - yield () => { - const index = layer.indexOf(provider) - /* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */ - if (index >= 0) layer.splice(index, 1) - if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope) - this.ctx.emit('system-prompt/change') - } - this.ctx.emit('system-prompt/change') - }.bind(this), 'systemPrompt.tools()') - // Return the exact disposer so composite effects preserve teardown order. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose + return this.layers.effect( + this.ctx, + layer => layer.toolProviders.append(provider), + { label: 'systemPrompt.tools()' }, + ) } /** @@ -330,32 +319,11 @@ export class SystemPrompt extends Service { if (!VARIABLE_NAME.test(name)) { throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) } - const scope = scopeOf(this.ctx) - const dispose = this.ctx.effect(function* (this: SystemPrompt) { - const layer = scope === undefined - ? this.variableProviders - : this.scopedVariableProviders.get(scope) ?? (() => { - const created = new Map string | undefined>() - this.scopedVariableProviders.set(scope, created) - return created - })() - if (layer.has(name)) { - throw new Error(scope === undefined - ? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)` - : `prompt variable "${name}" is already registered in this scope`) - } - layer.set(name, provider) - // Install rollback before notifying listeners that may throw. - yield () => { - layer.delete(name) - if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope) - this.ctx.emit('system-prompt/change') - } - this.ctx.emit('system-prompt/change') - }.bind(this), 'systemPrompt.variable()') - // Return the exact disposer so composite effects preserve teardown order. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose + return this.layers.effect( + this.ctx, + layer => layer.variables.insert(name, provider), + { label: 'systemPrompt.variable()' }, + ) } /** @@ -370,23 +338,19 @@ export class SystemPrompt extends Service { const scope = context.scope // Scoped variables shadow globals. const variables: Record = {} - for (const [name, provider] of this.variableProviders) { + for (const [name, provider] of this.layers.global.variables.entries()) { variables[name] = provider(context) } - const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope) - for (const [name, provider] of scopedVariables ?? []) { + const scopedVariables = this.layers.peek(scope)?.variables + for (const [name, provider] of scopedVariables?.entries() ?? []) { variables[name] = provider(context) } // Scoped sections shadow globals before the stable order sort. - const sectionByName = new Map() - for (const section of this.sections) sectionByName.set(section.name, section) - for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) { - sectionByName.set(section.name, section) - } + const sectionByName = this.layers.merge(scope, layer => layer.sections) // Validate order against pre-restriction names while collecting visible schemas. const providers = [ - ...this.toolProviders, - ...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [], + ...this.layers.global.toolProviders.values(), + ...(this.layers.peek(scope)?.toolProviders.values() ?? []), ] const collected: ToolSchema[] = [] const knownNames = new Set() diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index aac3f79e88..a3c85b7886 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' @@ -63,6 +63,21 @@ describe('scoped sections', () => { expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/) }) + it('shadows a global section before evaluating either text provider', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + const globalText = vi.fn(() => 'global text') + const scopedText = vi.fn(() => 'scoped text') + ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText }) + scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText }) + + const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) + + expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text') + expect(globalText).not.toHaveBeenCalled() + expect(scopedText).toHaveBeenCalledOnce() + }) + }) describe('scoped variables', () => { diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index b084104d22..d4fdbdd684 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -157,6 +157,24 @@ describe('SystemPrompt', () => { expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t']) }) + it('snapshots tool-provider membership before evaluating an assembly', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + let added = false + ctx.systemPrompt.tools(() => { + if (!added) { + added = true + ctx.systemPrompt.tools(() => ({ + schemas: [{ name: 'late', description: '', parameters: {} }], + })) + } + return { schemas: [{ name: 'first', description: '', parameters: {} }] } + }) + + expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first']) + expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late']) + }) + it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -314,6 +332,24 @@ describe('SystemPrompt', () => { expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) }) + it('live-iterates variables registered by an earlier provider', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + let added = false + ctx.systemPrompt.variable('first', () => { + if (!added) { + added = true + ctx.systemPrompt.variable('late', () => 'second value') + } + return 'first value' + }) + + expect((await ctx.systemPrompt.assemble()).variables).toEqual({ + first: 'first value', + late: 'second value', + }) + }) + it('rejects a duplicate variable name and an unreferenceable name', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 086d6a3271..11f57995e9 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -6,8 +6,8 @@ import { Context, Service } from 'cordis' import z from 'schemastery' -import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' -import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' +import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' @@ -463,9 +463,40 @@ interface ToolView { */ export type ToolGuard = (execution: Readonly) => string | undefined -/** One guard registration; the wrapper preserves independent duplicate registrations. */ -interface ToolGuardRegistration { - guard: ToolGuard +/** One scope's complete tool-registry contribution. */ +class ToolLayer implements ScopeLayer { + readonly tools: NamedEntries + readonly restrictions = new AnonymousEntries() + readonly guards = new AnonymousEntries() + + constructor(scope: ScopeKey | undefined) { + this.tools = new NamedEntries(name => new Error(scope === undefined + ? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)` + : `tool "${name}" is already registered in this scope`)) + } + + /** Whether every contribution table in this aggregate layer is empty. */ + isEmpty(): boolean { + return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() + } + + /** Whether every compiled restriction in this layer admits a global tool name. */ + admits(name: string): boolean { + for (const filter of this.restrictions.values()) { + if ((filter.allow !== undefined && !filter.allow.has(name)) + || (filter.deny !== undefined && filter.deny.has(name))) return false + } + return true + } + + /** First monotonic denial from this layer's live guard registrations. */ + guardReason(exec: ToolExecution): string | undefined { + for (const guard of this.guards.values()) { + const reason = guard(exec) + if (reason !== undefined) return reason + } + return undefined + } } /** Approval decision plus whether the approval channel reported cancellation. */ @@ -509,13 +540,10 @@ export class ToolRegistry extends Service { private deferredContexts = new WeakMap() /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ private cancellationStates = new WeakMap() - private global = new Map() - private scoped = new Map>() - /** Compiled restriction filters, per scope (see {@link restrict}). */ - private restrictions = new Map() - /** Monotonic post-policy guards, split into global and per-agent layers. */ - private globalGuards = new Set() - private scopedGuards = new Map>() + private readonly layers = new ScopedLayers( + scope => new ToolLayer(scope), + () => { this.ctx.emit('tools/change') }, + ) private readonly mode: ToolPresentationMode /** Reserved presentation transport, kept outside the filterable registration layers. */ private readonly codeTransport: ToolDefinition | undefined @@ -593,7 +621,6 @@ export class ToolRegistry extends Service { * @returns the exact disposer that unregisters the tool. */ register(definition: ToolDefinition): () => void { - const scope = scopeOf(this.ctx) const name = definition.name const timeoutMs = definition.timeoutMs if (timeoutMs !== undefined @@ -603,26 +630,11 @@ export class ToolRegistry extends Service { if (this.codeTransport !== undefined && name === RUN_CODE_NAME) { throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) } - const dispose = this.ctx.effect(function* (this: ToolRegistry) { - const layer = scope === undefined ? this.global : this.layerFor(scope) - if (layer.has(name)) { - throw new Error(scope === undefined - ? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)` - : `tool "${name}" is already registered in this scope`) - } - layer.set(name, definition) - // Install rollback before notifying listeners. - yield () => { - layer.delete(name) - // Drop empty scope layers. - if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) - this.ctx.emit('tools/change') - } - this.ctx.emit('tools/change') - }.bind(this), 'tools.register()') - // Return the exact disposer so composite effects preserve teardown order. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose + return this.layers.effect( + this.ctx, + layer => layer.tools.insert(name, definition), + { label: 'tools.register()' }, + ) } /** @@ -655,22 +667,11 @@ export class ToolRegistry extends Service { if (unknown.length > 0) { throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`) } - const dispose = this.ctx.effect(function* (this: ToolRegistry) { - const list = this.restrictions.get(scope) ?? [] - this.restrictions.set(scope, list) - list.push(compiled) - yield () => { - const index = list.indexOf(compiled) - /* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */ - if (index >= 0) list.splice(index, 1) - if (list.length === 0) this.restrictions.delete(scope) - this.ctx.emit('tools/change') - } - this.ctx.emit('tools/change') - }.bind(this), 'tools.restrict()') - // Return the exact disposer so composite effects preserve teardown order. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose + return this.layers.effect( + this.ctx, + layer => layer.restrictions.append(compiled), + { label: 'tools.restrict()' }, + ) } /** @@ -684,63 +685,18 @@ export class ToolRegistry extends Service { * @returns the exact disposer that unregisters the guard. */ guard(guard: ToolGuard): () => void { - const scope = scopeOf(this.ctx) - const registration = { guard } - const dispose = this.ctx.effect(function* (this: ToolRegistry) { - const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope) - layer.add(registration) - yield () => { - layer.delete(registration) - if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope) - } - }.bind(this), 'tools.guard()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose - } - - /** The (created-on-demand) scoped layer for `scope`. */ - private layerFor(scope: ScopeKey): Map { - let layer = this.scoped.get(scope) - if (!layer) { - layer = new Map() - this.scoped.set(scope, layer) - } - return layer - } - - /** Get or create the guard layer for one agent scope. */ - private guardLayerFor(scope: ScopeKey): Set { - let layer = this.scopedGuards.get(scope) - if (layer === undefined) { - layer = new Set() - this.scopedGuards.set(scope, layer) - } - return layer + return this.layers.effect( + this.ctx, + layer => layer.guards.append(guard), + { label: 'tools.guard()', notify: false }, + ) } /** First monotonic denial from the global then matching scoped guard layers. */ private guardReason(exec: ToolExecution): string | undefined { - for (const { guard } of this.globalGuards) { - const reason = guard(exec) - if (reason !== undefined) return reason - } - if (exec.agent !== undefined) { - for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) { - const reason = guard(exec) - if (reason !== undefined) return reason - } - } - return undefined - } - - /** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */ - private admits(scope: ScopeKey | undefined, name: string): boolean { - if (scope === undefined) return true - const filters = this.restrictions.get(scope) - if (!filters) return true - return filters.every(filter => - (filter.allow === undefined || filter.allow.has(name)) - && (filter.deny === undefined || !filter.deny.has(name))) + const globalReason = this.layers.global.guardReason(exec) + if (globalReason !== undefined) return globalReason + return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec) } /** @@ -752,18 +708,18 @@ export class ToolRegistry extends Service { * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { - const layer = scope === undefined ? undefined : this.scoped.get(scope) + const layer = this.layers.peek(scope) const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() - for (const [name, definition] of this.global) { + for (const [name, definition] of this.layers.global.tools.entries()) { knownNames.add(name) restrictableNames.add(name) - if (this.admits(scope, name)) visible.set(name, definition) + if (layer?.admits(name) ?? true) visible.set(name, definition) } // Scoped layer second: same-name entries REPLACE (shadow) the global ones, // and scope-local registrations are never part of the global filter above. - for (const [name, definition] of layer ?? []) { + for (const [name, definition] of layer?.tools.entries() ?? []) { knownNames.add(name) visible.set(name, definition) } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index ab3b869953..cab0ee7155 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -266,6 +266,27 @@ describe('scoped execution dispatch', () => { expect(bodyCalls).toBe(0) }) + it('live-iterates a guard registered by an earlier guard', async () => { + const ctx = await mount() + const calls: string[] = [] + let added = false + ctx.tools.register(tool('t')) + ctx.tools.guard(() => { + calls.push('first') + if (!added) { + added = true + ctx.tools.guard(() => { + calls.push('late') + return 'late denial' + }) + } + return undefined + }) + + expect(await run(ctx, 't')).toBe('Error: late denial') + expect(calls).toEqual(['first', 'late']) + }) + it('shares one token and materialized argument value across the pipeline', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') From e56bcb4d5ce669b438ad2d083d437c7eea86cec0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:16:06 +0800 Subject: [PATCH 08/10] refactor(commands): use shared scoped layers --- packages/ui/commands/src/index.ts | 67 ++++++++++----------- packages/ui/commands/tests/commands.spec.ts | 13 ++++ 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 16e665e71f..56f88a9f20 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -5,8 +5,8 @@ import { Context, Service } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { scopeOf } from '@deepseek-ai/dsh-scope' -import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' export const name = 'commands' @@ -68,6 +68,26 @@ interface RegisteredCommand { readonly descriptor: CommandDescriptor } +/** All command registrations owned by one global or scoped layer. */ +class CommandLayer implements ScopeLayer { + readonly commands: NamedEntries + + /** + * Create one command layer with diagnostics specific to its ownership scope. + * @param scope - the scoped owner, or `undefined` for global registrations. + */ + constructor(scope: ScopeKey | undefined) { + this.commands = new NamedEntries(name => new Error(scope === undefined + ? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` + : `command "${name}" is already registered in this scope`)) + } + + /** @returns whether this layer owns no command registrations. */ + isEmpty(): boolean { + return this.commands.isEmpty() + } +} + declare module 'cordis' { interface Context { commands: CommandService @@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult { * globals for that agent. */ export class CommandService extends Service { - private readonly global = new Map() - private readonly scoped = new Map>() + private readonly layers = new ScopedLayers( + scope => new CommandLayer(scope), + () => { this.notifyChange() }, + ) constructor(ctx: Context) { super(ctx, 'commands') @@ -218,25 +240,12 @@ export class CommandService extends Service { * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void { - const scope = scopeOf(this.ctx) const registered = normalizeDefinition(definition) - const dispose = this.ctx.effect(function* (this: CommandService) { - const layer = scope === undefined ? this.global : this.layerFor(scope) - if (layer.has(registered.definition.name)) { - throw new Error(scope === undefined - ? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` - : `command "${registered.definition.name}" is already registered in this scope`) - } - layer.set(registered.definition.name, registered) - yield () => { - layer.delete(registered.definition.name) - if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) - this.notifyChange() - } - this.notifyChange() - }.bind(this), 'commands.register()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order - return dispose + return this.layers.effect( + this.ctx, + layer => layer.commands.insert(registered.definition.name, registered), + { label: 'commands.register()' }, + ) } /** @@ -285,19 +294,7 @@ export class CommandService extends Service { /** Resolve global definitions followed by exact scoped shadows. */ private view(agent: Agent): Map { - const visible = new Map(this.global) - for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command) - return visible - } - - /** Create the registration layer for one agent scope on demand. */ - private layerFor(scope: ScopeKey): Map { - let layer = this.scoped.get(scope) - if (layer === undefined) { - layer = new Map() - this.scoped.set(scope, layer) - } - return layer + return this.layers.merge(agent, layer => layer.commands) } /** Notify every registry observer without making UI refresh load-bearing. */ diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 839ccb0297..7b6fefb2d2 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -94,6 +94,19 @@ describe('CommandService', () => { expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global') }) + it('removes a registration when its contributing plugin fiber is disposed', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.commands.register(command('temporary')) + }, { inject: ['commands'] })) + expect(ctx.commands.find(agent, 'temporary')).toBeDefined() + + await fiber.dispose() + + expect(ctx.commands.find(agent, 'temporary')).toBeUndefined() + }) + it('rejects duplicates within one layer while allowing a scoped shadow', async () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a') From 04d7b435d28a70eae8802ff11ec19187ad8db3ac Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:24:45 +0800 Subject: [PATCH 09/10] docs(scope): record shared scoped-layer decision --- .../2026-07-12-agent-scope-runtime-design.md | 10 +++--- .../2026-07-12-scoped-layers-store.i18n.yaml | 4 +-- .../2026-07-12-scoped-layers-store.md | 33 ++++++++++--------- .../2026-07-12-scoped-layers-store.zh.md | 33 ++++++++++--------- docs/architecture.md | 4 +-- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 6 ++-- docs/core-data-structures/scope.md | 20 +++++++++-- docs/event-producer-consumer.md | 2 +- packages/core/scope/README.md | 6 ++++ scripts/type-equiv.manifest.json | 1 + 11 files changed, 75 insertions(+), 46 deletions(-) rename .agents/notes/{proposed => implemented}/architecture/2026-07-12-scoped-layers-store.i18n.yaml (65%) rename .agents/notes/{proposed => implemented}/architecture/2026-07-12-scoped-layers-store.md (64%) rename .agents/notes/{proposed => implemented}/architecture/2026-07-12-scoped-layers-store.zh.md (67%) diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index eb7b1d873d..e0de18e90a 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -12,13 +12,13 @@ The implementation needs enough state to preserve real ownership and settlement ## Decision -The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; the cooperative prompt-assembly result is authoritative; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race. +The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier and shared layer store; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; the cooperative prompt-assembly result is authoritative; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race. The design can be skimmed as seven choices: | Problem | Authoritative mechanism | |---|---| -| Select global plus one agent's registrations | Opaque scope key and routing carrier | +| Select global plus one agent's registrations | Opaque scope key, routing carrier, and shared layer store | | Own one live agent or session | One registry entry captured by its disposer | | Coordinate create/resume | One `AgentCreationTransaction` | | Protect durable, queued, model, or wire data | Materialize once at that boundary | @@ -68,11 +68,11 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`. -### Registry reads overlay one exact map +### Registry reads overlay one exact layer -Scope-aware registries store global contributions separately from identity-keyed local contributions. A read resolves the global layer and at most one local layer; it never traverses parentage. +Scope-aware registries use `ScopedLayers` to own one eager global aggregate and lazily created identity-keyed aggregates. A read resolves the global layer and at most one exact local layer; it never creates state or traverses parentage. Registration visibility and Cordis effect ownership derive from the same context, and reclamation waits until the concrete layer's complete aggregate is empty ([decision](2026-07-12-scoped-layers-store.md)). -Each service retains its domain rule. Named prompt values and tools use local shadowing, tool restrictions filter globals before local tools are added, and events select listener audiences rather than registered data. Scope supplies identity and ownership, not a universal merge algorithm. +Each service retains its domain rule. Named command and prompt views use the shared insertion-ordered shadow merge; tools keep a richer resolver because restrictions filter globals before local tools are added and the reserved Code Mode transport is inserted separately. Prompt variables and tool guards retain live iteration, while tool-provider membership is materialized per assembly. Scope supplies storage lifecycle and named shadowing, not a universal registry view. ### Fused dispatch helpers prevent subject drift diff --git a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml similarity index 65% rename from .agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml index 40694cc6d2..4ece091d15 100644 --- a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.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 -2026-07-12-scoped-layers-store.md: 64d565542e39a5ffcea98bc3343504760f31a416 -2026-07-12-scoped-layers-store.zh.md: ebe823b980864f4f24d4888312a73d2a5ee34fb5 +2026-07-12-scoped-layers-store.md: 5ba91f33eb079a44f966d7f0b5ff097ff529e700 +2026-07-12-scoped-layers-store.zh.md: c84792e468a435e2463b5ff682fad8a8129189ed diff --git a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md similarity index 64% rename from .agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.md rename to .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md index 64d565542e..5ba91f33eb 100644 --- a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md @@ -1,14 +1,14 @@ # Agent Note: Shared scoped-layer storage -Status: proposed +Status: implemented English | [中文](2026-07-12-scoped-layers-store.zh.md) ## Problem -Agent scoping ([decision](../../implemented/architecture/2026-07-08-agent-scope-contexts.md), [runtime design](../../implemented/architecture/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 implement that shape independently: `tools.register`, `tools.restrict`, and `tools.guard` in `dsh-tools`; `SystemPrompt.section`, `SystemPrompt.tools`, and `SystemPrompt.variable` in `dsh-system-prompt`; and `CommandService.register` in `dsh-commands`. +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.variable` in `dsh-system-prompt`; and `CommandService.register` in `dsh-commands`. -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. The copies use separate maps and collection types, so a service has no object representing one scope's complete contribution and must reproduce cleanup for every table. +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: @@ -18,9 +18,9 @@ The duplicated code carries three non-obvious requirements: 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. -## Proposal +## Decision -`@deepseek-ai/dsh-scope` gains 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. +`@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. @@ -108,16 +108,19 @@ All seven facades keep validation and diagnostics in their owning registry and c **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. -## Acceptance criteria +## Consequences -- `dsh-scope` exports exactly the four proposed storage symbols from its root and covers global construction, lazy scoped construction, non-creating reads, named shadowing, aggregate reclamation, failure cleanup, notification ordering, exact disposer identity, caller-owned duplicate errors, independent anonymous duplicates, and live iterators. -- `dsh-tools`, `dsh-system-prompt`, and `dsh-commands` migrate all seven registration facades while preserving validation order, exact diagnostics, views, notification policy, re-entrancy, and HMR disposal. -- The `dsh-scope` README and scoped core-data documentation describe the public contract; architecture and runtime-design references identify the shared store without duplicating it. Consumer READMEs remain focused on their unchanged public behavior. -- This pair moves to `implemented/architecture` in the implementation PR, changes `Proposal` to present-tense `Decision`, and records shipped consequences and verification. Existing keyless snapshots remain byte-identical. +- 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 surface stays narrow: direct table iteration preserves explicitly live behavior, while `merge()` is the one shared materialized shadowing operation. A heterogeneous `ScopeLayer` has no layer-wide `values()` 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 `EntryValues` internal and consumer policy outside the helper limits the compatibility surface. +- The migration changes no public registry behavior and no model-, human-, wire-, persistence-, configuration-, or dependency-graph output. -## Risks +## Verification -- A future registration may need asynchronous setup or several independently owned undos. That consumer must identify its ownership and settlement boundary before widening this deliberately synchronous interface. -- A throwing action that mutates outside the returned undo contract cannot be repaired generically. Entry operations are atomic, migrations perform fallible validation before insertion, and tests pin cleanup for factory and pre-retention action failures. -- Aggregate reclamation keeps a scoped layer alive until every table empties. This is intentional and observable only as internal storage lifetime; tests pin that one table's disposal does not discard sibling contributions. -- The public classes add a reusable package contract. Keeping reads narrow and domain policy in consumers limits how much future code must preserve. +- `dsh-scope` unit 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, and live iterators. +- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal. +- The scoped core-data type-equivalence check ties `ScopeLayer` documentation 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, prompt assembly, and human commands. The implementation does not update any expected transcript. diff --git a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.zh.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md similarity index 67% rename from .agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.zh.md rename to .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md index ebe823b980..c84792e468 100644 --- a/.agents/notes/proposed/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md @@ -1,14 +1,14 @@ # Agent Note: 共享作用域分层存储 -Status: proposed +Status: implemented [English](2026-07-12-scoped-layers-store.md) | 中文 ## 问题 -agent(智能体)作用域机制([决策](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)、[运行时设计](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面各自独立实现这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandService.register`(位于 `dsh-commands`)。 +agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandService.register`(位于 `dsh-commands`)。 -每个门面都围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各份实现采用不同的映射与集合类型,因此服务内没有一个对象能表示某个 scope 的完整贡献,而且每张表都必须重复清理逻辑。 +如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。 重复代码承载着三项不明显的要求: @@ -18,9 +18,9 @@ agent(智能体)作用域机制([决策](../../implemented/architecture/20 共享的是生命周期与保持插入顺序的存储,而不是注册表策略。工具限制、保留传输处理、提示词求值时机、命令规范化、精确诊断和回调异常隔离,仍分别属于不同的领域契约。 -## 提案 +## 决策 -`@deepseek-ai/dsh-scope` 新增与键类型无关的 `store.ts` 实现模块。该包(package)继续将 Cordis 和 `@deepseek-ai/dsh-invariants` 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:`ScopeLayer`、`ScopedLayers`、`NamedEntries` 和 `AnonymousEntries`。`EntryValues` 仍是内部接口,`store.ts` 不是包子路径。 +`@deepseek-ai/dsh-scope` 提供与键类型无关的 `store.ts` 实现模块。该包(package)继续将 Cordis 和 `@deepseek-ai/dsh-invariants` 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:`ScopeLayer`、`ScopedLayers`、`NamedEntries` 和 `AnonymousEntries`。`EntryValues` 仍是内部接口,`store.ts` 不是包子路径。 `ScopeLayer` 保留显式的聚合概念,同时只要求判断整个层是否为空。服务定义一个具体层,使其表结构与领域 helper 适合该服务;`ScopedLayers` 负责构造、选择、生命周期挂接、通知和聚合回收。 @@ -108,16 +108,19 @@ export class AnonymousEntries { **通过 mapped-type 表描述生成层。** 三表与单表具体层都很短、易于检查,并可自由持有领域 helper。类生成器会增加第二种构造模型和生成式运行时形状,收益却很小。 -## 验收标准 +## 后果 -- `dsh-scope` 从包根恰好导出拟议的四个存储符号,并覆盖全局构造、专属层延迟构造、非创建式读取、命名遮蔽、聚合回收、失败清理、通知顺序、原始 disposer 身份、调用方拥有的重名错误、相同匿名值的独立登记和活迭代器。 -- `dsh-tools`、`dsh-system-prompt` 与 `dsh-commands` 迁移全部七个注册门面,同时保留校验顺序、精确诊断、视图、通知策略、重入行为和 HMR 清理。 -- `dsh-scope` README 与作用域核心数据文档描述公开契约;架构和运行时设计引用标识共享 store,但不重复其内容。各消费方 README 继续聚焦其未改变的公开行为。 -- 实现 PR 将本组文件移入 `implemented/architecture`,把 `Proposal` 改为以现在时书写的 `Decision`,并记录已落地的后果与验证。现有无密钥快照保持逐字节一致。 +- 支持作用域的注册表各自通过一个聚合层表达状态,并复用相同的构造、属主、回滚、通知和回收编排。各注册表仍各自保有领域特有的校验、诊断、过滤、求值和观察者策略。 +- 公开读取接口保持狭窄:直接遍历条目表可保留显式的活语义,`merge()` 是唯一共享的物化遮蔽操作。异构的 `ScopeLayer` 不具备整层 `values()` 契约。 +- helper 刻意保持同步。未来的登记若需要异步 setup 或多份分别拥有属主的 undo,必须先明确属主与 settlement 边界,再拓宽这项契约。 +- action 必须在保留贡献前抛错,或者为自己保留的一切返回 undo;helper 无法修复超出这项契约的变更。提供的条目操作是原子的,迁移后的注册表会在插入前执行可能失败的校验。 +- 专属层会一直保持已分配状态,直到其聚合内的所有表都为空。因此,销毁一个门面不会丢弃同一 scope 拥有的其他贡献。 +- 四个公开符号构成一项可复用的包契约。将 `EntryValues` 保持为内部接口,并把消费方策略留在 helper 之外,可以限制兼容性范围。 +- 迁移不改变任何公开注册表行为,也不改变模型、人类、协议、持久化、配置或依赖图层面的任何输出。 -## 风险 +## 验证 -- 未来的登记可能需要异步 setup 或多份分别拥有属主的 undo。该消费方必须先明确其属主与 settlement 边界,再拓宽这个刻意保持同步的接口。 -- 抛错的 action 若在返回的 undo 契约之外产生变更,通用 helper 无法修复。条目操作是原子的;迁移会在插入前执行可能失败的校验;测试会钉住工厂失败和保留贡献前的 action 失败清理。 -- 聚合回收会让专属层一直存活到所有表都清空。这是有意行为,并且只能通过内部存储生命周期观察到;测试会钉住销毁一张表时不会丢弃同层的其他贡献。 -- 公开类新增了一项可复用的包契约。保持读取接口狭窄并把领域策略留在消费方,可以减少未来代码必须维持的契约范围。 +- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记和活迭代器。 +- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。 +- 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。 +- 现有 ACP(Agent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema、提示词组装和人类命令的回归边界。实现不会更新任何预期 transcript(文本记录)。 diff --git a/docs/architecture.md b/docs/architecture.md index 3844b981ab..5aaba96092 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute disp | ctx key | Package | Role | |---|---|---| -| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration primitive (library) | +| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration and shared layer storage (library) | | `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | @@ -130,7 +130,7 @@ Every session event is turn-enclosed. Reloading preserves an interrupted tail an ### Agent Scope -Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, receive only that agent's dispatches, and unwind with it; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs drivers inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`; turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +`agent.ctx` owns each live agent's scoped registrations; shared storage overlays global tool, prompt, and command entries while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Listeners match the agent; cleanup is awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs drivers inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`; turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 86118735d3..145a05a7fd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -435,7 +435,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:103`](../../packages/ui/commands/src/index.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0f65a1fae..922076a88b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -379,7 +379,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -Source: [`packages/core/system-prompt/src/index.ts:213`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` @@ -1393,7 +1393,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md index 93e6d76598..d23de86477 100644 --- a/docs/core-data-structures/scope.md +++ b/docs/core-data-structures/scope.md @@ -1,8 +1,8 @@ # Scoped Registration -The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics. +The [scope package](../../packages/core/scope) supplies the identity, carrier, and scoped-layer vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the lifecycle rationale, the [shared-storage Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md) owns the registry-layer decision, and the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics. -Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts). +Sources: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) and [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts). ## Identity and dispatch carrier @@ -39,3 +39,19 @@ interface Scope { dispose(): Promise } ``` + +## Scoped registry layer + +`ScopeLayer` represents one registry's complete contribution at the global or exact-scope level. A concrete layer may aggregate multiple named and anonymous tables; whole-layer emptiness lets `ScopedLayers` reclaim scoped state without discarding a sibling table. + +```ts type-equiv +/** One scope's aggregate contribution to a registry. */ +interface ScopeLayer { + /** Whether every table in this layer is empty. */ + isEmpty(): boolean +} +``` + +`ScopedLayers` owns the eager global layer and lazily created exact-scope layers. Reads do not create layers: `peek(undefined)` means no overlay, while `merge()` materializes insertion-ordered global named entries followed by scoped shadows. Registrations use one context for both visibility and Cordis effect ownership, collect one synchronous undo before optional notification, return Cordis's exact disposer, and reclaim a scoped layer only when its complete `ScopeLayer` is empty. + +`NamedEntries` supplies live insertion-ordered lookup and iteration with caller-owned duplicate errors. `AnonymousEntries` gives every append a unique identity so equal values remain independent. Both return idempotent exact-entry undos; the shared `EntryValues` implementation interface is not public. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bcccfb7785..60edc07705 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 7fc4a49102..1c83aec89b 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -12,6 +12,10 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c - `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics). - `Scoped` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. +- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation. +- `ScopedLayers` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer. +- `NamedEntries` Insertion-ordered named storage with caller-owned duplicate diagnostics, live lookup/iteration, and an idempotent exact-entry undo from `insert()`. +- `AnonymousEntries` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; `append()` returns an idempotent exact-entry undo. The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls. @@ -19,6 +23,8 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals. +Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md). + Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve. ## Known Limitations and Deferred Work diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d7446b2a58..b2919d8a0e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -28,6 +28,7 @@ { "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" }, { "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" }, { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeLayer", "source": "packages/core/scope/src/store.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" }, From 0180cdecd05f13b021569dcd45c9c98feaff4a01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:06:42 +0800 Subject: [PATCH 10/10] fix(scope): detach drained entry generations --- .../2026-07-12-scoped-layers-store.i18n.yaml | 4 +-- .../2026-07-12-scoped-layers-store.md | 12 ++++----- .../2026-07-12-scoped-layers-store.zh.md | 12 ++++----- docs/core-data-structures/scope.md | 2 +- packages/core/scope/README.md | 4 +-- packages/core/scope/src/store.ts | 26 ++++++++++++------- packages/core/scope/tests/store.spec.ts | 26 +++++++++++++++++++ .../core/system-prompt/tests/scoped.spec.ts | 22 ++++++++++++++++ packages/core/tools/tests/scoped.spec.ts | 22 ++++++++++++++++ 9 files changed, 103 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml index 4ece091d15..b49506364b 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.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 -2026-07-12-scoped-layers-store.md: 5ba91f33eb079a44f966d7f0b5ff097ff529e700 -2026-07-12-scoped-layers-store.zh.md: c84792e468a435e2463b5ff682fad8a8129189ed +2026-07-12-scoped-layers-store.md: b850b6bcbb22401b386b4458b6d5c65a160c85cd +2026-07-12-scoped-layers-store.zh.md: 8bfc0a0e8ec1e3de624ff8d9e48b7517833fc025 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md index 5ba91f33eb..b850b6bcbb 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md @@ -74,17 +74,17 @@ export class AnonymousEntries { - The constructor creates `global` once with `createLayer(undefined)`. A scoped layer is created only by `effect()`; `peek()` and `merge()` never create one, and `peek(undefined)` returns `undefined` because 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 native `Map` order and live iteration. -- `AnonymousEntries.append()` assigns a unique internal key per registration, so equal callbacks or values remain independent. Its iterator is live and insertion-ordered. +- `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 native `Map` order 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 with `scopeOf(ctx)` and attaches the action to that same `ctx.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 Cordis `Effect` union. - `effect()` collects the action's undo before calling `onChange` and returns the exact `ctx.effect()` disposer. Disposal runs the action undo before notification, is idempotent through Cordis, and removes a scoped layer only after its complete `ScopeLayer.isEmpty()` becomes true. - `options.notify` defaults to `true`. The callback's own policy stays authoritative: tool and prompt change callbacks may throw and trigger registration rollback; `CommandService.notifyChange()` contains observer failures; tool guards pass `notify: false`. ## Registry migrations -`dsh-tools` defines one `ToolLayer` containing named tools plus anonymous compiled restrictions and guard registrations. `ToolRegistry` 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 continues to live-iterate global then scoped registrations so re-entrant additions retain current behavior. +`dsh-tools` defines one `ToolLayer` containing named tools plus anonymous compiled restrictions and guard registrations. `ToolRegistry` 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 continue to live-iterate global then scoped tables, preserving re-entrant registration behavior. +`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`. Effective views use `merge()`, while `CommandService` retains definition normalization and freezing, exact duplicate diagnostics, sorted immutable descriptors, direct execution, HMR cleanup, and independently contained `commands/change` observers. @@ -120,7 +120,7 @@ All seven facades keep validation and diagnostics in their owning registry and c ## Verification -- `dsh-scope` unit 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, and live iterators. -- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal. +- `dsh-scope` unit 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 `ScopeLayer` documentation 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, prompt assembly, and human commands. The implementation does not update any expected transcript. diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md index c84792e468..8bfc0a0e8e 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md @@ -74,17 +74,17 @@ export class AnonymousEntries { - 构造器只创建一次 `global`,调用的是 `createLayer(undefined)`。只有 `effect()` 会创建专属层;`peek()` 和 `merge()` 从不创建专属层,而 `peek(undefined)` 返回 `undefined`,因为全局层已经显式存在。 - `merge()` 是唯一会物化结果的通用读取接口。它按插入顺序复制全局命名条目,再按专属条目的插入顺序应用这些条目;同名条目完成遮蔽,但不会移动无关名称。 -- `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序和活遍历语义。 -- `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器是保留插入顺序的活迭代器。 +- `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序,并在同一个非空表 generation 内保持活遍历;清空表会开启新的 generation,因此尚未结束的迭代器无法观察到自我替换。 +- `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器保留插入顺序,并采用同样的 generation 活遍历边界。 - `effect()` 通过 `scopeOf(ctx)` 导出键,并把 action 挂到同一个 `ctx.effect()` 上。它只接受一个同步 action,且该 action 只返回一个同步 undo;action 要么返回其 undo,要么必须在保留任何贡献之前抛错。helper 不会规范化更宽泛的 Cordis `Effect` union。 - `effect()` 在调用 `onChange` 前收集 action 的 undo,并原样返回 `ctx.effect()` 的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有整个层的 `ScopeLayer.isEmpty()` 变为 true 后,helper 才删除专属层。 - `options.notify` 默认为 `true`。回调自身的策略仍具最终效力:工具与提示词的 change 回调可以抛错并触发登记回滚;`CommandService.notifyChange()` 会隔离观察者失败;工具 guard 传入 `notify: false`。 ## 注册表迁移 -`dsh-tools` 定义一个 `ToolLayer`,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。`ToolRegistry` 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 `run_code` 插入。guard 求值继续先活遍历全局登记,再活遍历专属登记,因此重入时新增的登记保持现有行为。 +`dsh-tools` 定义一个 `ToolLayer`,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。`ToolRegistry` 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 `run_code` 插入。guard 求值会先活遍历全局登记,再活遍历专属登记:向非空 generation 新增的登记可以在当前分发中运行,而 guard 表清空后的自我替换则从下一次分发开始运行。 -`dsh-system-prompt` 定义一个 `PromptLayer`,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方继续先活遍历全局表,再活遍历专属表,从而保留重入登记行为。 +`dsh-system-prompt` 定义一个 `PromptLayer`,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方会先活遍历全局表,再活遍历专属表:向非空 generation 新增的提供方可以在当前组装中运行,而变量表清空后的自我替换则从下一次组装开始运行。 `dsh-commands` 定义一个单表层,其中包含 `NamedEntries`。生效视图使用 `merge()`;`CommandService` 则保留对定义的规范化与冻结处理、精确重名诊断、经过排序的不可变描述符、直接执行、HMR(热模块替换)清理,以及对各个 `commands/change` 观察者分别隔离失败的行为。 @@ -120,7 +120,7 @@ export class AnonymousEntries { ## 验证 -- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记和活迭代器。 -- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。 +- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记、活迭代器,以及表清空后的 generation 脱离。 +- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。 - 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。 - 现有 ACP(Agent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema、提示词组装和人类命令的回归边界。实现不会更新任何预期 transcript(文本记录)。 diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md index d23de86477..e9869f3152 100644 --- a/docs/core-data-structures/scope.md +++ b/docs/core-data-structures/scope.md @@ -54,4 +54,4 @@ interface ScopeLayer { `ScopedLayers` owns the eager global layer and lazily created exact-scope layers. Reads do not create layers: `peek(undefined)` means no overlay, while `merge()` materializes insertion-ordered global named entries followed by scoped shadows. Registrations use one context for both visibility and Cordis effect ownership, collect one synchronous undo before optional notification, return Cordis's exact disposer, and reclaim a scoped layer only when its complete `ScopeLayer` is empty. -`NamedEntries` supplies live insertion-ordered lookup and iteration with caller-owned duplicate errors. `AnonymousEntries` gives every append a unique identity so equal values remain independent. Both return idempotent exact-entry undos; the shared `EntryValues` implementation interface is not public. +`NamedEntries` supplies insertion-ordered lookup and live iteration with caller-owned duplicate errors. `AnonymousEntries` gives every append a unique identity so equal values remain independent. Iteration stays live within one nonempty table generation; draining the table detaches existing iterators from later insertions. Both return idempotent exact-entry undos; the shared `EntryValues` implementation interface is not public. diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 1c83aec89b..cfc09c1dca 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -14,8 +14,8 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. - `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation. - `ScopedLayers` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer. -- `NamedEntries` Insertion-ordered named storage with caller-owned duplicate diagnostics, live lookup/iteration, and an idempotent exact-entry undo from `insert()`. -- `AnonymousEntries` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; `append()` returns an idempotent exact-entry undo. +- `NamedEntries` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo. +- `AnonymousEntries` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo. The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls. diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts index 5f016df929..53f7e34135 100644 --- a/packages/core/scope/src/store.ts +++ b/packages/core/scope/src/store.ts @@ -23,11 +23,12 @@ interface EntryValues { /** * Insertion-ordered named entries with caller-owned duplicate diagnostics. * - * Values are borrowed. Iterators are live native `Map` iterators, and each + * Values are borrowed. Iterators are live within one nonempty table + * generation; draining the table detaches them from later insertions. Each * successful insertion returns an idempotent undo for that exact entry. */ export class NamedEntries implements EntryValues { - private readonly data = new Map() + private data = new Map() constructor( private readonly duplicateError: (name: string) => Error, @@ -40,13 +41,15 @@ export class NamedEntries implements EntryValues { * @returns an idempotent undo that removes only this insertion. */ insert(name: string, value: V): () => void { - if (this.data.has(name)) throw this.duplicateError(name) - this.data.set(name, value) + const data = this.data + if (data.has(name)) throw this.duplicateError(name) + data.set(name, value) let active = true return () => { if (!active) return active = false - this.data.delete(name) + data.delete(name) + if (data.size === 0 && this.data === data) this.data = new Map() } } @@ -104,11 +107,12 @@ export class NamedEntries implements EntryValues { /** * Insertion-ordered anonymous entries with independent registration identity. * - * Equal values remain separate registrations. Values are borrowed and the - * returned iterator retains native live `Map` semantics. + * Equal values remain separate registrations. Values are borrowed, and + * iterators are live within one nonempty table generation; draining the table + * detaches them from later appends. */ export class AnonymousEntries implements EntryValues { - private readonly data = new Map() + private data = new Map() /** * Append one independently owned value. @@ -116,13 +120,15 @@ export class AnonymousEntries implements EntryValues { * @returns an idempotent undo for this exact append. */ append(value: V): () => void { + const data = this.data const key = Symbol() - this.data.set(key, value) + data.set(key, value) let active = true return () => { if (!active) return active = false - this.data.delete(key) + data.delete(key) + if (data.size === 0 && this.data === data) this.data = new Map() } } diff --git a/packages/core/scope/tests/store.spec.ts b/packages/core/scope/tests/store.spec.ts index 9f70d2b527..622dbeb541 100644 --- a/packages/core/scope/tests/store.spec.ts +++ b/packages/core/scope/tests/store.spec.ts @@ -59,6 +59,19 @@ describe('NamedEntries', () => { undoB() expect([...entries.entries()]).toEqual([['a', 3]]) }) + + it('starts a fresh iterator generation after the table drains', () => { + const entries = new NamedEntries(name => new Error(`duplicate: ${name}`)) + const undo = entries.insert('first', 1) + const values = entries.values() + + expect(values.next()).toEqual({ value: 1, done: false }) + undo() + entries.insert('replacement', 2) + + expect(values.next().done).toBe(true) + expect([...entries.values()]).toEqual([2]) + }) }) describe('AnonymousEntries', () => { @@ -78,6 +91,19 @@ describe('AnonymousEntries', () => { undoSecond() expect(entries.isEmpty()).toBe(true) }) + + it('starts a fresh iterator generation after the table drains', () => { + const entries = new AnonymousEntries() + const undo = entries.append(1) + const values = entries.values() + + expect(values.next()).toEqual({ value: 1, done: false }) + undo() + entries.append(2) + + expect(values.next().done).toBe(true) + expect([...entries.values()]).toEqual([2]) + }) }) describe('ScopedLayers', () => { diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index a3c85b7886..23b55201b2 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -101,6 +101,28 @@ describe('scoped variables', () => { const again = await mintScope(ctx, 'child2') again.ctx.systemPrompt.variable('v', () => '3') }) + + it('defers a scoped variable that replaces the last provider in its generation', async () => { + const ctx = await mount({ persona: 'Mode: {{mode}}.' }) + const scope = await mintScope(ctx, 'child') + const key = scopeKeyOf(scope) + const calls: string[] = [] + scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' }) + const dispose = scope.ctx.systemPrompt.variable('mode', () => { + calls.push('first') + dispose() + scope.ctx.systemPrompt.variable('mode', () => { + calls.push('replacement') + return 'replacement' + }) + return 'first' + }) + + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.') + expect(calls).toEqual(['first']) + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.') + expect(calls).toEqual(['first', 'replacement']) + }) }) describe('scoped tool providers and toolOrder × restriction', () => { diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index cab0ee7155..a18adf8593 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -287,6 +287,28 @@ describe('scoped execution dispatch', () => { expect(calls).toEqual(['first', 'late']) }) + it('defers a scoped guard that replaces the last guard in its generation', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + const calls: string[] = [] + ctx.tools.register(tool('t')) + scope.ctx.tools.register(tool('scope_sibling')) + const lift = scope.ctx.tools.guard(() => { + calls.push('first') + lift() + scope.ctx.tools.guard(() => { + calls.push('replacement') + return 'replacement denial' + }) + return undefined + }) + + expect(await run(ctx, 't', key)).toBe('ran:t') + expect(calls).toEqual(['first']) + expect(await run(ctx, 't', key)).toBe('Error: replacement denial') + expect(calls).toEqual(['first', 'replacement']) + }) + it('shares one token and materialized argument value across the pipeline', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a')