Files
deepseek-harness/packages/preset/agent-presets/src/mount.ts
Yichen Jiang c58cc23d45 fix(web): address a session's own services from the host
A preset publishes its services behind `isolate` realms, which is what
makes them per session — and what makes them invisible to every host
context. The api-proxy kept reading the root realm, so requests that are
ABOUT a session but arrive from outside it answered for a singleton that
no longer exists: `goal.pause`/`clear` and `skill.list` returned "this
deployment does not mount @deepseek-ai/dsh-goal / dsh-skill" for sessions
whose composition mounts exactly that. Verified against a running host
before and after.

`agentPresets.serviceFor(agent, name)` addresses the instance instead,
reading the same subtree-ownership relation `leakedServices` already
uses, inverted. It is read addressing for a caller holding the agent: a
host row that `inject`s a service cannot use it, because injection
resolves before any session exists — which is why `tools` and
`subagents` stay host-plane and this is not a way around that.

Tool presenters had the same shape and the same cure: `viewFor` looked
definitions up without a scope while the global layer is empty by
design, so every card degraded to the generic renderer. It now takes the
owning agent.

Cold resume through `agentFor()` mounted no preset at all, so every
generic entry point — prompt, models, commands — rebuilt a restarted
session on host tools and the deployment persona. It composes the
recorded preset now, as the other resume path already did.
2026-08-07 00:35:30 +08:00

285 lines
12 KiB
TypeScript

/**
* Mount one preset composition under an agent's scope context, then prove the
* result is usable before the agent is published.
*
* The scope context is what makes the composition per-session: entry contexts
* chain to the context the subtree was plugged into, so every `ctx.tools`
* and `ctx.systemPrompt` registration inside the preset files into that
* agent's layer and unwinds with it. Two guards make that safe. A row that
* never reached a usable state is rejected, because a directly-plugged subtree
* is absent from `ctx.loader.entries()` and no boot audit covers it. A row that
* published a service into the ROOT realm is rejected, because such a service
* is process-global rather than per-session and the second session mounting the
* same preset collides with the first.
* @module @deepseek-ai/dsh-agent-presets/mount
*/
import { pathToFileURL } from 'node:url'
import { Context, type Fiber } from 'cordis'
import { Include } from '@cordisjs/plugin-include'
import type { EntryTree } from '@cordisjs/plugin-loader'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { PresetMountError, type AgentPreset } from './types.ts'
/** What one mounted subtree publishes about itself for the audit to read. */
interface MountedTree {
/** The rows the composition created. */
readonly tree: EntryTree
/**
* The subtree's own fiber. Captured here rather than taken from
* `ctx.plugin()`, which hands back a thenable `Object.create(fiber)` wrapper
* that is never identical to the fiber appearing in a parent chain.
*/
readonly fiber: Fiber
}
/**
* Subtrees captured by config identity. A subtree plugged directly (rather than
* created as a loader entry) never links itself to an `Entry`, so this is the
* only handle to the rows it created; config objects are minted per mount, so
* concurrent mounts cannot collide.
*/
const mounted = new WeakMap<object, MountedTree>()
/**
* Include subclass that publishes its tree and fiber for the audit, and never
* writes to the file it read.
*/
class PresetTree extends Include {
constructor(ctx: Context, config: Include.Config) {
super(ctx, config)
mounted.set(config, { tree: this, fiber: ctx.fiber })
}
/**
* A preset is an input, never a persistence target.
*
* The Loader writes a tree back through this method whenever it decides the
* config changed — a plugin self-disposing is enough, and tearing an agent
* down disposes its whole subtree. Inherited, that rewrites the preset file
* with whatever the dying tree held, which in practice means truncating a
* shipped composition to `[]` the first time a session ends. Persisting a
* preset is also meaningless: nothing here is user state, and the same file
* backs every session that names it.
*/
override write(): void {
}
}
/** One preset composition currently installed under some agent. */
export interface PresetMount {
/** The preset the subtree was composed from. */
readonly presetId: string
/** The mounted subtree's fiber. */
readonly fiber: Fiber
}
const mounts = new Set<PresetMount>()
/**
* Drop every record whose subtree is gone.
*
* Records are pruned by observation rather than through a disposal hook
* because a subtree can be torn down by its owning agent, by a failed mount, or
* by the whole tree unloading, and a cleared `uid` is what all three share.
*
* Pruning therefore has to happen on a path this module owns. Reading is one
* such path, but not a reliable one: the only production reader is the
* invariant companion's service listener, and `dsh-invariants` is a
* development composition — a shipped host never loads it. Mounting is the
* other, and it is the one every session takes, which bounds the set at one
* generation of dead records rather than one per session ever composed. Each
* record would otherwise retain its whole disposed subtree: the fiber holds
* its config, and that config is the key its `EntryTree` is stored under.
*/
function pruneDisposedMounts(): void {
for (const mount of mounts) {
if (mount.fiber.uid === null) mounts.delete(mount)
}
}
/**
* Every preset composition still installed, pruning fibers disposed since the
* last read.
* @returns the live mounts.
*/
export function livePresetMounts(): PresetMount[] {
pruneDisposedMounts()
return [...mounts]
}
/**
* Whether `fiber` is `root` itself or is mounted anywhere inside its subtree.
*
* Membership is object identity. `uid` looks like a cheaper key but is a
* per-registry counter, so fibers in two different roots collide on it and a
* subtree in one runtime would be blamed for a service published in another.
* @param fiber - the fiber to locate.
* @param root - the subtree root to test membership against.
* @returns true when `fiber` belongs to `root`'s subtree.
*/
function withinFiber(fiber: Fiber, root: Fiber): boolean {
let current = fiber
while (true) {
if (current === root) return true
const parent = current.parent.fiber
if (parent === current) return false
current = parent
}
}
/**
* Service names the mounted subtree published into the root realm.
*
* A provider without an `isolate` realm stores its implementation under the
* root's symbol for that name, which is exactly the comparison below; a
* provider inside an `isolate` realm stores under a realm-private symbol and
* is correctly absent here.
* @param ctx - any context of the runtime whose service store is inspected.
* @param mount - the mounted subtree's fiber.
* @returns the leaked service names in lexical order.
*/
export function leakedServices(ctx: Context, mount: Fiber): string[] {
const store = ctx.reflect.store
const rootIsolate = ctx.root[Context.isolate]
const leaked: string[] = []
for (const key of Object.getOwnPropertySymbols(store)) {
const impl = store[key]
/* v8 ignore next -- cordis deletes a store slot on disposal rather than
clearing it, so an own symbol always resolves; the guard exists only
because the store's index signature is optional. */
if (impl === undefined) continue
if (!withinFiber(impl.fiber, mount)) continue
if (rootIsolate[impl.name] === key) leaked.push(impl.name)
}
return leaked.sort((left, right) => left.localeCompare(right))
}
/**
* One agent's instance of a service its preset mounted.
*
* A preset publishes a service behind an `isolate` realm so two sessions
* cannot collide, and an entry-local realm is invisible to everything outside
* the group — including the agent's own scope context and the host. That is
* right for the rows inside the group and wrong for one caller: a request that
* is ABOUT a session but arrives from outside it, which is every browser RPC
* the api-proxy serves.
*
* Ownership is the same relation {@link leakedServices} reads, inverted: there
* it names implementations a subtree published into the ROOT realm, here it
* names the one this subtree published anywhere. Fiber membership is object
* identity for the reason stated on {@link withinFiber}.
*
* This is READ addressing for a caller that already holds the agent. It is not
* a general host handle on a session's internals: a host row that `inject`s a
* service cannot use it, because injection resolves before any session exists
* and has no agent to key by — such a service belongs on the host plane.
* @param ctx - any context of the runtime whose service store is inspected.
* @param agent - the agent whose mounted 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.
*/
export function serviceForAgent<K extends string & keyof Context>(
ctx: Context,
agent: { ctx: Context },
name: K,
): Context[K] | undefined {
const root = agent.ctx.fiber
const store = ctx.reflect.store
for (const key of Object.getOwnPropertySymbols(store)) {
const impl = store[key]
/* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
if (impl === undefined) continue
if (impl.name !== name) continue
if (withinFiber(impl.fiber, root)) return impl.value as Context[K]
}
return undefined
}
/**
* Rows that did not reach a usable state, each rendered as one diagnostic line.
*
* A row whose module failed to import or whose plugin threw already rejects the
* mount through the loader; what remains observable here is a row still waiting
* for a service the composition never supplies.
* @param tree - the mounted subtree.
* @returns one line per unusable row, empty when every enabled row is usable.
*/
export function inactiveRows(tree: EntryTree): string[] {
const lines: string[] = []
for (const entry of tree.entries()) {
if (entry.disabled) continue
const fiber = entry.fiber
/* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed,
so a settled tree never holds an enabled fiber-less entry; the branch exists
only because `Entry.fiber` is declared optional. */
if (fiber === undefined) {
lines.push(`${entry.options.id} (${entry.options.name}): never started`)
continue
}
const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined)
if (missing.length > 0) {
lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`)
}
}
return lines
}
/**
* Mount `preset` under `agentCtx` and return only once every row is usable.
*
* The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and
* the caller receives no disposer. A rejection leaves nothing mounted.
* @param agentCtx - the agent's scope context, from the agent factory's `setup`.
* @param preset - the resolved preset to compose the agent from.
* @throws when `agentCtx` carries no scope, a row is unusable, or a row
* published a service into the root realm.
*/
export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise<void> {
if (scopeOf(agentCtx) === undefined) {
throw new Error(
`agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; `
+ 'its registrations would apply to every agent in the process',
)
}
const config: Include.Config = { path: pathToFileURL(preset.path).href }
// Before the record this mount is about to add: every session takes this
// path, so it is what keeps the set bounded on a host that never reads it.
pruneDisposedMounts()
const handle = agentCtx.plugin(PresetTree, config)
try {
await handle.await()
const subtree = mounted.get(config)
/* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */
if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree')
const { tree, fiber } = subtree
const unusable = inactiveRows(tree)
if (unusable.length > 0) {
throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`)
}
const leaked = leakedServices(agentCtx, fiber)
if (leaked.length > 0) {
throw new Error(
`row(s) published process-global service(s) [${leaked.join(', ')}]; `
+ 'a preset service must sit behind an `isolate` realm or move to the host composition',
)
}
mounts.add({ presetId: preset.id, fiber })
} catch (error) {
try {
await handle.dispose()
/* v8 ignore next 5 -- teardown of a subtree nothing else references has no
observed failure mode; the guard exists so a teardown error cannot
replace the mount diagnostic the caller needs. */
} catch {
// Swallows only this subtree's teardown failure. The mount error below is
// the actionable one, and the discarded fiber is unreachable either way.
}
/* v8 ignore next -- every path into this catch throws an Error: the loader
wraps a row's thrown value before it propagates, and this module's own
rejections are Errors. The fallback keeps a hostile value readable. */
const detail = error instanceof Error ? error.message : String(error)
throw new PresetMountError(preset.id, `${detail} (${preset.path})`, { cause: error })
}
}