Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring
Authoring meets standing mounts: write() and remove() drop the standing pointer so the NEXT session composes the edited roster, while every session already joined keeps the generation it runs on — a superseded generation is never disposed while the process lives. The settings-dialog golden re-records with this layer's Agent Preset nav entry, which the incoming layer-3 record had overwritten.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<void>` 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<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff 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<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
|
||||
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
|
||||
- `ScopedLayers<L>` 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<V>` 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<V>` 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.
|
||||
|
||||
@@ -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<void>`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。
|
||||
@@ -15,7 +16,7 @@
|
||||
- `Scoped<T>`:编译期不透明载体 brand。按作用域筛选的事件要求它作为 `this` 类型,因此使用裸主体分发会产生编译错误。类型参数记录主体类型,但不公开其属性。
|
||||
- `isScopeCarrier(value)`/`carrierKeyOf(value)`:运行时载体标记,开发不变式使用它们断言每次按作用域筛选的分发都携带载体,而且载体键与参数所指名的主体一致。
|
||||
- `ScopeLayer`:一个注册表的完整全局贡献或精确作用域贡献的聚合契约;`isEmpty()` 控制带作用域层的回收。
|
||||
- `ScopedLayers<L>`:拥有一个立即创建的全局层和按需创建的精确作用域层。`peek()` 从不创建;`merge()` 物化按插入顺序排列的具名遮蔽项;`effect()` 从同一上下文推导可见性与所有权,同时返回原样 Cordis disposer。
|
||||
- `ScopedLayers<L>`:持有一个立即构造的全局层与惰性的精确作用域层。`peek()` 从不创建且刻意不看链(某作用域**自己**的贡献——限制、守卫——不得悄悄继承祖先的),`chainLayers()` 按最远祖先在前返回已存在的各层,`merge()` 沿链物化按插入序的具名遮蔽,`effect()` 从同一上下文推导可见性与所有权,并返回精确的 Cordis disposer。
|
||||
- `NamedEntries<V>`:按插入顺序排列的具名存储,调用方拥有重复项诊断、查找,以及一个非空表世代内的实时迭代。表清空后,现有迭代器与后续插入项脱离;`insert()` 返回幂等的精确条目撤销函数。
|
||||
- `AnonymousEntries<V>`:按插入顺序排列的匿名存储;唯一内部键使相同值仍作为独立注册存在。它使用相同的清空世代迭代器边界;`append()` 返回幂等的精确条目撤销函数。
|
||||
|
||||
@@ -32,5 +33,5 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有感知作用域的表层才会隔离状态**:注册表必须按 `scopeOf()` 归档,事件必须通过 `scopeTarget()` 分发;仅仅通过带作用域的上下文调用任意 Cordis 服务,并不会改变该服务仍为上下文全局这一事实。
|
||||
- **一个上下文只携带一个最近的作用域键**:嵌套作用域会遮蔽父作用域的标签,而不会形成层级策略集或多成员策略集。
|
||||
- **一个上下文只携带一个最近的作用域键**:层级关系存在于键级父关系中而非上下文标签里;嵌套作用域**上下文**仍遮蔽为单一标签,多成员策略集仍不受支持。
|
||||
- **服务可达性来自作用域创建者**:交出 `Scope.ctx` 也会交出创建插件注入的服务表层,因此,若作用域创建者提供的服务范围较宽,持有者之后也无法将其收窄。
|
||||
|
||||
@@ -29,6 +29,54 @@ export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
|
||||
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
|
||||
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
|
||||
|
||||
/**
|
||||
* 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<ScopeKey, ScopeKey>()
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
/** 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<void> | 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<T extends object>(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)
|
||||
|
||||
@@ -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<L extends ScopeLayer> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<L extends ScopeLayer> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<L extends ScopeLayer> {
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V> {
|
||||
const merged = new Map(pick(this.global).entries())
|
||||
const layer = this.peek(scope)
|
||||
if (layer === undefined) return merged
|
||||
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
|
||||
for (const layer of this.chainLayers(scope)) {
|
||||
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Scoped<typeof subject>>()
|
||||
})
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
@@ -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<string, ToolDefinition>()
|
||||
const knownNames = new Set<string>()
|
||||
const restrictableNames = new Set<string>()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user