fix(agent-presets): let the invariant follow the roster it actually scans

The service moved its "is a roster composed" reads to the derived root set;
the invariant companion still read `config.roots`. In the shape this change
exists for — an app configures nothing and the roster is the harness home
alone — that made the advisory warning fire while the fail-loud invariant
stayed silent, so an agent could address a model against an empty global
layer unchecked.

Both now read one source: `roots` exposes the resolved set, and the invariant
asks it. That decides the behavior deliberately rather than by omission — a
composition that mounts the roster now fails an unjoined agent whether its
roots were configured or derived, and `includeUserRoot: false` with no
configured roots is how a deployment keeps its agents on the host plane. Both
shapes are pinned; the derived-only case fails against the old predicate.

Three pieces of prose went stale with the first commit: the web-app bundle
comment still called the writable root an assembly fact patched in by
AppCLIEntry (removed in the profile-plugin-bundles refactor — `composeProfile`
owns it now, and only for the shipped root), and the shipped skill and its
Agent Note still called both roots "configuration". The README gains the
resolved-roster reader and the discoverable-but-undeletable preset a second
writable root produces.
This commit is contained in:
Yichen Jiang
2026-08-11 21:13:41 +08:00
parent 101e7f2382
commit 715baae6c1
11 changed files with 60 additions and 21 deletions

View File

@@ -374,12 +374,15 @@
disabled: true
# The preset roster. `config/agent-presets/` ships with the deployment and is
# read-only (its entries carry `system` trust);
# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and
# carries the same trust as shell access because a preset IS a composition.
# `roots` is an assembly fact, not user config: the shipped preset directory
# ships beside this file, so AppCLIEntry resolves it and patches it in — the
# same treatment `distIndex` gets on the webserver row.
# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is
# where a person — or an agent — authors their own, and carries the same trust
# as shell access because a preset IS a composition.
#
# Only the SHIPPED root is an assembly fact: it sits beside the installed app's
# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the
# same treatment `distIndex` gets on the webserver row. The writable root is
# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that
# never reaches that patch still finds a person's presets.
- insert:
- id: agent-presets
name: '@deepseek-ai/dsh-agent-presets'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
README.md: fb140e6ba7330b88b3c4fede775e3967a3ece0eb
README.zh.md: ceb8f0284cba25110bd95e20d597942e0b919446
README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967
README.zh.md: 505cb017a3a2439a11e0f3ed1c7a950893f5e7de

View File

@@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
- `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.
- `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()`.
- `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()`.
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it.
- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all.
- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
- `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.
- `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.
@@ -143,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
## Known Limitations and Deferred Work
- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`.
- **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
- **A 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.
- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`.

View File

@@ -18,7 +18,8 @@
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。
- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
@@ -143,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
## Known Limitations and Deferred Work
- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。

View File

@@ -337,7 +337,17 @@ export class AgentPresets extends Service {
return standingMountFor(agentCtx)?.presetId
}
/** Whether this deployment configures a root locally authored presets go to. */
/**
* The roots this roster scans, which is not `config.roots`: it is every
* configured root in order, then the harness-home user root unless
* `includeUserRoot` is false. Read this — not the config field — to answer
* whether a roster is composed at all, so one derivation decides it.
*/
get roots(): readonly PresetRoot[] {
return this.resolvedRoots
}
/** Whether this deployment has a root locally authored presets go to. */
get authorable(): boolean {
return this.resolvedRoots.some(root => root.trust === 'user')
}

View File

@@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => {
ctx.on('system-prompt/assemble', (_assembly, context, next) => {
const presets = ctx.get('agentPresets')
const agent = context.agent
if (presets !== undefined && presets.config.roots.length > 0
if (presets !== undefined && presets.roots.length > 0
&& agent !== undefined && presets.composedPreset(agent.ctx) === undefined) {
fail(
`agent "${agent.id}" addressed a model without joining any agent preset while a roster is `

View File

@@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } 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 AgentPresets, { livePresetMounts, type Config } 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')
@@ -20,7 +20,7 @@ const ROOTS = [
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
]
async function harness(): Promise<Context> {
async function harness(roster: Partial<Config> = {}): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
await ctx.plugin(Loader)
@@ -31,7 +31,7 @@ async function harness(): Promise<Context> {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false })
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster })
await ctx.plugin(InvariantService)
await ctx.plugin(AgentPresetsInvariant)
return ctx
@@ -97,6 +97,28 @@ describe('agent-presets invariants', () => {
.rejects.toThrow(/without joining any agent preset/)
})
it('rejects one just the same when the derived home root is the whole roster', async () => {
// The shape this plugin defaults to: an app configures nothing and the
// roster is the harness home alone. A roster is a roster however its roots
// were resolved, so the fail-loud half must not go quiet here — it read
// `config.roots` once, which is empty in exactly this case.
const ctx = await harness({ roots: [], includeUserRoot: true })
const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') })
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent)))
.rejects.toThrow(/without joining any agent preset/)
})
it('stays silent for a composition that opted out of every root', async () => {
// `includeUserRoot: false` with no configured roots is a deployment that
// mounts the roster but keeps its agents on the host plane; there is no
// roster to join, so an unjoined agent is not a violation.
const ctx = await harness({ roots: [], includeUserRoot: false })
const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') })
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined()
})
it('admits a joined agent, a scopeless read, and a standing-key read', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({