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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user