|
@@ -1,21 +1,32 @@
|
|
|
/**
|
|
/**
|
|
|
* Agent presets: each session composes its model-facing plugin set from one
|
|
* Agent presets: each session composes its model-facing plugin set from one
|
|
|
- * preset `cordis.yml` mounted under that agent's scope context.
|
|
|
|
|
|
|
+ * 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
|
|
* 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.
|
|
|
|
|
|
|
+ * 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
|
|
* @module @deepseek-ai/dsh-agent-presets
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import { Context, Service } from 'cordis'
|
|
|
-import { scopeOf } from '@deepseek-ai/dsh-scope'
|
|
|
|
|
import z from 'schemastery'
|
|
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 { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
|
import { discoverPresets } from './discovery.ts'
|
|
import { discoverPresets } from './discovery.ts'
|
|
|
-import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts'
|
|
|
|
|
|
|
+import { mountPreset, serviceForAgent } from './mount.ts'
|
|
|
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
|
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
|
|
|
|
|
|
|
/** Settings namespace carrying the user's chosen default preset. */
|
|
/** Settings namespace carrying the user's chosen default preset. */
|
|
@@ -35,7 +46,7 @@ export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
|
|
|
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
|
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
|
|
export {
|
|
export {
|
|
|
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
|
|
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
|
|
|
- unmountPresetFor, type PresetMount,
|
|
|
|
|
|
|
+ type PresetMount,
|
|
|
} from './mount.ts'
|
|
} from './mount.ts'
|
|
|
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
|
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
|
|
export { PresetMountError, UnknownPresetError } from './types.ts'
|
|
export { PresetMountError, UnknownPresetError } from './types.ts'
|
|
@@ -73,8 +84,19 @@ export class AgentPresets extends Service {
|
|
|
*/
|
|
*/
|
|
|
private settings: SettingsScope<AgentPresetSettings> | undefined
|
|
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) {
|
|
constructor(ctx: Context, public config: Config) {
|
|
|
super(ctx, 'agentPresets')
|
|
super(ctx, 'agentPresets')
|
|
|
|
|
+ this.selfCtx = ctx
|
|
|
// Deliberately not `installSettingsSection`: that helper exists to re-judge
|
|
// Deliberately not `installSettingsSection`: that helper exists to re-judge
|
|
|
// what a consumer DERIVED from the source — memoized resolutions,
|
|
// what a consumer DERIVED from the source — memoized resolutions,
|
|
|
// registration-level facts — across attach, detach, and change. Nothing
|
|
// registration-level facts — across attach, detach, and change. Nothing
|
|
@@ -126,19 +148,37 @@ export class AgentPresets extends Service {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Compose one agent from a preset, installing it under that agent alone.
|
|
|
|
|
|
|
+ * 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
|
|
* 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
|
|
* the agent creation back, so a broken preset never yields a half-composed
|
|
|
* session.
|
|
* session.
|
|
|
* @param agentCtx - the agent's scope context.
|
|
* @param agentCtx - the agent's scope context.
|
|
|
* @param id - the preset id, or `undefined` for {@link defaultId}.
|
|
* @param id - the preset id, or `undefined` for {@link defaultId}.
|
|
|
- * @returns the preset that was mounted, for the caller to record.
|
|
|
|
|
|
|
+ * @returns the preset that was composed, for the caller to record.
|
|
|
* @throws when the preset is unknown or its composition is unusable.
|
|
* @throws when the preset is unknown or its composition is unusable.
|
|
|
*/
|
|
*/
|
|
|
async mount(agentCtx: Context, id?: string): Promise<AgentPreset> {
|
|
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 preset = await this.resolve(id)
|
|
|
- await mountPreset(agentCtx, preset)
|
|
|
|
|
|
|
+ const standing = await this.ensureStanding(preset)
|
|
|
|
|
+ setScopeParent(agentKey, standing.key)
|
|
|
return preset
|
|
return preset
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -162,50 +202,77 @@ export class AgentPresets extends Service {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Replace the composition installed for one agent.
|
|
|
|
|
|
|
+ * Re-link one agent to a different preset's standing composition.
|
|
|
*
|
|
*
|
|
|
* Only valid while the agent has produced nothing: swapping tools mid
|
|
* 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.
|
|
|
|
|
|
|
+ * 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.
|
|
|
|
|
|
|
+ * 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 agentCtx - the agent's scope context.
|
|
|
* @param id - the preset to compose the agent from instead.
|
|
* @param id - the preset to compose the agent from instead.
|
|
|
* @returns the preset now installed.
|
|
* @returns the preset now installed.
|
|
|
- * @throws when the preset is unknown or its composition is unusable; the
|
|
|
|
|
- * previous composition is restored first.
|
|
|
|
|
|
|
+ * @throws when the preset is unknown or its composition is unusable.
|
|
|
*/
|
|
*/
|
|
|
async recompose(agentCtx: Context, id: string): Promise<AgentPreset> {
|
|
async recompose(agentCtx: Context, id: string): Promise<AgentPreset> {
|
|
|
- const scope = scopeOf(agentCtx)
|
|
|
|
|
- if (scope === undefined) {
|
|
|
|
|
|
|
+ const agentKey = scopeOf(agentCtx)
|
|
|
|
|
+ if (agentKey === undefined) {
|
|
|
throw new Error('agent-presets: refusing to recompose an unscoped context')
|
|
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 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
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const standing = await this.ensureStanding(preset)
|
|
|
|
|
+ setScopeParent(agentKey, standing.key)
|
|
|
return preset
|
|
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
|
|
export default AgentPresets
|