recompose becomes a parent re-link: the new preset's standing mount is ensured BEFORE the link moves, so a failed switch leaves the agent exactly as it was — the unmount-then-restore dance (and unmountPresetFor with it) is gone, and the restore-failure test now asserts the agent KEEPS its tools with the source directory deleted, because the standing mount is not the file.
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
/**
|
|
* 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<AgentPresetSettings> = 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<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
|
|
|
|
/**
|
|
* 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<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
|
|
}
|
|
|
|
/**
|
|
* 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 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)
|
|
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<K extends string & keyof Context>(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<AgentPreset> {
|
|
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<ScopeKey> {
|
|
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<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
|