/** * Agent presets: each session composes its model-facing plugin set from one * 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 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 { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { mountPreset, serviceForAgent } from './mount.ts' import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' /** The user-writable slice of this plugin's config. */ export interface AgentPresetSettings { /** Preset mounted when a session names none. */ default?: string } /** Runtime schema for the user-writable slice. */ export const AgentPresetSettingsSchema: z = z.object({ default: z.string(), }) export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount, } from './mount.ts' export { resolveSessionPreset, type PresetBearingSession } from './session.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' declare module 'cordis' { interface Context { agentPresets: AgentPresets } } /** * Registry over the deployment's agent presets. * * Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every * call so a preset authored while the process runs is visible immediately, * and a preset deleted underneath a picker disappears from the next read. */ export class AgentPresets extends Service { static inject = ['loader'] /** Runtime schema for the preset roster. */ static Config = z.object({ default: z.string().required(), roots: z.array(z.object({ path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), }) as z /** * The user layer over `config.default`, present only while a settings * provider is composed. Held rather than snapshotted so a hot-reloaded * document takes effect without a restart. */ private settings: SettingsScope | undefined /** * 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 // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing // here is derived. `defaultId` reads through on every call, so both of its // hooks would be no-ops and the source thunk would restate this field. ctx.inject(['settings'], (settingsCtx) => { this.settings = settingsCtx.settings.register( settingsNamespace(SETTINGS_NAMESPACE), AgentPresetSettingsSchema, { base: { default: config.default } }, ) settingsCtx.effect(() => () => { this.settings = undefined }, 'agentPresets.settings()') }) } /** * The preset id mounted when a caller names none. * * Read per call rather than cached: the settings document is hot-reloaded, so * changing the default takes effect on the next session created and leaves * every running session on the preset it was composed from. */ get defaultId(): string { return this.settings?.get().default ?? this.config.default } /** * Every preset the configured roots currently supply. * @returns the presets, first-root-wins per id. */ async list(): Promise { return await discoverPresets(this.config.roots) } /** * Resolve one preset by id. * @param id - the preset id, or `undefined` for {@link defaultId}. * @returns the resolved preset. * @throws when no configured root supplies that id. */ async resolve(id?: string): Promise { const wanted = id ?? this.defaultId const presets = await this.list() const found = presets.find(preset => preset.id === wanted) if (found === undefined) { throw new UnknownPresetError(wanted, presets.map(preset => preset.id)) } return found } /** * 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 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) const standing = await this.ensureStanding(preset) setScopeParent(agentKey, standing.key) return preset } /** * One agent's instance of a service its preset mounted. * * A preset publishes services behind `isolate` realms, which are invisible * outside the group that declares them — including to the host. This is how a * caller holding the agent reads one anyway: a request that is ABOUT a * session but arrives from outside it, which is every browser RPC. * * Read addressing only. A host row that `inject`s a service cannot use this, * because injection resolves before any session exists and has no agent to * key by; such a service belongs on the host plane instead. * @param agent - the agent whose composition to look inside. * @param name - the service name as the preset's rows resolve it. * @returns the agent's instance, or undefined when its preset mounts none. */ serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { return serviceForAgent(this.ctx, agent, name) } /** * Re-link one agent to a different preset's standing composition. * * Only valid while the agent has produced nothing: swapping tools mid * conversation would leave logged tool calls the new composition cannot * make. The CALLER owns that check — this method does not read session * history. * * The swap is a parent re-link, not an unmount: standing mounts are shared * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down * state to restore. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. * @throws when the preset is unknown or its composition is unusable. */ async recompose(agentCtx: Context, id: string): Promise { const agentKey = scopeOf(agentCtx) if (agentKey === undefined) { throw new Error('agent-presets: refusing to recompose an unscoped context') } const preset = await this.resolve(id) const standing = await this.ensureStanding(preset) setScopeParent(agentKey, standing.key) return preset } /** * The standing scope key of one preset, for a host reader with no agent. * * A cold transcript read resolves tool presenters against the composition * the session recorded, and the standing mount makes that possible without * resuming anything: ensuring the mount composes plugins but starts no * agent, no session, and no turn. * @param id - the preset id, or `undefined` for {@link defaultId}. * @returns the standing scope key readers pass as a registry view scope. * @throws when the preset is unknown or its composition is unusable. */ async standingKeyFor(id?: string): Promise { const preset = await this.resolve(id) return (await this.ensureStanding(preset)).key } /** 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