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-09-18-declarative-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 (historical 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.
Entry points join Agents explicitly. No runtime check rejects an unjoined Agent, because a bare Agent can legally be bound later through recompose.
Projection key presence is not a per-session capability signal (session projection). Temporary plugins mounted through cordis_mount belong to the composition rather than the invoking session (tool Cordis). Declarative presets own revision retention and reclaim retired trees after their final reference releases.
Testing
apps/cli/tests/web-agent-presets.e2e.ts reads ctx.get('tokenMeter') on the booted Web composition before any Agent is created — 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.
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. A veto would convert a capability gap into an outage.
Check the join at agent/created. Rejected: publication cannot distinguish a missed join from an agent that will be bound later, so the check would reject a documented path.
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 is a per-session fact independent of mount history. Presets resolve the host meter for token accounting while selecting their own compaction backend; minimal supports both automatic compaction and the human /compact command.
Production entry points must compose Agents explicitly; installing the registry alone does not bind them. An unjoined Agent receives no tools, prompt sections, or skills and reports no error.
中文
问题
逐会话 agent preset 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。tasks 因为 realm 之外的 preset 行要解析它而搬回宿主;goals 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 toolFilter 也已被修好(历史子 agent 加入父方 preset)。
还有两个读取点仍站在这条线的错误一侧。
dsh-token-meter 在宿主侧被禁用,改挂进每个 preset 的 compaction realm。它不接受任何配置,每次折叠都以 Session 建键,也不注册工具或提示段——但它拥有 tokenUsage、contextPressure 与 contextBreakdown 三个投影单元,而 sessionProjections 是一张进程级、没有作用域分层的表。因此从某个 preset 内部注册的单元会替所有会话作答:一个 minimal 会话是否显示 context meter,取决于本次启动以来有没有别的会话挂过 standard;而只跑过 minimal 的进程根本不显示。
没有加入任何 preset 的 agent 也无人指出。加入是一条 scope 父链链接;缺了它,tools、system-prompt 与 skill 的视图都解析到空的全局层,模型什么也收不到——不报错,也没有空目录可看,只是一个无法行动的 agent。被委派的子 agent 在 preset 存在的整段时间里都是这样运行的,而同一个洞在每一个早于 preset 的入口点上都开着。
决策
meter 属于宿主平面。 dsh-token-meter 回到宿主组装,并离开各 preset 的 isolate 映射,于是 compaction-basic 与 tool-result-pruner 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 tasks 与 goals 已经采用的判据,只是这次适用于一个因投影触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。
入口显式让 Agent 加入。 没有运行时检查会拒绝未加入的 Agent,因为未绑定的 Agent 可以合法地稍后通过 recompose 绑定。
投影 key 是否存在不能代表逐会话能力(会话投影)。通过 cordis_mount 挂载的临时插件属于组装,而非调用会话(Cordis 工具)。声明式 preset 负责代际保留,在最后一个引用释放后回收退役树。
测试
apps/cli/tests/web-agent-presets.e2e.ts 在创建任何 Agent 之前,于已启动的 Web 组装上读取 ctx.get('tokenMeter')——preset 侧的 meter 会待在 isolate realm 里,对 ctx.get 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 minimal 会话的快照带齐三个单元。
考虑过的替代方案
把 meter 留在 preset,改为给投影注册表分层。 这是更精确的修法,代价也大得多:snapshot、checkpoint 与主动驱动都需要一次「会话 → 作用域」的解析,而冷读在没有 api-proxy 的 presenterScopeFor 时并不具备。相对于一个完全没有 per-preset 状态的 Service,这不成比例,因此改为把通则写在注册表上。
对未加入的 agent 否决发布。 大声胜过静默,注册表也支持这么做——同步的 agent/created 监听器抛出会把创建整体回滚。否决的理由是:在名单之外组装 agent 是合法的——recompose 写明了它随后绑定的那个裸 agent,而 ACP 桥、SDK server 与 headless bundle 都会创建一个。否决会把能力缺口变成一次故障。
在 agent/created 处检查加入情况。 否决:发布时分不清漏掉的加入与之后才会被绑定的 agent,因此该检查会拒绝一条已写明的路径。
基于同样的投影理由,把 plan-mode 与 tool-todo 也搬离 agent 平面。 否决:两者确实是逐 preset 的能力,且对从不使用它们的会话,其单元算出的就是空值,而客户端本来就按值读取(plan.active、空列表)。只有空值与真实值无法区分的单元——meter——才被迫归宿主所有。
后果
context meter 是独立于挂载历史的逐会话事实。Preset 使用宿主 meter 进行 token 记账,并选择自己的压缩后端;minimal 同时支持自动压缩和用户 /compact 命令。
生产入口必须显式组装 Agent;仅安装注册表不会自动绑定。未加入的 Agent 不会获得工具、提示词段落或 skill,也不会报告错误。