Merge branch 'feat/subagent-settlement-delivery' into feat/subagent-list-agents-vocabulary

This commit is contained in:
Hypatia May
2026-08-11 14:53:11 +08:00
67 changed files with 862 additions and 205 deletions
@@ -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-10-host-plane-ownership-after-presets.md
2026-08-10-host-plane-ownership-after-presets.md: 5b0a340e875005182a0e6cd0f880b34b14258fb2
2026-08-10-host-plane-ownership-after-presets.zh.md: 4b1e04f924e656b0f6ad4d070a3b77ce0189c608
@@ -0,0 +1,45 @@
# Agent Note: What stays host-plane once presets own the agent plane
Status: implemented
English | [中文](2026-08-10-host-plane-ownership-after-presets.zh.md)
## Problem
[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 contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)).
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 `compact-basic` and `tool-result-prune` 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`](../../../../packages/session/session-projection/README.md)); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost ([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md)); and a temporary plugin mounted through `cordis_mount` belongs to the composition rather than the session that mounted it ([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md)).
## 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.
@@ -0,0 +1,45 @@
# Agent Note: What stays host-plane once presets own the agent plane
Status: implemented
[English](2026-08-10-host-plane-ownership-after-presets.md) | 中文
## Problem
[逐会话 agent preset](2026-08-03-per-session-agent-presets.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md))。
还有两个读取点仍站在这条线的错误一侧。
`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 的入口点上都开着。
## Decision
**meter 属于宿主平面。** `dsh-token-meter` 回到宿主组装,并离开各 preset 的 `isolate` 映射,于是 `compact-basic``tool-result-prune` 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 `tasks``goals` 已经采用的判据,只是这次适用于一个因**投影**触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。
**未加入的 agent 在两个不同的点上被指出两次。** 在配置了名单的前提下,`AgentPresets` 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 `system-prompt/assemble` 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:`recompose` 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。
有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号([`dsh-session-projection`](../../../../packages/session/session-projection/README.md));被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md));通过 `cordis_mount` 挂上的临时插件属于组装而非挂载它的会话([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md))。
## Testing
`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 的组装与不带作用域的宿主组装都通过。
## Alternatives considered
**把 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——才被迫归宿主所有。
## Consequences
context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。
那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 `dsh-invariants` 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。
@@ -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/bug-fix/2026-08-11-preset-card-description-clamp.md
2026-08-11-preset-card-description-clamp.md: 16ebf371d5af7c9e54fcc37819696b380856d5cb
2026-08-11-preset-card-description-clamp.zh.md: 5b7a18f41e4b3e8acd681a001f6826b19ca7026d
@@ -0,0 +1,43 @@
# Agent Note: Preset cards clamp their description instead of sizing the roster
Status: implemented
English | [中文](2026-08-11-preset-card-description-clamp.zh.md)
## Problem
A preset publishes its own `description`, of any length, and the settings section renders the roster as a card grid. The description had a `min-height` and no upper bound, while the grid sizes rows with `grid-auto-rows: 1fr` — which makes every implicit row the same height, not just the row holding the tall card. One long description therefore set the height of the whole roster: with a 250-character description in the custom group, all four cards measured 421px and the short-description cards filled with blank space.
The description is also the field that tells presets apart, so hiding it is not an option; the card has to bound it and still make the whole text reachable.
## Decision
The description clamps to four lines and offers the rest through the shared `Tooltip`, attached only while the element actually overflows (`scrollHeight > clientHeight`, re-measured through a ResizeObserver because the settings pane width follows the window). This mirrors the chat stats line, which clamps to one line on the same measure-then-attach rule.
Card height stays derived rather than fixed. With the description bounded, `grid-auto-rows: 1fr` already equalizes the grid, and a card carrying the broken-preset reason or a revealed path still sizes itself — a pixel height would clip both.
Three smaller decisions ride along:
- `.cardId` takes the card's free space with `margin-top: auto`, and the description no longer grows. A flex-stretched box leaves the clamp height and the box height disagreeing; sizing the clamped box by content alone keeps the behavior independent of that interaction.
- The description carries `title=""`. An empty `title` means the element has no advisory information and the lookup stops there, so the card body's native tooltip does not climb to the description and a cut-off description answers with one bubble instead of two.
- `Tooltip` gains an optional `maxWidth`. Its default half-viewport cap renders a description as a slab wider than the settings dialog it belongs to, spilling across the application behind it.
- `Tooltip` also flips a `top` or `bottom` bubble to the other side when the viewport has no room for it, which its horizontal-only clamp previously left unhandled. Custom presets sit at the bottom of the roster and carry the longest descriptions, so the common case put a tall bubble under an anchor low on the page. The flip only moves into a side that genuinely fits, so an anchor with room on neither side keeps the requested placement rather than oscillating; sliding the bubble vertically instead would cover the text being read.
A roster row that failed its shape check is badged `Failed to load` (`加载失败`) rather than `Broken` (`已损坏`). Discovery sets `broken` when the composition file is missing, unreadable, or malformed — most often a file the user just edited or deleted — so a damage claim overstates what was observed, and the verbatim reason under the badge already names the file and the fix.
## Alternatives considered
- **A fixed card height.** It states the intent directly but clips the two rows whose height legitimately varies: the broken-preset reason and the revealed preset directory.
- **The native `title` attribute carrying the full description.** No measurement and no component, but a roughly one-second delay, operating-system styling, and it takes over the card's `set as default` hint across most of the card's area.
- **Attaching the tooltip unconditionally.** It drops the ResizeObserver, at the cost of answering a hover over a short description with a bubble repeating what is already on the card.
- **Expanding the clamp on hover.** It shows the text in place, and moves the grid under the pointer.
## Consequences
The section owns a small measured component and the shared primitive owns one more optional prop. In exchange, no card's height follows the longest description anywhere in the roster, and the whole description stays in the accessibility tree because the clamp is CSS rather than truncated text.
The `title=""` suppression is pinned by a DOM assertion, not by observing the native tooltip: a browser tooltip is drawn outside the page and cannot be captured. If a browser ever resumes climbing past an empty `title`, the fallback is to drop the card body's `title` — its content is already in the body's `aria-label`.
## Testing
Package tests cover the three measurement outcomes (cut off, fitting, and a runtime without `ResizeObserver`) and the tooltip width cap. The web e2e goldens replay unchanged except `damaged.expected.md`, re-recorded for the badge copy.
@@ -0,0 +1,43 @@
# Agent Note: 预设卡片截断自身描述,而不是由描述决定整份名单的高度
Status: implemented
[English](2026-08-11-preset-card-description-clamp.md) | 中文
## 问题
preset 自行发布 `description`,长度不限,而设置分区把名单渲染为卡片网格。描述只有 `min-height` 没有上限,网格则以 `grid-auto-rows: 1fr` 排布行——该取值让每一个隐式行等高,而不只是承载高卡片的那一行。因此一条长描述决定了整份名单的高度:自定义组里放入一条 250 字的描述后,四张卡片全部量得 421px,短描述卡片被大片空白填满。
描述同时又是区分各个 preset 的字段,因此不能藏起来;卡片必须既给它设上限,又让全文仍然可达。
## 决定
描述截断为四行,其余内容通过共享的 `Tooltip` 呈现,且仅在元素确实溢出时才挂载(`scrollHeight > clientHeight`,并经 ResizeObserver 重新测量,因为设置面板宽度跟随窗口)。这与聊天统计行一致:它按同样的「先测量再挂载」规则截断为一行。
卡片高度仍是推导得出而非固定。描述有了上限之后,`grid-auto-rows: 1fr` 本身就让网格等高,而承载损坏原因或已展示目录的卡片仍能按自身内容定高——写死像素高度会把两者一并裁掉。
随之而来三个更小的决定:
- `.cardId``margin-top: auto` 吃掉卡片的空余空间,描述不再拉伸。被 flex 拉伸的盒子会让截断高度与盒子高度不一致;让截断盒子只按内容定高,行为便不依赖这层交互。
- 描述带有 `title=""`。空 `title` 表示该元素没有提示信息,查找就此停止,因此卡片主体的原生 tooltip 不会向上找到描述,被裁切的描述只回应一个气泡而不是两个。
- `Tooltip` 新增可选的 `maxWidth`。它默认的半视口上限会把描述渲染成比所属设置弹窗还宽的一整块,溢出到背后的应用界面上。
- `Tooltip` 同时在视口放不下时把 `top``bottom` 气泡翻到另一侧,此前它只做水平收敛。自定义 preset 位于名单末尾、又恰恰承载最长的描述,因此常见情形正是让一个高气泡挂在页面靠下的锚点之下。翻转只会移向确实放得下的一侧,两侧都放不下时保持请求的位置而不来回摆动;改为垂直滑动则会盖住正在阅读的文本。
形状检查未通过的名单行,徽记从 `Broken``已损坏`)改为 `Failed to load``加载失败`)。discovery 在组装文件缺失、读不出或格式错误时置位 `broken`——最常见的是用户刚编辑或删除的文件——因此断言损坏超出了观察到的事实,而徽记下方原样展示的原因本就点名了文件与修法。
## 备选方案
- **写死卡片高度。** 它直接表达了意图,却会裁掉两处高度本就可变的行:损坏预设的原因行和已展示的预设目录。
- **用原生 `title` 属性承载完整描述。** 无需测量也无需组件,代价是约一秒的延迟、操作系统的样式,以及在卡片大部分区域内顶替掉「设为默认」的提示。
- **无条件挂载 tooltip。** 省掉 ResizeObserver,代价是把鼠标停在短描述上时,弹出一个重复卡片已有内容的气泡。
- **hover 时展开截断。** 它就地展示文本,同时让网格在指针下方发生位移。
## 后果
分区多了一个带测量的小组件,共享基元多了一个可选 prop。换来的是:任何卡片的高度都不再跟随名单中最长的那条描述;而且截断由 CSS 完成而非截短文本,完整描述始终留在无障碍树中。
`title=""` 的抑制作用由一条 DOM 断言钉住,而非通过观察原生 tooltip:浏览器 tooltip 画在页面之外,无法被捕获。若某个浏览器日后重新越过空 `title` 继续向上查找,退路是去掉卡片主体的 `title`——它的内容已经在主体的 `aria-label` 里。
## 测试
包内测试覆盖三种测量结果(被裁切、放得下、运行时没有 `ResizeObserver`)以及 tooltip 的宽度上限。web e2e golden 除 `damaged.expected.md` 按徽记文案重录外,其余原样回放通过。
@@ -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 .agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md
2026-08-05-per-agent-tool-presentation.md: 348f7ab0a26e9b39057dbac885304e0d52e0b1fb
2026-08-05-per-agent-tool-presentation.zh.md: 4920ee6eb061d44934bfc9f5176e244f5aac8553
2026-08-05-per-agent-tool-presentation.md: adb93b51c73d341c153b8fcafe2a08f0a5598478
2026-08-05-per-agent-tool-presentation.zh.md: fa83bd4daec9d8e6e9e43295bb81af97782b1fdf
@@ -12,16 +12,16 @@ The naive reading of "move tools down to the agent plane" does not work. `ctx.to
## Decision
Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there.
Split the registry from its projection. The registry stays host-plane; the **presentation** becomes scope state inside it, alongside the scoped restrictions and guards that already live there.
`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's.
`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `code` preset carries the `tool-mode` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's.
Two consequences fell out and are load-bearing:
- **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need.
- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted.
The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section.
The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section.
The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act.
@@ -12,16 +12,16 @@ agent preset 已经能按会话组装一个 agent 的工具,却管不了这些
## Decision
把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。
把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。
`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。
`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`code` preset 携带 `tool-mode` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。
有两个随之而来的结果,且都是承重的:
- **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。
- **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。
SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。
SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。
preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。
@@ -129,17 +129,20 @@
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the rows here resolve that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
@@ -110,17 +110,20 @@
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the rows here resolve that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
@@ -49,16 +49,19 @@
# Model capacity comes from routed model metadata; this block states the
# compaction policy explicitly.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the row here resolves that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
config:
@@ -122,17 +122,20 @@
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the rows here resolve that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
+1
View File
@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
+30
View File
@@ -17,6 +17,9 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type {} from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-tools'
// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-token-meter'
const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
@@ -138,6 +141,33 @@ describe('the shipped Web composition', () => {
expect(toolNames(ctx)).toEqual([])
})
it('keeps the token meter and its context-meter projections on the host plane', async () => {
// Read before any preset in this file mounts, which is what makes this an
// ownership assertion rather than a mount-order coincidence: a preset-side
// meter sits behind an `isolate` realm and is invisible to `ctx.get`.
//
// The projection registry is process-wide rather than scope-layered, so a
// preset-side meter would also make the browser's context meter appear for
// a `minimal` session the moment some OTHER session mounted a preset that
// carries one, and vanish entirely in a process that only ever ran
// `minimal`. Host ownership is what makes the meter a per-session fact.
expect(ctx.get('tokenMeter')).toBeDefined()
const projections = ctx.get('sessionProjections')
if (projections === undefined) throw new Error('the Web composition must compose a projection registry')
const handle = await ctx.agents.create({
sessionId: SessionId('preset-minimal-meter'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
// A subset assertion: `tasks`, `goal`, and the rest register into the
// same process-wide table, and this is about the meter's three units.
expect(Object.keys(projections.snapshot(handle.agent.session).values))
.toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage']))
} finally {
await handle.dispose()
}
})
it('supplies both shipped presets, and only those, from the system root', async () => {
const listed = await ctx.agentPresets.list()
+4 -4
View File
@@ -196,18 +196,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '通用设置' }).click()
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
// Both damage shapes surface as marked, unselectable, uncopyable cards
// that still carry their metadata and the discovery-reported reason.
expect(snapshot).toContain('已损坏: broken-yaml')
expect(snapshot).toContain('已损坏: 幽灵预设')
expect(snapshot).toContain('加载失败: broken-yaml')
expect(snapshot).toContain('加载失败: 幽灵预设')
expect(snapshot).toContain('not valid YAML')
expect(snapshot).toContain('agent.cordis.yml is missing')
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
// A broken card offers no "set default" affordance at all — the aria name
// IS the broken marking, so the picking name must not exist.
+5 -16
View File
@@ -18,7 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import { join } from 'node:path'
@@ -195,21 +194,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
// The meter belongs to an agent's preset, not to the process — token
// accounting is per session. It is used here as a pure pricing function
// over fixture content, so a throwaway composition is enough to reach one.
const priced = await scaffold.ctx.agents.create({
sessionId: SessionId('seeded-history-pricing'),
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
let realizedWithCompaction: string
try {
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
} finally {
await priced.dispose()
}
// The meter is host-plane — it takes no configuration and keys every
// fold by Session — so pricing fixture content needs no agent at all.
const meter = scaffold.ctx.get('tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the host token meter')
const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
}
browser = await chromium.launch()
@@ -61,8 +61,8 @@
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "已损坏: broken-yaml" [disabled]':
- text: broken-yaml 已损坏 自定义 暂无描述。
- 'button "加载失败: broken-yaml" [disabled]':
- text: broken-yaml 加载失败 自定义 暂无描述。
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
- code: broken-yaml
- 'button "查看路径: broken-yaml"':
@@ -70,13 +70,13 @@
- text: 查看路径
- 'button "复制: broken-yaml" [disabled]':
- img
- text: 预设已损坏,无法复制
- text: 预设加载失败,不能复制
- 'button "删除: broken-yaml"':
- img
- text: 删除
- listitem:
- 'button "已损坏: 幽灵预设" [disabled]':
- text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
- 'button "加载失败: 幽灵预设" [disabled]':
- text: 幽灵预设 加载失败 自定义 composition 已被手动删除。
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
- code: ghost
- 'button "查看路径: 幽灵预设"':
@@ -84,7 +84,7 @@
- text: 查看路径
- 'button "复制: 幽灵预设" [disabled]':
- img
- text: 预设已损坏,无法复制
- text: 预设加载失败,不能复制
- 'button "删除: 幽灵预设"':
- img
- text: 删除
+2 -2
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 docs/config-catalog.md
config-catalog.md: 2436424d32f3255ccef6fe1861812da5cdf90aba
config-catalog.zh.md: 08d0b3f0d1552c51b9ef3964bc48919105cd50b2
config-catalog.md: e833cebe73865ea7bcb0d64ac129761168671a25
config-catalog.zh.md: 190143156685d6db13cc8fca56edec44e36ce36b
+1 -1
View File
@@ -259,7 +259,7 @@ export interface Config {
Depends on: [`ToolPresentationMode`](subsystems/tools.md)
Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts)
Source: [`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts)
## `@deepseek-ai/dsh-attachment-local`
+1 -1
View File
@@ -261,7 +261,7 @@ export interface Config {
依赖:[`ToolPresentationMode`](subsystems/tools.md)
来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts)
来源:[`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts)
## `@deepseek-ai/dsh-attachment-local`
+2 -2
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 docs/event-producer-consumer.md
event-producer-consumer.md: d5a61bf384ee38b5d69ccef6283a661c9c4ac36f
event-producer-consumer.zh.md: af1ca18d728320940af40b5b89f9ff842f9077f7
event-producer-consumer.md: e14bad2b7a0f604f101281e271c4250a60b8c8eb
event-producer-consumer.zh.md: 81ced8b25dc43a4f0d37b73ea954b1e4a997bdf9
+2 -2
View File
@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
@@ -41,7 +41,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+2 -2
View File
@@ -10,7 +10,7 @@
| 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
@@ -43,7 +43,7 @@
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+2 -2
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 docs/module-graph.md
module-graph.md: fe581d7462e5891f4e43bcdb9da07c29513998ba
module-graph.zh.md: 4edd897b9513e99b827c4a59163569af7a1101ea
module-graph.md: 59e22a8b82a210dd66f6e2186f0b827a541e00cc
module-graph.zh.md: 00a433ebaeabba4ce0e39919c3e0fe817608e718
+18 -16
View File
@@ -515,12 +515,6 @@ flowchart TD
pkg_host_directory_picker_native --> pkg_client_ui_slots
pkg_host_directory_picker_native --> pkg_client_ui_workspace
pkg_host_directory_picker_native --> pkg_invariants
pkg_agent_presets --> pkg_atomic_write
pkg_agent_presets --> pkg_invariants
pkg_agent_presets --> pkg_paths
pkg_agent_presets --> pkg_scope
pkg_agent_presets --> pkg_session
pkg_agent_presets --> pkg_settings
pkg_persona --> pkg_invariants
pkg_persona --> pkg_system_prompt
pkg_sandbox --> pkg_invariants
@@ -579,8 +573,6 @@ flowchart TD
pkg_message_feedback --> pkg_session_persistence
pkg_message_feedback --> pkg_storage_domain
pkg_message_feedback --> pkg_type_meta
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
@@ -600,6 +592,14 @@ flowchart TD
pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_invariants
pkg_user_interaction --> pkg_llm
pkg_agent_presets --> pkg_agent
pkg_agent_presets --> pkg_atomic_write
pkg_agent_presets --> pkg_invariants
pkg_agent_presets --> pkg_paths
pkg_agent_presets --> pkg_scope
pkg_agent_presets --> pkg_session
pkg_agent_presets --> pkg_settings
pkg_agent_presets --> pkg_system_prompt
pkg_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_pty --> pkg_invariants
@@ -709,11 +709,6 @@ flowchart TD
pkg_headless --> pkg_invariants
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_client_test_runtime --> pkg_client_runtime
pkg_client_test_runtime --> pkg_client_ui_slots
pkg_client_test_runtime --> pkg_client_web_react
pkg_client_test_runtime --> pkg_host_apiproxy
pkg_client_test_runtime --> pkg_invariants
pkg_tmux_context --> pkg_agent
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
@@ -726,6 +721,8 @@ flowchart TD
pkg_command_feedback --> pkg_session
pkg_command_feedback --> pkg_session_telemetry
pkg_command_feedback --> pkg_user_id
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_permission --> pkg_bash
pkg_permission --> pkg_commands
pkg_permission --> pkg_invariants
@@ -901,6 +898,11 @@ flowchart TD
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_client_test_runtime --> pkg_client_runtime
pkg_client_test_runtime --> pkg_client_ui_slots
pkg_client_test_runtime --> pkg_client_web_react
pkg_client_test_runtime --> pkg_host_apiproxy
pkg_client_test_runtime --> pkg_invariants
pkg_client_ui_trajectory --> pkg_agent
pkg_client_ui_trajectory --> pkg_client_locale
pkg_client_ui_trajectory --> pkg_client_runtime
@@ -1345,7 +1347,6 @@ flowchart TD
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
@@ -1360,11 +1361,11 @@ flowchart TD
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
@@ -1391,10 +1392,10 @@ flowchart TD
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1423,6 +1424,7 @@ flowchart TD
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+18 -16
View File
@@ -517,12 +517,6 @@ flowchart TD
pkg_host_directory_picker_native --> pkg_client_ui_slots
pkg_host_directory_picker_native --> pkg_client_ui_workspace
pkg_host_directory_picker_native --> pkg_invariants
pkg_agent_presets --> pkg_atomic_write
pkg_agent_presets --> pkg_invariants
pkg_agent_presets --> pkg_paths
pkg_agent_presets --> pkg_scope
pkg_agent_presets --> pkg_session
pkg_agent_presets --> pkg_settings
pkg_persona --> pkg_invariants
pkg_persona --> pkg_system_prompt
pkg_sandbox --> pkg_invariants
@@ -581,8 +575,6 @@ flowchart TD
pkg_message_feedback --> pkg_session_persistence
pkg_message_feedback --> pkg_storage_domain
pkg_message_feedback --> pkg_type_meta
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
@@ -602,6 +594,14 @@ flowchart TD
pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_invariants
pkg_user_interaction --> pkg_llm
pkg_agent_presets --> pkg_agent
pkg_agent_presets --> pkg_atomic_write
pkg_agent_presets --> pkg_invariants
pkg_agent_presets --> pkg_paths
pkg_agent_presets --> pkg_scope
pkg_agent_presets --> pkg_session
pkg_agent_presets --> pkg_settings
pkg_agent_presets --> pkg_system_prompt
pkg_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_pty --> pkg_invariants
@@ -711,11 +711,6 @@ flowchart TD
pkg_headless --> pkg_invariants
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_client_test_runtime --> pkg_client_runtime
pkg_client_test_runtime --> pkg_client_ui_slots
pkg_client_test_runtime --> pkg_client_web_react
pkg_client_test_runtime --> pkg_host_apiproxy
pkg_client_test_runtime --> pkg_invariants
pkg_tmux_context --> pkg_agent
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
@@ -728,6 +723,8 @@ flowchart TD
pkg_command_feedback --> pkg_session
pkg_command_feedback --> pkg_session_telemetry
pkg_command_feedback --> pkg_user_id
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_permission --> pkg_bash
pkg_permission --> pkg_commands
pkg_permission --> pkg_invariants
@@ -903,6 +900,11 @@ flowchart TD
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_client_test_runtime --> pkg_client_runtime
pkg_client_test_runtime --> pkg_client_ui_slots
pkg_client_test_runtime --> pkg_client_web_react
pkg_client_test_runtime --> pkg_host_apiproxy
pkg_client_test_runtime --> pkg_invariants
pkg_client_ui_trajectory --> pkg_agent
pkg_client_ui_trajectory --> pkg_client_locale
pkg_client_ui_trajectory --> pkg_client_runtime
@@ -1347,7 +1349,6 @@ flowchart TD
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
@@ -1362,11 +1363,11 @@ flowchart TD
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
@@ -1393,10 +1394,10 @@ flowchart TD
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1425,6 +1426,7 @@ flowchart TD
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+2 -2
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 docs/subsystems/core.md
core.md: bea9b29f66f3189e47540f4d51f908e3d139c0ce
core.zh.md: 7f0a91d9a6b6e67530d99de3ac7339cb42658e1f
core.md: c9d8eb1b854432668092754206535a6985ebc6fa
core.zh.md: 73f9954959df692f91f43bc7bf8e02bbfa77c6b3
+1 -1
View File
@@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
Types: [ScopeKey](scope.md)
Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts)
Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts)
<a id="ctxagents--agentregistry"></a>
+1 -1
View File
@@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
Types: [ScopeKey](scope.md)
Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts)
Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts)
<a id="ctxagents--agentregistry"></a>
+2 -2
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 docs/subsystems/tools.md
tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493
tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7
tools.md: 4e56d420f9ba9541725e41e4da87846654206119
tools.zh.md: f6eee0f0f9b3c549679cc4437755c749caf68c9c
+6 -4
View File
@@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v
```ts cordis-catalog
/**
* Present this agent's tools in `mode` instead of the deployment default.
* Present the calling scope's tools in `mode` instead of the deployment
* default. Nearest scope on the chain wins, so a preset's standing
* declaration covers every agent joined under it.
*
* Scoped only, and one declaration per agent: this is how an agent preset
* composes a Code Mode agent beside native ones in the same process, and a
* Scoped only, and one declaration per scope: this is how an agent preset
* composes Code Mode agents beside native ones in the same process, and a
* process-global override would be the `mode` config field instead.
* @param mode - the presentation this agent's model sees.
* @param mode - the presentation the covered agents' models see.
* @returns the exact disposer that restores the deployment default.
*/
presentAs(mode: ToolPresentationMode): () => void
+6 -4
View File
@@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v
```ts cordis-catalog
/**
* Present this agent's tools in `mode` instead of the deployment default.
* Present the calling scope's tools in `mode` instead of the deployment
* default. Nearest scope on the chain wins, so a preset's standing
* declaration covers every agent joined under it.
*
* Scoped only, and one declaration per agent: this is how an agent preset
* composes a Code Mode agent beside native ones in the same process, and a
* Scoped only, and one declaration per scope: this is how an agent preset
* composes Code Mode agents beside native ones in the same process, and a
* process-global override would be the `mode` config field instead.
* @param mode - the presentation this agent's model sees.
* @param mode - the presentation the covered agents' models see.
* @returns the exact disposer that restores the deployment default.
*/
presentAs(mode: ToolPresentationMode): () => void
File diff suppressed because one or more lines are too long
+4
View File
@@ -252,6 +252,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
assertOpen()
validateSessionParams(params)
const sessionId = SessionId(randomUUID())
// No preset composition: the ACP bundle keeps the model-facing rows in
// the host plane, so this agent reads them from the global layer. A
// deployment that configures a roster has to join one here first
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
const handle = await agents.create({
sessionId,
meta: { cwd: params.cwd },
+4
View File
@@ -106,6 +106,10 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
const selection = defaultModel.currentSelection()
// This bundle composes no preset roster, so the model-facing rows sit in the
// host plane and the agent reads them from the global layer. A deployment
// that DOES configure one has to join it here first
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
const { agent } = await agents.create({
sessionId: SessionId(`session-${randomUUID()}`),
meta: { cwd: process.cwd() },
+6 -2
View File
@@ -309,8 +309,12 @@
- id: plan-mode
disabled: true
- id: token-meter
disabled: true
# The token METER stays on the host plane; only the compaction backend that
# reads it moves. It owns the context-meter projection units, and that table is
# process-wide, so preset ownership would make the meter a function of which
# presets happen to be mounted rather than a per-session fact. Same criterion as
# `tasks` and `goals`; the reasoning has one home in
# `.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md`.
- id: compact-basic
disabled: true
@@ -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/client/ui-agent-preset/README.md
README.md: 008066114e9c49e5c74299979e24c27a4c9621c9
README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00
+3 -1
View File
@@ -36,6 +36,8 @@ A fourth surface, its own settings page (`settings.section` id `agent-presets`,
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
A preset publishes its own description, of any length, and the grid sizes every card row alike — so an unbounded description would set the height of the whole roster. Cards clamp it to four lines and offer the rest in a tooltip, attached only while the text is actually cut off. The clamp is CSS, so the whole description stays in the accessibility tree whatever the card shows.
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
@@ -44,7 +46,7 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a "Failed to load" badge (what discovery observed, not a claim that the files are damaged — the usual cause is a composition the user just edited or deleted), the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
+3 -1
View File
@@ -36,6 +36,8 @@ preset 文件提供一套未国际化的 `name` 与 `description`Web 将其
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
preset 自行发布描述,长度不限,而网格让每一行卡片等高——因此不加约束的描述会决定整份名单的高度。卡片把描述截断为四行,其余内容由 tooltip 承载,且仅在文本确实被裁切时才挂载。截断由 CSS 完成,因此无论卡片显示多少,完整描述始终留在无障碍树中。
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
@@ -44,7 +46,7 @@ preset 文件提供一套未国际化的 `name` 与 `description`Web 将其
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「加载失败」徽记(discovery 观察到的事实,而非断言文件已损坏——常见起因是用户刚编辑或删除了组装文件)、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
@@ -161,15 +161,28 @@
color: var(--dsw-alias-bg-layer-3);
}
/* Bounded to four lines. A preset publishes its own description, so one long
one would otherwise stretch every card in its grid row (`.cards` sizes rows
1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive
tech, and the card offers it on hover when it is actually cut off. The
description does not grow to fill the card — `-webkit-line-clamp` on a
flex-stretched box leaves the clamp height and the box height disagreeing,
so `.cardId` takes the free space with an auto margin instead. */
.cardDesc {
font-size: 13px;
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
flex: 1;
min-height: 42px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
/* A user-authored description may carry an unbreakable path or URL. */
overflow-wrap: anywhere;
}
.cardId {
margin-top: auto;
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--dsw-alias-label-dimmed);
@@ -10,10 +10,10 @@
* mounted once at session creation and nothing re-reads the file.
*/
import { useEffect } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import {
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -137,6 +137,39 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
)
}
/**
* Render one card's description, clamped by CSS and offered in full on hover.
* The tooltip is attached only while the text is actually cut off, so a short
* description does not answer a hover with a bubble repeating the card.
* @param props.text - the description as rendered, already localized.
* @returns the description element, tooltip-anchored while it overflows.
*/
function CardDescription({ text }: { text: string }): ReactNode {
const ref = useRef<HTMLSpanElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const el = ref.current
/* v8 ignore next -- the ref is attached before layout effects run. */
if (el === null) return
const measure = () => { setTruncated(el.scrollHeight > el.clientHeight) }
measure()
// Card width follows the settings pane, which resizes with the window.
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [text])
return (
// Capped near the card's own width: the default half-viewport bubble would
// spill a description out of the settings dialog and across the app behind it.
<Tooltip label={text} side="bottom" delayMs={400} disabled={!truncated} maxWidth={360}>
{/* The empty title stops the card body's native tooltip from climbing to
this span: a cut-off description answers with one bubble, not two. */}
<span ref={ref} className={css.cardDesc} title="">{text}</span>
</Tooltip>
)
}
/**
* Render the Agent presets section content column.
* @param props - composed slot props.
@@ -247,7 +280,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
<CardDescription text={text.description ?? t('noDescription')} />
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
@@ -57,8 +57,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
builtInGroup: 'Built-in',
customGroup: 'Custom',
noDescription: 'No description.',
brokenBadge: 'Broken',
brokenNoCopy: 'Broken presets cannot be duplicated',
brokenBadge: 'Failed to load',
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
copyOf: 'Copied from',
composition: 'Composition (agent.cordis.yml)',
cancel: 'Cancel',
@@ -117,8 +117,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
builtInGroup: '内置',
customGroup: '自定义',
noDescription: '暂无描述。',
brokenBadge: '已损坏',
brokenNoCopy: '预设已损坏,无法复制',
brokenBadge: '加载失败',
brokenNoCopy: '预设加载失败,不能复制',
copyOf: '复制自',
composition: '组装(agent.cordis.yml',
cancel: '取消',
@@ -6,8 +6,8 @@
* action follows the host's desktop capability.
*/
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
@@ -452,3 +452,68 @@ describe('deleting a preset', () => {
expect(actions.remove).not.toHaveBeenCalled()
})
})
describe('a long card description', () => {
/** jsdom has no ResizeObserver; the description watches its own box through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
const LONG = '始终用简体中文交流的友好通用助手,提供持久 bash 与文件编辑能力。'.repeat(8)
/** Force the clamp to report an overflow: jsdom lays nothing out, so both heights are 0. */
function clamp(overflowing: boolean): void {
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflowing ? 400 : 80)
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80)
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('offers the whole description on hover once the card cuts it off', () => {
clamp(true)
vi.useFakeTimers()
try {
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: LONG }] })
fireEvent.mouseEnter(within(rowFor('zh')).getByText(LONG))
act(() => { vi.advanceTimersByTime(400) })
expect(screen.getByRole('tooltip').textContent).toBe(LONG)
} finally {
vi.useRealTimers()
}
})
it('stays quiet when the description already fits', () => {
clamp(false)
vi.useFakeTimers()
try {
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: '短描述。' }] })
fireEvent.mouseEnter(within(rowFor('zh')).getByText('短描述。'))
act(() => { vi.advanceTimersByTime(400) })
// A bubble repeating what is already fully on the card is noise.
expect(screen.queryByRole('tooltip')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('renders where the runtime has no ResizeObserver', () => {
vi.unstubAllGlobals()
clamp(true)
expect(() => {
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, description: LONG }] })
}).not.toThrow()
// The first measurement does not depend on the observer.
expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('')
})
})
+48 -21
View File
@@ -1,7 +1,7 @@
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
// TODO: interaction is a placeholder (horizontal overflow clamps, but there
// is no vertical flip on viewport collision and no arrow) — visuals and
// behavior get a proper pass later.
// TODO: interaction is a placeholder (horizontal overflow clamps and a
// vertical collision flips the bubble to the other side, but there is no
// arrow) — visuals and behavior get a proper pass later.
// The anchor is the child element itself (cloneElement, no wrapper node), so
// attaching a tooltip never changes the anchor's layout context. The bubble is
// position:fixed and coordinates come from the anchor's rect at show time, so
@@ -33,10 +33,12 @@ type TooltipLabel = string | (() => string)
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
* half-viewport cap would render a slab wider than the surface the anchor sits on.
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -46,33 +48,53 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
if (typeof childRef === 'function') childRef(el)
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
}, [childRef])
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
// The anchor's edges rather than final coordinates: a vertical flip has to
// re-derive the bubble's own top from the opposite edge.
const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
// Where the bubble actually sits, which is the requested side until the
// viewport refuses it.
const [placement, setPlacement] = useState<TooltipSide>(side)
const bubble = useRef<HTMLSpanElement | null>(null)
const resolvedLabel = pos === null
? null
: typeof label === 'function' ? label() : label
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
// a centered bubble near the right edge would clip. Each measurement resets
// the base position before applying a direct style offset, allowing a shorter
// label or wider viewport to release a previous clamp without another render.
const y = pos === null
? 0
: placement === 'right'
? pos.top + (pos.bottom - pos.top) / 2
: placement === 'top' ? pos.top - 8 : pos.bottom + 8
const EDGE_MARGIN = 12
// Viewport fit: fixed positioning knows nothing about edges, so a centered
// bubble near the right edge would clip and a long label under an anchor low
// on the page would run off the bottom. Horizontally the bubble slides back
// inside; vertically it flips to the opposite side, which is the only move
// that does not cover the anchor being read. Each measurement resets the base
// position first, so a shorter label or a larger viewport releases a previous
// adjustment without another render.
useLayoutEffect(() => {
if (pos === null) return
const clamp = () => {
const fit = () => {
const el = bubble.current
/* v8 ignore next -- pos is set only while the bubble is mounted. */
if (el === null) return
const EDGE_MARGIN = 12
el.style.left = `${pos.x}px`
const r = el.getBoundingClientRect()
let dx = 0
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
el.style.left = `${pos.x + dx}px`
if (side === 'right') return
// Flip only into a side that genuinely fits, so an anchor with room on
// neither side keeps the requested placement instead of oscillating.
const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN
const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN
if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
}
clamp()
window.addEventListener('resize', clamp)
return () => { window.removeEventListener('resize', clamp) }
}, [pos, resolvedLabel])
fit()
window.addEventListener('resize', fit)
return () => { window.removeEventListener('resize', fit) }
}, [placement, pos, resolvedLabel, side])
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
@@ -100,11 +122,10 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
if (el === null) return
const r = el.getBoundingClientRect()
setPos(side === 'right'
? { x: r.right + 10, y: r.top + r.height / 2 }
: side === 'top'
? { x: r.left + r.width / 2, y: r.top - 8 }
: { x: r.left + r.width / 2, y: r.bottom + 8 })
// Every show starts from the requested side; the fit pass flips it only
// where this anchor's position demands it.
setPlacement(side)
setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
}
const showAfterHoverDelay = () => {
cancelShow()
@@ -132,7 +153,13 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{pos !== null && (
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
<span
ref={bubble}
className={css.bubble}
data-side={placement}
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
role="tooltip"
>
{resolvedLabel}
</span>
)}
@@ -95,6 +95,18 @@ describe('Tooltip', () => {
const rect = (left: number, right: number): DOMRect =>
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
it('caps the bubble width where the label would otherwise slab across the surface', () => {
render(
<Tooltip label="A description long enough to need a cap" side="bottom" maxWidth={360}>
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
// The stylesheet's half-viewport cap stays the default; this one overrides it.
expect(screen.getByRole('tooltip').style.maxWidth).toBe('360px')
})
it('clamps a bubble overflowing the right viewport edge back inside', () => {
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
try {
@@ -161,19 +173,88 @@ describe('Tooltip', () => {
}
})
/** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */
const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) =>
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
const [top, bottom] = this.getAttribute('role') === 'tooltip'
? [0, bubbleHeight]
: [anchorTop, anchorBottom]
return {
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
}
})
it('supports top placement for anchors at the viewport bottom', () => {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
// zero-width measured rect clamps left to the 12px edge margin.
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('-8px')
const spy = placed(700, 720, 20)
try {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
// There is room above, so the requested side stands: the bubble's own
// top sits at the anchor's top less the 8px gutter.
expect(bubble.getAttribute('data-side')).toBe('top')
expect(bubble.style.top).toBe('692px')
expect(bubble.style.left).toBe('150px')
} finally {
spy.mockRestore()
}
})
it('flips a bottom bubble above an anchor with no room below', () => {
// jsdom's viewport is 768 tall: a 300px bubble under an anchor ending at
// 700 would run off, and there is room for it above.
const spy = placed(600, 700, 300)
try {
render(
<Tooltip label="Tall" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
expect(bubble.style.top).toBe('592px')
} finally {
spy.mockRestore()
}
})
it('flips a top bubble below an anchor with no room above', () => {
const spy = placed(10, 40, 100)
try {
render(
<Tooltip label="Tall" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('bottom')
expect(bubble.style.top).toBe('48px')
} finally {
spy.mockRestore()
}
})
it('keeps the requested side when neither side fits', () => {
// A bubble taller than the viewport has no home; oscillating between the
// two would be worse than honouring the request.
const spy = placed(300, 400, 900)
try {
render(
<Tooltip label="Huge" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
} finally {
spy.mockRestore()
}
})
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
+6 -4
View File
@@ -5,8 +5,10 @@
* The tool registry itself stays on the host plane — the agent loop's
* scheduler, the API proxy's presenters, and every tool plugin are all its
* consumers, so it cannot move into a preset. What a preset CAN own is the
* presentation: `ctx.tools.presentAs()` declares it for the mounting agent
* alone, so a Code Mode agent runs beside native ones in one process.
* presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE,
* which is the preset's standing mount, so the declaration covers every agent
* joined to that preset and a Code Mode preset runs beside native ones in one
* process. One row per composition, not one per session.
*
* A code mode needs a TypeScript code runtime, which is a host-plane service
* ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)).
@@ -50,8 +52,8 @@ export const Config: z<Config> = z.object({
})
/**
* Declare this agent's tool presentation.
* @param ctx - the mounting agent's scope context.
* Declare the tool presentation for every agent this composition covers.
* @param ctx - the mounting composition's scope context (a preset's standing scope).
* @param config - the selected presentation.
*/
export function apply(ctx: Context, config: Config): void {
+10 -8
View File
@@ -786,7 +786,7 @@ export class ToolRegistry extends Service {
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
/** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */
/** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */
private readonly defaultMode: ToolPresentationMode
private readonly maxParallelSubCalls: number
/**
@@ -811,7 +811,7 @@ export class ToolRegistry extends Service {
/**
* The generated-SDK prompt section, registered globally by a code-mode
* deployment and per agent by {@link presentAs}.
* deployment and per scope by {@link presentAs}.
*
* The body regenerates from the CALLING scope, and renders empty for an
* agent presenting natively — an agent that opted out under a code-mode
@@ -880,12 +880,14 @@ export class ToolRegistry extends Service {
}
/**
* Present this agent's tools in `mode` instead of the deployment default.
* Present the calling scope's tools in `mode` instead of the deployment
* default. Nearest scope on the chain wins, so a preset's standing
* declaration covers every agent joined under it.
*
* Scoped only, and one declaration per agent: this is how an agent preset
* composes a Code Mode agent beside native ones in the same process, and a
* Scoped only, and one declaration per scope: this is how an agent preset
* composes Code Mode agents beside native ones in the same process, and a
* process-global override would be the `mode` config field instead.
* @param mode - the presentation this agent's model sees.
* @param mode - the presentation the covered agents' models see.
* @returns the exact disposer that restores the deployment default.
*/
presentAs(mode: ToolPresentationMode): () => void {
@@ -898,14 +900,14 @@ export class ToolRegistry extends Service {
ctx,
(layer) => {
if (layer.mode !== undefined) {
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`)
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`)
}
layer.mode = mode
return () => { layer.mode = undefined }
},
{ label: 'tools.presentAs()' },
)
// The SDK section is per agent for the same reason the mode is. Under a
// The SDK section is per scope for the same reason the mode is. Under a
// deployment that already defaults to a code mode this shadows the
// global registration with an identical body, which costs nothing and
// keeps one rule instead of a case analysis.
+71 -23
View File
@@ -712,6 +712,16 @@ function historyPage(
* registry). An absent registry means the deployment has no projection seam:
* the whole block is absent and clients treat every key as capability-absent.
*/
/**
* Which session a transcript read is served from. An attached session is the
* live object and keeps appending, so its events and projection baseline are
* read together in one synchronous step; a detached one is already a frozen
* inspection.
*/
type HistorySource =
| { readonly kind: 'attached'; readonly session: Session }
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] }
function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
@@ -1355,24 +1365,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return undefined
}
/** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
/**
* Resolve which session one transcript read is served from, without
* acquiring an Agent owner. This is the read's only asynchronous step
* besides ensuring the composition; {@link historyCutOf} takes the cut.
* @param sessionId - the transcript being read.
* @returns the attached session, or the inspected detached header and events.
* @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity.
*/
async function historySourceFor(sessionId: SessionId): Promise<HistorySource> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
}
if (attached !== undefined) return { kind: 'attached', session: attached }
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
header: inspected.meta,
events: inspected.events,
...projections === undefined ? {} : { projections },
return { kind: 'detached', header: inspected.meta, events: inspected.events }
}
/**
* The header and events {@link presenterScopeFor} reads to decide which
* composition a transcript ran under.
* @param source - the live or detached session this read is served from.
* @returns that session's creation header and its events.
*/
function sourceSession(source: HistorySource): PresetBearingSession {
if (source.kind === 'detached') return { header: source.header, events: source.events }
return { header: source.session.header, events: source.session.events }
}
/**
* One transcript cut: the events and the projection baseline that describe
* the SAME log position.
*
* Synchronous, and the two reads sit next to each other, because an attached
* session keeps appending: an `await` between them would serve events cut at
* N beside a baseline folded to N+1, which is one response describing two
* moments. The caller does its awaiting before this call.
* @param source - the live or detached session this read is served from.
* @param includeProjections - whether the caller asked for the baseline (a tail page does).
* @returns the events and, when asked, the baseline for that same position.
*/
function historyCutOf(
source: HistorySource,
includeProjections: boolean,
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
if (source.kind === 'detached') {
const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
return { events: source.events, ...projections === undefined ? {} : { projections } }
}
const events = [...source.session.events]
const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined
return { events, ...projections === undefined ? {} : { projections } }
}
/**
@@ -2032,9 +2073,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
const source = await historySourceFor(sessionId)
// Both awaits happen BEFORE the cut. Ensuring the recorded
// composition's standing mount is what registers its projection
// units, so a first cold read would otherwise serve a baseline
// missing every preset-owned key; and an attached session keeps
// appending, so awaiting between the two reads would pair events cut
// at N with a baseline folded to N+1.
const scope = await presenterScopeFor(sessionId, sourceSession(source))
const cut = historyCutOf(source, beforeSeq === undefined)
const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope)
return ok(request, {
events: page.events,
hasMore: page.hasMore,
...cut.projections === undefined ? {} : { projections: cut.projections },
})
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
@@ -2045,12 +2099,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
return ok(request, {
events: page.events,
hasMore: page.hasMore,
...state.projections === undefined ? {} : { projections: state.projections },
})
},
async models(request) {
@@ -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: e31dc8e666096baf6fd6b2cf9407110c7678c6e1
README.zh.md: cf7e24bb0f256623beec1b86c70473a70be03cb5
README.md: 98891c8710adc7d72dee20a8466742ee6f649956
README.zh.md: 0fcc5fa4d697affc2185b9251c6a60dee6510042
+2 -1
View File
@@ -133,7 +133,8 @@ Prefix-stable for the life of an agent: a composition is installed once, before
## Known Limitations and Deferred Work
- **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. 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).
- **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`.
- **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.
- **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.
- **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.
+2 -1
View File
@@ -133,7 +133,8 @@ Indirectly, through the plugins a standing composition registers, which own ever
## Known Limitations and Deferred Work
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`
- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
- **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。
- **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis``code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。
@@ -34,12 +34,14 @@
"peerDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
@@ -25,6 +25,8 @@ import { stat } from 'node:fs/promises'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
// Type-only: resolves the `agent/created` lifecycle event this service watches.
import type {} from '@deepseek-ai/dsh-agent'
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
@@ -130,6 +132,28 @@ export class AgentPresets extends Service {
this.settingsService = undefined
}, 'agentPresets.settings()')
})
// Advisory, not fatal: a synchronous `agent/created` listener that throws
// VETOES publication, and this service must not, because composing an agent
// outside the roster is legal — `recompose` binds exactly such a bare agent
// below, and the ACP, SDK-server, and headless entry points all create one.
// The invariant companion is the check that fails loud, at assembly. Why an
// unjoined agent matters at all has one home: the [Agent
// Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md).
//
// Known false positive: a session created bare and bound later by
// `recompose` is warned about once, before its first bind. No shipped flow
// does that today — the Web surface mounts in `setup` and children join
// through `composeFrom` before publication.
ctx.on('agent/created', ({ agent }) => {
if (this.config.roots.length === 0) return
if (this.composedPreset(agent.ctx) !== undefined) return
ctx.logger.warn(
`agent "${agent.id}" was published without joining an agent preset; `
+ 'its tools, prompt sections, and skill catalog resolve against the empty global layer '
+ '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)',
)
})
}
/**
@@ -440,6 +464,12 @@ export class AgentPresets extends Service {
// disappearing, and failing the session over a stat would not.
const current = await compositionStamp(preset.path)
if (current === undefined || sameStamp(mounted.stamp, current)) return mounted
// TODO: reclaim the superseded generation once the last agent joined to
// it is gone. The subtree is not inert — `dsh-skill-local` watches its
// roots — and the settings-page authoring flow turns "a composition
// changed" into a per-save event. This needs a joined-agent count on
// StandingMount, incremented in `mount`/`composeFrom`/`recompose` and
// decremented when the agent's scope key dies.
// Guarded delete: a caller that raced this one may have already started
// the next generation, and dropping THAT pointer would fork a third.
if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id)
+34 -2
View File
@@ -5,6 +5,10 @@
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
// Type-only: resolves the `system-prompt/assemble` waterfall this companion
// joins, and the `agent` field `dsh-agent` merges into its context.
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-agent'
// Imported through the package name, not `./mount.ts`: a module shared between
// the two build entry points becomes a third chunk that the published `files`
// list does not carry, which `verify-built-package-invariants` rejects.
@@ -18,9 +22,10 @@ export const name = 'agent-presets-invariant'
export const inject = ['invariants']
/**
* Assert that no installed preset composition reaches the root service realm.
* Assert that no installed preset composition reaches the root service realm,
* and that a deployment configuring a roster composes every agent from it.
*
* `mountPreset` proves this once, when the subtree settles. A row that
* `mountPreset` proves the first once, when the subtree settles. A row that
* publishes later — from a timer, or an asynchronous continuation after its
* plugin returned — would escape that one-shot audit, so re-check every live
* mount whenever a service registration changes.
@@ -37,6 +42,33 @@ const install: InvariantInstaller = (ctx, fail) => {
)
}
}, { global: true })
// An agent that joined no preset resolves `tools`, `system-prompt`, and
// `skill` against the empty global layer, so the model receives nothing.
// `composedPreset()` is the roster's own answer to "did this agent join",
// read from the live scope chain — see the [Agent
// Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md)
// for why the warning beside it is advisory while this one fails.
//
// Two conditions, each load-bearing. `context.agent` is what makes this an
// AGENT assembly: a scope-only assembly — a cold read resolving presenters
// in a standing key, a diagnostic — is not an agent and must not be judged
// on whether it joined anything. And assembly rather than publication is the
// moment that matters, because an unjoined agent is legal until it addresses
// a model: `recompose` binds a bare agent as its first link, and that agent
// is unjoined for its whole life up to the switch.
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
&& agent !== undefined && presets.composedPreset(agent.ctx) === undefined) {
fail(
`agent "${agent.id}" addressed a model without joining any agent preset while a roster is `
+ 'composed; its tools, prompt sections, and skill catalog resolve against the empty global layer',
)
}
return next()
})
}
/**
@@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
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'
@@ -84,4 +84,34 @@ describe('agent-presets invariants', () => {
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'),
})).resolves.toBeDefined()
})
it('rejects an agent that addresses a model without joining any preset', async () => {
const ctx = await harness()
// The delegation shape: an agent composed outside the roster joined no
// standing mount, so every registry view it reads is the empty global
// layer. Publication alone stays legal — `recompose` binds exactly such an
// agent — so nothing fires until that empty world reaches a prompt.
const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') })
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent)))
.rejects.toThrow(/without joining any agent preset/)
})
it('admits a joined agent, a scopeless read, and a standing-key read', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
sessionId: SessionId('inv-joined'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined()
// A scopeless assembly belongs to no agent, so it cannot be an unjoined one.
await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined()
// Neither can a scope that is not an agent at all: a standing preset key
// has no parent of its own, so a chain-length rule would reject the cold
// read that resolves presenters in it. `context.agent` is what keeps this
// check to agent assemblies.
const standing = await ctx.agentPresets.standingKeyFor('standard')
await expect(ctx.systemPrompt.assemble({ scope: standing })).resolves.toBeDefined()
})
})
@@ -499,6 +499,35 @@ describe('replacing a composition', () => {
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
})
it('names an agent that was published without joining any preset', async () => {
const ctx = await harness()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
await ctx.agents.create({ sessionId: SessionId('sess-unjoined-warn') })
// Advisory, not fatal: a synchronous `agent/created` throw would veto
// publication, and creating an agent outside the roster stays legal.
expect(warnings.filter(line => line.includes('sess-unjoined-warn'))).toHaveLength(1)
expect(warnings.at(-1)).toMatch(/without joining an agent preset/)
warnings.length = 0
await agentOn(ctx, 'sess-joined-quiet', 'minimal')
expect(warnings).toEqual([])
})
it('says nothing when the deployment configures no roster at all', async () => {
// Presets are optional: every surface except the Web bundle keeps its
// model-facing rows in the host plane, so an agent with a chain of one is
// exactly right there and the diagnostic must stay silent.
const rosterless = await harness({ default: 'standard', roots: [] })
const warnings: string[] = []
rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn
await rosterless.agents.create({ sessionId: SessionId('sess-no-roster') })
expect(warnings).toEqual([])
})
it('composes an agent that had nothing installed', async () => {
// An agent created without a preset has no binding to re-link, so the
// switch is its first bind — exactly a mount — and once bound only the
@@ -18,12 +18,18 @@
{
"path": "../../../vendor/include"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/scope"
},
{
"path": "../../core/session"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../settings/settings"
},
+4
View File
@@ -216,6 +216,10 @@ export class HarnessSdkServer {
}
private async createSession(sessionId: string): Promise<SessionRecord> {
// No preset composition: this server's compositions keep the model-facing
// rows in the host plane, so this agent reads them from the global layer. A
// deployment that configures a roster has to join one here first
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
const handle = await this.ctx.agents.create({
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
@@ -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/self-modification/tool-cordis/README.md
README.md: 4f856523cca4800cdbb98951183fbea1e3c96c87
README.zh.md: 7bb21396452ddbe49cf3008cd89cd6044b3c4a51
README.md: be629ca9be5bff1e6f658f05476e8f2880804e44
README.zh.md: 45f3536d3460106c45051093d097729a366eca1a
@@ -87,3 +87,4 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi
- **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance).
- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths.
- **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code.
- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. Several sessions running one preset concurrently is where that becomes observable. Per-session temporary plugins would need the group and table keyed by the calling agent.
@@ -87,3 +87,4 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
- **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。
- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on``provide``tools.register` 是受支持的清理路径。
- **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。
- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。多个会话并发运行同一 preset 时这一点才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。
@@ -1242,7 +1242,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'presentAs(mode: ToolPresentationMode): () => void',
jsDoc: '/**\n * Present this agent\'s tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent\'s model sees.\n * @returns the exact disposer that restores the deployment default.\n */',
jsDoc: '/**\n * Present the calling scope\'s tools in `mode` instead of the deployment\n * default. Nearest scope on the chain wins, so a preset\'s standing\n * declaration covers every agent joined under it.\n *\n * Scoped only, and one declaration per scope: this is how an agent preset\n * composes Code Mode agents beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation the covered agents\' models see.\n * @returns the exact disposer that restores the deployment default.\n */',
},
{
signature: 'register(definition: ToolDefinition): () => void',
@@ -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/session/session-projection/README.md
README.md: 2615b253999c798172168ec9d0232eb965b07fbc
README.zh.md: 0712e9c7a61fbcf43939791b3e7cd24af6777c3a
README.md: 9018b133bb69ed4717fede14c9a2070a07c3fa62
README.zh.md: b91908117fd452855a82976515d13165803def43
@@ -42,6 +42,7 @@ None; projections never assemble or send provider requests.
## Known Limitations and Deferred Work
- **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there.
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
- **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.
@@ -42,6 +42,7 @@
## 已知限制与暂缓事项
- **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。
- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
- **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。
+3
View File
@@ -204,6 +204,9 @@ importers:
'@deepseek-ai/dsh-pwsh-sandbox':
specifier: workspace:^
version: link:../../packages/bash/pwsh-sandbox
'@deepseek-ai/dsh-session-projection':
specifier: workspace:^
version: link:../../packages/session/session-projection
'@deepseek-ai/dsh-session-reference':
specifier: workspace:^
version: link:../../packages/context/session-reference