DSH / Atlas
2026-08-10implementedarchitecture

What stays host-plane once presets own the agent plane

What stays host-plane once presets own the agent plane

[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribut

English

Problem

Per-session agent presets moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. tasks came back to the host because a preset row outside its realm resolved it; goals never left for the same reason; a child agent's toolFilter was repaired once every model-facing tool became an ancestor contribution rather than a global one (child agents join their parent's preset).

Two more readers were still on the wrong side of that line.

dsh-token-meter was disabled on the host and mounted inside each preset's compaction realm. It takes no configuration, keys every fold by Session, and registers no tool or prompt section — but it owns the tokenUsage, contextPressure, and contextBreakdown projection units, and sessionProjections is a process-wide table with no scope layering. A unit registered from inside one preset therefore answers for every session: whether a minimal session showed a context meter depended on whether some other session had mounted standard since boot, and a process that only ever ran minimal showed none at all.

Nothing named an agent that joined no preset. The join is a scope-parent link; without it the tools, system-prompt, and skill views resolve the empty global layer and the model receives nothing — no error, no empty catalog, just an agent that cannot act. That is how delegated subagents ran for as long as presets existed, and the same hole is open at every entry point that predates them.

Decision

The meter is host-plane. dsh-token-meter returns to the host composition and leaves the presets' isolate map, so compaction-basic and tool-result-pruner resolve the one host instance from inside their realm. The presets keep the realm and the backend — what a preset chooses is whether its agent compacts, not whether its tokens are counted. This is the criterion tasks and goals are already read by, applied to a Service whose projection reach is what made preset ownership wrong: a unit whose empty value is indistinguishable from a real one cannot be per-composition while the table it registers into is per-process.

An unjoined agent is named twice, at two different points. AgentPresets logs one warning per agent published with a scope chain of length one while a roster is configured. The invariant companion fails instead — and at system-prompt/assemble, not at publication, because an unjoined agent is legal until it addresses a model: recompose binds exactly such an agent as its first link, and prompt assembly is the only caller that supplies an agent scope, so a host assembly and a standing mount are both correctly out of range.

Three limits stay open and are recorded where they bite rather than fixed here: projection key presence is not a per-session capability signal (dsh-session-projection); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost (dsh-agent-presets); and a temporary plugin mounted through cordis_mount belongs to the composition rather than the session that mounted it (dsh-tool-cordis).

Testing

apps/cli/tests/web-agent-presets.e2e.ts reads ctx.get('tokenMeter') on the booted Web composition before any preset in the file mounts — a preset-side meter sits behind an isolate realm and is invisible to ctx.get, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a minimal session's snapshot carries all three units.

packages/preset/agent-presets/tests/mount.spec.ts asserts the warning fires exactly once for a bare agent and not at all for a joined one. tests/invariant.spec.ts carries the negative control: an unjoined agent's assembly rejects, while a joined agent's assembly and a scopeless host assembly both pass.

Alternatives considered

Keep the meter in the preset and scope-layer the projection registry. The precise fix, and much larger: snapshot, checkpoint, and the eager drive would each need a session→scope resolution that a cold read does not have without the api-proxy's presenterScopeFor. Rejected as disproportionate to one Service with no per-preset state at all; the general rule is documented on the registry instead.

Veto publication for an unjoined agent. Loud beats silent, and the registry supports it — a synchronous agent/created listener that throws rolls the creation back. Rejected because composing an agent outside the roster is legal: recompose documents the bare agent it then binds, and the ACP bridge, the SDK server, and the headless bundle all create one today. A veto would convert a capability gap into an outage.

Check the join at agent/created in the companion too. Rejected: publication cannot distinguish a missed join from an agent that will be bound later, so the check would reject a documented path. Prompt assembly can distinguish them.

Move plan-mode and tool-todo off the agent plane for the same projection reason. Rejected: both are genuinely per-preset capabilities, and their units compute an empty value for a session that never uses them, which clients already read by value (plan.active, an empty list). Only a unit whose empty value is indistinguishable from a real one — the meter — forces host ownership.

Consequences

The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and minimal now says it drops auto-compaction rather than the accounting.

The warning is advisory, so a deployment that adds a roster to the ACP or SDK-server entry points still starts agents with no tools — it just says so once per agent instead of silently. The invariant reaches only compositions that load dsh-invariants, which fences package tests and development hosts, not a shipped one.

中文

问题

逐会话 agent preset 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。tasks 因为 realm 之外的 preset 行要解析它而搬回宿主;goals 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 toolFilter 也已被修好(子 agent 加入父方 preset)。

还有两个读取点仍站在这条线的错误一侧。

dsh-token-meter 在宿主侧被禁用,改挂进每个 preset 的 compaction realm。它不接受任何配置,每次折叠都以 Session 建键,也不注册工具或提示段——但它拥有 tokenUsagecontextPressurecontextBreakdown 三个投影单元,而 sessionProjections 是一张进程级、没有作用域分层的表。因此从某个 preset 内部注册的单元会替所有会话作答:一个 minimal 会话是否显示 context meter,取决于本次启动以来有没有别的会话挂过 standard;而只跑过 minimal 的进程根本不显示。

没有加入任何 preset 的 agent 也无人指出。加入是一条 scope 父链链接;缺了它,toolssystem-promptskill 的视图都解析到空的全局层,模型什么也收不到——不报错,也没有空目录可看,只是一个无法行动的 agent。被委派的子 agent 在 preset 存在的整段时间里都是这样运行的,而同一个洞在每一个早于 preset 的入口点上都开着。

决策

meter 属于宿主平面。 dsh-token-meter 回到宿主组装,并离开各 preset 的 isolate 映射,于是 compaction-basictool-result-pruner 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 tasksgoals 已经采用的判据,只是这次适用于一个因投影触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。

未加入的 agent 在两个不同的点上被指出两次。 在配置了名单的前提下,AgentPresets 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 system-prompt/assemble 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:recompose 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。

有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号(dsh-session-projection);被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价(dsh-agent-presets);通过 cordis_mount 挂上的临时插件属于组装而非挂载它的会话(dsh-tool-cordis)。

测试

apps/cli/tests/web-agent-presets.e2e.ts 在本文件中任何 preset 挂载之前,于已启动的 Web 组装上读取 ctx.get('tokenMeter')——preset 侧的 meter 会待在 isolate realm 里,对 ctx.get 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 minimal 会话的快照带齐三个单元。

packages/preset/agent-presets/tests/mount.spec.ts 断言警告对裸 agent 恰好触发一次、对已加入的 agent 完全不触发。tests/invariant.spec.ts 承担负控:未加入 agent 的组装被拒绝,而已加入 agent 的组装与不带作用域的宿主组装都通过。

考虑过的替代方案

把 meter 留在 preset,改为给投影注册表分层。 这是更精确的修法,代价也大得多:snapshotcheckpoint 与主动驱动都需要一次「会话 → 作用域」的解析,而冷读在没有 api-proxy 的 presenterScopeFor 时并不具备。相对于一个完全没有 per-preset 状态的 Service,这不成比例,因此改为把通则写在注册表上。

对未加入的 agent 否决发布。 大声胜过静默,注册表也支持这么做——同步的 agent/created 监听器抛出会把创建整体回滚。否决的理由是:在名单之外组装 agent 是合法的——recompose 写明了它随后绑定的那个裸 agent,而 ACP 桥、SDK server 与 headless bundle 今天都会创建一个。否决会把能力缺口变成一次故障。

让配套也在 agent/created 处检查加入情况。 否决:发布时分不清漏掉的加入与之后才会被绑定的 agent,因此该检查会拒绝一条已写明的路径。提示词组装分得清。

基于同样的投影理由,把 plan-modetool-todo 也搬离 agent 平面。 否决:两者确实是逐 preset 的能力,且对从不使用它们的会话,其单元算出的就是空值,而客户端本来就按值读取(plan.active、空列表)。只有空值与真实值无法区分的单元——meter——才被迫归宿主所有。

后果

context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,minimal 现在也写明它放弃的是自动压缩而非记账。

那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 dsh-invariants 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。