feat(web): move the agent plane behind per-session presets

The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.

`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.

A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.

Two bugs the real-composition test caught, both invisible to unit tests:

`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.

`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.

The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.

Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.

The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.

The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
This commit is contained in:
Yichen Jiang
2026-08-03 23:44:09 +08:00
parent 91b55b9245
commit 3d68185480
15 changed files with 564 additions and 11 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626
README.zh.md: c6b815bef59eb1f14be0892078694f129366d004
README.md: be5b5b98865328ccd5f8a4666bff04acc8fd5927
README.zh.md: dd84a7a24835c7f55b2b9b694197abd1fa30f77a

View File

@@ -12,7 +12,7 @@ At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for
Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing cannot decide whether a republish is needed and consumers never re-parse the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Identity is compared against the definition this plugin registered rather than a lookup of its own name, so the plugin works mounted globally or inside one agent's composition, where `register()` files into that agent's layer alone. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.
`catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.

View File

@@ -12,7 +12,7 @@
每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `<system-reminder>` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。身份比对针对本插件所注册的那个定义,而非按自身名字回查,因此本插件既可全局挂载,也可挂在单个 agent 的组装内——在后者中 `register()` 只归档进该 agent 的分层。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。

View File

@@ -154,14 +154,14 @@ export function apply(ctx: Context, config: Config = {}): void {
},
})
ctx.tools.register(skillTool)
const registeredSkillTool = ctx.tools.get(skillTool.name)
/* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */
if (registeredSkillTool === undefined) {
throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry')
}
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
//
// The comparison is against the definition this plugin registered, not against
// a lookup of its own name: `register()` files into the CALLING context's
// scope, so a plugin mounted inside an agent preset registers for that agent
// alone and an unscoped lookup correctly finds nothing.
ctx.on('agent/pre-step', async (
{ agent, signal },
next,
@@ -169,7 +169,7 @@ export function apply(ctx: Context, config: Config = {}): void {
const decision = await next()
if (decision.kind === 'reject') return decision
signal.throwIfAborted()
const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool
const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool
const snapshot = toolVisible
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
: { skills: [], complete: true }