mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #479 from deepseek-harness/worktree/scoped-layers-implementation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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<V> {
|
||||
|
||||
**通过 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(文本记录)。
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/*`
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<Comma
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md)
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts)
|
||||
Source: [`packages/ui/commands/src/index.ts:227`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `ctx.compact` — `CompactService` (abstract seam)
|
||||
|
||||
@@ -1150,7 +1150,7 @@ async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
|
||||
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<ToolExecutionResult>
|
||||
|
||||
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`
|
||||
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
```
|
||||
|
||||
## 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<L>` 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<V>` supplies live insertion-ordered lookup and iteration with caller-owned duplicate errors. `AnonymousEntries<V>` 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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -12,6 +12,10 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` 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<T>` 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<L>` 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<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, live lookup/iteration, and an idempotent exact-entry undo from `insert()`.
|
||||
- `AnonymousEntries<V>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
241
packages/core/scope/src/store.ts
Normal file
241
packages/core/scope/src/store.ts
Normal file
@@ -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<V> {
|
||||
values(): IterableIterator<V>
|
||||
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<V> implements EntryValues<V> {
|
||||
private readonly data = new Map<string, V>()
|
||||
|
||||
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<string> {
|
||||
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<V> {
|
||||
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<V> implements EntryValues<V> {
|
||||
private readonly data = new Map<symbol, V>()
|
||||
|
||||
/**
|
||||
* 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<V> {
|
||||
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<L extends ScopeLayer> {
|
||||
/** The eagerly constructed context-global layer. */
|
||||
readonly global: L
|
||||
|
||||
private readonly scoped = new Map<ScopeKey, L>()
|
||||
|
||||
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<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V> {
|
||||
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<L>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
263
packages/core/scope/tests/store.spec.ts
Normal file
263
packages/core/scope/tests/store.spec.ts
Normal file
@@ -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<number>
|
||||
readonly anonymous = new AnonymousEntries<string>()
|
||||
|
||||
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<Scope> {
|
||||
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<number>(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<object>()
|
||||
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<ScopeKey | undefined> = []
|
||||
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<ScopeKey | undefined> = []
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, string
|
||||
return result + text.slice(last)
|
||||
}
|
||||
|
||||
/** One tool-schema provider stored in a prompt layer. */
|
||||
type ToolProvider = (context: AssembleContext) => 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<PromptSection>
|
||||
readonly toolProviders = new AnonymousEntries<ToolProvider>()
|
||||
readonly variables: NamedEntries<VariableProvider>
|
||||
|
||||
/**
|
||||
* 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<Config> = 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, (context: AssembleContext) => 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<ScopeKey, PromptSection[]>()
|
||||
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
||||
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => 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, (context: AssembleContext) => 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<string, string | undefined> = {}
|
||||
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<string, PromptSection>()
|
||||
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<string>()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ToolExecution>) => 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<ToolDefinition>
|
||||
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
|
||||
readonly guards = new AnonymousEntries<ToolGuard>()
|
||||
|
||||
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<ToolRunContext, HookContext[]>()
|
||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||
private global = new Map<string, ToolDefinition>()
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Compiled restriction filters, per scope (see {@link restrict}). */
|
||||
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
|
||||
/** Monotonic post-policy guards, split into global and per-agent layers. */
|
||||
private globalGuards = new Set<ToolGuardRegistration>()
|
||||
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
|
||||
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<string, ToolDefinition> {
|
||||
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<ToolGuardRegistration> {
|
||||
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<string, ToolDefinition>()
|
||||
const knownNames = new Set<string>()
|
||||
const restrictableNames = new Set<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<RegisteredCommand>
|
||||
|
||||
/**
|
||||
* 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<string, RegisteredCommand>()
|
||||
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
|
||||
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<string, RegisteredCommand> {
|
||||
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<string, RegisteredCommand> {
|
||||
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. */
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user