Six review findings on the select surface, all reachable from the wire: **Resume read the header, not the log.** The switch was recorded as `agent-preset/selected` and every projection resolved from it, but `agentFor` still composed from `inspected.meta.agentPreset` — the value written once at creation. A blank session that switched and then ran turns came back after a restart under the ORIGINAL preset, restoring that history under the tool set it was not produced with, which is the mismatch this feature exists to prevent. `inspected` already carries the events. **Cold summaries dropped the preset entirely.** `summarizeCold` hand-copied three header fields and omitted the fourth, so a restored session reported no preset and the picker showed the deployment default. It now uses the same projection the attached path does. **`select` had no gate.** Two concurrent selects both passed the blank check; the second `unmountPresetFor` then found no record, because the first had already removed it, and both mounts installed into one agent layer. Selects on one session now queue, and the blank check is re-read inside the queue. This is not turn admission — a `session.prompt` racing a switch is the agent loop's to reserve — but it closes the select-versus-select tear-down. **A same-id restore was skipped.** The roster is a live directory, so "the same inputs that worked a moment ago" does not hold: a changed file is exactly how a same-id reselect fails, and skipping the restore left the agent with no composition at all. **`writable` was dead state**, initialized true and never set, so the row could never disable. It now carries `settings.describe`'s bit — a browser that may not write settings sees the current default and no control, rather than one whose write answers `settings-not-exposed`. **`list` was documented as id-ordered.** It is root-precedence order with each root's own presets sorted, first root to supply an id winning.
212 lines
8.4 KiB
TypeScript
212 lines
8.4 KiB
TypeScript
/**
|
|
* 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 { scopeOf } from '@deepseek-ai/dsh-scope'
|
|
import z from 'schemastery'
|
|
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
import { discoverPresets } from './discovery.ts'
|
|
import { mountPreset, serviceForAgent, unmountPresetFor } 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<AgentPresetSettings> = z.object({
|
|
default: z.string(),
|
|
})
|
|
|
|
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
|
export {
|
|
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
|
|
unmountPresetFor, 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<Config>
|
|
|
|
/**
|
|
* 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<AgentPresetSettings> | undefined
|
|
|
|
constructor(ctx: Context, public config: Config) {
|
|
super(ctx, 'agentPresets')
|
|
// 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<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.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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* 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<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined {
|
|
return serviceForAgent(this.ctx, agent, name)
|
|
}
|
|
|
|
/**
|
|
* Replace the composition installed for one agent.
|
|
*
|
|
* 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 unmount-then-mount because two compositions cannot coexist:
|
|
* both would register the same tool names into one layer. A failed mount
|
|
* therefore restores the previous composition rather than leaving the agent
|
|
* with nothing.
|
|
* @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; the
|
|
* previous composition is restored first.
|
|
*/
|
|
async recompose(agentCtx: Context, id: string): Promise<AgentPreset> {
|
|
const scope = scopeOf(agentCtx)
|
|
if (scope === undefined) {
|
|
throw new Error('agent-presets: refusing to recompose an unscoped context')
|
|
}
|
|
// Resolve before tearing anything down, so an unknown id leaves the agent
|
|
// exactly as it was.
|
|
const preset = await this.resolve(id)
|
|
const previous = await unmountPresetFor(scope)
|
|
try {
|
|
await mountPreset(agentCtx, preset)
|
|
} catch (error) {
|
|
if (previous !== undefined) {
|
|
// Restored unconditionally, same id included: the roster is a live
|
|
// directory, so "the same inputs that worked a moment ago" does not
|
|
// hold — the file may have changed between the original mount and
|
|
// this one, which is exactly how a same-id reselect fails. Skipping
|
|
// the restore there left the agent with no composition at all.
|
|
await this.mount(agentCtx, previous).catch(() => {
|
|
// The agent now has no composition, but the switch failure below is
|
|
// the actionable diagnostic; reporting the restore's instead would
|
|
// hide why the switch was attempted and what the operator must fix.
|
|
})
|
|
}
|
|
throw error
|
|
}
|
|
return preset
|
|
}
|
|
}
|
|
|
|
export default AgentPresets
|