From e18aa2745ce8c445202386a5c84fa79a55ec168b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 17:51:49 +0800 Subject: [PATCH] refactor(scope,agent-presets): per-preset standing mounts over a scope parent chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset is now ONE composition per process, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by having its scope key parented to the mount's. Two mechanisms in dsh-scope carry the whole change: registration views walk the parent chain (global → preset → agent, nearest shadowing farthest — ScopedLayers.chainLayers), and scoped event dispatch admits a listener tagged with an ancestor of the carrier key, which is what lets a standing composition's plan/compaction/token listeners observe each agent composed under it while a sibling preset's stay deaf. The preset plugins already key their state by Session/Agent — they predate presets and were written for the shared world — so sharing one instance is a return to their design, not a rewrite. Preset ymls are unchanged: one mount per preset means one Entry per preset, whose entry-local realms keep two presets' services apart exactly as they kept two sessions' apart before. The standing scope hangs off the service's UNTRACED context (selfCtx): a method invoked through the traceable proxy sees this.ctx rebound to the caller and carrying its shadow, and a subtree minted from that resolves every service through the shadow's fiber instead of each entry's own inject store — preset rows then fail on the very services they declare. A standing mount survives its agents deliberately. The composition a running session joined must outlive the file changing or disappearing underneath it; reclamation happens at whole-tree teardown, and file edits reach only future generations (the authoring layer swaps the pointer, never disposes a joined generation). --- packages/core/scope/src/index.ts | 71 +++++++++++++++- packages/core/scope/src/store.ts | 35 ++++++-- packages/core/scope/tests/scope.spec.ts | 61 ++++++++++++- packages/core/system-prompt/src/index.ts | 10 ++- packages/core/tools/src/index.ts | 29 +++++-- packages/preset/agent-presets/src/index.ts | 85 +++++++++++++++++-- packages/preset/agent-presets/src/mount.ts | 5 +- .../agent-presets/tests/invariant.spec.ts | 14 ++- 8 files changed, 275 insertions(+), 35 deletions(-) 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..dbbef13106 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,26 @@ 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 chain = scopeChainOf(scope) + const layers: L[] = [] + for (let index = chain.length - 1; index >= 0; index -= 1) { + const layer = this.scoped.get(chain[index]!) + 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 +211,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..6e783a7b22 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 16fc1394fa..7576e81aef 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -429,9 +429,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) @@ -439,7 +441,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 b49350c1a3..30e139bca8 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -950,11 +950,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 } /** @@ -966,20 +971,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/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 778b0d7275..3d99e69182 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -1,17 +1,29 @@ /** * 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 z from 'schemastery' +import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope' import { discoverPresets } from './discovery.ts' import { mountPreset } from './mount.ts' import type { AgentPreset, Config } from './types.ts' @@ -45,8 +57,19 @@ export class AgentPresets extends Service { })).default([]), }) as z + /** + * 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 } /** The preset id mounted when a caller names none. */ @@ -80,21 +103,67 @@ 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 } + + /** 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 39d55b17e6..60e8cf3ecb 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -185,8 +185,9 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi ) } const config: Include.Config = { path: pathToFileURL(preset.path).href } - // 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 { 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') })