Files
deepseek-harness/packages/preset/agent-presets/src/index.ts
Yichen Jiang e18aa2745c refactor(scope,agent-presets): per-preset standing mounts over a scope parent chain
A preset is now ONE composition per process, not one per session. The roster
mounts it once under a synthetic standing scope; each agent joins by having
its scope key parented to the mount's. Two mechanisms in dsh-scope carry the
whole change: registration views walk the parent chain (global → preset →
agent, nearest shadowing farthest — ScopedLayers.chainLayers), and scoped
event dispatch admits a listener tagged with an ancestor of the carrier key,
which is what lets a standing composition's plan/compaction/token listeners
observe each agent composed under it while a sibling preset's stay deaf.

The preset plugins already key their state by Session/Agent — they predate
presets and were written for the shared world — so sharing one instance is a
return to their design, not a rewrite. Preset ymls are unchanged: one mount
per preset means one Entry per preset, whose entry-local realms keep two
presets' services apart exactly as they kept two sessions' apart before.

The standing scope hangs off the service's UNTRACED context (selfCtx): a
method invoked through the traceable proxy sees this.ctx rebound to the
caller and carrying its shadow, and a subtree minted from that resolves every
service through the shadow's fiber instead of each entry's own inject store —
preset rows then fail on the very services they declare.

A standing mount survives its agents deliberately. The composition a running
session joined must outlive the file changing or disappearing underneath it;
reclamation happens at whole-tree teardown, and file edits reach only future
generations (the authoring layer swaps the pointer, never disposes a joined
generation).
2026-08-08 17:51:49 +08:00

170 lines
6.7 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 { 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>
/**
* 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
}
/** 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
}
/**
* 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
}
/** 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