Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui
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.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
|
||||
README.md: b60d89b6dcda97a7570680072195231885fd0d45
|
||||
README.zh.md: f2485663ada031a9e8fa5ce3a06327e6cbc1de10
|
||||
README.md: 657952bde099e40acbc52ca7f74365c2cbbbde69
|
||||
README.zh.md: 6bac227d050af7577ab17b9207e100486f24ff2d
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Per-session agent composition. A **preset** is a directory holding one `agent.cordis.yml`; mounting it under an agent's scope context gives that one session its own tools, prompt sections, and other model-facing contributions, while every other live session keeps its own.
|
||||
Per-preset agent composition. A **preset** is a directory holding one `agent.cordis.yml`; the roster mounts it ONCE per process under a standing scope, and each session that names it joins by having its agent scope key parented to the mount's (`dsh-scope`'s parent chain). The mount's tools, prompt sections, and projection units exist exactly once and cover every joined agent — its plugins key their state by Session/Agent, so sessions stay apart inside one shared instance — and a host reader with no agent at all (a cold transcript read) resolves the same standing registrations by preset id.
|
||||
|
||||
The mechanism is entirely Cordis: entry contexts chain to the context a subtree was plugged into, and both [`dsh-tools`](../../core/tools/README.md) and [`dsh-system-prompt`](../../core/system-prompt/README.md) file registrations into the calling context's scope layer. Mounting a composition under `agent.ctx` therefore makes it that agent's alone, and unwinds it with the agent, without any new layering in those registries.
|
||||
The mechanism is two seams. Entry contexts chain to the context a subtree was plugged into, and both [`dsh-tools`](../../core/tools/README.md) and [`dsh-system-prompt`](../../core/system-prompt/README.md) file registrations into the calling context's scope layer — so the standing mount's contributions land in the PRESET's layer. What carries them to each session is `dsh-scope`'s parent chain: an agent's views resolve `agent → preset → global` (nearest shadowing farthest), and the mount's listeners are admitted for every agent parented under it while a sibling preset's stay deaf.
|
||||
|
||||
## Service: `AgentPresets` (ctx key: `agentPresets`)
|
||||
|
||||
@@ -13,13 +13,13 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
- `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none.
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id.
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it.
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset and return the preset that was mounted, for the caller to record.
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record.
|
||||
|
||||
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
|
||||
|
||||
### Where to call `mount()`
|
||||
|
||||
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the composition installed while the agent is still unpublished, so a rejected mount rolls the whole creation back rather than leaving a half-composed session. The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and the caller receives no disposer.
|
||||
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. A settled mount is permanent for the process: the composition a running session joined must outlive its file changing or disappearing underneath it, so file edits reach only future generations.
|
||||
|
||||
### Which preset a session runs
|
||||
|
||||
@@ -55,7 +55,7 @@ A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot aud
|
||||
|
||||
**A row that never became usable.** The loader already rejects a row whose module failed to import or whose plugin threw; what remains is a row still waiting for a service the composition never supplies, which the audit names.
|
||||
|
||||
**A row that published a service into the root realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local for one session's private instance, or a shared label when several sessions should share one — or the service belongs in the host composition instead.
|
||||
**A row that published a service into the root realm.** Such a service is process-global, so the second preset publishing the same name collides with the first, and a host reader would resolve one preset's instance for every session. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local realms keep two presets' same-named services apart exactly as they once kept two sessions' apart — or the service belongs in the host composition instead.
|
||||
|
||||
The package invariant re-checks that last rule on every service notification, because a row that publishes from a timer or an asynchronous continuation would escape the one-shot audit.
|
||||
|
||||
@@ -71,7 +71,7 @@ Presets are compositions, so a preset is exactly as privileged as the plugins it
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the plugins a mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent.
|
||||
Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -79,6 +79,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A preset cannot be changed on a live agent** — the mount happens once during creation, so switching a running session's composition would mean unwinding its subtree mid-turn, dropping tools the model may already have called. Changing the default affects only sessions created afterwards.
|
||||
- **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
|
||||
- **A standing mount reads its file once per process** — the first session to name a preset fixes its composition until the whole tree unloads; edits reach only future generations, and nothing reclaims a superseded generation while the process lives (bounded by how often compositions are edited, not by sessions).
|
||||
- **Display names are the directory id** — a preset carries no manifest, so pickers and settings surfaces show the id until a consumer needs richer metadata.
|
||||
- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
按会话组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;把它挂载到某个 agent 的 scope 上下文之下,该会话就拥有自己的工具、提示词段落以及其他面向模型的贡献,而其他在运行的会话各自保持不变。
|
||||
按 preset 组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;roster 在整个进程内只把它挂载一次(常驻 scope),命名它的每个会话通过把自己 agent 的 scope key 认父到该挂载(`dsh-scope` 的父链)来加入。挂载的工具、提示词段落与投影单元只存在一份,覆盖所有已加入的 agent——其插件本就按 Session/Agent 分键存状态,会话在共享实例内互不串扰——而完全没有 agent 的宿主读取方(冷读记录)也能按 preset id 解析到同一份常驻注册。
|
||||
|
||||
其机制完全来自 Cordis:entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册。因此把一份组装挂到 `agent.ctx` 之下,它就只属于该 agent,并随 agent 一起卸载,无需在这些注册表中新增任何分层。
|
||||
其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。
|
||||
|
||||
## 服务:`AgentPresets`(ctx 键:`agentPresets`)
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
- `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent,并返回所挂载的 preset 供调用方记录。
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。
|
||||
|
||||
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
|
||||
|
||||
### 应在何处调用 `mount()`
|
||||
|
||||
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。
|
||||
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。挂载一旦成功即进程级永久:正在运行的会话所加入的组装必须在其文件被修改或删除后继续存活,因此文件编辑只影响未来的代际。
|
||||
|
||||
### 会话实际运行的是哪个 preset
|
||||
|
||||
@@ -55,7 +55,7 @@ agent-presets:
|
||||
|
||||
**某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。
|
||||
|
||||
**某一行把服务发布进了根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——用 entry 本地 realm 得到该会话私有的实例,或用共享 label 让多个会话共用一个——否则该服务应改放进宿主组装。
|
||||
**某一行把服务发布进了根 realm。** 这类服务是进程级全局的,因此第二个发布同名服务的 preset 会与第一个相撞,宿主读取方也会把某一个 preset 的实例当成所有会话的。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——entry 本地 realm 让两个 preset 的同名服务互不相干,正如它从前隔开两个会话——否则该服务应改放进宿主组装。
|
||||
|
||||
最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。
|
||||
|
||||
@@ -71,7 +71,7 @@ preset 就是组装,因此一个 preset 的权限恰好等于它所引用的
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the plugins a mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent.
|
||||
Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -79,6 +79,7 @@ Indirectly, through the plugins a mounted composition registers, which own every
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无法在存活的 agent 上更换 preset** —— 挂载只在创建时发生一次,因此切换运行中会话的组装意味着要在轮次进行途中卸载其子树,抽走模型可能已经调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **常驻挂载每进程只读一次文件** —— 首个命名某 preset 的会话固定其组装直到整棵树卸载;编辑只影响未来的代际,且进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
|
||||
- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。
|
||||
- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
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, unmountPresetFor } from './mount.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. */
|
||||
@@ -35,7 +46,7 @@ export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||
export {
|
||||
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
|
||||
unmountPresetFor, type PresetMount,
|
||||
type PresetMount,
|
||||
} from './mount.ts'
|
||||
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||
@@ -73,8 +84,19 @@ export class AgentPresets extends Service {
|
||||
*/
|
||||
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
|
||||
@@ -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
|
||||
* 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.
|
||||
* @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)
|
||||
await mountPreset(agentCtx, preset)
|
||||
const standing = await this.ensureStanding(preset)
|
||||
setScopeParent(agentKey, standing.key)
|
||||
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
|
||||
* 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 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.
|
||||
* @throws when the preset is unknown or its composition is unusable.
|
||||
*/
|
||||
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')
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ 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, type ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { PresetMountError, type AgentPreset } from './types.ts'
|
||||
|
||||
/** What one mounted subtree publishes about itself for the audit to read. */
|
||||
@@ -77,8 +77,8 @@ export interface PresetMount {
|
||||
readonly presetId: string
|
||||
/** The mounted subtree's fiber. */
|
||||
readonly fiber: Fiber
|
||||
/** The scope the subtree was mounted for — the agent that owns it. */
|
||||
readonly scope: ScopeKey
|
||||
/** The standing scope key agents are parented to (undefined only in torn-down records). */
|
||||
readonly key: ScopeKey | undefined
|
||||
}
|
||||
|
||||
const mounts = new Set<PresetMount>()
|
||||
@@ -115,24 +115,6 @@ export function livePresetMounts(): PresetMount[] {
|
||||
return [...mounts]
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the composition currently installed for one scope, if any.
|
||||
*
|
||||
* Only a composition that has produced nothing may be replaced: swapping a
|
||||
* live agent's tools mid-conversation would leave logged tool calls the new
|
||||
* composition cannot make. The caller owns that check — this function does the
|
||||
* teardown and returns once the subtree is quiescent.
|
||||
* @param scope - the agent whose installed composition to discard.
|
||||
* @returns the preset id that was discarded, or `undefined` when none was.
|
||||
*/
|
||||
export async function unmountPresetFor(scope: ScopeKey): Promise<string | undefined> {
|
||||
const installed = livePresetMounts().find(mount => mount.scope === scope)
|
||||
if (installed === undefined) return undefined
|
||||
mounts.delete(installed)
|
||||
await Promise.resolve(installed.fiber.dispose())
|
||||
return installed.presetId
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `fiber` is `root` itself or is mounted anywhere inside its subtree.
|
||||
*
|
||||
@@ -209,14 +191,22 @@ export function serviceForAgent<K extends string & keyof Context>(
|
||||
agent: { ctx: Context },
|
||||
name: K,
|
||||
): Context[K] | undefined {
|
||||
const root = agent.ctx.fiber
|
||||
// The agent's own key is parented to its preset's standing key; the mount
|
||||
// is no longer under the agent's fiber, so the search roots at the standing
|
||||
// mount instead of walking up from the agent.
|
||||
const agentKey = scopeOf(agent.ctx)
|
||||
if (agentKey === undefined) return undefined
|
||||
const standingKey = scopeParentOf(agentKey)
|
||||
if (standingKey === undefined) return undefined
|
||||
const mount = livePresetMounts().find(candidate => candidate.key === standingKey)
|
||||
if (mount === undefined) return undefined
|
||||
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]
|
||||
if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -269,8 +259,9 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
|
||||
)
|
||||
}
|
||||
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.
|
||||
// Before the record this mount is about to add: standing mounts are one per
|
||||
// preset and live until whole-tree teardown, so pruning here only sweeps
|
||||
// records of torn-down runtimes (tests; an HMR reload of the roster).
|
||||
pruneDisposedMounts()
|
||||
const handle = agentCtx.plugin(PresetTree, config)
|
||||
try {
|
||||
@@ -290,7 +281,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
|
||||
+ 'a preset service must sit behind an `isolate` realm or move to the host composition',
|
||||
)
|
||||
}
|
||||
mounts.add({ presetId: preset.id, fiber, scope })
|
||||
mounts.add({ presetId: preset.id, fiber, key: scopeOf(agentCtx) })
|
||||
} catch (error) {
|
||||
try {
|
||||
await handle.dispose()
|
||||
|
||||
@@ -38,7 +38,7 @@ async function harness(): Promise<Context> {
|
||||
}
|
||||
|
||||
describe('agent-presets invariants', () => {
|
||||
it('tracks a mounted composition and forgets it once the agent is gone', async () => {
|
||||
it('keeps the standing composition alive across the agents that joined it', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('inv-live'),
|
||||
@@ -47,8 +47,20 @@ describe('agent-presets invariants', () => {
|
||||
|
||||
expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard')
|
||||
|
||||
// A standing mount survives its agents: the composition a session joined
|
||||
// is shared, so one session ending must not strip it from the next.
|
||||
await handle.dispose()
|
||||
expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard')
|
||||
|
||||
// A second agent reuses the same mount rather than adding one.
|
||||
await ctx.agents.create({
|
||||
sessionId: SessionId('inv-live-2'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
||||
})
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(1)
|
||||
|
||||
// Whole-tree teardown is the boundary that does reclaim it.
|
||||
await ctx.fiber.dispose()
|
||||
expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard')
|
||||
})
|
||||
|
||||
|
||||
@@ -164,21 +164,21 @@ describe('rejecting a composition that cannot be used', () => {
|
||||
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
||||
})
|
||||
|
||||
it('addresses one agent\'s instance of a realm-private service', async () => {
|
||||
it('addresses the standing instance of a realm-private service through either agent', async () => {
|
||||
const first = await agentOn(ctx, 'sess-reach-a', 'isolated')
|
||||
const second = await agentOn(ctx, 'sess-reach-b', 'isolated')
|
||||
|
||||
// The realm keeps the service out of every host context — that is what
|
||||
// makes it per session — so a caller holding the agent is the only way a
|
||||
// request from OUTSIDE the session can read the instance it is about.
|
||||
// The realm keeps the service out of every host context, so a caller
|
||||
// holding the agent is how a request from OUTSIDE the session reads the
|
||||
// instance it is about.
|
||||
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
||||
const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc')
|
||||
const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc')
|
||||
expect(mine).toBeDefined()
|
||||
expect(theirs).toBeDefined()
|
||||
// Each agent gets ITS own: the addressing is per subtree, not a lookup
|
||||
// that happens to find the first match.
|
||||
expect(mine).not.toBe(theirs)
|
||||
// ONE composition per preset: both agents joined the same standing mount,
|
||||
// so they address the same instance — sessions stay apart inside it by
|
||||
// the plugin's own Session/Agent keying, not by instance count.
|
||||
expect(theirs).toBe(mine)
|
||||
})
|
||||
|
||||
it('answers undefined for a service the agent\'s preset does not mount', async () => {
|
||||
@@ -342,7 +342,7 @@ describe('replacing a composition', () => {
|
||||
expect(toolNames(ctx, handle.agent)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the switch failure when the restore fails too', async () => {
|
||||
it('keeps the agent on its standing composition when a switch fails, even with the source deleted', async () => {
|
||||
// A preset root this test owns, so removing the composition mid-flight
|
||||
// cannot disturb the shipped fixtures.
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
|
||||
@@ -368,14 +368,17 @@ describe('replacing a composition', () => {
|
||||
})
|
||||
|
||||
// The roster is a live directory: the composition the agent came from can
|
||||
// be gone by the time the restore reaches for it.
|
||||
// be gone from DISK by the time a switch fails. The standing mount is not
|
||||
// the file — it outlives deletion, so there is nothing to "restore".
|
||||
await rm(join(root, 'first'), { recursive: true })
|
||||
|
||||
await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken'))
|
||||
.rejects.toThrow(/failed to mount/)
|
||||
|
||||
// The switch failure is the actionable one; the restore's is swallowed.
|
||||
expect(toolNames(scoped, handle.agent)).toEqual([])
|
||||
// The failed switch left the agent EXACTLY as it was: the new standing
|
||||
// mount is ensured before the parent link moves, so a rejection never
|
||||
// strips the old composition.
|
||||
expect(toolNames(scoped, handle.agent)).toEqual(['only'])
|
||||
})
|
||||
|
||||
it('refuses an unscoped context', async () => {
|
||||
|
||||
Reference in New Issue
Block a user