Переглянути джерело

refactor(scope,agent-presets): per-preset standing mounts over a scope parent chain

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).
Yichen Jiang 2 місяців тому
батько
коміт
e18aa2745c

+ 68 - 3
packages/core/scope/src/index.ts

@@ -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)

+ 28 - 7
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<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,26 @@ 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 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<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
   }
 

+ 60 - 1
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<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'])
+  })
+})

+ 6 - 4
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<string>()

+ 20 - 9
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<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

+ 77 - 8
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<Config>
 
+  /**
+   * 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<string, Promise<StandingMount>>()
+
+  /**
+   * 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<AgentPreset> {
+    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<StandingMount> {
+    const pending = this.standing.get(preset.id)
+    if (pending !== undefined) return pending
+    const created = (async (): Promise<StandingMount> => {
+      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

+ 3 - 2
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 {

+ 13 - 1
packages/preset/agent-presets/tests/invariant.spec.ts

@@ -38,7 +38,7 @@ async function harness(): Promise<Context> {
 }
 
 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')
   })