Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

recompose becomes a parent re-link: the new preset's standing mount is
ensured BEFORE the link moves, so a failed switch leaves the agent exactly as
it was — the unmount-then-restore dance (and unmountPresetFor with it) is
gone, and the restore-failure test now asserts the agent KEEPS its tools with
the source directory deleted, because the standing mount is not the file.
This commit is contained in:
Yichen Jiang
2026-08-08 18:52:45 +08:00
24 changed files with 600 additions and 185 deletions

View 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 .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md
2026-08-08-per-preset-standing-mounts.md: 19a53926e9f07b01115cfbb4ddc89eb7ee0c59a0
2026-08-08-per-preset-standing-mounts.zh.md: 856fbb742a843935902f9aa7578ba6821a9a13c3

View File

@@ -0,0 +1,32 @@
# Agent Note: Per-preset standing mounts over a scope parent chain
Status: implemented
English | [中文](2026-08-08-per-preset-standing-mounts.zh.md)
## Problem
Per-session preset mounts made the model-facing registry surface per-agent while three independent host readers still assumed it was static: cold `session.history` found no presenters (every card silently degraded to the generic renderer — indistinguishable from "tool has no presenter"), the projections block dropped preset-registered keys (clients treat an omitted key as capability absence and CLEAR the row), and the TypeRT gateway resolved `goals` on the host root (`service-unavailable`). Patching each reader individually traded one silent degradation for another: resuming to reach presenters flipped the projections fold from detached to live and wiped the token counts instead.
## Decision
A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by `setScopeParent(agentKey, standingKey)`. Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf.
## Consequences
Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap
- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite.
- Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: <name>: true`) keep two presets' same-named services apart exactly as they kept two sessions' apart.
- A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount.
## Load-bearing details
- **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer.
- **A settled mount is permanent for the process.** The composition a running session joined must survive its file changing or disappearing; deletion and edits reach only future generations (the authoring layer swaps the map pointer, never disposes a joined generation), and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations.
- **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it).
- **Re-linking a key (`setScopeParent` on a live agent) is the blank-session recompose path** — valid only while nothing produced under the old parent is retained, which the caller must uphold; the relation cannot see session logs.
## Alternatives considered
Resume-on-read (wipes detached projections), a host-plane presenter table plus a block completeness flag (fixes two readers, leaves the class), per-session template mounts (duplicates every instance to serve pure functions). Kept for the record: the gateway-facing `goals` domain stays host-plane regardless — a Remote method whose receiver comes from a generated descriptor resolves on the host, which is the `bash-env` host-plane criterion read from the consuming side.

View File

@@ -0,0 +1,32 @@
# Agent Note: Per-preset standing mounts over a scope parent chain
Status: implemented
[English](2026-08-08-per-preset-standing-mounts.md) | 中文
## Problem
按会话挂载 preset 让面向模型的注册面变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 `session.history` 找不到 presenter每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并**清掉**该行、TypeRT 网关在宿主根上解析 `goals``service-unavailable`)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume会把投影折叠从 detached 翻到 livetoken 计数随之被抹掉。
## Decision
一个 preset 是**每进程**一份组装而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过 `setScopeParent(agentKey, standingKey)` 加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。
## Consequences
常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因
- 有状态的 preset 插件(`plan-mode``token-meter``compact-basic``tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。
- preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry其 entry 本地 realm`isolate: <name>: true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。
- 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。
## Load-bearing details
- **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。
- **挂载一旦成功即进程级永久。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;删除与编辑只影响未来的代际(创作层替换 map 指针,绝不 dispose 已被加入的代际),被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。
- **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。
- **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。
## Alternatives considered
冷读时 resume抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 `goals` 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析这正是 `bash-env` 宿主平面判据从消费侧读出的样子。