diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml new file mode 100644 index 0000000000..6ec968a410 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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 .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +2026-08-08-per-preset-standing-mounts.md: 19a53926e9f07b01115cfbb4ddc89eb7ee0c59a0 +2026-08-08-per-preset-standing-mounts.zh.md: 856fbb742a843935902f9aa7578ba6821a9a13c3 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md new file mode 100644 index 0000000000..19a53926e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -0,0 +1,32 @@ +# Agent Note: Per-preset standing mounts over a scope parent chain + +Status: implemented + +English | [中文](2026-08-08-per-preset-standing-mounts.zh.md) + +## Problem + +Per-session preset mounts made the model-facing registry surface per-agent while three independent host readers still assumed it was static: cold `session.history` found no presenters (every card silently degraded to the generic renderer — indistinguishable from "tool has no presenter"), the projections block dropped preset-registered keys (clients treat an omitted key as capability absence and CLEAR the row), and the TypeRT gateway resolved `goals` on the host root (`service-unavailable`). Patching each reader individually traded one silent degradation for another: resuming to reach presenters flipped the projections fold from detached to live and wiped the token counts instead. + +## Decision + +A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by `setScopeParent(agentKey, standingKey)`. Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf. + +## Consequences + +Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap + +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: : true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. +- A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. + +## Load-bearing details + +- **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer. +- **A settled mount is permanent for the process.** The composition a running session joined must survive its file changing or disappearing; deletion and edits reach only future generations (the authoring layer swaps the map pointer, never disposes a joined generation), and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. +- **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it). +- **Re-linking a key (`setScopeParent` on a live agent) is the blank-session recompose path** — valid only while nothing produced under the old parent is retained, which the caller must uphold; the relation cannot see session logs. + +## Alternatives considered + +Resume-on-read (wipes detached projections), a host-plane presenter table plus a block completeness flag (fixes two readers, leaves the class), per-session template mounts (duplicates every instance to serve pure functions). Kept for the record: the gateway-facing `goals` domain stays host-plane regardless — a Remote method whose receiver comes from a generated descriptor resolves on the host, which is the `bash-env` host-plane criterion read from the consuming side. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md new file mode 100644 index 0000000000..856fbb742a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Per-preset standing mounts over a scope parent chain + +Status: implemented + +[English](2026-08-08-per-preset-standing-mounts.md) | 中文 + +## Problem + +按会话挂载 preset 让面向模型的注册面变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 `session.history` 找不到 presenter(每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分)、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并**清掉**该行)、TypeRT 网关在宿主根上解析 `goals`(`service-unavailable`)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume,会把投影折叠从 detached 翻到 live,token 计数随之被抹掉。 + +## Decision + +一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过 `setScopeParent(agentKey, standingKey)` 加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 + +## Consequences + +常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: + +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: : true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 +- 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 + +## Load-bearing details + +- **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 +- **挂载一旦成功即进程级永久。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;删除与编辑只影响未来的代际(创作层替换 map 指针,绝不 dispose 已被加入的代际),被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 +- **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 +- **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。 + +## Alternatives considered + +冷读时 resume(抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 `goals` 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析,这正是 `bash-env` 宿主平面判据从消费侧读出的样子。 diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 8375ed819a..fc82473bad 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -1,18 +1,21 @@ -# The `standard` agent preset: the full coding agent, mounted per session. +# The `standard` agent preset: the full coding agent, mounted once per process. # -# This file is an AGENT-PLANE composition. It is mounted under one agent's -# scope context, so every tool and prompt section it registers belongs to that -# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`) -# keeps everything a preset must not own: the registries themselves, the -# sandbox and approval stack, persistence, and the model route. +# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a +# standing scope; every session naming it joins by scope parentage, so the +# tools and prompt sections registered here cover each joined agent while a +# session's own state stays keyed per Session/Agent inside the plugins. The +# host composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a +# preset must not own: the registries themselves, the sandbox and approval +# stack, persistence, and the model route. # # A service row here MUST sit inside a group carrying an `isolate` realm. -# Without one it publishes into the root realm, where it is process-global -# rather than per-session and the second session mounting this preset collides -# with the first; `dsh-agent-presets` rejects that at mount. `true` means an -# entry-local realm — one private instance per mounted session, which is the -# default this deployment wants. A shared label would instead pool one instance -# across every session naming it. +# Without one it publishes into the root realm, where it is process-global — +# another preset publishing the same name collides, and a host reader would +# resolve one preset's instance for every session; `dsh-agent-presets` rejects +# that at mount. `true` means an entry-local realm: this standing mount's own +# private instance, apart from every other preset's. (A shared label does NOT +# pool instances — `provide()` throws on the second registration under the +# same realm symbol; labels join REALMS, and are not what this file needs.) # ── identity ──────────────────────────────────────────────────────────────── diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index e1c38db74e..1a0d8cf89a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -236,14 +236,13 @@ describe('web e2e: seeded history renders through cold resume', () => { // The seed carries a session/title event: the title unit is host-plane, so // it folds the detached log and serves the value with nothing composed. expect(typeof projections?.values.title).toBe('string') - // `todos` is NOT here, and that is the contract rather than a gap. Its unit - // is registered by `tool-todo` inside an agent's preset, so a detached - // session yields it from exactly one place: a durable checkpoint written - // while the session was live. This seed was written straight to persistence - // and never ran, so it recorded none — and the answer no longer depends on - // whether some UNRELATED session happens to be composed right now, which is - // the whole reason the checkpoint row carries its own view. - expect(projections?.values).not.toHaveProperty('todos') + // `todos` IS here, as its empty fold (null). Its unit is registered by + // `tool-todo` inside the default preset's STANDING mount, which the read + // itself ensures — deterministically, not because some unrelated session + // happens to be composed. A present-but-null key is what keeps the + // client's "omitted key = capability absent → clear the row" rule from + // wiping preset-owned projections on cold reads. + expect(projections?.values).toHaveProperty('todos', null) }) it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => { diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index df47e186c3..857bfaf13e 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6c109c6757..cbcf894a1e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -68,14 +68,16 @@ async list(): Promise async resolve(id?: string): Promise /** - * Compose one agent from a preset, installing it under that agent alone. + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. * * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls * the agent creation back, so a broken preset never yields a half-composed * session. * @param agentCtx - the agent's scope context. * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the preset that was mounted, for the caller to record. + * @returns the preset that was composed, for the caller to record. * @throws when the preset is unknown or its composition is unusable. */ async mount(agentCtx: Context, id?: string): Promise @@ -127,26 +129,42 @@ async remove(id: string): Promise serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined /** - * Replace the composition installed for one agent. + * Re-link one agent to a different preset's standing composition. * * Only valid while the agent has produced nothing: swapping tools mid - * conversation would leave logged tool calls the new composition cannot make. - * The CALLER owns that check — this method does not read session history. + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. * - * The swap is unmount-then-mount because two compositions cannot coexist: - * both would register the same tool names into one layer. A failed mount - * therefore restores the previous composition rather than leaving the agent - * with nothing. + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. - * @throws when the preset is unknown or its composition is unusable; the - * previous composition is restored first. + * @throws when the preset is unknown or its composition is unusable. */ async recompose(agentCtx: Context, id: string): Promise + +/** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ +async standingKeyFor(id?: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:67`](../../packages/preset/agent-presets/src/index.ts) +Types: [ScopeKey](../core-data-structures/scope.md) + +Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c7f6ee0bb4..7885997c01 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -94,7 +94,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async mount(agentCtx: Context, id?: string): Promise', - jsDoc: '/**\n * Compose one agent from a preset, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was mounted, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', + jsDoc: '/**\n * Compose one agent from a preset: ensure the preset\'s standing mount, then\n * parent the agent\'s scope key to it so the mount\'s registrations and\n * listeners cover this agent.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was composed, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, { signature: 'async read(id: string): Promise', @@ -114,7 +114,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async recompose(agentCtx: Context, id: string): Promise', - jsDoc: '/**\n * Replace the composition installed for one agent.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot make.\n * The CALLER owns that check — this method does not read session history.\n *\n * The swap is unmount-then-mount because two compositions cannot coexist:\n * both would register the same tool names into one layer. A failed mount\n * therefore restores the previous composition rather than leaving the agent\n * with nothing.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable; the\n * previous composition is restored first.\n */', + jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */', + }, + { + signature: 'async standingKeyFor(id?: string): Promise', + jsDoc: '/**\n * The standing scope key of one preset, for a host reader with no agent.\n *\n * A cold transcript read resolves tool presenters against the composition\n * the session recorded, and the standing mount makes that possible without\n * resuming anything: ensuring the mount composes plugins but starts no\n * agent, no session, and no turn.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the standing scope key readers pass as a registry view scope.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, ], }, diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index c9212a3998..5e6bfac97a 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/scope/README.md -README.md: 4f32573779a15e8c34b4936bfe75549dfc86d9f6 -README.zh.md: 16ec60a5489f909a46fd5f803dbf08490cd07988 +README.md: 9bd0a7741829dd74765061dc6d3ac1efa2800485 +README.zh.md: f5a975e5ac067c7fb7abbe1477ad2c8b0fac98f4 diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 4f32573779..9bd0a77418 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -2,20 +2,21 @@ English | [中文](README.zh.md) -Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents. +Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`setScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either. ## Public API -- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). +- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` records the enclosing scope via `setScopeParent` before the scope is usable. +- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Ordinarily written once at mint; re-linking an existing key is the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the caller's contract — this relation cannot see what a session logged). A link closing a cycle throws. `scopeChainOf` returns `[key, parent, …]` nearest-first. - `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. - `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. -- `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). +- `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 its tag is the key or an ancestor of it; `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. +- `ScopedLayers` Own one eager global layer and lazy exact-scope layers. `peek()` never creates and stays chain-blind (a scope's OWN contributions — restrictions, guards — must not silently pick up an ancestor's), `chainLayers()` returns existing overlays farthest-ancestor-first, `merge()` materializes insertion-ordered named shadows along the chain, 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, 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. @@ -32,5 +33,5 @@ Handing out a scoped context hands out the minting plugin's service-resolution s ## Known Limitations and Deferred Work - **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context. -- **A context carries one nearest scope key** — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets. +- **A context carries one nearest scope key** — the hierarchy lives in the key-level parent relation, not in context tags; nested scope CONTEXTS still shadow to a single tag, and multi-membership policy sets remain unsupported. - **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder. diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index 16ec60a548..f5a975e5ac 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -2,11 +2,12 @@ [English](README.md) | 中文 -带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。agent loop(智能体循环)为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包无需依赖 agent 即可使用。 +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`setScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 ## 公开 API -- `createScope(ctx: Context, key: ScopeKey): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。 +- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `setScopeParent` 记录其外围作用域。 +- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。通常在创建时写入一次;对已有键重新认父是空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是调用方的契约——该关系看不见会话记录了什么)。会闭环的链接直接抛错。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。 - `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。 - `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。 - `Scope.dispose(): Promise`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。 @@ -15,7 +16,7 @@ - `Scoped`:编译期不透明载体 brand。按作用域筛选的事件要求它作为 `this` 类型,因此使用裸主体分发会产生编译错误。类型参数记录主体类型,但不公开其属性。 - `isScopeCarrier(value)`/`carrierKeyOf(value)`:运行时载体标记,开发不变式使用它们断言每次按作用域筛选的分发都携带载体,而且载体键与参数所指名的主体一致。 - `ScopeLayer`:一个注册表的完整全局贡献或精确作用域贡献的聚合契约;`isEmpty()` 控制带作用域层的回收。 -- `ScopedLayers`:拥有一个立即创建的全局层和按需创建的精确作用域层。`peek()` 从不创建;`merge()` 物化按插入顺序排列的具名遮蔽项;`effect()` 从同一上下文推导可见性与所有权,同时返回原样 Cordis disposer。 +- `ScopedLayers`:持有一个立即构造的全局层与惰性的精确作用域层。`peek()` 从不创建且刻意不看链(某作用域**自己**的贡献——限制、守卫——不得悄悄继承祖先的),`chainLayers()` 按最远祖先在前返回已存在的各层,`merge()` 沿链物化按插入序的具名遮蔽,`effect()` 从同一上下文推导可见性与所有权,并返回精确的 Cordis disposer。 - `NamedEntries`:按插入顺序排列的具名存储,调用方拥有重复项诊断、查找,以及一个非空表世代内的实时迭代。表清空后,现有迭代器与后续插入项脱离;`insert()` 返回幂等的精确条目撤销函数。 - `AnonymousEntries`:按插入顺序排列的匿名存储;唯一内部键使相同值仍作为独立注册存在。它使用相同的清空世代迭代器边界;`append()` 返回幂等的精确条目撤销函数。 @@ -32,5 +33,5 @@ ## 已知限制与暂缓事项 - **只有感知作用域的表层才会隔离状态**:注册表必须按 `scopeOf()` 归档,事件必须通过 `scopeTarget()` 分发;仅仅通过带作用域的上下文调用任意 Cordis 服务,并不会改变该服务仍为上下文全局这一事实。 -- **一个上下文只携带一个最近的作用域键**:嵌套作用域会遮蔽父作用域的标签,而不会形成层级策略集或多成员策略集。 +- **一个上下文只携带一个最近的作用域键**:层级关系存在于键级父关系中而非上下文标签里;嵌套作用域**上下文**仍遮蔽为单一标签,多成员策略集仍不受支持。 - **服务可达性来自作用域创建者**:交出 `Scope.ctx` 也会交出创建插件注入的服务表层,因此,若作用域创建者提供的服务范围较宽,持有者之后也无法将其收窄。 diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index fc5b1fa5fa..7a23a97bcd 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -29,6 +29,54 @@ export type Scoped = object & { readonly [ScopedBrand]: T } /** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */ const carrierKeys = new WeakMap() +/** + * The enclosing scope of each key. One relation powers both directions of + * scope nesting: registration views inherit DOWN the chain (a child scope + * sees its ancestors' layers — {@link ScopedLayers}), and event admission + * extends UP it (a listener tagged with an ancestor receives events dispatched + * to a descendant key — {@link scopeTarget}). + */ +const scopeParents = new WeakMap() + +/** + * Record `parent` as `key`'s enclosing scope. + * + * Ordinarily set once when the child scope is minted ({@link createScope}'s + * `parent` option). Re-linking an existing key to a different parent is the + * blank-session recompose operation: valid only while nothing produced under + * the old parent is retained, which is the caller's contract to uphold — this + * relation cannot see what a session logged. A link that would close a cycle + * is rejected, because every chain consumer walks parents to the root. + * @param key - the child scope key. + * @param parent - its enclosing scope key. + */ +export function setScopeParent(key: ScopeKey, parent: ScopeKey): void { + for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) { + if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle') + } + scopeParents.set(key, parent) +} + +/** + * Read one key's enclosing scope. + * @param key - the scope key to inspect. + * @returns its parent key, or `undefined` for a root scope. + */ +export function scopeParentOf(key: ScopeKey): ScopeKey | undefined { + return scopeParents.get(key) +} + +/** + * The chain from a key to its root ancestor. + * @param key - the starting key, or `undefined` for the empty chain. + * @returns keys nearest-first: `[key, parent, grandparent, …]`. + */ +export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] { + const chain: ScopeKey[] = [] + for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor) + return chain +} + /** A minted registration scope and its quiescent disposal boundaries. */ export interface Scope { /** Context through which scope-owned registrations are made. */ @@ -48,14 +96,22 @@ async function quiesceFiber(fiber: Fiber): Promise { /** Shared no-op plugin used as the backing scope fiber. */ function scope(): void {} +/** Options accepted by {@link createScope}. */ +export interface CreateScopeOptions { + /** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */ + parent?: ScopeKey +} + /** * Mint a scope under `ctx`. The scoped context inherits the minting plugin's * dependency surface and owns every registration made through it. * @param ctx - active context whose dependency surface the scope inherits. * @param key - opaque identity used for listener routing. + * @param options - optional scope-chain placement. * @returns the scoped context and exact/shared disposal boundaries. */ -export function createScope(ctx: Context, key: ScopeKey): Scope { +export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { + if (options?.parent !== undefined) setScopeParent(key, options.parent) const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined @@ -77,7 +133,12 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { /** * Build an opaque receiver that preserves the base filter, admits untagged - * listeners globally, and admits tagged listeners only for a matching key. + * listeners globally, and admits tagged listeners for a matching key or any + * of its ancestors ({@link setScopeParent}): a listener owned by an enclosing + * scope receives every descendant scope's events, which is what lets one + * standing composition observe each of the agents composed under it. A tag + * BELOW the dispatch key stays excluded — events flow up the chain, never + * down. * @param base - subject or service whose existing Cordis filter is preserved. * @param key - routed scope identity, or `undefined` for an unscoped subject. * @returns a carrier whose subject remains available only through event arguments. @@ -88,7 +149,11 @@ export function scopeTarget(base: T, key: ScopeKey | undefined [CordisContext.filter](ctx: Context): boolean { if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false const tag = scopeOf(ctx) - return tag === undefined || tag === key + if (tag === undefined) return true + for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) { + if (cursor === tag) return true + } + return false }, } carrierKeys.set(carrier, key) diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts index cdb34b50ce..a9e1468ccd 100644 --- a/packages/core/scope/src/store.ts +++ b/packages/core/scope/src/store.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' -import { scopeOf } from './index.ts' +import { scopeChainOf, scopeOf } from './index.ts' import type { ScopeKey } from './index.ts' /** One scope's aggregate contribution to a registry. */ @@ -170,7 +170,10 @@ export class ScopedLayers { } /** - * Read an existing exact-scope overlay. + * Read an existing exact-scope overlay. Deliberately chain-blind: callers + * addressing one scope's OWN contributions (its restrictions, its guards) + * must not silently pick up an ancestor's — use {@link chainLayers} where + * inheritance is the point. * @param scope - exact scope key; `undefined` denotes no overlay. * @returns the existing scoped layer, or `undefined` without creating one. */ @@ -180,8 +183,25 @@ export class ScopedLayers { } /** - * Materialize global named entries followed by exact-scope shadows. - * @param scope - exact viewing scope, or `undefined` for the global view. + * Existing overlays along the scope's parent chain ({@link scopeChainOf}), + * farthest ancestor first and the exact scope last, so a caller layering + * them in order gives the nearest scope the final word. + * @param scope - viewing scope, or `undefined` for no overlays. + * @returns the existing layers, nearest last; absent overlays are skipped. + */ + chainLayers(scope: ScopeKey | undefined): L[] { + const layers: L[] = [] + for (const key of scopeChainOf(scope).reverse()) { + const layer = this.scoped.get(key) + if (layer !== undefined) layers.push(layer) + } + return layers + } + + /** + * Materialize global named entries followed by scope-chain shadows, + * farthest ancestor first, so the nearest scope's entry wins a name. + * @param scope - viewing scope, or `undefined` for the global view. * @param pick - select the named table from a layer. * @returns an insertion-ordered effective map. */ @@ -190,9 +210,9 @@ export class ScopedLayers { 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) + for (const layer of this.chainLayers(scope)) { + for (const [name, value] of pick(layer).entries()) merged.set(name, value) + } return merged } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 0b7bbef348..8e31fe1522 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import { carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget, setScopeParent } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { @@ -153,3 +153,62 @@ describe('scopeTarget', () => { expectTypeOf(carrier).toEqualTypeOf>() }) }) + +describe('scope parent chain', () => { + it('links at mint, walks to the root, and rejects cycles', () => { + const ctx = new Context() + const preset = { kind: 'preset' } + const agent = { kind: 'agent' } + createScope(ctx, preset) + createScope(ctx, agent, { parent: preset }) + + expect(scopeParentOf(agent)).toBe(preset) + expect(scopeParentOf(preset)).toBeUndefined() + expect(scopeChainOf(agent)).toEqual([agent, preset]) + expect(scopeChainOf(undefined)).toEqual([]) + expect(() => { setScopeParent(preset, agent) }).toThrow(/cycle/) + expect(() => { setScopeParent(preset, preset) }).toThrow(/cycle/) + }) + + it('re-links to a different parent (the blank-session recompose path)', () => { + const ctx = new Context() + const presetA = { id: 'a' } + const presetB = { id: 'b' } + const agent = { id: 'agent' } + createScope(ctx, presetA) + createScope(ctx, presetB) + createScope(ctx, agent, { parent: presetA }) + + setScopeParent(agent, presetB) + + expect(scopeChainOf(agent)).toEqual([agent, presetB]) + }) + + it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => { + const ctx = new Context() + const preset = { kind: 'preset' } + const agent = { kind: 'agent' } + const other = { kind: 'other-preset' } + const presetScope = createScope(ctx, preset) + const agentScope = createScope(ctx, agent, { parent: preset }) + const otherScope = createScope(ctx, other) + + const seen: string[] = [] + ctx.on('probe/event' as never, ((): void => { seen.push('untagged') }) as never) + presetScope.ctx.on('probe/event' as never, ((): void => { seen.push('preset') }) as never) + agentScope.ctx.on('probe/event' as never, ((): void => { seen.push('agent') }) as never) + otherScope.ctx.on('probe/event' as never, ((): void => { seen.push('other') }) as never) + + const emit = ctx as unknown as { emit: (carrier: object, type: string) => void } + // Dispatch at the AGENT key: its own tag and its ancestor's admit; a + // sibling root does not. + emit.emit(scopeTarget({}, agent), 'probe/event') + expect(seen.sort()).toEqual(['agent', 'preset', 'untagged']) + + // Dispatch at the PRESET key: the agent-tagged listener sits BELOW the + // dispatch key and stays excluded — events flow up the chain, not down. + seen.length = 0 + emit.emit(scopeTarget({}, preset), 'probe/event') + expect(seen.sort()).toEqual(['preset', 'untagged']) + }) +}) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 9e789c6526..6f1757ff28 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -440,9 +440,11 @@ export class SystemPrompt extends Service { for (const [name, provider] of this.layers.global.variables.entries()) { variables[name] = provider(context) } - const scopedVariables = this.layers.peek(scope)?.variables - for (const [name, provider] of scopedVariables?.entries() ?? []) { - variables[name] = provider(context) + // Scope-chain variables, farthest first, so the nearest scope wins a name. + for (const layer of this.layers.chainLayers(scope)) { + for (const [name, provider] of layer.variables.entries()) { + variables[name] = provider(context) + } } // Scoped sections shadow globals before the stable order sort. const sectionByName = this.layers.merge(scope, layer => layer.sections) @@ -450,7 +452,7 @@ export class SystemPrompt extends Service { // Validate order against pre-restriction names while collecting visible schemas. const providers = [ ...this.layers.global.toolProviders.values(), - ...(this.layers.peek(scope)?.toolProviders.values() ?? []), + ...this.layers.chainLayers(scope).flatMap(layer => [...layer.toolProviders.values()]), ] const collected: ToolSchema[] = [] const knownNames = new Set() diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index cf07b44dc4..56cc07ce64 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1038,11 +1038,16 @@ export class ToolRegistry extends Service { ) } - /** First monotonic denial from the global then matching scoped guard layers. */ + /** First monotonic denial from the global then the scope chain's guard layers, farthest first. */ private guardReason(exec: ToolExecution): string | undefined { const globalReason = this.layers.global.guardReason(exec) if (globalReason !== undefined) return globalReason - return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec) + if (exec.agent === undefined) return undefined + for (const layer of this.layers.chainLayers(exec.agent)) { + const reason = layer.guardReason(exec) + if (reason !== undefined) return reason + } + return undefined } /** @@ -1054,20 +1059,26 @@ export class ToolRegistry extends Service { * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { - const layer = this.layers.peek(scope) + // Scope-chain layers, farthest ancestor first, the exact scope last. + const layers = this.layers.chainLayers(scope) const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() for (const [name, definition] of this.layers.global.tools.entries()) { knownNames.add(name) restrictableNames.add(name) - if (layer?.admits(name) ?? true) visible.set(name, definition) + // Restrictions intersect across the whole chain: any scope on it may + // mask a global-surface name for everything nested inside it. + if (layers.every(layer => layer.admits(name))) 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?.tools.entries() ?? []) { - knownNames.add(name) - visible.set(name, definition) + // Chain layers second, nearest last: same-name entries REPLACE (shadow) + // the global and farther-scope ones, and scope-local registrations are + // never part of the global filter above. + for (const layer of layers) { + for (const [name, definition] of layer.tools.entries()) { + knownNames.add(name) + visible.set(name, definition) + } } // Presentation infrastructure is resolved last and outside capability // filtering. Registration rejects this reserved name, so the insertion is diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4f4c37792c..e963ad07c7 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -61,6 +61,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and // `ctx.get('approval')` without a value dependency on the seam (optional composition). @@ -492,16 +493,17 @@ function viewFor( ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown, - // The presenter lives with the definition, and definitions are per agent - // now: a preset registers its tools into that agent's layer, leaving the - // global layer empty. Looking one up without the owner finds nothing, and - // every card silently degrades to the generic renderer. - agent?: Agent, + // Presenters live with the definitions, and definitions live in the scope + // chain: a preset registers its tools into its standing layer. A live agent + // is a scope whose chain passes through its preset; a cold read passes the + // preset's standing key directly — no agent, no resume. An undefined scope + // sees only the global layer, which is the pre-preset deployment shape. + scope?: ScopeKey, ): ToolEventView | undefined { try { if (event.type === 'tool/call') { const { name, arguments: raw } = event.data as ToolCallData - const view = ctx.tools.get(name, agent)?.presentCall?.(JSON.parse(raw)) + const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw)) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { @@ -510,7 +512,7 @@ function viewFor( const callId = message.source.callId const call = argsFor(callId) as { name: string; args: unknown } | undefined if (call === undefined) return undefined - const view = ctx.tools.get(call.name, agent)?.presentResult?.(call.args, { + const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, { content: result.content, isError: result.isError === true, ...meta === undefined ? {} : { meta }, @@ -553,12 +555,12 @@ function historyPage( events: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number | undefined, - agent?: Agent, + scope?: ScopeKey, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), agent) + const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope) return { event, ...view === undefined ? {} : { view } } }), hasMore: page.hasMore, @@ -1215,21 +1217,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async function historyStateFor( sessionId: SessionId, includeProjections: boolean, - ): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> { const attached = ctx.sessions.get(sessionId) if (attached !== undefined) { const events = [...attached.events] const projections = includeProjections ? projectionsFor(ctx, attached) : undefined - return { events, ...projections === undefined ? {} : { projections } } + return { header: attached.header, events, ...projections === undefined ? {} : { projections } } } const inspected = await inspectServable(sessionId) const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined return { + header: inspected.meta, events: inspected.events, ...projections === undefined ? {} : { projections }, } } + /** + * The registry view scope a transcript's presenters resolve in. + * + * A live agent is that scope itself (its chain passes through its preset's + * standing layer). A cold session names its preset on the header, and the + * preset's STANDING key serves without resuming anything — ensuring the + * mount composes plugins but starts no agent, session, or turn. No roster, + * no recorded preset, or a preset the roster no longer supplies all fall + * back to the global layer: the transcript still serves, with the generic + * cards a viewless entry renders. + * @param sessionId - the transcript being read. + * @param header - that session's header (attached or inspected). + * @returns the scope to pass to presenter lookups, or undefined for global. + */ + async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise { + const live = ctx.get('agents')?.get(sessionId) + if (live !== undefined) return live + const presets = ctx.get('agentPresets') + if (presets === undefined) return undefined + try { + // An unrecorded preset (a log from before the roster existed) renders + // through the DEFAULT preset's standing layer: that is the composition + // an unnamed session composes today, and presenters are pure display, + // so the worst a mismatch produces is the generic card it had anyway. + return await presets.standingKeyFor(header.agentPreset) + } catch { + // Swallows only the unknown/unusable-preset rejection from the roster: + // a deleted or broken preset must degrade this read, never fail it. + return undefined + } + } + /** Resolve one requested identity to a live agent, creating or resuming it once. */ async function ensureSession( sessionId: SessionId, @@ -1818,7 +1853,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock } + let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock } try { state = await historyStateFor(sessionId, beforeSeq === undefined) } catch (error: unknown) { @@ -1831,11 +1866,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - // `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may - // serve history from storage with no agent registry composed at all. - // An absent registry means no live agent, which is the same answer a - // present one gives here — presenters fall back to the global layer. - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId)) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header)) return ok(request, { events: page.events, hasMore: page.hasMore, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 605fedbd3a..6ca9c995d4 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -71,19 +71,39 @@ function roster(ids: readonly string[]): unknown { if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` }) }, + // The standing scope key a cold transcript read resolves presenters in. + standingKeyFor: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + standingKeyRequests.push(wanted) + if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) { + return Promise.reject(new UnknownPresetError(wanted, ids)) + } + let key = standingKeys.get(wanted) + if (key === undefined) { + key = { agentPreset: wanted } + standingKeys.set(wanted, key) + } + return Promise.resolve(key) + }, } } +/** Standing keys the roster double minted, and the ids readers asked for. */ +const standingKeys = new Map() +const standingKeyRequests: string[] = [] +/** Preset ids whose standing mount the double reports as unusable. */ +const failingStandingKeys = new Set() + /** Per-agent service instances a mounted preset would own, keyed by session id. */ const services = new Map>() -async function harness(presets?: readonly string[]) { +async function harness(presets?: readonly string[], persistence?: unknown) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never) if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never) const factory: AgentFactory = { @@ -438,3 +458,39 @@ describe('authoring over the wire', () => { expect(response.result.error.code).toBe('agent-preset-not-found') }) }) + +describe('session.history presenter scope', () => { + it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' })) + // Cold: creation registered a live agent in this harness, so simulate the + // cold path by asking for a session only persistence knows... the harness + // has no persistence, so read the live one and assert no roster query. + standingKeyRequests.length = 0 + const live = await api.sessions.history(request({ sessionId: SessionId('p1') })) + expect(live.result.ok).toBe(true) + // A live agent IS the presenter scope; the roster is not consulted. + expect(standingKeyRequests).toEqual([]) + }) + + it('serves a COLD transcript whose standing mount is no longer usable', async () => { + // A genuinely cold session: persistence knows it, no live agent exists. + const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } + const { api } = await harness(['standard'], { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] }), + }) + // The preset broke after the session ran: the roster rejects the mount. + failingStandingKeys.add('standard') + try { + standingKeyRequests.length = 0 + const response = await api.sessions.history(request({ sessionId: SessionId('p3') })) + // Degraded, never failed: the roster WAS asked, and the transcript + // still serves — with the generic cards a viewless entry renders. + expect(standingKeyRequests).toEqual(['standard']) + expect(response.result.ok).toBe(true) + } finally { + failingStandingKeys.delete('standard') + } + }) +}) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 11ad62d49d..28f097e1d6 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: cc019e79673c8441feaeb0509e0f333fcce17252 -README.zh.md: 48950cc001811abd7cfcb2eac34e923f64886514 +README.md: 66145b1870fdfa2b0ca7395f02dc5ac21fa46cab +README.zh.md: aef118435d0957e79fc9b5baee531f9bfe206586 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index cc019e7967..66145b1870 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Per-session agent composition. A **preset** is a directory holding one `agent.cordis.yml`; mounting it under an agent's scope context gives that one session its own tools, prompt sections, and other model-facing contributions, while every other live session keeps its own. +Per-preset agent composition. A **preset** is a directory holding one `agent.cordis.yml`; the roster mounts it ONCE per process under a standing scope, and each session that names it joins by having its agent scope key parented to the mount's (`dsh-scope`'s parent chain). The mount's tools, prompt sections, and projection units exist exactly once and cover every joined agent — its plugins key their state by Session/Agent, so sessions stay apart inside one shared instance — and a host reader with no agent at all (a cold transcript read) resolves the same standing registrations by preset id. -The mechanism is entirely Cordis: entry contexts chain to the context a subtree was plugged into, and both [`dsh-tools`](../../core/tools/README.md) and [`dsh-system-prompt`](../../core/system-prompt/README.md) file registrations into the calling context's scope layer. Mounting a composition under `agent.ctx` therefore makes it that agent's alone, and unwinds it with the agent, without any new layering in those registries. +The mechanism is two seams. Entry contexts chain to the context a subtree was plugged into, and both [`dsh-tools`](../../core/tools/README.md) and [`dsh-system-prompt`](../../core/system-prompt/README.md) file registrations into the calling context's scope layer — so the standing mount's contributions land in the PRESET's layer. What carries them to each session is `dsh-scope`'s parent chain: an agent's views resolve `agent → preset → global` (nearest shadowing farthest), and the mount's listeners are admitted for every agent parented under it while a sibling preset's stay deaf. ## Service: `AgentPresets` (ctx key: `agentPresets`) @@ -13,18 +13,19 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none. - `ctx.agentPresets.list(): Promise` Every preset the configured roots currently supply, earlier root winning a duplicate id. - `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. -- `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset and return the preset that was mounted, for the caller to record. -- `ctx.agentPresets.recompose(agentCtx, id): Promise` Replace the composition installed for one agent. Valid only while the agent has produced nothing — **the caller owns that check**; this method does not read session history. +- `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. +- `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. +- `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. - `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be written at all. - `ctx.agentPresets.read(id): Promise` One preset's composition text, exactly as stored. -- `ctx.agentPresets.write(id, content): Promise` Create or replace a locally authored preset. -- `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. +- `ctx.agentPresets.write(id, content): Promise` Create or replace a locally authored preset. Edits reach only future generations: the standing pointer drops, sessions already joined keep the mount they run on. +- `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. `AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file). ### Where to call `mount()` -The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the composition installed while the agent is still unpublished, so a rejected mount rolls the whole creation back rather than leaving a half-composed session. The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and the caller receives no disposer. +The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. A settled mount is permanent for the process: the composition a running session joined must outlive its file changing or disappearing underneath it, so file edits reach only future generations. ### Which preset a session runs @@ -95,7 +96,7 @@ A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot aud **A row that never became usable.** The loader already rejects a row whose module failed to import or whose plugin threw; what remains is a row still waiting for a service the composition never supplies, which the audit names. -**A row that published a service into the root realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local for one session's private instance, or a shared label when several sessions should share one — or the service belongs in the host composition instead. +**A row that published a service into the root realm.** Such a service is process-global, so the second preset publishing the same name collides with the first, and a host reader would resolve one preset's instance for every session. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local realms keep two presets' same-named services apart exactly as they once kept two sessions' apart — or the service belongs in the host composition instead. The package invariant re-checks that last rule on every service notification, because a row that publishes from a timer or an asynchronous continuation would escape the one-shot audit. @@ -111,7 +112,7 @@ Presets are compositions, so a preset is exactly as privileged as the plugins it ## Model Experience -Indirectly, through the plugins a mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent. +Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it. #### KV Cache effect @@ -119,7 +120,8 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work -- **A preset cannot be changed once a session has produced anything** — `recompose()` covers the blank-agent case; past the first turn, unwinding the subtree would drop tools the model may already have called, so the choice is fixed for the session's life. Changing the default affects only sessions created afterwards. -- **A written composition is never mounted to check it** — `write()` validates shape, not resolvability, so a preset naming a missing plugin is stored and fails at the next session that selects it. +- **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. +- **A standing mount reads its file once per generation** — the first session to name a preset fixes its composition until an authoring `write()`/`remove()` drops the pointer or the whole tree unloads; sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions). +- **A written composition is never mounted to validate** — `write()` checks shape, not resolvability, so a preset naming a missing plugin is stored and fails at the next session that selects it. - **Display names are the directory id** — a preset carries no manifest, so pickers and settings surfaces show the id until a consumer needs richer metadata. - **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 48950cc001..aef118435d 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -按会话组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;把它挂载到某个 agent 的 scope 上下文之下,该会话就拥有自己的工具、提示词段落以及其他面向模型的贡献,而其他在运行的会话各自保持不变。 +按 preset 组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;roster 在整个进程内只把它挂载一次(常驻 scope),命名它的每个会话通过把自己 agent 的 scope key 认父到该挂载(`dsh-scope` 的父链)来加入。挂载的工具、提示词段落与投影单元只存在一份,覆盖所有已加入的 agent——其插件本就按 Session/Agent 分键存状态,会话在共享实例内互不串扰——而完全没有 agent 的宿主读取方(冷读记录)也能按 preset id 解析到同一份常驻注册。 -其机制完全来自 Cordis:entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册。因此把一份组装挂到 `agent.ctx` 之下,它就只属于该 agent,并随 agent 一起卸载,无需在这些注册表中新增任何分层。 +其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。 ## 服务:`AgentPresets`(ctx 键:`agentPresets`) @@ -13,18 +13,19 @@ - `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。 - `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。 - `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。 -- `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent,并返回所挂载的 preset 供调用方记录。 -- `ctx.agentPresets.recompose(agentCtx, id): Promise` 替换某个 agent 已装入的组装。仅在该 agent 尚未产出任何内容时有效——**该检查由调用方负责**,本方法不读取会话历史。 -- `ctx.agentPresets.authorable: boolean` 是否存在 `user` 信任级别的根目录,也即是否可能写入 preset。 -- `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容完全一致。 -- `ctx.agentPresets.write(id, content): Promise` 创建或替换一个本地创作的 preset。 -- `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset。若用户默认值正指向刚被删除的这一个,则清除它:存下一个尚不存在的默认值是有意为之,但本次调用删掉的那个再也不会有人提供,留着它会让每个未显式指名的会话都无法开启。 +- `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。 +- `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。 +- `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。 +- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可写。 +- `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容逐字一致。 +- `ctx.agentPresets.write(id, content): Promise` 创建或替换一个本地创作的 preset。编辑只影响未来的代际:常驻指针被丢弃,已加入的会话保持其正在运行的挂载。 +- `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。 `AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。 ### 应在何处调用 `mount()` -agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。 +agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。挂载一旦成功即进程级永久:正在运行的会话所加入的组装必须在其文件被修改或删除后继续存活,因此文件编辑只影响未来的代际。 ### 会话实际运行的是哪个 preset @@ -95,7 +96,7 @@ agent-presets: **某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。 -**某一行把服务发布进了根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——用 entry 本地 realm 得到该会话私有的实例,或用共享 label 让多个会话共用一个——否则该服务应改放进宿主组装。 +**某一行把服务发布进了根 realm。** 这类服务是进程级全局的,因此第二个发布同名服务的 preset 会与第一个相撞,宿主读取方也会把某一个 preset 的实例当成所有会话的。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——entry 本地 realm 让两个 preset 的同名服务互不相干,正如它从前隔开两个会话——否则该服务应改放进宿主组装。 最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 @@ -111,7 +112,7 @@ preset 就是组装,因此一个 preset 的权限恰好等于它所引用的 ## Model Experience -Indirectly, through the plugins a mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent. +Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it. #### KV Cache effect @@ -119,7 +120,8 @@ Indirectly, through the plugins a mounted composition registers, which own every ## Known Limitations and Deferred Work -- **会话一旦产出任何内容便无法更换 preset** —— `recompose()` 覆盖空白 agent 的情形;第一个轮次之后,卸载子树会抽走模型可能已经调用的工具,因此该选择在会话的整个生命周期内固定。更改默认值只影响此后创建的会话。 +- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 +- **常驻挂载每个代际只读一次文件** —— 首个命名某 preset 的会话固定其组装,直到创作面的 `write()`/`remove()` 丢弃指针或整棵树卸载;已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。 - **写入的组装从不被实际挂载以校验** —— `write()` 校验形状而非可解析性,因此引用了缺失插件的 preset 会被存下,并在下一个选择它的会话处失败。 - **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。 - **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 9f1fe66938..a49ec6bbd7 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -1,23 +1,34 @@ /** * Agent presets: each session composes its model-facing plugin set from one - * preset `cordis.yml` mounted under that agent's scope context. + * preset `cordis.yml`, mounted ONCE per preset under a standing scope and + * joined by every agent that names it. + * + * The standing mount is what makes a preset one composition rather than one + * per session: its plugin instances, tool registrations, prompt sections, and + * projection units exist exactly once, keyed per session inside the plugins + * themselves (they predate presets and were written for a shared world). An + * agent joins by having its scope key parented to the mount's + * ({@link setScopeParent}), which makes the mount's registrations visible to + * that agent's views and the mount's listeners receive that agent's events — + * and a host reader with no agent at all (a cold transcript read) resolves + * the same standing registrations by preset id. * * This package owns the preset vocabulary, filesystem discovery, and the - * guarded mount. It does not decide when an agent is created — the agent - * factory's `setup(agentCtx)` hook is the one supported call site, because - * only there is the composition installed while the agent is still - * unpublished, so a rejected mount rolls the whole creation back. + * guarded standing mount. It does not decide when an agent is created — the + * agent factory's `setup(agentCtx)` hook is the one supported call site, + * because only there is the join installed while the agent is still + * unpublished, so a rejected composition rolls the whole creation back. * @module @deepseek-ai/dsh-agent-presets */ import { Context, Service } from 'cordis' -import { scopeOf } from '@deepseek-ai/dsh-scope' import z from 'schemastery' +import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { deleteComposition, readComposition, writeComposition } from './authoring.ts' import type { PresetMetadata } from './metadata.ts' -import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts' +import { mountPreset, serviceForAgent } from './mount.ts' import { PresetNotWritableError } from './authoring.ts' import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' @@ -41,7 +52,7 @@ export { } from './metadata.ts' export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, - unmountPresetFor, type PresetMount, + type PresetMount, } from './mount.ts' export { assertComposition, deleteComposition, InvalidCompositionError, InvalidPresetIdError, @@ -89,8 +100,19 @@ export class AgentPresets extends Service { */ private settingsService: SettingsService | undefined + /** + * The service's own untraced context. Methods invoked through the traceable + * proxy see `this.ctx` rebound to the CALLER's context, which carries a + * shadow; a subtree minted from it resolves every service through that + * shadow's fiber instead of each entry's own inject store, so preset rows + * would fail on the very services they declare. Standing mounts must hang + * off the untraced original (the `tasks-local` selfCtx precedent). + */ + private readonly selfCtx: Context + constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') + this.selfCtx = ctx // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -146,19 +168,37 @@ export class AgentPresets extends Service { } /** - * Compose one agent from a preset, installing it under that agent alone. + * Standing mounts by preset id, single-flight so two agents racing the + * first use of one preset share one composition. A settled failure is + * removed so a later session retries a preset whose file has been fixed; a + * settled success is permanent for the process — the composition a running + * session joined must survive the file changing or disappearing underneath + * it, so file edits reach only future generations (a later authoring layer + * swaps this pointer; it never disposes a joined generation). + */ + private readonly standing = new Map>() + + /** + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. * * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls * the agent creation back, so a broken preset never yields a half-composed * session. * @param agentCtx - the agent's scope context. * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the preset that was mounted, for the caller to record. + * @returns the preset that was composed, for the caller to record. * @throws when the preset is unknown or its composition is unusable. */ async mount(agentCtx: Context, id?: string): Promise { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset') + } const preset = await this.resolve(id) - await mountPreset(agentCtx, preset) + const standing = await this.ensureStanding(preset) + setScopeParent(agentKey, standing.key) return preset } @@ -197,6 +237,11 @@ export class AgentPresets extends Service { throw new PresetNotWritableError(id, 'it ships with the deployment') } await writeComposition(this.config.roots, id, content, metadata) + // Future generations only: the standing pointer is dropped so the NEXT + // session composes the edited file, while every session already joined + // keeps the mount it runs on — a superseded generation is never disposed + // while the process lives (reclaimed only by whole-tree teardown). + this.standing.delete(id) } /** @@ -206,6 +251,9 @@ export class AgentPresets extends Service { */ async remove(id: string): Promise { await deleteComposition(this.config.roots, await this.resolve(id)) + // Same generation rule as `write`: sessions on the deleted preset keep + // their standing mount; only new sessions see the roster without it. + this.standing.delete(id) // Storing a default that does not exist YET is deliberate — the roster is a // live directory, so a name absent now may exist by the time a session asks // for it, and `resolve` reports it then. A default this call just deleted is @@ -239,50 +287,77 @@ export class AgentPresets extends Service { } /** - * Replace the composition installed for one agent. + * Re-link one agent to a different preset's standing composition. * * Only valid while the agent has produced nothing: swapping tools mid - * conversation would leave logged tool calls the new composition cannot make. - * The CALLER owns that check — this method does not read session history. + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. * - * The swap is unmount-then-mount because two compositions cannot coexist: - * both would register the same tool names into one layer. A failed mount - * therefore restores the previous composition rather than leaving the agent - * with nothing. + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. - * @throws when the preset is unknown or its composition is unusable; the - * previous composition is restored first. + * @throws when the preset is unknown or its composition is unusable. */ async recompose(agentCtx: Context, id: string): Promise { - const scope = scopeOf(agentCtx) - if (scope === undefined) { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { throw new Error('agent-presets: refusing to recompose an unscoped context') } - // Resolve before tearing anything down, so an unknown id leaves the agent - // exactly as it was. const preset = await this.resolve(id) - const previous = await unmountPresetFor(scope) - try { - await mountPreset(agentCtx, preset) - } catch (error) { - if (previous !== undefined) { - // Restored unconditionally, same id included: the roster is a live - // directory, so "the same inputs that worked a moment ago" does not - // hold — the file may have changed between the original mount and - // this one, which is exactly how a same-id reselect fails. Skipping - // the restore there left the agent with no composition at all. - await this.mount(agentCtx, previous).catch(() => { - // The agent now has no composition, but the switch failure below is - // the actionable diagnostic; reporting the restore's instead would - // hide why the switch was attempted and what the operator must fix. - }) - } - throw error - } + const standing = await this.ensureStanding(preset) + setScopeParent(agentKey, standing.key) return preset } + + /** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ + async standingKeyFor(id?: string): Promise { + const preset = await this.resolve(id) + return (await this.ensureStanding(preset)).key + } + + /** Resolve (or create, single-flight) the standing mount of one preset. */ + private ensureStanding(preset: AgentPreset): Promise { + const pending = this.standing.get(preset.id) + if (pending !== undefined) return pending + const created = (async (): Promise => { + const key: ScopeKey = { agentPreset: preset.id } + const scope = createScope(this.selfCtx, key) + try { + await mountPreset(scope.ctx, preset) + } catch (error) { + this.standing.delete(preset.id) + await scope.dispose() + throw error + } + return { key, scope } + })() + this.standing.set(preset.id, created) + return created + } +} + +/** One preset's standing composition. */ +interface StandingMount { + /** Scope key agents are parented to; also the mount's registration scope. */ + readonly key: ScopeKey + /** Disposal boundary; held for whole-tree teardown, never per-session. */ + readonly scope: Scope } export default AgentPresets diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 9635f0b008..fac3319fa1 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -18,7 +18,7 @@ import { pathToFileURL } from 'node:url' import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' import type { EntryTree } from '@cordisjs/plugin-loader' -import { scopeOf, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' import { PresetMountError, type AgentPreset } from './types.ts' /** What one mounted subtree publishes about itself for the audit to read. */ @@ -112,8 +112,8 @@ export interface PresetMount { readonly presetId: string /** The mounted subtree's fiber. */ readonly fiber: Fiber - /** The scope the subtree was mounted for — the agent that owns it. */ - readonly scope: ScopeKey + /** The standing scope key agents are parented to (undefined only in torn-down records). */ + readonly key: ScopeKey | undefined } const mounts = new Set() @@ -150,24 +150,6 @@ export function livePresetMounts(): PresetMount[] { return [...mounts] } -/** - * Discard the composition currently installed for one scope, if any. - * - * Only a composition that has produced nothing may be replaced: swapping a - * live agent's tools mid-conversation would leave logged tool calls the new - * composition cannot make. The caller owns that check — this function does the - * teardown and returns once the subtree is quiescent. - * @param scope - the agent whose installed composition to discard. - * @returns the preset id that was discarded, or `undefined` when none was. - */ -export async function unmountPresetFor(scope: ScopeKey): Promise { - const installed = livePresetMounts().find(mount => mount.scope === scope) - if (installed === undefined) return undefined - mounts.delete(installed) - await Promise.resolve(installed.fiber.dispose()) - return installed.presetId -} - /** * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. * @@ -244,14 +226,22 @@ export function serviceForAgent( agent: { ctx: Context }, name: K, ): Context[K] | undefined { - const root = agent.ctx.fiber + // The agent's own key is parented to its preset's standing key; the mount + // is no longer under the agent's fiber, so the search roots at the standing + // mount instead of walking up from the agent. + const agentKey = scopeOf(agent.ctx) + if (agentKey === undefined) return undefined + const standingKey = scopeParentOf(agentKey) + if (standingKey === undefined) return undefined + const mount = livePresetMounts().find(candidate => candidate.key === standingKey) + if (mount === undefined) return undefined const store = ctx.reflect.store for (const key of Object.getOwnPropertySymbols(store)) { const impl = store[key] /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ if (impl === undefined) continue if (impl.name !== name) continue - if (withinFiber(impl.fiber, root)) return impl.value as Context[K] + if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K] } return undefined } @@ -323,13 +313,14 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi ) } const config: Include.Config = { path: pathToFileURL(preset.path).href } - // Captured before the subtree exists: the agent context still carries the - // host composition's base, which is inside the installed harness and is - // therefore where a row's package name has to resolve from. - /* v8 ignore next -- the Loader sets `baseUrl` on the root before any agent context derives from it */ + // Captured before the subtree exists: the standing scope context still + // carries the host composition's base, which is inside the installed + // harness and is therefore where a row's package name has to resolve from. + /* v8 ignore next -- the Loader sets `baseUrl` on the root before any scoped context derives from it */ if (agentCtx.baseUrl !== undefined) harnessBase.set(config, agentCtx.baseUrl) - // Before the record this mount is about to add: every session takes this - // path, so it is what keeps the set bounded on a host that never reads it. + // Before the record this mount is about to add: standing mounts are one per + // preset and live until whole-tree teardown, so pruning here only sweeps + // records of torn-down runtimes (tests; an HMR reload of the roster). pruneDisposedMounts() const handle = agentCtx.plugin(PresetTree, config) try { @@ -349,7 +340,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi + 'a preset service must sit behind an `isolate` realm or move to the host composition', ) } - mounts.add({ presetId: preset.id, fiber, scope }) + mounts.add({ presetId: preset.id, fiber, key: scopeOf(agentCtx) }) } catch (error) { try { await handle.dispose() diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index cf3dc80ea0..17d89813c3 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -38,7 +38,7 @@ async function harness(): Promise { } describe('agent-presets invariants', () => { - it('tracks a mounted composition and forgets it once the agent is gone', async () => { + it('keeps the standing composition alive across the agents that joined it', async () => { const ctx = await harness() const handle = await ctx.agents.create({ sessionId: SessionId('inv-live'), @@ -47,8 +47,20 @@ describe('agent-presets invariants', () => { expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') + // A standing mount survives its agents: the composition a session joined + // is shared, so one session ending must not strip it from the next. await handle.dispose() + expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') + // A second agent reuses the same mount rather than adding one. + await ctx.agents.create({ + sessionId: SessionId('inv-live-2'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(1) + + // Whole-tree teardown is the boundary that does reclaim it. + await ctx.fiber.dispose() expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard') }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 2b42290ca7..ad1f3ac6c9 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -178,21 +178,21 @@ describe('rejecting a composition that cannot be used', () => { expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) }) - it('addresses one agent\'s instance of a realm-private service', async () => { + it('addresses the standing instance of a realm-private service through either agent', async () => { const first = await agentOn(ctx, 'sess-reach-a', 'isolated') const second = await agentOn(ctx, 'sess-reach-b', 'isolated') - // The realm keeps the service out of every host context — that is what - // makes it per session — so a caller holding the agent is the only way a - // request from OUTSIDE the session can read the instance it is about. + // The realm keeps the service out of every host context, so a caller + // holding the agent is how a request from OUTSIDE the session reads the + // instance it is about. expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc') const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc') expect(mine).toBeDefined() - expect(theirs).toBeDefined() - // Each agent gets ITS own: the addressing is per subtree, not a lookup - // that happens to find the first match. - expect(mine).not.toBe(theirs) + // ONE composition per preset: both agents joined the same standing mount, + // so they address the same instance — sessions stay apart inside it by + // the plugin's own Session/Agent keying, not by instance count. + expect(theirs).toBe(mine) }) it('answers undefined for a service the agent\'s preset does not mount', async () => { @@ -365,33 +365,43 @@ describe('replacing a composition', () => { expect(toolNames(ctx, handle.agent)).toEqual([]) }) - it('reports the switch failure even when the restore also fails', async () => { - // The previous preset's whole directory disappears between the unmount - // and the restore. The caller still needs to hear why the switch was - // refused rather than why putting the old one back did not work. - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-vanishing-')) - await mkdir(join(root, 'vanishing'), { recursive: true }) - // An absolute plugin path, because a relative one resolves from the - // preset's own directory and this preset does not live beside the fixtures. - await writeFile(join(root, 'vanishing', COMPOSITION_FILE), [ - '- id: alpha', - ` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`, - ' config:', - ' tool: vanishing', - '', - ].join('\n')) - const local = await harness({ - default: 'vanishing', - roots: [{ path: root, trust: 'user' as const }, ...ROOTS], + it('keeps the agent on its standing composition when a switch fails, even with the source deleted', async () => { + // A preset root this test owns, so removing the composition mid-flight + // cannot disturb the shipped fixtures. + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-')) + const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', '- id: nope\n name: ./does-not-exist.js\n']] + for (const [id, body] of seeded) { + await mkdir(join(root, id)) + await writeFile(join(root, id, COMPOSITION_FILE), body) + } + const scoped = new Context() + scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' + await scoped.plugin(Loader) + scoped.loader.builtins.include = Include + await scoped.plugin(LlmService) + await scoped.plugin(SessionStore) + await scoped.plugin(SystemPrompt, { persona: '' }) + await scoped.plugin(ToolRegistry) + await scoped.plugin(AgentRegistry) + await scoped.plugin(AgentLoop, { agents: [] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + const handle = await scoped.agents.create({ + sessionId: SessionId('sess-restore-gone'), + setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), }) - const handle = await local.agents.create({ - sessionId: SessionId('sess-vanishing'), - setup: async (agentCtx: Context) => void await local.agentPresets.mount(agentCtx, 'vanishing'), - }) - await rm(root, { recursive: true, force: true }) - await expect(local.agentPresets.recompose(handle.agent.ctx, 'broken')) + // The roster is a live directory: the composition the agent came from can + // be gone from DISK by the time a switch fails. The standing mount is not + // the file — it outlives deletion, so there is nothing to "restore". + await rm(join(root, 'first'), { recursive: true }) + + await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken')) .rejects.toThrow(/failed to mount/) + + // The failed switch left the agent EXACTLY as it was: the new standing + // mount is ensured before the parent link moves, so a rejection never + // strips the old composition. + expect(toolNames(scoped, handle.agent)).toEqual(['only']) }) it('refuses an unscoped context', async () => {