@deepseek-ai/dsh-agent-presets 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, DeepSeek
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
5
+ README.md: e31dc8e666096baf6fd6b2cf9407110c7678c6e1
6
+ README.zh.md: cf7e24bb0f256623beec1b86c70473a70be03cb5
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # dsh-agent-presets
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ ## Service: `AgentPresets` (ctx key: `agentPresets`)
10
+
11
+ 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. Discovery also owns preset **health**: a directory whose composition is missing or unloadable (unparsable YAML — checked with the loader's own dialect, `!!js` included — or not a list of named plugin rows) is listed with a `broken` reason rather than skipped, because a skipped directory would still occupy its id on disk while every surface shows nothing to delete. A directory whose name is not a usable preset id (`[a-z0-9][a-z0-9-]*`) is skipped outright: no copy could ever claim it.
12
+
13
+ - `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none.
14
+ - `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason.
15
+ - `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row.
16
+ - `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. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved.
17
+ - `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined).
18
+ - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
19
+ - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
20
+ - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
21
+ - `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
22
+ - `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
23
+ - `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
24
+ - `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
25
+
26
+ `AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), `path` (the absolute composition file), and — only when the preset cannot compose a session — `broken` (one human-readable reason, shown verbatim on roster surfaces).
27
+
28
+ ### Where to call `mount()`
29
+
30
+ 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. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions.
31
+
32
+ ### Composing a child agent
33
+
34
+ A subagent's child joins its parent's standing composition through `composeFrom()`, never through `mount()`. Every model-facing row lives on the agent plane, so the tool registry's global layer is empty and a child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections.
35
+
36
+ Re-mounting the parent's preset by id would differ from the bind in two ways that both matter. A composition file edited since the parent started would hand the child a DIFFERENT generation than the one its parent's history was produced under, and a preset deleted since would fail the child outright while its parent keeps running. The bind is also synchronous, which is what lets the in-process subagent drivers use it at all — they compose their children inside a synchronous creation window.
37
+
38
+ The child records the joined id on its own durable header ([`dsh-subagent`](../../subagent/subagent/README.md)), so a cold read of the child's history rebuilds the composition it actually ran under rather than the deployment default.
39
+
40
+ ### Which preset a session runs
41
+
42
+ The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
43
+
44
+ The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
45
+
46
+ ### Switching a blank agent
47
+
48
+ `recompose()` unmounts the installed subtree and mounts the new one, because two compositions cannot coexist — both would register the same tool names into one layer. A failed mount restores the previous composition rather than leaving the agent with nothing, and an unknown id is rejected before anything is torn down.
49
+
50
+ The restriction to a produced-nothing agent is a product rule, not a mechanical one: swapping tools mid-conversation would leave logged tool calls the new composition cannot make. The gateway enforces it at the wire ([`dsh-apiproxy`](../../host/apiproxy/README.md) answers `agent-preset-locked`), which is where session history is in hand.
51
+
52
+ ## Authoring
53
+
54
+ Authoring is copy-only. A new preset is a whole-directory copy of an existing one — composition, metadata, skill directories, assets — landed under the first `user` root; the inputs are two ids the service resolves against its own roots plus an optional display name, so no caller ever supplies composition text and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files. `copy()` refuses three things before anything lands:
55
+
56
+ - **An id that is not `[a-z0-9][a-z0-9-]*`.** The id becomes a directory name, so containment is a property of the id itself rather than of a path check after the fact — `../escape`, `a/b`, and an absolute path are all rejected as ids.
57
+ - **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk refuses it too. Discovery lists such a directory as a broken preset, so the refusal's way out — delete it — is on the same page that reported it.
58
+ - **An unknown source.** The source may be any trust — copying a shipped preset is the primary case — but it must exist; a failed copy rolls its half-made directory back rather than leaving one discovery cannot see.
59
+
60
+ The copied tree is re-tightened to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), symlinks are dereferenced so the copy is self-contained, and the root is created on first copy — a deployment configuring a user root that does not exist yet is the normal first-run state. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit in place, but its name and roster `order` are dropped — a copy presenting itself identically to its source, or sorted into the shipped set's declared order, would make the roster stop distinguishing them. `remove()` refuses a preset that ships with the deployment; the shipped set is the known-good compositions copies start from.
61
+
62
+ ### How a preset's rows resolve
63
+
64
+ A row's **package name** resolves from the host composition, not from the preset directory. The Loader normally resolves an entry against its own tree's `baseUrl`, which for a preset is wherever the composition file sits; a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the harness, so every `@deepseek-ai/dsh-*` row would fail to import. The mount records the host base before plugging the subtree and sends bare specifiers there.
65
+
66
+ A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
67
+
68
+ An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts.
69
+
70
+ ### Display metadata
71
+
72
+ A preset may publish display text in an optional `preset.yml` beside its composition:
73
+
74
+ ```yaml
75
+ name: 极简模式
76
+ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
77
+ ```
78
+
79
+ It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load.
80
+
81
+ Every read failure degrades to no metadata — absent, malformed, wrongly typed, or blank all mean the same thing, and a picker falls back to the id. Presentation is not capability: a preset with a broken name still mounts.
82
+
83
+ ## Config
84
+
85
+ | Field | Default | Meaning |
86
+ |---|---|---|
87
+ | `default` | required | Preset id mounted when a caller names none |
88
+ | `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
89
+
90
+ 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.
91
+
92
+ ### The default preset is a user setting
93
+
94
+ When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default:
95
+
96
+ ```yaml
97
+ agent-presets:
98
+ default: minimal
99
+ ```
100
+
101
+ The value is read per resolution rather than snapshotted, so a hot-reloaded document takes effect on the next session created and every running session stays on the preset it was composed from. Clearing the user field re-inherits the composition default. A default naming a preset no root supplies is stored without complaint and fails at the next `resolve()` — the roster is a live directory, so a name absent now may exist by the time a session asks for it.
102
+
103
+ ## What a mount rejects
104
+
105
+ 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.
106
+
107
+ **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.
108
+
109
+ **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.
110
+
111
+ **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.
112
+
113
+ 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.
114
+
115
+ ## A preset file is an input, never a persistence target
116
+
117
+ The Loader writes a tree back to its source file whenever it decides the config changed, and a row disposing its own fiber is enough to decide that: the entry is marked `disabled` and the tree is written. Inherited, that would burn one session's runtime state into a file every session shares — comments stripped by the YAML round trip, and a `writeFile` rejection inside a `setTimeout` for a read-only shipped preset.
118
+
119
+ The mounted subtree therefore overrides `write()` as a no-op. Nothing in this package writes a composition; authoring one is a separate, explicit operation.
120
+
121
+ ## Trust
122
+
123
+ 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.
124
+
125
+ ## Model Experience
126
+
127
+ 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.
128
+
129
+ #### KV Cache effect
130
+
131
+ 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.
132
+
133
+ ## Known Limitations and Deferred Work
134
+
135
+ - **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.
136
+ - **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
137
+ - **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start.
138
+ - **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back.
139
+ - **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file.
140
+ - **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()`.
package/README.zh.md ADDED
@@ -0,0 +1,140 @@
1
+ # dsh-agent-presets
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 按 preset 组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;roster 在整个进程内只把它挂载一次(常驻 scope),命名它的每个会话通过把自己 agent 的 scope key 认父到该挂载(`dsh-scope` 的父链)来加入。挂载的工具、提示词段落与投影单元只存在一份,覆盖所有已加入的 agent——其插件本就按 Session/Agent 分键存状态,会话在共享实例内互不串扰——而完全没有 agent 的宿主读取方(冷读记录)也能按 preset id 解析到同一份常驻注册。
6
+
7
+ 其机制是两条 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 的监听器保持失聪。
8
+
9
+ ## 服务:`AgentPresets`(ctx 键:`agentPresets`)
10
+
11
+ 发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。发现过程同时负责 preset 的**健康**:组装文件缺失或不可加载(YAML 无法解析——用加载器自己的方言检查,含 `!!js`——或不是由具名插件行组成的列表)的目录会作为携带 `broken` 原因的行列出而不是被跳过,因为被跳过的目录仍在磁盘上占着它的 id,而各个界面却没有任何可删的东西。目录名不是可用 preset id(`[a-z0-9][a-z0-9-]*`)的目录才被直接跳过:复制永远不可能占用那种名字。
12
+
13
+ - `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。
14
+ - `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。
15
+ - `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。
16
+ - `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。
17
+ - `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝。
18
+ - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。
19
+ - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
20
+ - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
21
+ - `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
22
+ - `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
23
+ - `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
24
+ - `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
25
+
26
+ `AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)、`path`(组装文件的绝对路径),以及——仅当该 preset 无法组装会话时——`broken`(一条人类可读的原因,名单界面原样展示)。
27
+
28
+ ### 应在何处调用 `mount()`
29
+
30
+ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。
31
+
32
+ ### 组装子 agent
33
+
34
+ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,绝不走 `mount()`。所有面向模型的行都在 agent 平面,工具注册表的全局层是空的,因此没有加入任何组装的子 agent 抵达模型时既没有任何工具,也没有父方的任何提示段。
35
+
36
+ 按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。
37
+
38
+ 子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。
39
+
40
+ ### 会话实际运行的是哪个 preset
41
+
42
+ 创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。
43
+
44
+ 头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
45
+
46
+ ### 切换空白 agent
47
+
48
+ `recompose()` 先卸载已装入的子树、再装入新的,因为两份组装无法共存——它们会把相同的工具名注册进同一个层。挂载失败会恢复先前的组装,而不是让 agent 一无所有;未知 id 则在任何东西被拆除之前就被拒绝。
49
+
50
+ "仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。
51
+
52
+ ## 创作
53
+
54
+ 创作即复制。新 preset 是某个既有 preset 的整目录副本——组装、元数据、skill 目录、附带资产——落在首个 `user` 根目录之下;输入只有两个由服务对照自身根目录解析的 id 加一个可选显示名,因此调用方从不提供组装文本,一次复制不会授予 roster 尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。`copy()` 在任何内容落盘之前拒绝三种情况:
55
+
56
+ - **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。
57
+ - **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字的目录同样拒绝。发现过程会把这样的目录列为损坏的 preset,所以这条拒绝的出路——删掉它——就在报告它的同一页面上。
58
+ - **未知的来源。** 来源可以是任何信任级别——复制随附 preset 正是主要用途——但必须存在;复制失败会回滚做到一半的目录,而不是留下一个 discovery 看不见的目录。
59
+
60
+ 复制出的目录树被收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),符号链接被解引用以保证副本自包含,且根目录在首次复制时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。复制出的 `preset.yml` 会被重写:保留来源的描述供作者就地编辑,但丢弃其名称与 roster `order`——副本若与来源呈现得一模一样、或按随附集合声明的顺序排序,roster 就不再能区分它们。`remove()` 拒绝随部署提供的 preset;随附集合正是副本的已知良好起点。
61
+
62
+ ### preset 的各行如何解析
63
+
64
+ 行的**包名**从宿主组装解析,而非从 preset 目录解析。Loader 通常按 entry 所属树的 `baseUrl` 解析,而对 preset 而言那就是组装文件所在之处;本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败。挂载在插入子树之前先记录宿主的基址,并把裸标识符送往那里。
65
+
66
+ **相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
67
+
68
+ **绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。
69
+
70
+ ### 展示用元信息
71
+
72
+ preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:
73
+
74
+ ```yaml
75
+ name: 极简模式
76
+ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
77
+ ```
78
+
79
+ 它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。
80
+
81
+ 任何读取失败都退化为「没有元信息」——缺失、格式错误、类型不对、内容为空,含义相同,选择器回退到 id。展示不是能力:名字坏掉的 preset 依然能挂载。
82
+
83
+ ## 配置
84
+
85
+ | 字段 | 默认值 | 含义 |
86
+ |---|---|---|
87
+ | `default` | 必填 | 调用方未指定时挂载的 preset id |
88
+ | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
89
+
90
+ 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
91
+
92
+ ### 默认 preset 是一项用户设置
93
+
94
+ 当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值:
95
+
96
+ ```yaml
97
+ agent-presets:
98
+ default: minimal
99
+ ```
100
+
101
+ 该值在每次解析时读取而非快照,因此热重载的文档对**此后创建**的会话生效,而每个运行中的会话仍停留在它当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。若默认值指向没有任何根目录提供的 preset,写入时不会报错,而在下一次 `resolve()` 时失败——名单是一个活动目录,此刻不存在的名字,等到某个会话真正索取时可能已经存在。
102
+
103
+ ## 挂载会拒绝什么
104
+
105
+ 直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。
106
+
107
+ **目标上下文没有 scope。** 挂载到不带 agent scope 的上下文,会把该 preset 的工具注册成全局的,作用于进程内每一个 agent。
108
+
109
+ **某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。
110
+
111
+ **某一行把服务发布进了根 realm。** 这类服务是进程级全局的,因此第二个发布同名服务的 preset 会与第一个相撞,宿主读取方也会把某一个 preset 的实例当成所有会话的。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——entry 本地 realm 让两个 preset 的同名服务互不相干,正如它从前隔开两个会话——否则该服务应改放进宿主组装。
112
+
113
+ 最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。
114
+
115
+ ## preset 文件是输入,不是持久化目标
116
+
117
+ 只要 Loader 认为配置变了,它就会把树写回源文件——而一个行释放自己的 fiber 就足以让它这么认为:该 entry 被标记 `disabled`,随即触发写回。若继承该行为,一个会话的运行时状态就会被烧进所有会话共享的文件里:YAML 往返会抹掉注释,而对随附的只读 preset,`writeFile` 还会在 `setTimeout` 内抛出无人接管的 rejection。
118
+
119
+ 因此被挂载的子树把 `write()` 覆写为空操作。本包不写任何组装;创作组装是另一件独立且显式的操作。
120
+
121
+ ## 信任
122
+
123
+ preset 就是组装,因此一个 preset 的权限恰好等于它所引用的插件。`user` preset——无论由人还是由 agent 写出——与 shell 访问权限同级;`trust` 字段的存在是为了让消费方呈现这一差异,而不是用来强制隔离。
124
+
125
+ ## Model Experience
126
+
127
+ 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.
128
+
129
+ #### KV Cache effect
130
+
131
+ 在一个 agent 的整个生命周期内保持前缀稳定:组装只装入一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间不再重新读取。为新会话选择不同的 preset,只会为该会话建立不同的前缀,无法让任何已在运行的会话失去缓存复用。
132
+
133
+ ## Known Limitations and Deferred Work
134
+
135
+ - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
136
+ - **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
137
+ - **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
138
+ - **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。
139
+ - **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。
140
+ - **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。