feat(agent-presets): compose each session's agent from a preset cordis.yml
A preset is a directory holding one `agent.cordis.yml`. Mounting it under an agent's scope context during `setup(agentCtx)` gives that one session its own tools and prompt sections while every other live session keeps its own. No registry gains a tier. `dsh-tools` and `dsh-system-prompt` already file registrations into the calling context's scope layer, and entry contexts chain to the context a subtree was plugged into, so a composition mounted under `agent.ctx` is that agent's alone and unwinds with it. The mount audits itself because a directly-plugged subtree is absent from `ctx.loader.entries()` and no boot audit covers it. It rejects an unscoped target, a row that never became usable, and a row that published a service into the root service realm — that last one is process-global rather than per-session, and its collision with the next session surfaces as an unhandled rejection `setup` never observes, leaving a half-composed agent that looks healthy. The package invariant re-checks that rule on every service notification, since a row publishing from a timer would escape a one-shot audit. Raises the `packages/README.md` word ceiling from 920 to 980: the group table must enumerate every group, and the new `preset/` row is necessary content. Design: .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
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/README.md
|
||||
README.md: dec4d71ca2d323fe05f918dd3bf4709cfa01878e
|
||||
README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009
|
||||
README.md: 365659617c97c44dd0f30fbcd3347b6438024eb3
|
||||
README.zh.md: 9edabd67ea728e77e2863a32c250675a5b9359f8
|
||||
|
||||
@@ -31,6 +31,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
|
||||
|
||||
@@ -80,6 +80,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'agentPresets',
|
||||
summary: 'Registry over the deployment\'s agent presets.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'async list(): Promise<AgentPreset[]>',
|
||||
jsDoc: '/**\n * Every profile the configured roots currently supply.\n * @returns the profiles, first-root-wins per id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolve(id?: string): Promise<AgentPreset>',
|
||||
jsDoc: '/**\n * Resolve one profile by id.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the resolved profile.\n * @throws when no configured root supplies that id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async mount(agentCtx: Context, id?: string): Promise<AgentPreset>',
|
||||
jsDoc: '/**\n * Compose one agent from a profile, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken profile never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the profile that was mounted, for the caller to record.\n * @throws when the profile is unknown or its composition is unusable.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'agents',
|
||||
summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.',
|
||||
@@ -1601,6 +1619,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AgentOptions',
|
||||
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentPreset',
|
||||
declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentSetup',
|
||||
declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void;',
|
||||
@@ -2193,6 +2215,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PresetSpec',
|
||||
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PresetTrust',
|
||||
declaration: 'export type PresetTrust = \'system\' | \'user\';',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionChangeListener',
|
||||
declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: number) => void;',
|
||||
|
||||
6
packages/preset/README.i18n.yaml
Normal file
6
packages/preset/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/README.md
|
||||
README.md: e7940642166f81e370e3a328f3097d15fd367151
|
||||
README.zh.md: 0767ca5074071e9ef2fa38769d27d8ef2344188e
|
||||
13
packages/preset/README.md
Normal file
13
packages/preset/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# preset/ — per-session agent composition
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it under an agent's scope context gives that session its own tools and prompt sections while every other live session keeps its own, so one process can run several differently composed agents at once.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` |
|
||||
|
||||
The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session.
|
||||
|
||||
Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md).
|
||||
13
packages/preset/README.zh.md
Normal file
13
packages/preset/README.zh.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# preset/:按会话组装 agent
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**agent preset** 是一个目录,其中放置一份 `agent.cordis.yml`。把它挂载到某个 agent(智能体)的 scope 上下文之下,该会话就获得自己的工具与提示词段落,而其他在运行的会话各自保持不变,因此一个进程可以同时运行多个组装方式不同的 agent。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` |
|
||||
|
||||
本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。
|
||||
|
||||
设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。
|
||||
6
packages/preset/agent-presets/README.i18n.yaml
Normal file
6
packages/preset/agent-presets/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: 6068a68d3c81081074165077a8afa6b42af48d1f
|
||||
README.zh.md: 9f951f566a51a7b7acb666c7d9ea80061aa45d73
|
||||
62
packages/preset/agent-presets/README.md
Normal file
62
packages/preset/agent-presets/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# dsh-agent-presets
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Service: `AgentPresets` (ctx key: `agentPresets`)
|
||||
|
||||
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 deleted one disappears from the next read.
|
||||
|
||||
- `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.
|
||||
|
||||
`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.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `default` | required | Preset id mounted when a caller names none |
|
||||
| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
|
||||
|
||||
An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution.
|
||||
|
||||
## What a mount rejects
|
||||
|
||||
A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things.
|
||||
|
||||
**An unscoped target.** Mounting into a context that carries no agent scope would register the preset's tools globally, for every agent in the process.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
## Trust
|
||||
|
||||
Presets are compositions, so a preset is exactly as privileged as the plugins it names. A `user` preset — authored by a person or by an agent — carries the same trust as shell access; the `trust` field exists so consumers can present that difference, not to enforce 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.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable for the life of an agent: a composition is installed once, before the agent is published and therefore before its first request, and is never re-read while the agent runs. Choosing a different preset for a new session establishes a different prefix for that session alone and cannot invalidate reuse for any session already running.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **`isolate` realms cannot be expressed across rows without `cordis:group`** — an entry-local realm works on a single row, but grouping a provider with its consumers under one shared realm needs the group builtin, which `dsh-app-boot` does not register.
|
||||
- **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()`.
|
||||
62
packages/preset/agent-presets/README.zh.md
Normal file
62
packages/preset/agent-presets/README.zh.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# dsh-agent-presets
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
按会话组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;把它挂载到某个 agent 的 scope 上下文之下,该会话就拥有自己的工具、提示词段落以及其他面向模型的贡献,而其他在运行的会话各自保持不变。
|
||||
|
||||
其机制完全来自 Cordis:entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册。因此把一份组装挂到 `agent.ctx` 之下,它就只属于该 agent,并随 agent 一起卸载,无需在这些注册表中新增任何分层。
|
||||
|
||||
## 服务:`AgentPresets`(ctx 键:`agentPresets`)
|
||||
|
||||
发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。
|
||||
|
||||
- `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 供调用方记录。
|
||||
|
||||
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
|
||||
|
||||
### 应在何处调用 `mount()`
|
||||
|
||||
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `default` | 必填 | 调用方未指定时挂载的 preset id |
|
||||
| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
|
||||
|
||||
根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
|
||||
|
||||
## 挂载会拒绝什么
|
||||
|
||||
直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。
|
||||
|
||||
**目标上下文没有 scope。** 挂载到不带 agent scope 的上下文,会把该 preset 的工具注册成全局的,作用于进程内每一个 agent。
|
||||
|
||||
**某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。
|
||||
|
||||
**某一行把服务发布进了根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——用 entry 本地 realm 得到该会话私有的实例,或用共享 label 让多个会话共用一个——否则该服务应改放进宿主组装。
|
||||
|
||||
最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。
|
||||
|
||||
## 信任
|
||||
|
||||
preset 就是组装,因此一个 preset 的权限恰好等于它所引用的插件。`user` preset——无论由人还是由 agent 写出——与 shell 访问权限同级;`trust` 字段的存在是为了让消费方呈现这一差异,而不是用来强制隔离。
|
||||
|
||||
## 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.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
在一个 agent 的整个生命周期内保持前缀稳定:组装只装入一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间不再重新读取。为新会话选择不同的 preset,只会为该会话建立不同的前缀,无法让任何已在运行的会话失去缓存复用。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无法在存活的 agent 上更换 preset** —— 挂载只在创建时发生一次,因此切换运行中会话的组装意味着要在轮次进行途中卸载其子树,抽走模型可能已经调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。
|
||||
- **跨多行的 `isolate` realm 需要 `cordis:group` 才能表达** —— 单行可用 entry 本地 realm,但要把一个提供方与它的消费方归入同一个共享 realm,需要 group 内建插件,而 `dsh-app-boot` 并未注册它。
|
||||
- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
|
||||
54
packages/preset/agent-presets/package.json
Normal file
54
packages/preset/agent-presets/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-presets",
|
||||
"description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
75
packages/preset/agent-presets/src/discovery.ts
Normal file
75
packages/preset/agent-presets/src/discovery.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Filesystem discovery of agent presets. A preset is a directory holding
|
||||
* {@link COMPOSITION_FILE}; the directory name is the preset id. Discovery
|
||||
* re-reads the roots on every call so a preset authored while the process is
|
||||
* running is visible without a restart.
|
||||
* @module @deepseek-ai/dsh-agent-presets/discovery
|
||||
*/
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
|
||||
/** The composition file that makes a directory a preset. */
|
||||
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
||||
|
||||
/**
|
||||
* Whether `path` names an existing regular file.
|
||||
* @param path - absolute path to test.
|
||||
* @returns true when the path resolves to a file.
|
||||
*/
|
||||
async function isFile(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isFile()
|
||||
} catch {
|
||||
// Any stat failure — absent, unreadable, a dangling link — means this
|
||||
// directory does not present a composition, which is not an error: the
|
||||
// directory simply is not a preset.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one root for preset directories.
|
||||
*
|
||||
* An absent root yields no presets rather than throwing: the user root does
|
||||
* not exist until the first locally authored preset, and naming a default
|
||||
* that no root supplies already fails loud at resolution.
|
||||
* @param root - the directory and the trust its presets inherit.
|
||||
* @returns the root's presets ordered by id.
|
||||
*/
|
||||
export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
||||
const dir = resolve(expandHomePath(root.path))
|
||||
let children
|
||||
try {
|
||||
children = await readdir(dir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error })
|
||||
}
|
||||
const found: AgentPreset[] = []
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
const path = join(dir, child.name, COMPOSITION_FILE)
|
||||
if (!await isFile(path)) continue
|
||||
found.push({ id: child.name, trust: root.trust, path })
|
||||
}
|
||||
return found.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan every root in precedence order.
|
||||
* @param roots - roots in precedence order; an earlier root wins a duplicate id.
|
||||
* @returns every discovered preset, first-root-wins per id.
|
||||
*/
|
||||
export async function discoverPresets(roots: readonly PresetRoot[]): Promise<AgentPreset[]> {
|
||||
const byId = new Map<string, AgentPreset>()
|
||||
for (const root of roots) {
|
||||
for (const preset of await scanRoot(root)) {
|
||||
if (byId.has(preset.id)) continue
|
||||
byId.set(preset.id, preset)
|
||||
}
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
100
packages/preset/agent-presets/src/index.ts
Normal file
100
packages/preset/agent-presets/src/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Agent presets: each session composes its model-facing plugin set from one
|
||||
* preset `cordis.yml` mounted under that agent's scope context.
|
||||
*
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-agent-presets
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
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>
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one agent from a preset, installing it under that agent alone.
|
||||
*
|
||||
* 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.
|
||||
* @throws when the preset is unknown or its composition is unusable.
|
||||
*/
|
||||
async mount(agentCtx: Context, id?: string): Promise<AgentPreset> {
|
||||
const preset = await this.resolve(id)
|
||||
await mountPreset(agentCtx, preset)
|
||||
return preset
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentPresets
|
||||
48
packages/preset/agent-presets/src/invariant.ts
Normal file
48
packages/preset/agent-presets/src/invariant.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-agent-presets`.
|
||||
* @module @deepseek-ai/dsh-agent-presets/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
// Imported through the package name, not `./mount.ts`: a module shared between
|
||||
// the two build entry points becomes a third chunk that the published `files`
|
||||
// list does not carry, which `verify-built-package-invariants` rejects.
|
||||
import { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'agent-presets-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Assert that no installed preset composition reaches the root service realm.
|
||||
*
|
||||
* `mountPreset` proves this once, when the subtree settles. A row that
|
||||
* publishes later — from a timer, or an asynchronous continuation after its
|
||||
* plugin returned — would escape that one-shot audit, so re-check every live
|
||||
* mount whenever a service registration changes.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/service', function (this: Context, name) {
|
||||
for (const mount of livePresetMounts()) {
|
||||
const leaked = leakedServices(ctx, mount.fiber)
|
||||
if (leaked.length === 0) continue
|
||||
fail(
|
||||
`preset "${mount.presetId}" published process-global service(s) [${leaked.join(', ')}] `
|
||||
+ `after its mount was audited (observed while notifying "${name}") — `
|
||||
+ 'a preset service must sit behind an `isolate` realm or move to the host composition',
|
||||
)
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
205
packages/preset/agent-presets/src/mount.ts
Normal file
205
packages/preset/agent-presets/src/mount.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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 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 whose only addition is publishing its tree and fiber for the audit. */
|
||||
class PresetTree extends Include {
|
||||
constructor(ctx: Context, config: Include.Config) {
|
||||
super(ctx, config)
|
||||
mounted.set(config, { tree: this, fiber: ctx.fiber })
|
||||
}
|
||||
}
|
||||
|
||||
/** 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>()
|
||||
|
||||
/**
|
||||
* Every preset composition still installed, pruning fibers disposed since the
|
||||
* last read. Records are dropped lazily 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.
|
||||
* @returns the live mounts.
|
||||
*/
|
||||
export function livePresetMounts(): PresetMount[] {
|
||||
for (const mount of mounts) {
|
||||
if (mount.fiber.uid === null) mounts.delete(mount)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
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 Error(`agent-presets: preset "${preset.id}" (${preset.path}) failed to mount: ${detail}`, { cause: error })
|
||||
}
|
||||
}
|
||||
34
packages/preset/agent-presets/src/types.ts
Normal file
34
packages/preset/agent-presets/src/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** Agent-preset vocabulary shared by discovery, mounting, and consumers. @module @deepseek-ai/dsh-agent-presets/types */
|
||||
|
||||
/**
|
||||
* Where a preset's composition came from. A `system` preset ships with the
|
||||
* deployment; a `user` preset was authored locally, by a person or by an
|
||||
* agent, and therefore carries the same trust as shell access.
|
||||
*/
|
||||
export type PresetTrust = 'system' | 'user'
|
||||
|
||||
/** One preset directory that carries a mountable agent composition. */
|
||||
export interface AgentPreset {
|
||||
/** Stable identifier; the preset directory's name. */
|
||||
readonly id: string
|
||||
/** Trust recorded from the root this preset was discovered under. */
|
||||
readonly trust: PresetTrust
|
||||
/** Absolute path of the preset's agent composition file. */
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** One directory scanned for preset subdirectories. */
|
||||
export interface PresetRoot {
|
||||
/** Directory holding one subdirectory per preset; a leading `~` expands. */
|
||||
path: string
|
||||
/** Trust recorded on every preset discovered under this root. */
|
||||
trust: PresetTrust
|
||||
}
|
||||
|
||||
/** Plugin config: which preset is the default, and where presets live. */
|
||||
export interface Config {
|
||||
/** Preset id mounted when a caller names none. Missing at mount time fails loud. */
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
}
|
||||
77
packages/preset/agent-presets/tests/discovery.spec.ts
Normal file
77
packages/preset/agent-presets/tests/discovery.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
|
||||
const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
|
||||
|
||||
describe('preset discovery', () => {
|
||||
it('reports one preset per directory holding a composition, ordered by id', async () => {
|
||||
const found = await scanRoot(SYSTEM)
|
||||
|
||||
expect(found.map(preset => preset.id)).toEqual(['minimal', 'standard'])
|
||||
expect(found[0]).toEqual({
|
||||
id: 'minimal',
|
||||
trust: 'system',
|
||||
path: join(SYSTEM.path, 'minimal', COMPOSITION_FILE),
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a directory that holds no composition file', async () => {
|
||||
const found = await scanRoot(USER)
|
||||
|
||||
expect(found.map(preset => preset.id)).not.toContain('not-a-preset')
|
||||
})
|
||||
|
||||
it('records the root trust on every preset it discovers', async () => {
|
||||
const found = await scanRoot(USER)
|
||||
|
||||
expect(found.every(preset => preset.trust === 'user')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the earlier root win a duplicate id', async () => {
|
||||
const found = await discoverPresets([SYSTEM, USER])
|
||||
|
||||
const standard = found.filter(preset => preset.id === 'standard')
|
||||
expect(standard).toHaveLength(1)
|
||||
expect(standard[0]?.trust).toBe('system')
|
||||
})
|
||||
|
||||
it('treats an absent root as supplying no presets', async () => {
|
||||
const found = await scanRoot({ path: join(FIXTURES, 'no-such-root'), trust: 'user' })
|
||||
|
||||
expect(found).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a plain file sitting beside the preset directories', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
|
||||
await writeFile(join(root, 'stray.yml'), '- id: x\n')
|
||||
await mkdir(join(root, 'real'))
|
||||
await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n')
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'user' })
|
||||
|
||||
expect(found.map(preset => preset.id)).toEqual(['real'])
|
||||
})
|
||||
|
||||
it('reports a root it cannot read rather than treating it as empty', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
|
||||
const notADirectory = join(root, 'file-as-root')
|
||||
await writeFile(notADirectory, 'not a directory\n')
|
||||
|
||||
await expect(scanRoot({ path: notADirectory, trust: 'user' }))
|
||||
.rejects.toThrow(/cannot read preset root/)
|
||||
})
|
||||
|
||||
it('expands a leading tilde in a root path', async () => {
|
||||
// `~` alone resolves to the home directory, which exists but holds no
|
||||
// preset directories; the point is that it did not throw on a literal `~`.
|
||||
const found = await scanRoot({ path: '~/.dsh-agent-presets-absent', trust: 'user' })
|
||||
|
||||
expect(found).toEqual([])
|
||||
})
|
||||
})
|
||||
20
packages/preset/agent-presets/tests/fixtures/plugins/contribute.js
vendored
Normal file
20
packages/preset/agent-presets/tests/fixtures/plugins/contribute.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
// A preset row: registers one tool and one prompt section, both named from
|
||||
// config. Import-free on purpose — the Loader resolves entry modules through
|
||||
// Node's ESM resolver, which cannot see this workspace's TypeScript sources.
|
||||
export const name = 'contribute'
|
||||
export const inject = ['tools', 'systemPrompt']
|
||||
|
||||
export function apply(ctx, config) {
|
||||
ctx.effect(() => ctx.tools.register({
|
||||
name: config.tool,
|
||||
description: `fixture tool ${config.tool}`,
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
|
||||
execute: () => Promise.resolve(config.tool),
|
||||
}))
|
||||
ctx.effect(() => ctx.systemPrompt.section({
|
||||
name: `preset:${config.tool}`,
|
||||
order: 10,
|
||||
text: `section for ${config.tool}`,
|
||||
}))
|
||||
}
|
||||
5
packages/preset/agent-presets/tests/fixtures/plugins/global-service.js
vendored
Normal file
5
packages/preset/agent-presets/tests/fixtures/plugins/global-service.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// Publishes a service with no `isolate` realm, so it lands in the ROOT realm.
|
||||
export const name = 'global-service'
|
||||
export function apply(ctx, config) {
|
||||
ctx.effect(() => ctx.reflect.provide(config.service, { label: config.label }))
|
||||
}
|
||||
6
packages/preset/agent-presets/tests/fixtures/plugins/late-service.js
vendored
Normal file
6
packages/preset/agent-presets/tests/fixtures/plugins/late-service.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
// Publishes into the ROOT realm only after its plugin body returned, escaping
|
||||
// the one-shot mount audit. Exercises the package invariant.
|
||||
export const name = 'late-service'
|
||||
export function apply(ctx, config) {
|
||||
globalThis.__PUBLISH_LATE__ = () => ctx.effect(() => ctx.reflect.provide(config.service, { label: 'late' }))
|
||||
}
|
||||
5
packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js
vendored
Normal file
5
packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// Waits forever for a service the composition never supplies: the row stays
|
||||
// pending rather than failing, which only the mount audit can catch.
|
||||
export const name = 'needs-missing'
|
||||
export const inject = ['serviceThatDoesNotExist']
|
||||
export function apply() {}
|
||||
4
packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml
vendored
Normal file
4
packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
- id: beta
|
||||
name: ../../plugins/contribute.js
|
||||
config:
|
||||
tool: beta
|
||||
12
packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml
vendored
Normal file
12
packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Shipped preset: one tool plus its guidance section.
|
||||
- id: alpha
|
||||
name: ../../plugins/contribute.js
|
||||
config:
|
||||
tool: alpha
|
||||
|
||||
# A row switched off in the composition stays off without failing the mount.
|
||||
- id: alpha-extra
|
||||
name: ../../plugins/contribute.js
|
||||
disabled: true
|
||||
config:
|
||||
tool: alpha-extra
|
||||
6
packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml
vendored
Normal file
6
packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
- id: ok
|
||||
name: ../../plugins/contribute.js
|
||||
config:
|
||||
tool: ok
|
||||
- id: missing
|
||||
name: ../../plugins/does-not-exist.js
|
||||
9
packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml
vendored
Normal file
9
packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Accepted: the same provider behind an entry-local realm never reaches the
|
||||
# root realm, so it is per-session rather than process-global.
|
||||
- id: svc
|
||||
name: ../../plugins/global-service.js
|
||||
isolate:
|
||||
fixtureIsolatedSvc: true
|
||||
config:
|
||||
service: fixtureIsolatedSvc
|
||||
label: ISOLATED
|
||||
6
packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml
vendored
Normal file
6
packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Publishes into the root realm only after the mount audit ran, which only the
|
||||
# package invariant can catch.
|
||||
- id: late
|
||||
name: ../../plugins/late-service.js
|
||||
config:
|
||||
service: fixtureLateSvc
|
||||
13
packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml
vendored
Normal file
13
packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Rejected: publishes services into the root realm, which would be
|
||||
# process-global rather than per-session. Two rows, so the diagnostic has to
|
||||
# order the names it reports.
|
||||
- id: leak-z
|
||||
name: ../../plugins/global-service.js
|
||||
config:
|
||||
service: zzzFixtureLeakedSvc
|
||||
label: LEAKED-Z
|
||||
- id: leak-a
|
||||
name: ../../plugins/global-service.js
|
||||
config:
|
||||
service: aaaFixtureLeakedSvc
|
||||
label: LEAKED-A
|
||||
1
packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt
vendored
Normal file
1
packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
placeholder, not a preset
|
||||
2
packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml
vendored
Normal file
2
packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
- id: waits
|
||||
name: ../../plugins/needs-missing.js
|
||||
5
packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml
vendored
Normal file
5
packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Same id as the shipped preset: proves the earlier root wins.
|
||||
- id: shadowed
|
||||
name: ../../plugins/contribute.js
|
||||
config:
|
||||
tool: shadowed
|
||||
75
packages/preset/agent-presets/tests/invariant.spec.ts
Normal file
75
packages/preset/agent-presets/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
|
||||
]
|
||||
|
||||
async function harness(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentPresetsInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('agent-presets invariants', () => {
|
||||
it('tracks a mounted composition and forgets it once the agent is gone', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('inv-live'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
||||
})
|
||||
|
||||
expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard')
|
||||
|
||||
await handle.dispose()
|
||||
|
||||
expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard')
|
||||
})
|
||||
|
||||
it('rejects a composition that publishes a process-global service after its audit', async () => {
|
||||
const ctx = await harness()
|
||||
await ctx.agents.create({
|
||||
sessionId: SessionId('inv-late'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'late'),
|
||||
})
|
||||
const publishLate = (globalThis as { __PUBLISH_LATE__?: () => void }).__PUBLISH_LATE__
|
||||
expect(publishLate).toBeTypeOf('function')
|
||||
|
||||
expect(() => { publishLate?.() }).toThrow(/published process-global service\(s\) \[fixtureLateSvc\]/)
|
||||
})
|
||||
|
||||
it('stays quiet while every composition keeps its services out of the root realm', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
sessionId: SessionId('inv-isolated'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'),
|
||||
})).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
204
packages/preset/agent-presets/tests/mount.spec.ts
Normal file
204
packages/preset/agent-presets/tests/mount.spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
|
||||
]
|
||||
|
||||
/** A composition carrying the registries a preset contributes to, plus the preset roster. */
|
||||
async function harness(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Create one agent composed from `presetId`, exactly as a factory `setup` would. */
|
||||
async function agentOn(ctx: Context, id: string, presetId?: string): Promise<Agent> {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId(id),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId),
|
||||
})
|
||||
return handle.agent
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
||||
|
||||
/** Every service registration in the runtime, regardless of which realm holds it. */
|
||||
function providedServiceNames(ctx: Context): string[] {
|
||||
const store = ctx.reflect.store
|
||||
return Object.getOwnPropertySymbols(store)
|
||||
.map(key => store[key]?.name)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
}
|
||||
|
||||
/** Whether the root realm maps `name` to a live registration. */
|
||||
function rootResolves(ctx: Context, name: string): boolean {
|
||||
const key = ctx.root[Context.isolate][name]
|
||||
return key !== undefined && ctx.reflect.store[key] !== undefined
|
||||
}
|
||||
|
||||
let ctx: Context
|
||||
beforeEach(async () => {
|
||||
ctx = await harness()
|
||||
})
|
||||
|
||||
describe('composing an agent from a preset', () => {
|
||||
it('gives each session only its own preset\'s tools', async () => {
|
||||
const alpha = await agentOn(ctx, 'sess-alpha', 'standard')
|
||||
const beta = await agentOn(ctx, 'sess-beta', 'minimal')
|
||||
|
||||
expect(toolNames(ctx, alpha)).toEqual(['alpha'])
|
||||
expect(toolNames(ctx, beta)).toEqual(['beta'])
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('scopes prompt sections and assembled schemas to the same session', async () => {
|
||||
const alpha = await agentOn(ctx, 'sess-alpha', 'standard')
|
||||
const beta = await agentOn(ctx, 'sess-beta', 'minimal')
|
||||
|
||||
const alphaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(alpha))
|
||||
const betaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(beta))
|
||||
|
||||
expect(alphaPrompt.sections.map(section => section.name)).toContain('preset:alpha')
|
||||
expect(alphaPrompt.sections.map(section => section.name)).not.toContain('preset:beta')
|
||||
expect(betaPrompt.sections.map(section => section.name)).toContain('preset:beta')
|
||||
expect(alphaPrompt.tools.map(schema => schema.name)).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('mounts the default preset when the caller names none', async () => {
|
||||
const agent = await agentOn(ctx, 'sess-default')
|
||||
|
||||
expect(toolNames(ctx, agent)).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('lets two sessions share one preset without colliding', async () => {
|
||||
const first = await agentOn(ctx, 'sess-first', 'standard')
|
||||
const second = await agentOn(ctx, 'sess-second', 'standard')
|
||||
|
||||
expect(toolNames(ctx, first)).toEqual(['alpha'])
|
||||
expect(toolNames(ctx, second)).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('unwinds one session\'s composition without touching another\'s', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('sess-gone'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
||||
})
|
||||
const survivor = await agentOn(ctx, 'sess-stays', 'minimal')
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
|
||||
|
||||
await handle.dispose()
|
||||
|
||||
expect(ctx.agents.get(SessionId('sess-gone'))).toBeUndefined()
|
||||
expect(toolNames(ctx, survivor)).toEqual(['beta'])
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rejecting a composition that cannot be used', () => {
|
||||
it('refuses to mount into a context that carries no agent scope', async () => {
|
||||
await expect(ctx.agentPresets.mount(ctx, 'standard'))
|
||||
.rejects.toThrow(/unscoped context/)
|
||||
})
|
||||
|
||||
it('rolls the whole agent back when a row fails to load', async () => {
|
||||
await expect(agentOn(ctx, 'sess-broken', 'broken')).rejects.toThrow(/failed to mount/)
|
||||
|
||||
expect(ctx.agents.get(SessionId('sess-broken'))).toBeUndefined()
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('names the unresolved service when a row never activates', async () => {
|
||||
await expect(agentOn(ctx, 'sess-pending', 'pending'))
|
||||
.rejects.toThrow(/waiting for serviceThatDoesNotExist/)
|
||||
})
|
||||
|
||||
it('rejects a row that publishes a process-global service', async () => {
|
||||
await expect(agentOn(ctx, 'sess-leaky', 'leaky'))
|
||||
.rejects.toThrow(/process-global service\(s\) \[aaaFixtureLeakedSvc, zzzFixtureLeakedSvc\]/)
|
||||
|
||||
// The rejected subtree is fully unwound, so its registrations are gone from
|
||||
// the store rather than merely unreachable.
|
||||
expect(providedServiceNames(ctx)).not.toContain('aaaFixtureLeakedSvc')
|
||||
expect(providedServiceNames(ctx)).not.toContain('zzzFixtureLeakedSvc')
|
||||
})
|
||||
|
||||
it('accepts the same provider behind an isolate realm', async () => {
|
||||
const agent = await agentOn(ctx, 'sess-isolated', 'isolated')
|
||||
|
||||
expect(agent.id).toBe(SessionId('sess-isolated'))
|
||||
// The provider ran, but under a realm-private symbol the root cannot reach.
|
||||
expect(providedServiceNames(ctx)).toContain('fixtureIsolatedSvc')
|
||||
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the known ids when a preset is unknown', async () => {
|
||||
await expect(ctx.agentPresets.resolve('nope'))
|
||||
.rejects.toThrow(/preset "nope" not found \(available: .*standard/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the preset roster', () => {
|
||||
it('lists every root\'s presets with the earlier root winning', async () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
expect(listed.map(preset => preset.id).sort())
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard'])
|
||||
expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
|
||||
})
|
||||
|
||||
it('exposes the configured default id', () => {
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a roster with nothing in it', () => {
|
||||
it('says so instead of naming an empty list of candidates', async () => {
|
||||
const bare = new Context()
|
||||
await bare.plugin(Loader)
|
||||
await bare.plugin(AgentPresets, { default: 'standard', roots: [] })
|
||||
|
||||
await expect(bare.agentPresets.resolve())
|
||||
.rejects.toThrow(/preset "standard" not found \(available: none\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attributing a service to a subtree', () => {
|
||||
it('attributes nothing to a subtree that is already torn down', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('sess-torn'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
||||
})
|
||||
const [mount] = livePresetMounts().filter(entry => entry.presetId === 'standard')
|
||||
expect(mount).toBeDefined()
|
||||
|
||||
await handle.dispose()
|
||||
|
||||
// A disposed subtree owns nothing, so it can never be blamed for a service
|
||||
// some other subtree published under the same name afterwards.
|
||||
expect(leakedServices(ctx, mount!.fiber)).toEqual([])
|
||||
})
|
||||
})
|
||||
31
packages/preset/agent-presets/tsconfig.json
Normal file
31
packages/preset/agent-presets/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user