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 index a8bd523cb6..d8c55c9f0a 100644 --- 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 @@ -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 .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 61aa737a4b7d6f15f160d3673a14cdc0c5df4a76 -2026-08-08-per-preset-standing-mounts.zh.md: 17de2cf3ec9ee9564cb5fde1fabb5014c389be39 +2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 +2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 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 index 61aa737a4b..834d645f5f 100644 --- 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 @@ -10,7 +10,7 @@ Per-session preset mounts made the model-facing registry surface per-agent while ## 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. +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 binding its scope key to the mount's (`bindScopeParent(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 @@ -25,7 +25,7 @@ Standing mounts fix the class, not the instances: the registrations a reader nee - **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 serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their 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. +- **Re-linking runs only through the `ScopeParentBinding` the mount's one bind returned** — the roster holds it privately, so the blank-session recompose path is the sole re-link and no other caller can move a composed agent; it stays valid only while nothing produced under the old parent is retained, which the holder must uphold because the relation cannot see session logs. ## Alternatives considered 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 index 17de2cf3ec..45ce0f4e7d 100644 --- 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 @@ -10,7 +10,7 @@ Status: implemented ## Decision -一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过 `setScopeParent(agentKey, standingKey)` 加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 +一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key(`bindScopeParent(agentKey, standingKey)`)加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 ## Consequences @@ -25,7 +25,7 @@ Status: implemented - **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 - **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 - **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 -- **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。 +- **重新认父只能经由挂载首绑返回的 `ScopeParentBinding`**——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent;其合法性仍以旧父之下产出一概不被保留为前提,由持有方保证,因为该关系看不见会话日志。 ## Alternatives considered diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index 3ceb7931b6..7df5df403c 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: b73f99fdffb7e3dba5e4eb31b35ff623e8f3d57c -README.zh.md: dd54ef053a8b5d8507c5049dd924b3fbe85bf5ee +README.md: a8fbe97ae3b59f223bb52e44860439803fda420c +README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8 diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index b73f99fdff..a8fbe97ae3 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -2,12 +2,12 @@ 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. 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. +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 (`bindScopeParent`): 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, 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. +- `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` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal. +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `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. diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index dd54ef053a..af23823298 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`setScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 ## 公开 API -- `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, …]`,最近者在前。 +- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。 +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`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 也是如此。 diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 7a23a97bcd..b5f58dbdf0 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -38,25 +38,49 @@ const carrierKeys = new WeakMap() */ 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 { +/** The privileged handle to move one scope key's parent link. */ +export interface ScopeParentBinding { + /** + * Re-link the bound key to a different parent, with the same cycle check as + * the bind. Valid only while nothing produced under the old parent is + * retained — the blank-session recompose contract, which the holder upholds + * because this relation cannot see what a session logged. + * @param parent - the new enclosing scope key. + */ + rebind(parent: ScopeKey): void +} + +/** Cycle-checked write shared by the bind and every rebind. */ +function linkScopeParent(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) } +/** + * Bind `parent` as `key`'s enclosing scope, once. + * + * A key that already has a parent throws: there is no open re-link path, so a + * scope's ancestry cannot be moved by anyone but the original binder, who + * alone receives the {@link ScopeParentBinding}. 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. + * @returns the binding that alone may re-link this key. + */ +export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding { + if (scopeParents.has(key)) { + throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind') + } + linkScopeParent(key, parent) + return { + rebind(next: ScopeKey): void { + linkScopeParent(key, next) + }, + } +} + /** * Read one key's enclosing scope. * @param key - the scope key to inspect. @@ -98,7 +122,7 @@ function scope(): void {} /** Options accepted by {@link createScope}. */ export interface CreateScopeOptions { - /** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */ + /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */ parent?: ScopeKey } @@ -111,7 +135,7 @@ export interface CreateScopeOptions { * @returns the scoped context and exact/shared disposal boundaries. */ export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { - if (options?.parent !== undefined) setScopeParent(key, options.parent) + if (options?.parent !== undefined) bindScopeParent(key, options.parent) const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined @@ -134,7 +158,7 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { /** * Build an opaque receiver that preserves the base filter, admits untagged * listeners globally, and admits tagged listeners for a matching key or any - * of its ancestors ({@link setScopeParent}): a listener owned by an enclosing + * of its ancestors ({@link bindScopeParent}): 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 diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 8e31fe1522..7007624d53 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, scopeChainOf, scopeOf, scopeParentOf, scopeTarget, setScopeParent } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { @@ -166,22 +166,30 @@ describe('scope parent chain', () => { 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/) + expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/) + expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/) }) - it('re-links to a different parent (the blank-session recompose path)', () => { + it('re-links only through the binding held by the original binder', () => { 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 }) + const binding = bindScopeParent(agent, presetA) + createScope(ctx, agent) - setScopeParent(agent, presetB) + // A bound key cannot be re-bound from the outside; only the binding moves it. + expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/) + binding.rebind(presetB) expect(scopeChainOf(agent)).toEqual([agent, presetB]) + // The rebind keeps the cycle check: a parent may not adopt its ancestor. + const child = { id: 'child' } + const childBinding = bindScopeParent(child, agent) + void childBinding + expect(() => { binding.rebind(child) }).toThrow(/cycle/) }) it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 5b90029f65..34758b840f 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1589,7 +1589,7 @@ describe('per-agent presentation', () => { }) it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { - const { setScopeParent } = await import('@deepseek-ai/dsh-scope') + const { bindScopeParent } = await import('@deepseek-ai/dsh-scope') const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it @@ -1597,7 +1597,7 @@ describe('per-agent presentation', () => { const standing = await mintAgentScope(ctx, 'preset:code-like') standing.scope.ctx.tools.presentAs('code') const joined = await mintAgentScope(ctx, 'joined-agent') - setScopeParent(joined.agent, standing.agent) + bindScopeParent(joined.agent, standing.agent) const loner = await mintAgentScope(ctx, 'loner-agent') expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6c7099da9b..58f523c4e3 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -8,7 +8,7 @@ * 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 + * ({@link bindScopeParent}), 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. @@ -24,7 +24,7 @@ import { stat } from 'node:fs/promises' import { Context, Service } from 'cordis' import z from 'schemastery' -import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' @@ -202,6 +202,14 @@ export class AgentPresets extends Service { */ private readonly standing = new Map>() + /** + * Parent bindings of the agents this roster composed, keyed by the agent's + * scope key. The binding is dsh-scope's only re-link capability; holding it + * here makes this service the sole authority that can move an agent between + * standing compositions. WeakMap: entries die with their agents. + */ + private readonly bindings = new WeakMap() + /** * 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 @@ -222,7 +230,11 @@ export class AgentPresets extends Service { } const preset = await this.resolveMountable(id) const standing = await this.ensureStanding(preset) - setScopeParent(agentKey, standing.key) + // The one bind of this agent's ancestry. The binding is the only re-link + // authority, held privately so nothing outside this roster can move a + // composed agent to another preset; a later recompose layer re-links + // through it under the caller-owned blank-session contract. + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) return preset } @@ -325,7 +337,10 @@ export class AgentPresets extends Service { * 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. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. @@ -338,7 +353,12 @@ export class AgentPresets extends Service { } const preset = await this.resolveMountable(id) const standing = await this.ensureStanding(preset) - setScopeParent(agentKey, standing.key) + const binding = this.bindings.get(agentKey) + if (binding === undefined) { + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + } else { + binding.rebind(standing.key) + } return preset } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index a1524fcc7a..b4988331cc 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -16,7 +16,7 @@ import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' -import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' declare module 'cordis' { interface Context { @@ -216,7 +216,7 @@ describe('rejecting a composition that cannot be used', () => { const loner = createScope(ctx, { test: 'loner' }) expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() const orphan = createScope(ctx, { test: 'orphan' }) - setScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' }) + bindScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' }) expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() }) @@ -410,8 +410,9 @@ describe('replacing a composition', () => { }) it('composes an agent that had nothing installed', async () => { - // An agent created without a preset has no subtree to discard, so the - // swap is a plain mount rather than a restore-on-failure path. + // An agent created without a preset has no binding to re-link, so the + // switch is its first bind — exactly a mount — and once bound only the + // roster's kept binding can move it again. const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') }) await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 103e34166c..a5e03b610a 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import SkillService, { isModelInvocable, isUserInvocable, @@ -1183,10 +1183,11 @@ describe('SkillService scoped layers', () => { }) } const agentKey = {} - setScopeParent(agentKey, scopeOf(presetA.ctx) as object) + const binding = bindScopeParent(agentKey, scopeOf(presetA.ctx) as object) expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-a']) - // A blank-session recompose re-parents the same key without any registry write. - setScopeParent(agentKey, scopeOf(presetB.ctx) as object) + // A blank-session recompose re-links the same key through its binding + // without any registry write. + binding.rebind(scopeOf(presetB.ctx) as object) expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-b']) await presetA.dispose() await presetB.dispose()