feat(agent-presets): compose each session's agent from a preset cordis.yml
A preset is a directory holding one `agent.cordis.yml`. Mounting it under an agent's scope context during `setup(agentCtx)` gives that one session its own tools and prompt sections while every other live session keeps its own. No registry gains a tier. `dsh-tools` and `dsh-system-prompt` already file registrations into the calling context's scope layer, and entry contexts chain to the context a subtree was plugged into, so a composition mounted under `agent.ctx` is that agent's alone and unwinds with it. The mount audits itself because a directly-plugged subtree is absent from `ctx.loader.entries()` and no boot audit covers it. It rejects an unscoped target, a row that never became usable, and a row that published a service into the root service realm — that last one is process-global rather than per-session, and its collision with the next session surfaces as an unhandled rejection `setup` never observes, leaving a half-composed agent that looks healthy. The package invariant re-checks that rule on every service notification, since a row publishing from a timer would escape a one-shot audit. Raises the `packages/README.md` word ceiling from 920 to 980: the group table must enumerate every group, and the new `preset/` row is necessary content. Design: .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Mount one preset composition under an agent's scope context, then prove the
|
||||
* result is usable before the agent is published.
|
||||
*
|
||||
* The scope context is what makes the composition per-session: entry contexts
|
||||
* chain to the context the subtree was plugged into, so every `ctx.tools`
|
||||
* and `ctx.systemPrompt` registration inside the preset files into that
|
||||
* agent's layer and unwinds with it. Two guards make that safe. A row that
|
||||
* never reached a usable state is rejected, because a directly-plugged subtree
|
||||
* is absent from `ctx.loader.entries()` and no boot audit covers it. A row that
|
||||
* published a service into the ROOT realm is rejected, because such a service
|
||||
* is process-global rather than per-session and the second session mounting the
|
||||
* same preset collides with the first.
|
||||
* @module @deepseek-ai/dsh-agent-presets/mount
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import { Include } from '@cordisjs/plugin-include'
|
||||
import type { EntryTree } from '@cordisjs/plugin-loader'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { AgentPreset } from './types.ts'
|
||||
|
||||
/** What one mounted subtree publishes about itself for the audit to read. */
|
||||
interface MountedTree {
|
||||
/** The rows the composition created. */
|
||||
readonly tree: EntryTree
|
||||
/**
|
||||
* The subtree's own fiber. Captured here rather than taken from
|
||||
* `ctx.plugin()`, which hands back a thenable `Object.create(fiber)` wrapper
|
||||
* that is never identical to the fiber appearing in a parent chain.
|
||||
*/
|
||||
readonly fiber: Fiber
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtrees captured by config identity. A subtree plugged directly (rather than
|
||||
* created as a loader entry) never links itself to an `Entry`, so this is the
|
||||
* only handle to the rows it created; config objects are minted per mount, so
|
||||
* concurrent mounts cannot collide.
|
||||
*/
|
||||
const mounted = new WeakMap<object, MountedTree>()
|
||||
|
||||
/** Include subclass whose only addition is publishing its tree and fiber for the audit. */
|
||||
class PresetTree extends Include {
|
||||
constructor(ctx: Context, config: Include.Config) {
|
||||
super(ctx, config)
|
||||
mounted.set(config, { tree: this, fiber: ctx.fiber })
|
||||
}
|
||||
}
|
||||
|
||||
/** One preset composition currently installed under some agent. */
|
||||
export interface PresetMount {
|
||||
/** The preset the subtree was composed from. */
|
||||
readonly presetId: string
|
||||
/** The mounted subtree's fiber. */
|
||||
readonly fiber: Fiber
|
||||
}
|
||||
|
||||
const mounts = new Set<PresetMount>()
|
||||
|
||||
/**
|
||||
* Every preset composition still installed, pruning fibers disposed since the
|
||||
* last read. Records are dropped lazily rather than through a disposal hook
|
||||
* because a subtree can be torn down by its owning agent, by a failed mount, or
|
||||
* by the whole tree unloading, and a cleared `uid` is what all three share.
|
||||
* @returns the live mounts.
|
||||
*/
|
||||
export function livePresetMounts(): PresetMount[] {
|
||||
for (const mount of mounts) {
|
||||
if (mount.fiber.uid === null) mounts.delete(mount)
|
||||
}
|
||||
return [...mounts]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `fiber` is `root` itself or is mounted anywhere inside its subtree.
|
||||
*
|
||||
* Membership is object identity. `uid` looks like a cheaper key but is a
|
||||
* per-registry counter, so fibers in two different roots collide on it and a
|
||||
* subtree in one runtime would be blamed for a service published in another.
|
||||
* @param fiber - the fiber to locate.
|
||||
* @param root - the subtree root to test membership against.
|
||||
* @returns true when `fiber` belongs to `root`'s subtree.
|
||||
*/
|
||||
function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
let current = fiber
|
||||
while (true) {
|
||||
if (current === root) return true
|
||||
const parent = current.parent.fiber
|
||||
if (parent === current) return false
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service names the mounted subtree published into the root realm.
|
||||
*
|
||||
* A provider without an `isolate` realm stores its implementation under the
|
||||
* root's symbol for that name, which is exactly the comparison below; a
|
||||
* provider inside an `isolate` realm stores under a realm-private symbol and
|
||||
* is correctly absent here.
|
||||
* @param ctx - any context of the runtime whose service store is inspected.
|
||||
* @param mount - the mounted subtree's fiber.
|
||||
* @returns the leaked service names in lexical order.
|
||||
*/
|
||||
export function leakedServices(ctx: Context, mount: Fiber): string[] {
|
||||
const store = ctx.reflect.store
|
||||
const rootIsolate = ctx.root[Context.isolate]
|
||||
const leaked: string[] = []
|
||||
for (const key of Object.getOwnPropertySymbols(store)) {
|
||||
const impl = store[key]
|
||||
/* v8 ignore next -- cordis deletes a store slot on disposal rather than
|
||||
clearing it, so an own symbol always resolves; the guard exists only
|
||||
because the store's index signature is optional. */
|
||||
if (impl === undefined) continue
|
||||
if (!withinFiber(impl.fiber, mount)) continue
|
||||
if (rootIsolate[impl.name] === key) leaked.push(impl.name)
|
||||
}
|
||||
return leaked.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that did not reach a usable state, each rendered as one diagnostic line.
|
||||
*
|
||||
* A row whose module failed to import or whose plugin threw already rejects the
|
||||
* mount through the loader; what remains observable here is a row still waiting
|
||||
* for a service the composition never supplies.
|
||||
* @param tree - the mounted subtree.
|
||||
* @returns one line per unusable row, empty when every enabled row is usable.
|
||||
*/
|
||||
export function inactiveRows(tree: EntryTree): string[] {
|
||||
const lines: string[] = []
|
||||
for (const entry of tree.entries()) {
|
||||
if (entry.disabled) continue
|
||||
const fiber = entry.fiber
|
||||
/* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed,
|
||||
so a settled tree never holds an enabled fiber-less entry; the branch exists
|
||||
only because `Entry.fiber` is declared optional. */
|
||||
if (fiber === undefined) {
|
||||
lines.push(`${entry.options.id} (${entry.options.name}): never started`)
|
||||
continue
|
||||
}
|
||||
const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined)
|
||||
if (missing.length > 0) {
|
||||
lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount `preset` under `agentCtx` and return only once every row is usable.
|
||||
*
|
||||
* The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and
|
||||
* the caller receives no disposer. A rejection leaves nothing mounted.
|
||||
* @param agentCtx - the agent's scope context, from the agent factory's `setup`.
|
||||
* @param preset - the resolved preset to compose the agent from.
|
||||
* @throws when `agentCtx` carries no scope, a row is unusable, or a row
|
||||
* published a service into the root realm.
|
||||
*/
|
||||
export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise<void> {
|
||||
if (scopeOf(agentCtx) === undefined) {
|
||||
throw new Error(
|
||||
`agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; `
|
||||
+ 'its registrations would apply to every agent in the process',
|
||||
)
|
||||
}
|
||||
const config: Include.Config = { path: pathToFileURL(preset.path).href }
|
||||
const handle = agentCtx.plugin(PresetTree, config)
|
||||
try {
|
||||
await handle.await()
|
||||
const subtree = mounted.get(config)
|
||||
/* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */
|
||||
if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree')
|
||||
const { tree, fiber } = subtree
|
||||
const unusable = inactiveRows(tree)
|
||||
if (unusable.length > 0) {
|
||||
throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`)
|
||||
}
|
||||
const leaked = leakedServices(agentCtx, fiber)
|
||||
if (leaked.length > 0) {
|
||||
throw new Error(
|
||||
`row(s) published process-global service(s) [${leaked.join(', ')}]; `
|
||||
+ 'a preset service must sit behind an `isolate` realm or move to the host composition',
|
||||
)
|
||||
}
|
||||
mounts.add({ presetId: preset.id, fiber })
|
||||
} catch (error) {
|
||||
try {
|
||||
await handle.dispose()
|
||||
/* v8 ignore next 5 -- teardown of a subtree nothing else references has no
|
||||
observed failure mode; the guard exists so a teardown error cannot
|
||||
replace the mount diagnostic the caller needs. */
|
||||
} catch {
|
||||
// Swallows only this subtree's teardown failure. The mount error below is
|
||||
// the actionable one, and the discarded fiber is unreachable either way.
|
||||
}
|
||||
/* v8 ignore next -- every path into this catch throws an Error: the loader
|
||||
wraps a row's thrown value before it propagates, and this module's own
|
||||
rejections are Errors. The fallback keeps a hostile value readable. */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`agent-presets: preset "${preset.id}" (${preset.path}) failed to mount: ${detail}`, { cause: error })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user