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,100 @@
|
||||
/**
|
||||
* Agent presets: each session composes its model-facing plugin set from one
|
||||
* preset `cordis.yml` mounted under that agent's scope context.
|
||||
*
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-agent-presets
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { mountPreset } from './mount.ts'
|
||||
import type { AgentPreset, Config } from './types.ts'
|
||||
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||
export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.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<Config>
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
}
|
||||
|
||||
/** The preset id mounted when a caller names none. */
|
||||
get defaultId(): string {
|
||||
return this.config.default
|
||||
}
|
||||
|
||||
/**
|
||||
* Every preset the configured roots currently supply.
|
||||
* @returns the presets, first-root-wins per id.
|
||||
*/
|
||||
async list(): Promise<AgentPreset[]> {
|
||||
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<AgentPreset> {
|
||||
const wanted = id ?? this.config.default
|
||||
const presets = await this.list()
|
||||
const found = presets.find(preset => preset.id === wanted)
|
||||
if (found === undefined) {
|
||||
const known = presets.map(preset => preset.id).join(', ')
|
||||
throw new Error(`agent-presets: preset "${wanted}" not found (available: ${known || 'none'})`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one agent from a preset, installing it under that agent alone.
|
||||
*
|
||||
* 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.
|
||||
* @throws when the preset is unknown or its composition is unusable.
|
||||
*/
|
||||
async mount(agentCtx: Context, id?: string): Promise<AgentPreset> {
|
||||
const preset = await this.resolve(id)
|
||||
await mountPreset(agentCtx, preset)
|
||||
return preset
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentPresets
|
||||
Reference in New Issue
Block a user