diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml new file mode 100644 index 0000000000..f3d763058b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -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-03-per-session-agent-presets.md +2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe +2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md new file mode 100644 index 0000000000..6f1643c250 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -0,0 +1,80 @@ +# Agent Note: A session's agent is composed from a preset cordis.yml + +Status: implemented + +English | [中文](2026-08-03-per-session-agent-presets.zh.md) + +## Problem + +One `dsh` process serves many sessions, but the composition that decides what an agent *is* — its tools, persona, prompt sections, delegation backends — is fixed for the whole process by the `cordis.yml` the launcher booted. A deployment that wants a benchmark-minimal agent beside a full coding agent has to run two processes, and the shipped workaround (`apps/cli/config/minimal.cordis.yml`, a `--config` overlay that disables tool rows) changes every session at once. + +The obvious reading of "let a session pick its composition" is that the loader needs a new tier. It does not. [`dsh-tools`](../../../../packages/core/tools/README.md) and [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) already file registrations into the calling context's scope layer, and [the agent is a registration scope](2026-07-08-agent-scope-contexts.md). What was missing is a way to point a whole `cordis.yml` at one agent's scope. + +## Decision + +A **preset** is a directory holding one `agent.cordis.yml`. The agent factory's `setup(agentCtx)` mounts it as a Cordis `include` subtree plugged into that agent's scope context. Entry contexts chain to the context a subtree was plugged into, so every registration inside the preset lands in that agent's layer and unwinds with the agent. No registry gains a tier, and no session already running is touched. + +Composition splits into two planes, decided by what must be shared rather than by what feels agent-related: + +| Plane | Instances | Contents | +|---|---|---| +| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host | +| Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy | + +Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. + +The presets the deployment ships are the directories under `apps/cli/config/agent-presets/`; the roster is that listing, not a list restated here. + +Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. + +Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own. + +## Consequences + +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes. + +**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. + +**A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers. + +**A preset may not publish into the root service realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first — and the collision surfaces as an unhandled rejection that `setup` never observes, leaving a half-composed agent that looks healthy. The mount rejects it instead, and the package invariant re-checks on every service notification because a row publishing from a timer or an asynchronous continuation would escape a one-shot audit. + +**Failure rolls the agent back.** `setup` runs before publication, so a rejected mount fails `ctx.agents.create()` and leaves nothing behind. This is why `setup` is the one supported call site. + +**A test that the preset file is never rewritten has to be able to fail.** The first version asserted the file was unchanged after an ordinary mount, and could not have caught anything: the Loader only reaches its write path when it decides the config changed, and nothing in that composition ever self-disposed. The regression plants a row that disposes itself — the shape a real preset hits every time an agent is torn down — and keeps the composition in a temp root rather than under `fixtures/`, because without the override the Loader rewrites the file it read: a committed fixture would be damaged by the very run that proves the bug, and every run after it would compare against the damaged file and pass. + +**Fiber membership is object identity, not `uid`.** A `uid` is a per-registry counter, so fibers in two different roots collide on it; comparing by `uid` made one runtime's subtree answer for a service published in another. `ctx.plugin()` returns a thenable `Object.create(fiber)` wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction. + +**A preset file is an input, never a persistence target.** `EntryTree.write()` persists a tree whenever the Loader decides the config changed, and a plugin self-disposing is enough — tearing an agent down disposes its whole subtree. Inherited, that rewrites the composition it read, in practice truncating a shipped preset to `[]` the first time a session ends. The subtree overrides `write()` to do nothing. + +**A plugin that looks itself up in the global registry breaks inside a preset.** `ctx.tools.register()` files into the CALLING context's scope, so a plugin mounted in a preset registers for one agent and an unscoped `ctx.tools.get(name)` correctly finds nothing. `dsh-tool-skill` did exactly that and threw on every preset mount; it now compares against the definition it registered. Any plugin meant to be preset-mountable must hold its own registration rather than re-read it by name. + +**An entry-local `isolate` realm is invisible to the agent's own scope, not only to the host.** Only rows inside that group resolve the service. That is what makes a preset's `skills` registry belong to one agent rather than being shared — and it means a consumer left outside its provider's group silently resolves the host registry and contributes nothing. + +**Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails. + +**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. + +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. + +**A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. + +**A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. + +**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. + +**A durable header field is not durable until every backend writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and neither persistence backend carried it: the JSONL header line, the SQLite `sessions` row, and the derived query index each map the header column by column, so a resumed session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same shape — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it. + +**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. + +**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `dsh-host-apiproxy` discards the `AgentHandle` it creates, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. + +- Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam. + +## Alternatives considered + +**Add a preset tier to the scoped registries.** `ScopedLayers.merge()` combines the global layer with exactly one exact-scope layer. A middle tier would let many sessions share one mounted composition, but it changes `dsh-scope` and every scope-aware registry to save a cost measured in milliseconds, and it gives a preset's registrations a lifetime no agent owns. + +**Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions. + +**Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md new file mode 100644 index 0000000000..7afe9ade5c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -0,0 +1,81 @@ +# Agent Note:会话的 agent 由一份 preset cordis.yml 组装而成 + +Status: implemented + +[English](2026-08-03-per-session-agent-presets.md) | 中文 + +## 问题 + +一个 `dsh` 进程服务多个会话,但决定 agent(智能体)究竟是什么的那套组装——它的工具、人设、提示词段落、委派后端——由启动器所引导的 `cordis.yml` 一次性固定给整个进程。若某个部署希望一个 benchmark 精简 agent 与一个完整编码 agent 并存,就必须跑两个进程;而现有的变通方案(`apps/cli/config/minimal.cordis.yml`,一个用来禁用工具行的 `--config` 覆盖层)会一次性改变所有会话。 + +对"让会话自选组装"最直觉的理解,是 loader 需要新增一层。其实不需要。[`dsh-tools`](../../../../packages/core/tools/README.md) 与 [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册,而且 [agent 本身就是一个注册 scope](2026-07-08-agent-scope-contexts.md)。此前缺的只是一种把整份 `cordis.yml` 指向某一个 agent scope 的办法。 + +## 决策 + +**preset** 是一个目录,其中放置一份 `agent.cordis.yml`。agent 工厂的 `setup(agentCtx)` 把它作为 Cordis `include` 子树,挂载到该 agent 的 scope 上下文之下。entry 上下文沿原型链连到子树被挂载时所在的上下文,因此 preset 内部的每一次注册都落进该 agent 的分层,并随 agent 一起卸载。没有任何注册表新增分层,也没有任何已在运行的会话被触及。 + +组装划分为两个平面,依据是什么必须共享,而不是什么感觉上与 agent 有关: + +| 平面 | 实例数 | 内容 | +|---|---|---| +| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 | +| agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 | + +模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 + +部署交付哪些 preset,取决于 `apps/cli/config/agent-presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 + +挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 + +未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。 + +## 后果 + +**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id,因此恢复重建的是那份组装而不是当下的默认值,网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 + + +**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 + +**preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。 + +**preset 不得把服务发布进根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞——而这次相撞表现为 `setup` 永远观察不到的未处理 rejection,留下一个看起来健康、实则组装到一半的 agent。挂载改为直接拒绝它;本包的运行时不变量还会在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +**失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。 + +**「preset 文件从不被回写」这条断言,必须先有失败的可能。** 最初那版在一次普通挂载之后断言文件未变,其实什么也抓不到:Loader 只在认定 config 变了时才会走到写路径,而那份组装里没有任何一行会自行销毁。回归用例改为植入一个自行销毁的行——真实 preset 在每次 agent 被拆除时都会命中的形状——并把组装放在临时根目录而不是 `fixtures/` 下:没有那个覆写,Loader 会回写它读入的文件,于是提交进仓库的 fixture 会被**恰恰是证明该缺陷的那次运行**改坏,之后每一次运行都拿改坏后的文件作比较从而通过。 + +**fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。 + +**preset 文件是输入,绝不是持久化目标。** 只要 loader 认为配置变了,`EntryTree.write()` 就会回写整棵树,而一个插件自我 dispose 就足以触发——销毁 agent 会 dispose 它的整棵子树。若继承该行为,它会重写自己读入的那份组装,实际后果是第一次会话结束时把随附 preset 截断成 `[]`。子树因此把 `write()` 覆盖为空操作。 + +**按自身名字回查全局注册表的插件,在 preset 里必然失效。** `ctx.tools.register()` 归档进**调用方**上下文的 scope,因此挂在 preset 里的插件只为一个 agent 注册,而不带 scope 的 `ctx.tools.get(name)` 理所当然查不到。`dsh-tool-skill` 正是这样写的,于是每次 preset 挂载都抛错;现在它与自己注册的那个定义比对。任何希望可被 preset 挂载的插件,都必须持有自己的注册对象,而不是按名字重新读取。 + +**entry 本地 `isolate` realm 不仅对宿主不可见,对 agent 自身的 scope 同样不可见。** 只有该组内部的行能解析到该服务。这正是让 preset 的 `skills` 注册表归属单个 agent 而非共享的原因——同时也意味着:被留在提供方组之外的消费方会静默解析到宿主注册表,然后什么都不贡献。 + +**只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。 + +**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 + +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 + +**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 + +**preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 + +**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 + +**持久化的头部字段,在每个后端都写入之前都算不上持久。** `agentPreset` 带着正确的理由落在了 `SessionHeader` 上,而两个持久化后端都没有携带它:JSONL 头部行、SQLite `sessions` 行、以及派生的查询索引各自逐列映射头部,于是被恢复的会话回来时没有 preset,所有据以命名它的表层随之失声。`summarizeCold` 是同一个形状——它手工拼装冷列表行,而没有复用共享的投影。声明为持久的字段,需要一个跨越真实存储的测试,而不只是声明它的那个类型。 + +**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 + +**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`dsh-host-apiproxy` 创建后直接丢弃 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 + +- 遗留 TODO:idle agent 驱逐——会话持久化后 dispose,恢复时重新挂载。它属于持有 handle 的那个宿主,不属于本 seam。 + +## 考虑过的替代方案 + +**在 scope 注册表中新增 preset 分层。** `ScopedLayers.merge()` 把全局层与恰好一个精确 scope 层合并。新增中间层可以让多个会话共用一份已挂载的组装,但它要改动 `dsh-scope` 及每个 scope 感知的注册表,换来的只是毫秒级的开销节省,而且会让 preset 的注册获得一个没有任何 agent 拥有的生命周期。 + +**把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。 + +**把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml new file mode 100644 index 0000000000..d8c55c9f0a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 +2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md new file mode 100644 index 0000000000..834d645f5f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -0,0 +1,32 @@ +# Agent Note: Per-preset standing mounts over a scope parent chain + +Status: implemented + +English | [中文](2026-08-08-per-preset-standing-mounts.zh.md) + +## Problem + +Per-session preset mounts made the model-facing registry surface per-agent while three independent host readers still assumed it was static: cold `session.history` found no presenters (every card silently degraded to the generic renderer — indistinguishable from "tool has no presenter"), the projections block dropped preset-registered keys (clients treat an omitted key as capability absence and CLEAR the row), and the TypeRT gateway resolved `goals` on the host root (`service-unavailable`). Patching each reader individually traded one silent degradation for another: resuming to reach presenters flipped the projections fold from detached to live and wiped the token counts instead. + +## Decision + +A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by binding its scope key to the mount's (`bindScopeParent(agentKey, standingKey)`). Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf. + +## Consequences + +Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap + +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: : true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. +- A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. + +## Load-bearing details + +- **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer. +- **A settled mount serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. +- **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it). +- **Re-linking runs only through the `ScopeParentBinding` the mount's one bind returned** — the roster holds it privately, so the blank-session recompose path is the sole re-link and no other caller can move a composed agent; it stays valid only while nothing produced under the old parent is retained, which the holder must uphold because the relation cannot see session logs. + +## Alternatives considered + +Resume-on-read (wipes detached projections), a host-plane presenter table plus a block completeness flag (fixes two readers, leaves the class), per-session template mounts (duplicates every instance to serve pure functions). Kept for the record: the gateway-facing `goals` domain stays host-plane regardless — a Remote method whose receiver comes from a generated descriptor resolves on the host, which is the `bash-env` host-plane criterion read from the consuming side. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md new file mode 100644 index 0000000000..45ce0f4e7d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Per-preset standing mounts over a scope parent chain + +Status: implemented + +[English](2026-08-08-per-preset-standing-mounts.md) | 中文 + +## Problem + +按会话挂载 preset 让面向模型的注册面变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 `session.history` 找不到 presenter(每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分)、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并**清掉**该行)、TypeRT 网关在宿主根上解析 `goals`(`service-unavailable`)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume,会把投影折叠从 detached 翻到 live,token 计数随之被抹掉。 + +## Decision + +一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key(`bindScopeParent(agentKey, standingKey)`)加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 + +## Consequences + +常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: + +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: : true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 +- 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 + +## Load-bearing details + +- **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 +- **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 +- **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 +- **重新认父只能经由挂载首绑返回的 `ScopeParentBinding`**——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent;其合法性仍以旧父之下产出一概不被保留为前提,由持有方保证,因为该关系看不见会话日志。 + +## Alternatives considered + +冷读时 resume(抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 `goals` 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析,这正是 `bash-env` 宿主平面判据从消费侧读出的样子。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml new file mode 100644 index 0000000000..22e5090312 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml @@ -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-09-layered-skill-registry.md +2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39 +2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75 diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md new file mode 100644 index 0000000000..3f092cfb4b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md @@ -0,0 +1,39 @@ +# Agent Note: The skill registry is host-held and layered per scope + +Status: implemented + +English | [中文](2026-08-09-layered-skill-registry.zh.md) + +## Problem + +The agent-preset stack moved the whole skill capability — registry, local provider, and the `skill` tool — into each preset's `isolate` realm, because "which skills an agent has" is an agent-plane choice. That framing conflated two different questions: which skills a *deployment* supplies, and whether an *agent* consumes them. A repository plugin's prepared wrapper declares `inject: ['skills']` and mounts its skill root as a host-plane provider; with no host registry composed in the web and headless profiles, that wrapper waited forever and the repository-plugin e2e hung, which was bypassed at the time by dropping the fixture's skill root. A per-preset realm registry also made the gateway's skill listing depend on a live agent — a cold session's `/` popup had no registry to read at all. + +The tools registry never had this problem: it is one host singleton layered per scope over `dsh-scope`, so deployment-level tools (MCP servers, plugin entries) register globally while a preset's rows register into that preset's layer. + +## Decision + +`SkillService` adopts the same shape. It holds `ScopedLayers`; `registerProvider()` and `register()` file into the layer of the calling context's scope, so host rows and repository plugins land in the global layer while a preset's `skill-local` — mounted by the standing composition, whose context carries the preset's scope key — lands in that preset's layer. Provider names are unique per layer rather than process-wide, which is what lets every preset mount its own `local` provider. + +Reads take the viewing scope through `SkillViewOptions` (the calling agent, which is its own scope key). The registry merges the global layer with the scope's chain: **the nearest layer wins a duplicate name outright, and rank decides duplicates only within one layer** — the tools registry's shadowing rule. Rank-pooling across layers was considered and rejected: ranks were designed to order sources that know about each other, and under a global pool a later-installed repository plugin could silently displace a preset's own same-named skill by registration-order tiebreak, changing a preset's behavior remotely. Nearest-wins keeps a composition's behavior decided by its author. + +Discovery caches are keyed by the resolved scope chain plus one revision counter, so a blank-session recompose — which re-parents the agent's scope key without touching the registry — is visible to the next read. + +The composition moves with it: the web-app bundle re-enables the base `skill` registry row (only `skill-local` and `tool-skill` stay preset-owned), and preset compositions drop their `isolate: skills` realm for bare rows over the host registry. The gateway's skills domain reads the host registry in the presenter scope — the live agent, else the recorded preset's standing key — so a cold session lists the catalog its composition actually serves instead of failing; the `serviceFor` branch stays for compositions that still realm-mount their own registry. + +## Consequences + +**A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only. + +**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. + +**Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee. + +**The TUI profile is unaffected.** With every row at host, there is exactly one (global) layer and the merged view equals the old single-registry view, ranks and all. + +**Shadowing across layers is silent.** Within a layer the loser is logged as before; a nearer layer replacing a farther name follows the tools registry's convention and logs nothing. The registry still exposes no API to inspect shadowed definitions. + +## Alternatives considered + +**Rank pool across all visible layers.** Faithful to the single-registry precedence, but cross-layer ties break on registration order (boot-time providers always beat standing mounts), and a preset's own skill could be displaced by a deployment change it never sees. Rejected for composition stability; see Decision. + +**Keep per-preset realm registries and deliver repository skills as directories a preset's provider scans.** Leaves the wrapper's `inject: ['skills']` contract broken (or forks the wrapper per profile), duplicates discovery configuration into every preset, and still gives cold sessions nothing to read. Rejected. diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md new file mode 100644 index 0000000000..38b17329c8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md @@ -0,0 +1,39 @@ +# Agent Note:skill 注册表由宿主持有并按 scope 分层 + +Status: implemented + +[English](2026-08-09-layered-skill-registry.md) | 中文 + +## 问题 + +agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和 `skill` 工具——搬进每个 preset 的 `isolate` realm,理由是"agent 拥有哪些 skill"属于 agent 平面的选择。这一框架混淆了两个不同的问题:*部署*供给哪些 skill,与*agent*是否消费它们。repository 插件的 prepared wrapper 声明 `inject: ['skills']` 并把它的 skill 根目录挂载为宿主平面的提供方;web 与 headless profile 不再组合宿主注册表后,该 wrapper 永远等待,repository-plugin e2e 因而挂死,当时通过删掉 fixture 的 skill 根目录绕过。按 preset 的 realm 注册表还让网关的 skill 列表依赖存活 agent——冷会话的 `/` 弹窗根本没有注册表可读。 + +工具注册表从未有过这个问题:它是一个宿主单例,基于 `dsh-scope` 按 scope 分层,因此部署级工具(MCP 服务器、插件 entry)注册进全局层,preset 的行注册进该 preset 的层。 + +## 决定 + +`SkillService` 采用同一形态。它持有 `ScopedLayers`;`registerProvider()` 与 `register()` 落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,preset 的 `skill-local`(由常驻组合挂载,其上下文携带该 preset 的 scope key)落入该 preset 的层。提供方名称在每层内唯一而非进程级唯一,这正是让每个 preset 都能挂载自己的 `local` 提供方的前提。 + +读取通过 `SkillViewOptions` 携带观察 scope(调用中的 agent,agent 本身就是自己的 scope key)。注册表将全局层与该 scope 的链合并:**最近层直接赢得重名,rank 只在单层内裁决重名**——即工具注册表的遮蔽规则。曾考虑跨层 rank 合池并予以否决:rank 的设计前提是各来源彼此知情;在全局池下,后安装的 repository 插件可能凭注册顺序平手规则静默顶掉 preset 自带的同名 skill,远程改变 preset 的行为。最近层优先让组合的行为由其作者决定。 + +发现缓存以解析后的 scope 链加一个修订计数为键,因此空会话重组——只重设 agent scope key 的父级、不触碰注册表——对下一次读取立即可见。 + +组合随之调整:web-app bundle 重新启用 base 的 `skill` 注册表行(只有 `skill-local` 与 `tool-skill` 仍归 preset),preset 组合拆掉 `isolate: skills` realm,改为直接落在宿主注册表上的平铺行。网关的 skills 域以 presenter scope 读取宿主注册表——存活 agent,否则记录在案的 preset 的 standing key——冷会话由此列出其组合真正供给的目录而不再报错;`serviceFor` 分支保留,兼容仍以 realm 自挂注册表的组合。 + +## 影响 + +**部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。 + +**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 + +**提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。 + +**TUI profile 不受影响。**所有行都在宿主时只有一个(全局)层,合并视图等于旧的单注册表视图,rank 行为不变。 + +**跨层遮蔽是静默的。**层内败者照旧记录日志;较近层顶替较远层的名称沿用工具注册表的惯例,不记录。注册表仍不提供检查被遮蔽定义的 API。 + +## 曾考虑的替代方案 + +**跨全部可见层的 rank 合池。**忠实于单注册表的优先级,但跨层平手按注册顺序裁决(启动期提供方永远赢过常驻挂载),preset 自带 skill 可能被它看不见的部署变更顶掉。因组合稳定性否决;见"决定"。 + +**保留按 preset 的 realm 注册表,把 repository skill 作为目录交给 preset 的提供方扫描。**wrapper 的 `inject: ['skills']` 契约仍然破损(或者按 profile 分叉 wrapper),发现配置在每个 preset 里重复,冷会话依旧无处可读。否决。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml new file mode 100644 index 0000000000..9ad1682b62 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml @@ -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-09-broken-preset-roster-rows.md +2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462 +2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md new file mode 100644 index 0000000000..fef6a183b1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md @@ -0,0 +1,33 @@ +# Agent Note: Broken presets are roster rows, not gaps + +Status: implemented + +English | [中文](2026-08-09-broken-preset-roster-rows.zh.md) + +## Problem + +With files as the only composition editor, hand-edit damage had two failure shapes and both were silent until the worst moment. A preset whose `agent.cordis.yml` no longer parsed listed as a perfectly ordinary row — selectable, copyable, settable as the default — and failed only when the next session tried to mount it; set as default, every new session failed to start. A directory whose composition file was deleted outright vanished from the roster while still occupying its id on disk: `copy` refused the name with "delete the existing preset first" and `remove` answered "not found" — two contradictory errors with no way out short of hand-deleting the directory. + +## Decision + +Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page. + +Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure. + +## Consequences + +- The ghost dead end is gone end to end: the directory lists broken, its delete clears it, and the freed id is immediately claimable (covered by unit, component, and e2e tests). +- A default that later breaks still fails the session start loudly — the pickers hide broken rows, but nothing rewrites a stored default; `resolveMountable`'s early refusal is the same message every unloadable shape gets, instead of loader-dependent errors. +- Health runs on every `list()`: one read+parse per preset per roster read, accepted for the same reason unmemoized discovery was — rosters are small and freshness is the contract. +- Copying broken is refused in the UI only (disabled with reason); the host keeps `copy` shape-agnostic. A broken source yields an equally broken, equally visible copy — no capability is gained, and the host-side refusal would have needed its own error vocabulary for no journey that survives the disabled button. + +## Load-bearing details + +- **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged. +- **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line. +- **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route. +- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user. + +## Alternatives considered + +Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md new file mode 100644 index 0000000000..196bcf4ef1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md @@ -0,0 +1,33 @@ +# Agent Note:损坏的 preset 是名单行,不是空缺 + +Status: implemented + +[English](2026-08-09-broken-preset-roster-rows.md) | 中文 + +## 问题 + +文件成为唯一的组装编辑器之后,手动编辑造成的损坏有两种形态,且都要拖到最糟的时刻才暴露。`agent.cordis.yml` 解析不了的 preset 在名单上是一张完全正常的行——可选择、可复制、可设为默认——直到下一个会话尝试挂载才失败;一旦被设为默认,所有新会话都无法启动。组装文件被整个删掉的目录则从名单上消失,却仍在磁盘上占着它的 id:`copy` 以「先删除既有 preset」拒绝这个名字,`remove` 却回答「找不到」——两条互相矛盾的错误,除了手动删目录别无出路。 + +## 决定 + +发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。 + +界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。 + +## 后果 + +- 幽灵死路端到端消除:目录以损坏行列出,删除即清掉,释放的 id 立刻可用(单测、组件测试与 e2e 各自覆盖)。 +- 事后才损坏的默认值仍会在会话启动处大声失败——选择器隐藏损坏行,但没有任何东西改写已存的默认;`resolveMountable` 的前置拒绝让每种不可加载形态得到同一条消息,而不是依赖加载器内部的报错。 +- 健康检查随每次 `list()` 运行:每次读名单对每个 preset 一次读取加解析,接受的理由与不做缓存的发现相同——名单很小,新鲜是契约。 +- 复制损坏 preset 只在 UI 层拒绝(按钮禁用并给出原因);宿主的 `copy` 保持形状无关。损坏来源产出同样损坏、同样可见的副本——没有能力增益,而宿主侧拒绝需要为一条被禁用按钮挡住的路径专门发明错误词汇。 + +## 关键细节 + +- **`PRESET_ID` 移到 `types.ts`**,让发现与创作共享同一份包含边界词汇;authoring 原样转发导出。 +- **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。 +- **mount.spec 的两个竞态用例特意不动**:`ensureStanding` 仍可能拿到删除前一刻解析出的 preset(私有路径测试),其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。 +- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实(preset 根目录在会话工作区之外)与诚实的验证方式(agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。 + +## 曾考虑的替代方案 + +隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml new file mode 100644 index 0000000000..24b04ea72f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml @@ -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/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 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md new file mode 100644 index 0000000000..348f7ab0a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -0,0 +1,46 @@ +# Agent Note: Per-agent tool presentation, and the `code` preset + +Status: implemented + +English | [中文](2026-08-05-per-agent-tool-presentation.zh.md) + +## Problem + +Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on. + +The naive reading of "move tools down to the agent plane" does not work. `ctx.tools` has host-plane consumers that cannot follow it: `dsh-agent-loop` reads the registry's private scheduler seam, `dsh-apiproxy` reads its presenters to render tool cards, and every tool plugin registers into it. By the stack's own rule — a service moves into a preset only when ALL of its consumers move with it — the registry stays where it is. + +## 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. + +`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. + +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 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. + +## Alternatives considered + +**A second `ToolRegistry` inside the preset's isolate realm.** Rejected: `dsh-agent-loop` resolves the registry once from the host context through a private symbol, so a per-agent registry would be invisible to the scheduler. Making the loop registry-per-agent is a far larger change than making one field scope-aware. + +**A top-level key in the preset's own YAML.** Rejected for the reason preset display metadata went to a separate `preset.yml`: the composition is a top-level list of plugin rows and cannot carry sibling keys. + +**Naming the package `dsh-tool-mode`.** Rejected by a gate, correctly. `gen-tool-catalog` globs `packages/*/tool-*` and requires every match to publish a model-facing tool schema, because that prefix means "ships a tool" in this repo. This row ships none. + +**Registering the SDK section unconditionally from the constructor.** Rejected after trying it: `renderPrompt` drops empty sections but `PromptAssembly.sections` retains them, so every native deployment would carry a `tools:sdk` entry rendering nothing, and two existing assertions on that list would have had to be weakened to accommodate it. + +**Sharing `standard`'s composition by include.** Rejected per the stack's own convention: `cordis` already duplicates `standard`, and a preset's value is that its whole composition is readable in one file. The cost — a third copy of ~240 lines that must move together — is real and is the strongest argument for a future include mechanism. + +## Consequences + +Two sessions in one process can now present differently, so "which tools does the model see" is no longer answerable from the deployment config alone; it requires the agent. Every diagnostic that quotes a mode now quotes the scope's, not the service's. + +`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `code` preset. + +The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no code-mode preset; the shipped Web overlay carries one, the base composition does not. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md new file mode 100644 index 0000000000..4920ee6eb0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -0,0 +1,46 @@ +# Agent Note: 按 agent 的工具呈现方式,以及 `code` 预设 + +Status: implemented + +[English](2026-08-05-per-agent-tool-presentation.md) | 中文 + +## Problem + +agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。Code Mode——一个 `run_code` 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 Code Mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。 + +「把 tools 下沉到 agent 平面」这个字面读法行不通。`ctx.tools` 有一批跟不下来的宿主平面消费者:`dsh-agent-loop` 读它私有的调度器 seam,`dsh-apiproxy` 读它的 presenter 来渲染工具卡,每个工具插件都往里注册。按本 stack 自己的规则——只有**所有**消费者一起下沉,服务才能下沉——注册表必须留在原地。 + +## Decision + +把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。 + +`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——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 段。 + +preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 + +## Alternatives considered + +**在 preset 的 isolate realm 里再起一个 `ToolRegistry`。** 否决:`dsh-agent-loop` 通过一个私有 symbol 从宿主上下文一次性解析注册表,因此按 agent 的注册表对调度器不可见。把 loop 改成按 agent 解析注册表,远比把一个字段变成 scope 感知的改动大。 + +**在 preset 自己的 YAML 里加一个顶层键。** 否决,理由与 preset 展示元数据落到独立 `preset.yml` 相同:组装是一个顶层的插件行列表,装不下并列的键。 + +**把包命名为 `dsh-tool-mode`。** 被一道 gate 否决,而且它是对的。`gen-tool-catalog` 以 `packages/*/tool-*` 通配,并要求每个命中项发布一个面向模型的工具 schema——因为在本仓库里这个前缀就意味着「带工具」。而这一行不带任何工具。 + +**在构造函数里无条件注册 SDK 段。** 试过之后否决:`renderPrompt` 会丢弃空段,但 `PromptAssembly.sections` 会保留它们,于是每个 native 部署都将携带一个什么也不渲染的 `tools:sdk` 条目,而两处既有断言不得不为此放宽。 + +**用 include 共享 `standard` 的组装。** 按本 stack 自己的惯例否决:`cordis` 已经复制了一份 `standard`,而 preset 的价值恰在于整份组装能在一个文件里读完。代价——第三份约 240 行、且必须同步演进的副本——是真实的,也正是未来引入 include 机制最有力的论据。 + +## Consequences + +同一进程内的两个会话现在可以有不同的呈现方式,因此「模型看到哪些工具」不再能只凭部署配置回答,必须给出 agent。凡是引用模式的诊断信息,现在引用的都是该 scope 的,而不是服务的。 + +`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;`web-agent-presets.spec.ts` 对随附的 `code` 预设同时断言了这个区分的两侧。 + +随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 code 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml new file mode 100644 index 0000000000..94466cd530 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml @@ -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/simplification/2026-08-08-copy-only-preset-authoring.md +2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde +2026-08-08-copy-only-preset-authoring.zh.md: dc2d7924cb0fd9363efa9387bc8ba68bf11af5d7 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md new file mode 100644 index 0000000000..c16518b087 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -0,0 +1,30 @@ +# Agent Note: Copy-only preset authoring, and the way into a preset's files + +Status: implemented + +English | [中文](2026-08-08-copy-only-preset-authoring.zh.md) + +## Problem + +The agent-preset settings page carried a web YAML editor: `agentPreset.write` accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own `entryListSchema` — whose dialect includes `!!js`, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-vs-roster races the section had to defend against. + +## Decision + +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers). + +## Consequences + +- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target. +- With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart. +- A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability. +- `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards. + +## Load-bearing details + +- **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see. +- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`. +- **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd. + +## Alternatives considered + +Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md new file mode 100644 index 0000000000..dc2d7924cb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口 + +Status: implemented + +[English](2026-08-08-copy-only-preset-authoring.md) | 中文 + +## Problem + +agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` 接收任意组装文本,页面是一个没有补全、高亮或 diff 的文本域,形状检查依赖 Loader 自己的 `entryListSchema`——其方言含 `!!js`,所以「过了形状检查的文本」在下一次挂载时仍是任意代码。作为编辑器很弱,作为能力很宽,还是该分区不得不防御的「编辑器 vs 名单」竞态的来源。 + +## Decision + +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供卡片以文本展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。 + +## Consequences + +- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。 +- 编辑器移除后,手改 `agent.cordis.yml` 成为**唯一**的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.md),已就地更新)。没有它,改过的文件要等进程重启才生效。 +- 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。 +- `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument` 与 `remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。 + +## Load-bearing details + +- **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError`,`errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。 +- **展示的路径是响应方向的披露,且钉在环回。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`。 +- **e2e lane 钉死 `nativeOpen: false`**(`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。 + +## Alternatives considered + +保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 diff --git a/.gitignore b/.gitignore index 4d3e1305d7..3d0fd8e322 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ oxlint-contract-*.ts .humanize/ tmp/ .claude/commands/ +.claude/launch.json .claude/settings.json .vscode/ .DS_Store diff --git a/AGENTS.md b/AGENTS.md index ff31eb0b65..7e481c62b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// workflow/ workflow capability + worker-thread provider + tool Consumer todo/ todo_write tool plan/ plan mode as logged state + preset/ per-session agent composition from preset cordis.yml files guard/ loop-hygiene + tool-timeout plugins self-modification/ the agent inspects/mounts its own plugins hooks/ Claude Code/Codex hook bridges + wire-protocol library diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml new file mode 100644 index 0000000000..65d2716458 --- /dev/null +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -0,0 +1,240 @@ +# The `code` agent preset: the standard coding agent, presented as Code Mode. +# +# Everything in `standard` is here unchanged. What is added is the `tool-mode` +# row: instead of one tool call per action, the model writes a TypeScript +# program against a generated SDK and `run_code` executes it, so a sequence +# that would be five round trips becomes one. +# +# The registry itself stays on the host plane — the agent loop's scheduler and +# the API proxy's presenters are its consumers — so what this preset owns is +# the PRESENTATION of that registry for this agent alone. Native sessions run +# beside this one in the same process, each seeing its own catalog. +# +# This file is an AGENT-PLANE composition. It is mounted under one agent's +# scope context, so every tool and prompt section it registers belongs to that +# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`) +# keeps everything a preset must not own: the registries themselves, the +# sandbox and approval stack, persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global +# rather than per-session and the second session mounting this preset collides +# with the first; `dsh-agent-presets` rejects that at mount. `true` means an +# entry-local realm — one private instance per mounted session, which is the +# default this deployment wants. A shared label would instead pool one instance +# across every session naming it. + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── skills ────────────────────────────────────────────────────────────────── + +# The skill REGISTRY lives in the host composition and is layered per scope: +# these rows register into THIS preset's layer of it, so they need no realm. +# `skill-local` contributes local-root discovery for agents on this preset, and +# `tool-skill` gives them the catalog and loader; the merged catalog also +# carries whatever the deployment registered globally (repository plugins). +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +# Only the model-facing tool. The goal SERVICE, its session driver, and the +# `/goal` command stay on the host plane: the Gateway serves the goal domain as +# Remote endpoints whose receiver comes from a generated descriptor, so it +# resolves `goals` on the host and an entry-local realm here would hide it. The +# registry is keyed by session anyway, so one host instance serves every +# session. What a preset chooses is whether its agent can call the goal tool. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- 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' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + +# ── presentation ──────────────────────────────────────────────────────────── + +# Code Mode for this agent alone. The row waits for the host's `codeRuntime` +# rather than assuming it: a deployment that composes no TypeScript runtime +# fails this preset at mount, naming this id, instead of at the first request. +- id: tool-mode + name: '@deepseek-ai/dsh-agent-tool-mode' + config: + mode: code diff --git a/apps/cli/config/agent-presets/code/preset.yml b/apps/cli/config/agent-presets/code/preset.yml new file mode 100644 index 0000000000..f3426e52f4 --- /dev/null +++ b/apps/cli/config/agent-presets/code/preset.yml @@ -0,0 +1,3 @@ +name: 代码模式 +description: 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 +order: 2 diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml new file mode 100644 index 0000000000..f2cdeea159 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -0,0 +1,240 @@ +# The `cordis` agent preset: the standard coding agent, plus the ability to +# read and write the runtime it is running in. +# +# It exists so a person can ask an agent to author another agent. Everything in +# `standard` is here unchanged; what is added is the self-referential Cordis +# toolset, a skill that teaches composition authoring, and a persona that says +# which of the two planes an edit belongs to. +# +# TRUST: `cordis_mount` evaluates model-written JavaScript against the live +# runtime, and a composition this agent writes becomes a preset other sessions +# mount. Treat a session on this preset as shell access — the toolset's own +# documentation makes the same statement. + + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are a coding agent powered by the {{model}} model, running on the DeepSeek Harness. Your working directory is {{cwd}}. + + You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session. + + Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it. + + Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. + + Load the `editing-cordis-compositions` skill before writing or changing a composition. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── goals ─────────────────────────────────────────────────────────────────── + +# Only the model-facing tool. The goal SERVICE, its session driver, and the +# `/goal` command stay on the host plane: the Gateway serves the goal domain as +# Remote endpoints whose receiver comes from a generated descriptor, so it +# resolves `goals` on the host and an entry-local realm here would hide it. The +# registry is keyed by session anyway, so one host instance serves every +# session. What a preset chooses is whether its agent can call the goal tool. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- 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' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +# +# `tool-subagent-report` is host-plane for the same reason as the registry, +# not because a preset may not want it: it registers a CONTINUABLE SETUP on +# that singleton rather than a tool this agent calls, and the setup list is +# not scope-aware — one copy per mounted preset means every child gets +# `report` registered once per live session, which throws on the second. +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + +# ── self-modification ─────────────────────────────────────────────────────── + +# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a +# trust boundary, not a sandbox — see this file's header. +- id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + +# The composition-authoring skill travels with this preset rather than living +# in the user's skill root: it documents THIS deployment's two planes, and a +# preset is the unit that gets copied and edited. `baseUrl` is the preset's +# own directory, so the root resolves wherever the preset is installed. +# Both rows register into THIS preset's layer of the host skill registry, so +# they need no realm; the agent's merged catalog also carries whatever the +# deployment registered globally (repository plugins). +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + config: + customSkillDirs: + - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))" + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml new file mode 100644 index 0000000000..49cb3c6d44 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -0,0 +1,3 @@ +name: 创造模式 +description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 +order: 4 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md new file mode 100644 index 0000000000..3810ec334b --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -0,0 +1,68 @@ +--- +name: editing-cordis-compositions +description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing. +--- + +# Editing Cordis compositions + +Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. + +## Decide the plane first + +Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared. + +**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. + +**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. + +**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. + +A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`. + +## Authoring a preset + +1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. +2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. +3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. +4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. + +The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. + +## The rule that catches people + +**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. + +Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service. + +When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm: + +```yaml +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' +``` + +`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate. + +A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing. + +Registry-shaped host capabilities need no realm at all: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally. + +## Verifying a change + +Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it. + +To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. + +`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. + +## What not to move into a preset + +`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml new file mode 100644 index 0000000000..8ca6f0dcdf --- /dev/null +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -0,0 +1,31 @@ +# The `minimal` agent preset: the two-tool benchmark surface. +# +# The native model surface is exactly persistent `bash` plus +# `str_replace_editor`. Everything else a session could reach — skills, goals, +# plan mode, delegation, workflows, todo, web — is simply absent rather than +# disabled, because a preset composes what an agent has instead of subtracting +# from a shared default. +# +# The host composition is unchanged: this agent still runs inside the same +# sandbox, approval, persistence, and model routing as any other session. + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml new file mode 100644 index 0000000000..5521dda140 --- /dev/null +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -0,0 +1,3 @@ +name: 极简模式 +description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +order: 3 diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml new file mode 100644 index 0000000000..66407faf1d --- /dev/null +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -0,0 +1,229 @@ +# The `standard` agent preset: the full coding agent, mounted once per process. +# +# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a +# standing scope; every session naming it joins by scope parentage, so the +# tools and prompt sections registered here cover each joined agent while a +# session's own state stays keyed per Session/Agent inside the plugins. The +# host composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a +# preset must not own: the registries themselves, the sandbox and approval +# stack, persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global — +# another preset publishing the same name collides, and a host reader would +# resolve one preset's instance for every session; `dsh-agent-presets` rejects +# that at mount. `true` means an entry-local realm: this standing mount's own +# private instance, apart from every other preset's. (A shared label does NOT +# pool instances — `provide()` throws on the second registration under the +# same realm symbol; labels join REALMS, and are not what this file needs.) + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── skills ────────────────────────────────────────────────────────────────── + +# The skill REGISTRY lives in the host composition and is layered per scope: +# these rows register into THIS preset's layer of it, so they need no realm. +# `skill-local` contributes local-root discovery for agents on this preset, and +# `tool-skill` gives them the catalog and loader; the merged catalog also +# carries whatever the deployment registered globally (repository plugins). +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +# Only the model-facing tool. The goal SERVICE, its session driver, and the +# `/goal` command stay on the host plane: the Gateway serves the goal domain as +# Remote endpoints whose receiver comes from a generated descriptor, so it +# resolves `goals` on the host and an entry-local realm here would hide it. The +# registry is keyed by session anyway, so one host instance serves every +# session. What a preset chooses is whether its agent can call the goal tool. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- 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' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +# +# `tool-subagent-report` is host-plane for the same reason as the registry, +# not because a preset may not want it: it registers a CONTINUABLE SETUP on +# that singleton rather than a tool this agent calls, and the setup list is +# not scope-aware — one copy per mounted preset means every child gets +# `report` registered once per live session, which throws on the second. +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 diff --git a/apps/cli/config/agent-presets/standard/preset.yml b/apps/cli/config/agent-presets/standard/preset.yml new file mode 100644 index 0000000000..8eddfbde48 --- /dev/null +++ b/apps/cli/config/agent-presets/standard/preset.yml @@ -0,0 +1,3 @@ +name: 标准模式 +description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 +order: 1 diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 0d6960c2a3..43860418c4 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -75,8 +75,11 @@ - id: tool-str-replace-editor disabled: true -# The matching browser controls must not offer host tools that this profile -# omits. ui-question's host half owns the ask_user_question registration. +# The matching browser controls must not offer surfaces whose tool this +# overlay omits: the panels would render for a capability the model does not +# have. Turning the row off no longer removes a tool — `ui-question`'s host +# half is empty and `tool-ask-user` is composed per preset — so this is a UI +# decision now, not a capability one. - id: ui-plan disabled: true diff --git a/apps/cli/package.json b/apps/cli/package.json index 08586708f3..031aa3df9b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,21 +17,50 @@ "@cordisjs/plugin-include": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/dsh-agent-tool-mode": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-pwsh": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0", @@ -46,6 +75,7 @@ "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index d1c5fb7624..3e3918c2ab 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -12,6 +12,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' +import { dshHomePath } from '@deepseek-ai/dsh-paths' import { boot, composeEntries, @@ -25,6 +26,12 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' + +/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ +const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) + +/** Harness-home directory holding locally authored agent presets. */ +const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -158,6 +165,24 @@ function composeProfile( if (typeof row.id === 'string') rows.set(row.id, row) } const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] + // The agent-preset roots are an assembly fact of every dsh launcher, not a + // patch author's choice: the shipped set sits beside this app's config and + // the user's own under the Harness home. Resolved per boot ($DSH_HOME may + // differ per run) and only patched when the composed tree actually mounts + // the roster — a one-shot `dsh run` composes agents from the same roster + // `dsh web` offers. + if (rows.has('agent-presets')) { + overlayAndFlags.push({ + id: 'agent-presets', + config: { + ...(rows.get('agent-presets')?.config ?? {}) as Record, + roots: [ + { path: SHIPPED_PRESET_ROOT, trust: 'system' }, + { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }, + ], + }, + }) + } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 72abee67d0..bdf301e2ae 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -97,6 +97,9 @@ function deriveWebFlagPatches( // inserts the client-hmr row), never pass-throughs of composed values. put('web-runtime', 'mode', flags.dev ? 'development' : 'production') put('web-runtime', 'lanAddresses', lanAddresses) + // The agent-preset roots are patched by the shared profile boot: they are + // an assembly fact of every dsh launcher, and `dsh run` composes agents + // from the same roster this alias offers. const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { const composed = rows.get(id) if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts new file mode 100644 index 0000000000..1bfaed8c67 --- /dev/null +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -0,0 +1,547 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { Context } from 'cordis' +import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { beforeAll, describe, expect, it } from 'vitest' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { CallId } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-skill' +import type {} from '@deepseek-ai/dsh-tools' + +const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ +const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') +const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') +/** The installation anchor whose dependency surface the preset module fallback mirrors. */ +const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') + +/** + * Boot the shipped Web composition, minus the rows that would bind a port, + * touch the network, or write outside the test. Everything that decides an + * agent's capabilities is the real thing, including both shipped presets. + */ +async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise { + const storageRoot = join(dirname(settingsFile), 'storages') + const patches: PatchOptions[] = [ + ...loadOverlayPatches('dsh-test', BASE_PATCH), + ...loadOverlayPatches('dsh-test', WEB_PATCH), + // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it + // reads the developer's own document — and since the default preset is a + // setting, a stored `agent-presets.default` would decide this file's + // outcome. Point it at a temp file for the same reason the roster below + // names only the shipped root. + { id: 'settings', config: { path: settingsFile, watch: false } }, + // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this + // file writes the developer's own `~/.dsh/storages/` — and then reads it + // back on the next run, so a stored document from any other build decides + // this test's boot. Same reason the settings row above is pinned. + { id: 'storage-json', config: { root: storageRoot } }, + // Host rows with side effects outside this process: a bound port, a served + // asset tree, a telemetry exporter. `api-gateway` and `directory-picker` + // stay ENABLED on purpose — the api-proxy is the host row that injects + // `subagents`, `workspace`, and the rest of the agent plane, so disabling + // it would hide exactly the breakage this file exists to catch: a service + // moved into the presets that a host row still waits for. The boot audit + // is that assertion. + { id: 'webserver', disabled: true }, + // The web bundle's runtime row injects `httpServer`, so it cannot + // activate without the bound port disabled above. It owns dist serving + // and the URL prompt line — surface glue, not anything that decides an + // agent's capabilities, which is all this file asserts. + { id: 'web-runtime', disabled: true }, + { id: 'telemetry-otel', disabled: true }, + // A deployment-level skill on the host registry's GLOBAL layer — the same + // registration shape a repository plugin's skill root uses. The layered + // skills test below proves it reaches preset-composed agents. + { id: 'skill-badge', disabled: false }, + { id: 'modules', disabled: true }, + { id: 'connection', disabled: true }, + // The shipped `-auto` chooser resolves its interaction from a running + // host and so waits for the webserver disabled above; the browse variant + // supplies `directoryPicker` without one. + { id: 'directory-picker', disabled: true }, + { insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] }, + // The roster AppCLIEntry would patch in; only the shipped root, so a + // developer's own `~/.dsh/.preset` cannot change this test's outcome. + // `default` here is the COMPOSITION default — the base layer the settings + // document overrides. + { + id: 'agent-presets', + config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, + }, + ...extra, + ] + // The surface is patch layers over an empty preset root, so the root sits + // outside this workspace and bare plugin names cannot resolve by Node's + // upward walk. The flat fallback the preset boot maintains is what makes + // them resolvable — the same mechanism, not a test-only shim. + const home = dirname(settingsFile) + healProfilesModuleFallback(INSTALL_ANCHOR, home) + const profileDir = join(home, 'profiles', 'spec') + await mkdir(profileDir, { recursive: true }) + const rootConfig = join(profileDir, 'cordis.yml') + await writeFile(rootConfig, '[]\n') + return await boot('dsh-test', rootConfig, patches) +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +let ctx: Context +beforeAll(async () => { + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + ctx = await bootWeb(settingsFile) +}, 120_000) + +describe('the shipped Web composition', () => { + it('leaves the global tool layer empty', () => { + // Every model-facing tool belongs to a preset, `ask_user_question` + // included: a tool in the global layer reaches EVERY agent regardless of + // which preset composed it, so a two-tool benchmark surface would really + // present three. A regression here means an agent-plane row came back to + // the host composition. + expect(toolNames(ctx)).toEqual([]) + }) + + it('supplies both shipped presets, and only those, from the system root', async () => { + const listed = await ctx.agentPresets.list() + + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('composes the full agent from `standard`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-standard'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The EXACT catalog, not a spot-check: an omission is this design's + // quietest failure mode, because a row that registers into the wrong + // layer mounts cleanly and simply contributes nothing. `glob`/`grep` are + // excluded for the reason the TUI composition e2e excludes them — they + // depend on ripgrep being present on the machine. + expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ + 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', + 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill', + 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', + 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', + 'workflow', 'write', + ]) + } finally { + await handle.dispose() + } + }) + + it('composes exactly two tools from `minimal`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + // Exactly what the preset lists — nothing arrives from the host. + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('keeps two differently composed sessions independent', async () => { + const full = await ctx.agents.create({ + sessionId: SessionId('preset-both-full'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + const minimal = await ctx.agents.create({ + sessionId: SessionId('preset-both-minimal'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + + await minimal.dispose() + + // Tearing the minimal session down leaves the full one whole. + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + expect(toolNames(ctx)).toEqual([]) + } finally { + await full.dispose() + } + }) + + it('composes the cordis agent with its own toolset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined), + }) + try { + const tools = toolNames(ctx, handle.agent) + // The self-referential toolset is what distinguishes this preset. + expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) + // And it keeps the standard agent's own tools rather than replacing them. + expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) + + // The preset's own authoring skill registers into ITS layer of the host + // registry: the cordis agent's view carries it, the global view does not. + const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) + expect(scoped).toContain('editing-cordis-compositions') + expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions') + } finally { + await handle.dispose() + } + }) + + it('presents `code` as Code Mode without disturbing a native session beside it', async () => { + const coded = await ctx.agents.create({ + sessionId: SessionId('preset-code'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined), + }) + const native = await ctx.agents.create({ + sessionId: SessionId('preset-code-native'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // One tool reaches the MODEL: the transport. The registry's catalog for + // this agent is unchanged — a code mode collapses the presentation, not + // the capabilities — so the assembly is what carries the claim. + const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) + expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor') + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' + expect(sdk).toContain('str_replace_editor') + expect(sdk).toContain('web_search') + + // The presentation is this agent's alone: the deployment default is + // native, and the session composed from `standard` still sees it. + const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent }) + expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash') + expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code') + expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + } finally { + await native.dispose() + await coded.dispose() + } + }) + + it('keeps the self-referential toolset out of every other preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-no-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // Editing the live runtime is opt-in per session, not ambient. + expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount') + } finally { + await handle.dispose() + } + }) + + it('ships the composition-authoring skill inside the preset directory', async () => { + // The preset's skill root is derived from its own `baseUrl`, so the skill + // travels with the directory wherever the preset is installed. + const skill = join( + CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + ) + + expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) + }) + + it('merges the global skill layer into a preset agent\'s catalog, keeping local discovery preset-side', async () => { + const proj = await mkdtemp(join(tmpdir(), 'dsh-preset-skill-proj-')) + await mkdir(join(proj, '.dsh', 'skills', 'project-proof'), { recursive: true }) + await writeFile(join(proj, '.dsh', 'skills', 'project-proof', 'SKILL.md'), [ + '---', + 'name: project-proof', + 'description: Proves the preset layer discovers project skills beside global ones.', + '---', + '', + 'Project proof body.', + '', + ].join('\n')) + + const handle = await ctx.agents.create({ + // Unique per run: the composition persists into the ambient DSH home, + // and a fixed id would collide with a log an earlier run left there. + sessionId: SessionId(`preset-skills-standard-${randomUUID()}`), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The host (global) view carries the deployment-level provider alone: + // local discovery moved behind the presets with `skill-local`. + expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge']) + + // The standard agent's view merges the global layer with its preset's + // own local discovery over the session cwd. + const scoped = (await ctx.skills.list({ cwd: proj, scope: handle.agent })).map(skill => skill.name) + expect(scoped).toContain('dsh-badge') + expect(scoped).toContain('project-proof') + + // The preset's own loader tool resolves the global-layer skill. + const loaded = await ctx.tools.execute({ + callId: CallId('preset-skills-load'), + name: 'skill', + arguments: { name: 'dsh-badge' }, + signal: new AbortController().signal, + agent: handle.agent, + }) + expect(loaded.isError).toBe(false) + expect(JSON.stringify(loaded.content)).toContain('powered by dsh') + } finally { + await handle.dispose() + } + }) + + it('shows a minimal agent the global layer but no loader tool', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId(`preset-skills-minimal-${randomUUID()}`), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + // Layer visibility is the registry's; whether an agent can USE skills + // stays the preset's choice — minimal mounts no `tool-skill`, so its + // tool table has no loader even though the global layer is readable. + expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('never rewrites the preset file it composed from', async () => { + // 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 it to `[]` the first time a session + // ended — so `PresetTree` refuses to write at all. + const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml') + const before = await readFile(path, 'utf8') + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-readonly'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await handle.dispose() + // Slack, not a race the number has to win. The write is driven by the + // Loader's fiber-unload listener, which fires as the subtree's fibers + // settle rather than when `dispose()` resolves, and the Loader exposes no + // flush to await. A regression writes synchronously inside that listener, + // so any wait past settlement fails; a longer one only slows the test. + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(await readFile(path, 'utf8')).toBe(before) + }) + + it('gives each session its own persona', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-persona'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) + .toContain('You are a coding agent powered by') + } finally { + await handle.dispose() + } + }) +}) + +describe('a switch survives the session', () => { + it('records the choice so the log states what the agent runs', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-switch-logged'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The api-proxy's select does exactly this pair while the session is blank. + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' }) + + // The header keeps the creation fact; the log carries what it runs. + expect(handle.agent.session.header.agentPreset).toBe('standard') + expect(resolveSessionPreset(handle.agent.session)).toBe('minimal') + } finally { + await handle.dispose() + } + }) + + it('rebuilds a switched session from the log, not the creation header', () => { + // The exact shape a resume reads back from disk: the header says standard, + // the log records the switch the user made while the session was blank. + const rebuilt = resolveSessionPreset({ + header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' }, + events: [ + { type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }, + { type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] as never, + }) + + // Reading the header alone would compose the creation-time preset over a + // history another one produced — the replay the blank-only lock prevents. + expect(rebuilt).toBe('minimal') + }) +}) + +describe('a forked session', () => { + it('inherits the composition its seeded history was produced under', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-fork-parent'), + meta: { agentPreset: 'minimal' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + const inherited = resolveSessionPreset(parent.agent.session) + const child = await ctx.agents.create({ + sessionId: SessionId('preset-fork-child'), + meta: { + parentSession: SessionId('preset-fork-parent'), + seedLength: 0, + ...inherited === undefined ? {} : { agentPreset: inherited }, + }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined), + }) + try { + // Composing nothing would leave the child empty: this layer moved every + // model-facing row out of the host plane, so there is nothing to inherit + // for free any more. + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0) + } finally { + await child.dispose() + await parent.dispose() + } + }) +}) + +describe('authoring a preset on the shipped composition', () => { + let authorCtx: Context + let userRoot: string + + beforeAll(async () => { + userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles') + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + authorCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + // The root does not exist yet: a deployment whose user has authored + // nothing is the normal first-run state. + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }) + + it('refuses to copy over or delete a shipped preset', async () => { + await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/) + await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/) + }) + + it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => { + // The id becomes a directory name under the user root, so containment is + // checked on the id rather than on the joined path afterwards. + await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow() + }) + + it('copies a shipped preset a session then really composes from', async () => { + await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式') + + // Round-trips through the roster as a `user` row carrying the given name + // and the source's description, over the source's own composition text. + const preset = await authorCtx.agentPresets.resolve('my-agent') + const source = await authorCtx.agentPresets.resolve('minimal') + expect(preset.trust).toBe('user') + expect(preset.name).toBe('我的模式') + expect(preset.description).toBe(source.description) + expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal')) + // Owner-only, in an owner-only directory: a composition is executable + // configuration on a machine that may have other users. + expect((await stat(preset.path)).mode & 0o777).toBe(0o600) + const handle = await authorCtx.agents.create({ + sessionId: SessionId('preset-authored'), + setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), + }) + try { + // The same tools the shipped `minimal` composes, from a directory copied + // through the service into a root outside the installed harness. + expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('deletes what it copied', async () => { + await authorCtx.agentPresets.copy('minimal', 'doomed') + + await authorCtx.agentPresets.remove('doomed') + + expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed') + }) +}) + +/** + * Which preset an unnamed session gets is a user setting layered over the + * composition's own default. The package suite proves the layering against a + * hand-built context; this proves it through the shipped `cordis.yml` — that + * the roster and the settings provider are actually wired to each other, and + * that the id the setting names is the one a session composes from. + */ +describe('the default preset as a user setting', () => { + it('composes an unnamed session from the stored default, not the composed one', async () => { + expect(ctx.agentPresets.defaultId).toBe('standard') + + await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'minimal' }) + try { + expect(ctx.agentPresets.defaultId).toBe('minimal') + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-user-default'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + // `mount()` with no id resolves the effective default. Two tools, not + // `standard`'s catalog: the setting decided the composition. + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + } finally { + // The context is shared with the rest of the file. `replace({})` drops + // the user section wholesale so the field re-inherits the composition + // base; `update` merges, and would leave the override standing. + await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {}) + } + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) + +describe('a session keeps the preset it was created with', () => { + it('refuses to adopt a live session under a different preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-locked'), + meta: { agentPreset: 'minimal' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + // The api-proxy guard reads exactly this: the header records what the + // session runs, so naming anything else is a caller error rather than a + // switch. Its history was produced under `minimal`'s two tools. + expect(handle.agent.session.header.agentPreset).toBe('minimal') + } finally { + await handle.dispose() + } + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 10c2dc4702..c58e5b9682 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@cordisjs/plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts new file mode 100644 index 0000000000..53b0a406ce --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -0,0 +1,272 @@ +// Web e2e scenario: the agent-preset settings section as copy-only authoring. +// The browser never edits composition text — a shipped preset opens in a +// read-only viewer, the copy dialog collects an id and an optional display +// name, and the host copies the whole directory. The section's other job is +// getting the user TO the files: this lane pins `nativeOpen: false` (see the +// overlay), so the location affordance answers the preset directory as text — +// the deterministic branch a golden can hold on every platform. +// +// Zero model calls: no replay fixture mounts, so a stray stream fails loud. +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Locator } from 'playwright' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url)) +const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') +const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') +const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') +const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md') +/** The shipped roster, beside the composition that names it. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: agent-preset authoring is a host-side copy', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let userRoot: string + + /** The settings dialog, opened on the Agent-presets section. */ + function settingsDialog(): Locator { + return page.getByRole('dialog', { name: '设置' }) + } + + /** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */ + function withPresetRoot(snapshot: string): string { + return snapshot.split(userRoot).join('{{presetRoot}}') + } + + beforeAll(async () => { + userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) + scaffold = await launchWebScaffold({ + extraOverlayPath: OVERLAY, + agentPresets: { + roots: [ + { path: SHIPPED_PRESETS, trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + default: 'standard', + }, + }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers the roster with copy as the only way to create', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = settingsDialog() + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) + await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) + // The intro carries the guidance a create button used to imply, and the + // shipped rows offer view/copy but never delete or a location — their + // install is overwritten by upgrades and is not the user's to manage. + expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建') + expect(snapshot).not.toContain('新建预设') + expect(snapshot).toContain('查看: 标准模式') + expect(snapshot).not.toContain('删除: 标准模式') + expect(snapshot).not.toContain('打开目录') + }, 60_000) + + it('views a shipped composition read-only instead of editing it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '查看: 标准模式' }).click() + const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' }) + await viewer.waitFor({ timeout: 10_000 }) + + // The real shipped composition, not a golden: the viewer shows whatever + // the deployment ships, and this lane only asserts it is shown read-only. + const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8') + expect(await viewer.locator('pre').textContent()).toBe(shipped) + expect(await viewer.getByRole('textbox').count()).toBe(0) + // The header X and the footer button share the 关闭 name; the footer one + // is last in the dialog. + await viewer.getByRole('button', { name: '关闭' }).last().click() + await viewer.waitFor({ state: 'detached', timeout: 10_000 }) + }, 60_000) + + it('copies 极简模式 whole under a new id and lands in its files', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) + await copyDialog.waitFor({ timeout: 10_000 }) + + const dialogSnapshot = await captureStableAria( + page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE) + // Two fields and nothing else: the id is the directory name the host + // needs up front; description and composition live in the files. + expect(dialogSnapshot).toContain('标识符') + expect(dialogSnapshot).not.toContain('描述') + + await copyDialog.getByPlaceholder('my-agent').fill('my-agent') + await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式') + await copyDialog.getByRole('button', { name: '创建' }).click() + await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) + + // The new row lands in the custom group, and — with no desktop opener — + // its directory is revealed as text right away: landing in the files is + // the completion of a copy, not a follow-up. + await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 }) + await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 }) + // The copy dialog is detached, so the settings dialog is the only one + // left (it names itself via aria-labelledby, which a CSS attribute + // selector cannot address). + const snapshot = withPresetRoot( + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('{{presetRoot}}/my-agent') + + // The host copied the whole directory and rewrote only the display + // metadata: the composition is byte-identical to the shipped source, the + // description rides along for the user to edit in place, and neither the + // source's name nor its roster order survives into the copy. + const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8') + expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) + const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') + expect(metadata).toContain('name: 我的模式') + expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。') + expect(metadata).not.toContain('order:') + }, 60_000) + + it('deletes the copy after confirmation and reclaims the roster', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '删除: 我的模式' }).click() + const confirm = page.getByRole('dialog', { name: '删除该预设?' }) + await confirm.waitFor({ timeout: 10_000 }) + await confirm.getByRole('button', { name: '删除', exact: true }).click() + await confirm.waitFor({ state: 'detached', timeout: 10_000 }) + + await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) + expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) + // Custom group gone with its only member; the shipped set stands. + expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0) + expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) + }, 60_000) + + it('marks damaged presets broken and clears a ghost through delete', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged')) + // The two hand-edit damage shapes: a composition that no longer parses, + // and a directory whose composition file was deleted outright. + await mkdir(join(userRoot, 'broken-yaml'), { recursive: true }) + await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n') + await mkdir(join(userRoot, 'ghost'), { recursive: true }) + await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n') + + // The section reads the roster when it mounts; hop away and back. + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '通用设置' }).click() + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + 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('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: '复制: 幽灵预设' }).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. + expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0) + + // The ghost's way out is the card's own delete — and the id it blocked + // is claimable again immediately afterwards. + await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click() + const confirm = page.getByRole('dialog', { name: '删除该预设?' }) + await confirm.waitFor({ timeout: 10_000 }) + await confirm.getByRole('button', { name: '删除', exact: true }).click() + await confirm.waitFor({ state: 'detached', timeout: 10_000 }) + await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0) + expect(existsSync(join(userRoot, 'ghost'))).toBe(false) + + await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) + await copyDialog.waitFor({ timeout: 10_000 }) + await copyDialog.getByPlaceholder('my-agent').fill('ghost') + await copyDialog.getByRole('button', { name: '创建' }).click() + await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) + await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 }) + + // Leave the roster as the earlier tests shaped it. + await dialog.getByRole('button', { name: '删除: ghost' }).click() + const cleanup = page.getByRole('dialog', { name: '删除该预设?' }) + await cleanup.waitFor({ timeout: 10_000 }) + await cleanup.getByRole('button', { name: '删除', exact: true }).click() + await cleanup.waitFor({ state: 'detached', timeout: 10_000 }) + await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true }) + }, 60_000) + + it('starts a creator-mode session from the section', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator')) + // Without a workspace the flow only stages (there is no session to land + // in until one is connected); connect first so the gesture carries all + // the way to a composed host session. + await settingsDialog().getByRole('button', { name: '关闭' }).last().click() + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = settingsDialog() + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click() + + // Leaving settings is part of the gesture: the flow lands on the + // new-session screen with the self-referential preset staged, and the + // blank session the flow produces composes from it on the host. + await dialog.waitFor({ state: 'detached', timeout: 10_000 }) + await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) + await expect.poll(async () => { + const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { sessions: unknown[] } } + } + return JSON.stringify(body.result.value?.sessions ?? body.result) + }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') + }, 60_000) + + it('drove every surface without a page error or a stream warning', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml new file mode 100644 index 0000000000..6644752bc2 --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -0,0 +1,12 @@ +# The authoring lane drives the location affordance. A real desktop open +# would pop a file manager on the machine running the tests and the +# capability itself is platform-detected (macOS yes, headless Linux CI no), +# so the gateway is pinned headless: `hasDocument` is false everywhere and +# `openDocument` answers the directory as text — the same branch on every +# host, and the one whose rendering a golden can hold. A patch replaces the +# row's complete config, so the shipped routing defaults ride along. +- id: api-gateway + config: + provider: deepseek-official + model: deepseek-v4-flash + nativeOpen: false diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts new file mode 100644 index 0000000000..69672f49e1 --- /dev/null +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -0,0 +1,153 @@ +// Web e2e scenario: agent-preset selection. The roster's `roots` is an +// assembly fact the CLI entry resolves and patches in, so every other lane +// boots with an empty roster and no preset surface at all; this is the one +// lane that mounts the SHIPPED presets and puts them in front of a browser. +// +// Two surfaces, one host rule: a session's composition is fixed when the +// session starts. Before that, the new-session chip stages the choice beside +// the workspace picker — the only screen where it still works. After it, the +// session header names what the session runs and offers no control at all, +// because the host answers `agent-preset-locked` to anything else. +// +// Zero model calls: no replay fixture mounts, so a stray stream fails loud. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', import.meta.url)) +const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') +const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') +/** The shipped roster, beside the composition that names it. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'agent-preset-selection-web-e2e' + +/** + * A settled one-turn session with no model content: this lane asserts chrome + * around a conversation, not a conversation, and a recorded turn would tie + * the golden to a provider's wording for no gain. + * @returns a tokenized session log ending on a closed turn. + */ +function seedLog(): string { + const time = 1784974100000 + const at = (index: number, event: Record): string => + JSON.stringify({ ...event, seq: index, time: time + index }) + return [ + JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }), + at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }), + at(1, { + type: 'user/message', + data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } }, + surfaceOp: 'append', + }), + at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }), + at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), + ].join('\n') +} + +describe('web e2e: agent-preset selection', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' }, + }) + // A resumed session runs what it was created with; seeding one that + // records `minimal` is what makes the header label a claim about the + // session rather than an echo of the current default. + await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers the chip on the new-session screen, beside the workspace picker', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero')) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + + const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) + // The chip opens on the deployment default, by the name that preset + // publishes rather than its directory name. + expect(snapshot).toContain('标准模式') + }) + + it('names every preset and what it is for', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu')) + await page.getByRole('button', { name: '标准模式' }).click() + const menu = page.getByRole('menu') + await menu.waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + // Every shipped preset, each with the sentence saying what it composes — + // the id alone never said what a preset does. + expect(snapshot).toContain('极简模式') + expect(snapshot).toContain('创造模式') + await page.keyboard.press('Escape') + }) + + it('applies the staged pick to the blank session, and the host honors it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage')) + await page.getByRole('button', { name: '标准模式' }).click() + await page.getByRole('menuitem', { name: /极简模式/ }).click() + + // The chip stages; the blank session the workspace connect produced is + // what the stage lands on. The host's own answer is what comes back. + await expect.poll(async () => { + const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } } + } + return JSON.stringify(body.result.value?.sessions ?? body.result) + }, { timeout: 15_000 }).toContain('minimal') + }) + + it('labels a resumed session with the preset it was created under', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header')) + // The seeded session's cwd is the scaffold root rather than the connected + // workspace, so it lists under Ungrouped; the group collapses by default. + await page.getByRole('treeitem', { name: /^Ungrouped/ }).click() + await page.locator('[role="treeitem"]').last().click() + await page.getByText('Seeded turn.').waitFor({ timeout: 15_000 }) + + const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('极简模式') + // Static chrome, not a control: the header can only report a composition + // the host would refuse to change. + expect(snapshot).not.toContain('button "极简模式"') + }) + + it('drove every surface without a page error or a stream warning', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index 6e14eebfa5..c504913b9b 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-skill' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' async function writeSkill(root: string, name: string): Promise { @@ -37,10 +39,25 @@ it('isolates replay skill discovery from every ambient host root', async () => { let scaffold: WebScaffold | undefined try { scaffold = await launchWebScaffold() - const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) - expect(names).not.toContain('ambient-dsh') - expect(names).not.toContain('ambient-agents') - expect(names).not.toContain('ambient-bundled') + const ctx = scaffold.ctx + // Local skill discovery belongs to the agent's preset LAYER of the host + // registry, so the roots under test are only reachable through a composed + // agent's view — the same scope the gateway's `skill.list` resolves for a + // browser request about a session. + const handle = await ctx.agents.create({ + sessionId: SessionId('hermetic-skills'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const skills = ctx.get('skills') + if (skills === undefined) throw new Error('the composition mounts no skill registry') + const names = (await skills.list({ cwd: scaffold.workspaceCwd, scope: handle.agent })).map(skill => skill.name) + expect(names).not.toContain('ambient-dsh') + expect(names).not.toContain('ambient-agents') + expect(names).not.toContain('ambient-bundled') + } finally { + await handle.dispose() + } } finally { try { await scaffold?.close() diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 4a922b9353..0c5524fc42 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -32,6 +32,7 @@ import { expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import Group from '@cordisjs/plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { addHarnessSourceSection, @@ -85,6 +86,8 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the profile module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +/** The deployment's own agent-preset root, shipped beside the app's config. */ +const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -226,6 +229,20 @@ export interface LaunchOptions { /** Credential reference resolved by the shipped search provider. */ apiKeyEnv: string } + /** + * Replace the roster the scaffold mounts by default (the shipped directory + * at `system` trust, default `standard`). Supply this only to change WHICH + * presets a scenario sees — a writable user root, a different default — + * never to turn the roster on: without one every session composes an agent + * with no tools, no persona, and no token meter, which is not a shape the + * product ever boots in. The patch lands after the default, so it wins. + */ + agentPresets?: { + /** Roots to discover, in precedence order; the shipped directory is `system`. */ + roots: { path: string; trust: 'system' | 'user' }[] + /** The preset a session that names none is composed from. */ + default: string + } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean /** @@ -281,6 +298,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise [key, process.env[key]]), + ) + let skillRootEnvironmentRestored = false + const restoreSkillRootEnvironment = (): void => { + if (skillRootEnvironmentRestored) return + skillRootEnvironmentRestored = true + for (const [key, value] of Object.entries(originalSkillRootEnvironment)) { + if (value === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = value + } + } + Object.assign(process.env, skillRootEnvironment) let persistenceRoot: string try { persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) @@ -310,6 +352,18 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0) { throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete') } @@ -496,6 +559,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0) throw new AggregateError(failures, 'web scaffold teardown failed') }, @@ -562,6 +626,8 @@ export function fixtureUserPrompts(fixtureText: string): string[] { * @param scaffold - the target scaffold. * @param fixtureText - raw recorded session.jsonl contents. * @param id - the seeded session id (stable for deterministic goldens). + * @param agentPreset - the preset the recorded session was composed from, + * for scenarios asserting what a resumed session reports running. * @returns the seeded id. */ /** @@ -585,7 +651,12 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i : realized.split(fixtureCwd).join(scaffold.workspaceCwd) } -export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise { +export async function seedSession( + scaffold: WebScaffold, + fixtureText: string, + id: string, + agentPreset?: string, +): Promise { const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id)) if (events.length === 0) throw new Error('seed fixture has no events') const last = events[events.length - 1]! @@ -598,6 +669,7 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id createdAt: Date.now() - 60_000, cwd: scaffold.workspaceCwd, delegationDepth: 0, + ...agentPreset === undefined ? {} : { agentPreset }, } const seeder = new Context() try { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 14de0e8d8f..d7080a5ed7 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -19,6 +19,7 @@ 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,10 +196,22 @@ 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]) - const meter = scaffold.ctx.get('tokenMeter') - if (meter === undefined) throw new Error('seeded-history requires the composed token meter') - const realized = realizeSeedFixture(scaffold, raw, SEED_ID) - await seedSession(scaffold, withCompaction(realized, meter), SEED_ID) + // 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() + } + await seedSession(scaffold, realizedWithCompaction, SEED_ID) } browser = await chromium.launch() page = await newEnglishPage(browser) @@ -245,10 +258,15 @@ describe('web e2e: seeded history renders through cold resume', () => { const projections = body.result.value?.projections expect(projections).toBeDefined() expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0) - // The seed carries a session/title event: the title unit must serve it. + // The seed carries a session/title event: the title unit is host-plane, so + // it folds the detached log and serves the value with nothing composed. expect(typeof projections?.values.title).toBe('string') - // tool-todo is composed but the seed has no todo/write: whole-value null, - // key PRESENT (absence would mean the unit never registered). + // `todos` IS here, as its empty fold (null). Its unit is registered by + // `tool-todo` inside the default preset's STANDING mount, which the read + // itself ensures — deterministically, not because some unrelated session + // happens to be composed. A present-but-null key is what keeps the + // client's "omitted key = capability absent → clear the row" rule from + // wiping preset-owned projections on cold reads. expect(projections?.values).toHaveProperty('todos', null) }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 11bbdcfd82..5d929044b3 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -12,6 +12,7 @@ import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-permission' +import type {} from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-commands' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' @@ -66,11 +67,26 @@ afterEach(async () => { it('assembles the shipped Web catalog with the confined access default', async () => { scaffold = await launchWebScaffold() - const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() - expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) - // The packaged ripgrep binary ships with the dependency, so the pair is a - // fixed roster member on every host. - expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) + const ctx = scaffold.ctx + // The catalog belongs to an AGENT, not to the process: every model-facing row + // now lives in a preset mounted under one session's scope, so the global + // layer holds nothing and a caller must name the agent to see anything. This + // composes from the deployment default — what a session that names no preset + // gets — which is the shape this test has always been about. + expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([]) + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-composition'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort() + expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) + // The packaged ripgrep binary ships with the dependency, so the pair is a + // fixed roster member on every host. + expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) + } finally { + await handle.dispose() + } // `workspace-write` is not "the workspace and nothing else": the shared roots // helper always admits the temp directories too. Pinning it against an // explicit mode keeps the claim independent of this surface's default, and @@ -83,18 +99,18 @@ it('assembles the shipped Web catalog with the confined access default', async ( expect(scaffold.ctx.approval.config.policy).toBe('ask') expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write') - const handle = await scaffold.ctx.agents.create({ + const commandHandle = await scaffold.ctx.agents.create({ sessionId: SessionId('shipped-command-catalog'), meta: { cwd: scaffold.workspaceCwd }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, }) try { - expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({ + expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({ name: 'feedback', description: 'record feedback about this session', input: { hint: '' }, }) } finally { - await handle.dispose() + await commandHandle.dispose() } }, 120_000) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md new file mode 100644 index 0000000000..dc4045c5cc --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md @@ -0,0 +1,14 @@ +- dialog "复制预设 · 复制自 极简模式": + - heading "复制预设 · 复制自 极简模式" [level=2] + - button "关闭": + - img + - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 + - text: 标识符 + - textbox "标识符": + - /placeholder: my-agent + - text: 名称 + - textbox "名称": + - /placeholder: 选择器中显示的名字,缺省用标识符 + - alert: 请填写标识符。 + - button "取消" + - button "创建" [disabled] diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md new file mode 100644 index 0000000000..e5cefe28ef --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -0,0 +1,81 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - heading "自定义" [level=3] + - list: + - listitem: + - 'button "设为默认: 我的模式"': + - text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: my-agent + - 'button "查看路径: 我的模式"': + - img + - text: 查看路径 + - 'button "复制: 我的模式"': + - img + - text: 复制 + - 'button "删除: 我的模式"': + - img + - text: 删除 + - paragraph: + - text: 预设文件: + - code: {{presetRoot}}/my-agent + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md new file mode 100644 index 0000000000..8269dc2993 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -0,0 +1,93 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - heading "自定义" [level=3] + - list: + - listitem: + - '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"': + - img + - text: 查看路径 + - 'button "复制: broken-yaml" [disabled]': + - img + - text: 预设已损坏,无法复制 + - 'button "删除: broken-yaml"': + - img + - text: 删除 + - listitem: + - '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 "查看路径: 幽灵预设"': + - img + - text: 查看路径 + - 'button "复制: 幽灵预设" [disabled]': + - img + - text: 预设已损坏,无法复制 + - 'button "删除: 幽灵预设"': + - img + - text: 删除 + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md new file mode 100644 index 0000000000..ac5d6f6736 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -0,0 +1,63 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md new file mode 100644 index 0000000000..ef2ad4ef57 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -0,0 +1,4 @@ +- navigation "Session hierarchy": + - button "Seeded turn" [disabled] +- img +- text: 极简模式 diff --git a/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md new file mode 100644 index 0000000000..f2d54eb579 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md @@ -0,0 +1,8 @@ +- button "Choose workspace": + - img + - text: workspace + - img +- button "标准模式": + - img + - text: 标准模式 + - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md new file mode 100644 index 0000000000..fd92ab8b5a --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -0,0 +1,7 @@ +- menu: + - menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。": + - text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - img + - menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。" + - menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。" + - menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。" diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 99b6bac89b..f426539e87 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - 'button "Using ONE run_code program: run" [disabled]' + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 72d0a79756..2bc6f76a93 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 92183ee6ea..0529e000b7 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the bash tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index b1c0cb52ca..fa178e30d8 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "workspace" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index dfa23ca508..223006d59d 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -25,6 +25,10 @@ - img - text: workspace - img +- button "标准模式": + - img + - text: 标准模式 + - img - textbox "Describe what you want to build" - button "Commands": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 4ccab18ac7..a234028a16 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -25,6 +25,10 @@ - img - text: workspace - img +- button "标准模式": + - img + - text: 标准模式 + - img - textbox "Describe what you want to build" - button "Commands": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index bf32465f2b..149d8ce3f9 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with the single word" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 01a8343313..1d87f01525 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index f75432e2e4..94d739baf1 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index 6c36405064..c461dd985a 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index a281ca26b2..70a9e69e95 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 036630c2d5..02746c7f76 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index df47e186c3..857bfaf13e 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index ab0a25b780..03169f8e72 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 931caf0acb..e50c347966 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c1cae54bb5..0ba0ebe1da 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - 'button "Plan a small change: add" [disabled]' + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index a524a02e23..32e3e4bc75 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 18b40d976a..30d585b271 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 7dc4f38f86..e55791787d 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 7370a15264..fdd94470b6 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "workspace" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index e1b1cf9084..a845590873 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 0d9ae5fcf3..46079d1faf 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index f358ff26f5..cf87f5acbd 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,10 +7,17 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img - text: 关闭 + - text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的预设。 + - button "标准模式": + - text: 标准模式 + - img - text: 权限 选择新会话的默认权限模式 - button "Workspace Write": - text: Workspace Write diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index c77081584a..63bd2ef401 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "/user-invoke-demo and confirm the fixtur" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5f3f24f709..0100e28b9a 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d598613fa3..7479c3a8c0 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md index 0dd1189e3c..b1a32406de 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 828350b846..32ea9a9b1e 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 0281d242f4..b24385d48f 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use web_search to search exactly" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 606f257e56..da25f0cc59 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,8 @@ "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", + "tests/agent-preset-selection.e2e.ts", + "tests/agent-preset-authoring.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index fe32a219de..911b25d0cc 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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/architecture.md -architecture.md: 771d7489ee338db56362a6ccc133b1ebf8cdc7c0 -architecture.zh.md: ca7c4fe2a463e01a8e14e63a53f13aea45cbfa16 +architecture.md: 90d64fb0ac62020e13a7ce995c8e3273a5a9f906 +architecture.zh.md: fec9a00484c495b0eed4773f262bb44543e304bd diff --git a/docs/architecture.md b/docs/architecture.md index 771d7489ee..90d64fb0ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -166,6 +166,10 @@ Exceptions combine LLM Service Definition/Consumer roles, filesystem policy, web `dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +### Agent Presets + +A deployment may compose each session's model-facing plugin set separately. An **agent preset** is a directory holding one `agent.cordis.yml`, mounted as an `include` subtree under that agent's scope during `setup(agentCtx)`, so its tool and prompt registrations file into that agent's layer and unwind with it — no new tier in the registries. The host composition keeps what must be shared: the registries themselves, cross-session facilities, the sandbox and approval stack, the model route. `ctx.agentPresets` owns discovery and the guarded mount, rejecting a row that never activates or that publishes into the root service realm. Details: [per-session agent presets](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md), [preset/](../packages/preset/README.md). + ### Where New Behavior Goes New behavior attaches to a documented extension point; a loop change updates this map. @@ -174,6 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register its adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas join prompt assembly | +| Give one session a different capability set | compose it in an agent preset; a service row there needs an `isolate` realm | | Add shell execution | implement and register a `ctx.bash` backend; the local backend spawns through `ctx.subprocess` | | Add persistent terminal execution | register a `ctx.pty` backend plus `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch without a model turn | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ca7c4fe2a4..fec9a00484 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -166,6 +166,10 @@ idle inject: `dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[interaction/](../packages/interaction/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 +### Agent Preset + +部署可为每个会话分别组装面向模型的插件集合。**agent preset** 是一个含 `agent.cordis.yml` 的目录,在 `setup(agentCtx)` 期间作为 `include` 子树挂到该 agent 的 scope 之下,其工具与提示词注册因而归档进该 agent 的分层并随之卸载,注册表无需新增层级。宿主组装保留必须共享的部分:注册表本身、跨会话设施、沙箱与审批栈、模型路由。`ctx.agentPresets` 负责发现与把关,拒绝未激活的行和把服务发布进根 realm 的行。详见 [按会话组装 agent preset](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)、[preset/](../packages/preset/README.md)。 + ### 新行为的归属位置 新行为附加到已有文档记录的扩展点;循环发生变更时,本架构图随之更新。 @@ -174,6 +178,7 @@ idle inject: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册其适配器 | | 添加面向模型的能力 | 在 `ctx.tools` 上注册;schema 加入提示词组装 | +| 让某个会话拥有不同的能力集合 | 在 agent preset 中组装它;其中的 service 行需要 `isolate` realm | | 添加 shell 执行 | 实现并注册 `ctx.bash` 后端;本地后端通过 `ctx.subprocess` spawn 进程 | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index aa09cb3036..05186f3e56 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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/capability-seams.md -capability-seams.md: af9f8ba48e67074a485019a4ad9dddd08b2faf81 -capability-seams.zh.md: 7dff963646991d8b1f763ed789109e93cce2ddb8 +capability-seams.md: 05788eb8044f91e31c82dc4b78af0421e2b11030 +capability-seams.zh.md: ae182cfeeb1d7461122d791eedb818160a772e9b diff --git a/docs/capability-seams.md b/docs/capability-seams.md index af9f8ba48e..05788eb804 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -81,6 +81,8 @@ flowchart LR svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode
Plan collaboration state"] + pkg_agent_presets["agent-presets"] + svc_agentPresets["ctx.agentPresets
Per-session agent composition"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] pkg_session_projection["session-projection"] @@ -181,6 +183,7 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop + pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval pkg_bash --> svc_bash @@ -398,6 +401,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/self-modification/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | +| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7dff963646..ae182cfeeb 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -83,6 +83,8 @@ flowchart LR svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode
Plan collaboration state"] + pkg_agent_presets["agent-presets"] + svc_agentPresets["ctx.agentPresets
Per-session agent composition"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] pkg_session_projection["session-projection"] @@ -183,6 +185,7 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop + pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval pkg_bash --> svc_bash @@ -400,6 +403,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop)、[`tool-ask-user`](../packages/interaction/tool-ask-user)、[`tool-bash`](../packages/bash/tool-bash)、[`tool-cordis`](../packages/self-modification/tool-cordis)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-skill`](../packages/skill/tool-skill)、[`tool-subagent`](../packages/subagent/tool-subagent)、[`tool-todo`](../packages/todo/tool-todo)、[`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 Code Mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 入口提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | +| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo)、[`session-title`](../packages/session/session-title)、[`host-apiproxy`](../packages/host/apiproxy) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,api-proxy 提供基线并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 80ac35ca4e..8743978d1e 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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: 10f0761fc5aa69852dff06f340f83f5a916975a9 -config-catalog.zh.md: ec0e44e9d39b801a5987f2bdab2584370c1b9333 +config-catalog.md: 8950c5bb1a72b5e06954d44f9ea3d8f37ec9f0e1 +config-catalog.zh.md: f0a4cedb44290ef1ecef3bff59538596235bcd96 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 10f0761fc5..8950c5bb1a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -124,6 +124,37 @@ Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/cor Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) +## `@deepseek-ai/dsh-agent-presets` + +Requires: `loader` + +```ts config-catalog +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' +``` + +Source: [`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts) + ## `@deepseek-ai/dsh-agent-spine-demo` ```ts config-catalog @@ -208,6 +239,28 @@ Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfi Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +## `@deepseek-ai/dsh-agent-tool-mode` + +Requires: `tools` + +```ts config-catalog +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a preset + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} +``` + +Depends on: [`ToolPresentationMode`](subsystems/tools.md) + +Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) + ## `@deepseek-ai/dsh-bash-env` ```ts config-catalog @@ -571,6 +624,14 @@ Requires: `agentDefaultModel` · `agents` · `directoryPicker` · `llm` · `sess export interface Config { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } ``` @@ -1050,6 +1111,24 @@ Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsys Source: [`packages/interaction/permission/src/index.ts:140`](../packages/interaction/permission/src/index.ts) +## `@deepseek-ai/dsh-persona` + +Requires: `systemPrompt` + +```ts config-catalog +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} +``` + +Source: [`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/src/index.ts) + ## `@deepseek-ai/dsh-plan-mode` Requires: `tools` · `systemPrompt` @@ -1514,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:266`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:279`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1867,7 +1946,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:166`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2303,11 +2382,16 @@ Requires: `systemPrompt` /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a `ctx.codeRuntime` whose `language` has a registered - * SDK renderer (TypeScript or Python) and fail prompt assembly when it is - * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * `ctx.codeRuntime` whose `language` has a registered SDK renderer + * (TypeScript or Python) and fail prompt assembly when it is absent or has + * no renderer. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** @@ -2581,6 +2665,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) @@ -2590,7 +2675,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ec0e44e9d3..f0a4cedb44 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -126,6 +126,37 @@ export interface Config { 来源:[`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) +## `@deepseek-ai/dsh-agent-presets` + +需要:`loader` + +```ts config-catalog +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' +``` + +来源:[`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts) + ## `@deepseek-ai/dsh-agent-spine-demo` ```ts config-catalog @@ -210,6 +241,28 @@ export interface GoalConfig { 来源:[`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +## `@deepseek-ai/dsh-agent-tool-mode` + +需要:`tools` + +```ts config-catalog +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a preset + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} +``` + +依赖:[`ToolPresentationMode`](subsystems/tools.md) + +来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) + ## `@deepseek-ai/dsh-bash-env` ```ts config-catalog @@ -573,6 +626,14 @@ export interface Config { export interface Config { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } ``` @@ -1052,6 +1113,24 @@ export interface PresetSpec { 来源:[`packages/interaction/permission/src/index.ts:140`](../packages/interaction/permission/src/index.ts) +## `@deepseek-ai/dsh-persona` + +需要:`systemPrompt` + +```ts config-catalog +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} +``` + +来源:[`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/src/index.ts) + ## `@deepseek-ai/dsh-plan-mode` 需要:`tools` · `systemPrompt` @@ -1516,7 +1595,7 @@ export interface Config { } ``` -来源:[`packages/skill/skill/src/index.ts:266`](../packages/skill/skill/src/index.ts) +来源:[`packages/skill/skill/src/index.ts:279`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1869,7 +1948,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:166`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2304,11 +2383,16 @@ export interface Config { /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a `ctx.codeRuntime` whose `language` has a registered - * SDK renderer (TypeScript or Python) and fail prompt assembly when it is - * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * `ctx.codeRuntime` whose `language` has a registered SDK renderer + * (TypeScript or Python) and fail prompt assembly when it is absent or has + * no renderer. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** @@ -2582,6 +2666,7 @@ export interface Config { - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `httpServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command`([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-deliverables`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) @@ -2591,7 +2676,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-models`([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-question` — 需要 `tools` · `userInteraction`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0a3d6007f1..20fa4fa5e5 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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: 11eecf81a4eccadf2b97026154a78e4ed8a72164 -event-producer-consumer.zh.md: 2db5e596465b4adaf98c1b05692b61de3ced47b9 +event-producer-consumer.md: 3b8a6b1dd155fd1350b164f1dd2d2bf0ec26a4a5 +event-producer-consumer.zh.md: 12de167fcd1217f00a8ae719ef3191a4873a2799 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 11eecf81a4..3b8a6b1dd1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../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:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2db5e59646..12de167fcd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -38,7 +38,7 @@ | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../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:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -68,7 +68,7 @@ | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets)、`gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 61e55cd997..3f45cabcdb 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 -module-graph.zh.md: 28185c255ffa18f3ebc02f177d20594af3356164 +module-graph.md: e182855f785ba77210d455f7c538596a2eddc784 +module-graph.zh.md: b65d1a4b35d0a593291423171ec9cbca611c5f04 diff --git a/docs/module-graph.md b/docs/module-graph.md index a248ed4fcb..e182855f78 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -27,6 +27,7 @@ flowchart TD pkg_agent["agent"] pkg_agent_default_model["agent-default-model"] pkg_agent_loop["agent-loop"] + pkg_agent_tool_mode["agent-tool-mode"] pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] @@ -141,6 +142,7 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] + pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -221,6 +223,10 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_preset["packages/preset"] + pkg_agent_presets["agent-presets"] + pkg_persona["persona"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -311,7 +317,6 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_e2b --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants @@ -379,6 +384,7 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_skill --> pkg_invariants pkg_skill --> pkg_llm + pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -388,11 +394,6 @@ flowchart TD pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots pkg_client_locale --> pkg_invariants - 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_models --> pkg_client_connection pkg_client_ui_models --> pkg_client_runtime pkg_client_ui_models --> pkg_client_schema_form @@ -489,6 +490,14 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + 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_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -561,6 +570,8 @@ flowchart TD pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs pkg_fs_e2b --> pkg_invariants + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -689,6 +700,11 @@ 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_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -734,6 +750,8 @@ flowchart TD pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools + pkg_agent_tool_mode --> pkg_invariants + pkg_agent_tool_mode --> pkg_tools pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_invariants @@ -1058,6 +1076,15 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_runtime + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_primitives + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_slots + pkg_client_ui_agent_preset --> pkg_client_web_react + pkg_client_ui_agent_preset --> pkg_invariants pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -1204,7 +1231,6 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | @@ -1231,11 +1257,10 @@ flowchart TD | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`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-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | @@ -1260,6 +1285,8 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`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) | +| [`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-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1279,6 +1306,7 @@ flowchart TD | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`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) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`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) | | [`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) | @@ -1306,6 +1334,7 @@ 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) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`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) | | [`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) | @@ -1314,6 +1343,7 @@ flowchart TD | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1367,6 +1397,7 @@ flowchart TD | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 28185c255f..b65d1a4b35 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -29,6 +29,7 @@ flowchart TD pkg_agent["agent"] pkg_agent_default_model["agent-default-model"] pkg_agent_loop["agent-loop"] + pkg_agent_tool_mode["agent-tool-mode"] pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] @@ -143,6 +144,7 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] + pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -223,6 +225,10 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_preset["packages/preset"] + pkg_agent_presets["agent-presets"] + pkg_persona["persona"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -313,7 +319,6 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_e2b --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants @@ -381,6 +386,7 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_skill --> pkg_invariants pkg_skill --> pkg_llm + pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -390,11 +396,6 @@ flowchart TD pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots pkg_client_locale --> pkg_invariants - 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_models --> pkg_client_connection pkg_client_ui_models --> pkg_client_runtime pkg_client_ui_models --> pkg_client_schema_form @@ -491,6 +492,14 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + 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_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -563,6 +572,8 @@ flowchart TD pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs pkg_fs_e2b --> pkg_invariants + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -691,6 +702,11 @@ 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_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -736,6 +752,8 @@ flowchart TD pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools + pkg_agent_tool_mode --> pkg_invariants + pkg_agent_tool_mode --> pkg_tools pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_invariants @@ -1060,6 +1078,15 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_runtime + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_primitives + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_slots + pkg_client_ui_agent_preset --> pkg_client_web_react + pkg_client_ui_agent_preset --> pkg_invariants pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -1184,7 +1211,7 @@ flowchart TD pkg_acp_demo --> pkg_workspace_context ``` -| 包 | 分组 | 依赖项 | +| Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | @@ -1206,7 +1233,6 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | @@ -1233,11 +1259,10 @@ flowchart TD | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`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-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | @@ -1262,6 +1287,8 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`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) | +| [`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-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1281,6 +1308,7 @@ flowchart TD | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`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) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`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) | | [`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) | @@ -1308,6 +1336,7 @@ 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) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`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) | | [`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) | @@ -1316,6 +1345,7 @@ flowchart TD | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1369,6 +1399,7 @@ flowchart TD | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e5dd4edde7..7ca14e31fb 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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/persistence-catalog.md -persistence-catalog.md: a17cae015eaa107a900069de916dddb216b87ec7 -persistence-catalog.zh.md: 3aef073dedcff0b6addb99d7c287f4e5f372c402 +persistence-catalog.md: 9953214182521ac2c1aac8b4589bad7ad45e3094 +persistence-catalog.zh.md: 730513ea259dde274c8c63948dd21fdc0b70417f diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a17cae015e..9953214182 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) ## Events @@ -104,6 +104,22 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +### `agent-preset/*` + +#### `agent-preset/selected` — log-only + +```ts persistence-catalog +/** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ +'agent-preset/selected': { agentPreset: string } +``` + +Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts) + ### `approval/*` #### `approval/asked` — log-only @@ -176,7 +192,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -192,7 +208,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) ### `command/*` @@ -472,7 +488,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -484,7 +500,7 @@ Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -537,7 +553,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -573,7 +589,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -582,7 +598,7 @@ Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -612,7 +628,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `tool/*` @@ -629,7 +645,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -698,7 +714,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `turn/*` @@ -718,7 +734,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -732,7 +748,7 @@ Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) ### `user/*` @@ -749,7 +765,7 @@ Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 3aef073ded..730513ea25 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -81,7 +81,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) ## 事件 @@ -106,6 +106,22 @@ export type SessionEvent = { 来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +### `agent-preset/*` + +#### `agent-preset/selected` — log-only + +```ts persistence-catalog +/** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ +'agent-preset/selected': { agentPreset: string } +``` + +来源:[`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts) + ### `approval/*` #### `approval/asked` — log-only @@ -178,7 +194,7 @@ export type SessionEvent = { 类型:[StreamChunk](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -194,7 +210,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) ### `command/*` @@ -474,7 +490,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -486,7 +502,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -539,7 +555,7 @@ export type SessionEvent = { 'session/end-seed': Record ``` -来源:[`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -575,7 +591,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -584,7 +600,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -614,7 +630,7 @@ export type SessionEvent = { 类型:[TodoItem](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `tool/*` @@ -631,7 +647,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.md) -来源:[`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -700,7 +716,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `turn/*` @@ -720,7 +736,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -734,7 +750,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) ### `user/*` @@ -751,7 +767,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 70ceac3b11..e540ed7d90 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -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: 27c4359e360223d336cd94695bb45a79f0fd370c -core.zh.md: 4e7519665b6d9efb8075546d93325debd961905d +core.md: 9a1fa827a7e0168e662bc4595a3c0fc486be8d49 +core.zh.md: e51f8b61fb6ed0c7a6e2de377f8f93877f972831 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 27c4359e36..9a1fa827a7 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -377,6 +377,138 @@ Types: [SessionHeader](persistence.md) Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-loop/src/index.ts) + + +### `ctx.agentPresets` — `AgentPresets` + +Registry over the deployment's agent presets. + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. + +```ts cordis-catalog +/** + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. + */ +async list(): Promise + +/** + * Resolve one preset by id. + * + * A broken preset resolves — deleting one, reading one, and reporting one + * all need the row — and the mounting paths refuse it AFTER resolution + * through {@link resolveMountable}. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. + * @throws when no configured root supplies that id. + */ +async resolve(id?: string): Promise + +/** + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken preset never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was composed, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ +async mount(agentCtx: Context, id?: string): Promise + +/** + * Read one preset's composition text. + * @param id - the preset id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ +async read(id: string): Promise + +/** + * Create a locally authored preset by copying an existing one whole. + * + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. + */ +async copy(from: string, id: string, name?: string): Promise + +/** + * Delete a locally authored preset. + * @param id - the preset id. + * @throws when the preset is unknown or ships with the deployment. + */ +async remove(id: string): Promise + +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined + +/** + * Re-link one agent to a different preset's standing composition. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. + * + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable. + */ +async recompose(agentCtx: Context, id: string): Promise + +/** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ +async standingKeyFor(id?: string): Promise +``` + +Types: [ScopeKey](scope.md) + +Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) + ### `ctx.agents` — `AgentRegistry` @@ -547,7 +679,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:255`](../../packages/core/agent/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 4e7519665b..e51f8b61fb 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -385,6 +385,138 @@ Types: [SessionHeader](persistence.md) Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-loop/src/index.ts) + + +### `ctx.agentPresets` — `AgentPresets` + +Registry over the deployment's agent presets. + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. + +```ts cordis-catalog +/** + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. + */ +async list(): Promise + +/** + * Resolve one preset by id. + * + * A broken preset resolves — deleting one, reading one, and reporting one + * all need the row — and the mounting paths refuse it AFTER resolution + * through {@link resolveMountable}. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. + * @throws when no configured root supplies that id. + */ +async resolve(id?: string): Promise + +/** + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken preset never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was composed, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ +async mount(agentCtx: Context, id?: string): Promise + +/** + * Read one preset's composition text. + * @param id - the preset id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ +async read(id: string): Promise + +/** + * Create a locally authored preset by copying an existing one whole. + * + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. + */ +async copy(from: string, id: string, name?: string): Promise + +/** + * Delete a locally authored preset. + * @param id - the preset id. + * @throws when the preset is unknown or ships with the deployment. + */ +async remove(id: string): Promise + +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined + +/** + * Re-link one agent to a different preset's standing composition. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. + * + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable. + */ +async recompose(agentCtx: Context, id: string): Promise + +/** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ +async standingKeyFor(id?: string): Promise +``` + +Types: [ScopeKey](scope.md) + +Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) + ### `ctx.agents` — `AgentRegistry` @@ -555,7 +687,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:255`](../../packages/core/agent/src/index.ts) diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index f198d0a25e..9e0a373532 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -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/persistence.md -persistence.md: 640e2c0122b01ae869d20c1805742abad782c95b -persistence.zh.md: 7e28d6d1f63a78741840fb74194d3249696423ae +persistence.md: 8f6872b77be8c7ae273e0fc1887dca30dbe1eb37 +persistence.zh.md: 25e72a69bd03cd5cc0715ae769055062466397dd diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 640e2c0122..8f6872b77b 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -77,12 +77,19 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 7e28d6d1f6..25e72a69bd 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -77,12 +77,19 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 823ed61107..e3e42cdc9e 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -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/session-projection.md -session-projection.md: d91a3faa50dc092d89aa2c7d5ce1e6118df7ebd6 -session-projection.zh.md: 7dac3db8470a2941b711db6a30fced8dbe7c7a8d +session-projection.md: 4cbe0babb22406f7a48f0c19e982bb4757b4f44d +session-projection.zh.md: 5eada67a6eed914021e284fc5eabf203125b4b83 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index d91a3faa50..4cbe0babb2 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -154,7 +154,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:156`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 7dac3db847..5eada67a6e 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -154,7 +154,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:156`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 3b9f9ce01d..e9d7a2f936 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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/session.md -session.md: f5b9e63e2320885cc41b30a09398dd341700152d -session.zh.md: 985e0a448d1cf860ccbb0f2885d855ad6830af9f +session.md: 6fb0cec4fd222ceafbd5b4111fe56f22505058ad +session.zh.md: d33a71e92e2bd9fd9fb7e9194255b7c1f5f0af77 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f5b9e63e23..6fb0cec4fd 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -733,7 +733,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 985e0a448d..d33a71e92e 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -737,7 +737,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index a169374140..6f089b7ff4 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.i18n.yaml @@ -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/skills.md -skills.md: 696f759179203230bf748c8a4be2cee333acd9a3 -skills.zh.md: d2244e86f0ceaf77c1eab02508dc3f07df727dbb +skills.md: f222da732ba3800a214e236bb7a64d5709067cdb +skills.zh.md: f20c68596f5350ac33c2956dfb124ae6c8972882 diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 696f759179..f222da732b 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -2,7 +2,7 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service provider ([dsh-skill-local](../../packages/skill/skill-local)), the optional packaged badge provider ([dsh-skill-badge](../../packages/skill/skill-badge)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service provider ([dsh-skill-local](../../packages/skill/skill-local)), the optional packaged badge provider ([dsh-skill-badge](../../packages/skill/skill-badge)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). @@ -10,7 +10,9 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind `ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. -Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation, while an explicit incomplete observation contributes usable candidates without making the result cacheable; malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries once when its provider generation changes; a second change returns the latest candidates incomplete and uncached. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. +The registry is host+per-scope layered, the shape the [tools registry](tools.md) established over [dsh-scope](../../packages/core/scope): a registration files into the layer of its calling context's scope, so host rows and repository plugins land in the global layer while a plugin mounted by an agent preset's standing composition lands in that preset's layer, and provider names are unique per layer rather than process-wide. A read merges the global layer with the viewing scope's chain — the nearest layer's entry wins a duplicate skill name outright, and the rank order below decides duplicates only within one layer. Discovery caches are keyed by the resolved scope chain, so re-parenting a scope (a blank-session recompose) is visible to the next read without a registry mutation. + +Within one layer, duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation, while an explicit incomplete observation contributes usable candidates without making the result cacheable; malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries once when its provider generation changes; a second change returns the latest candidates incomplete and uncached. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. An array returned by `SkillProvider.list()` is complete-discovery shorthand. `SkillProviderObservation` lets a provider expose candidates that remain directly loadable while reporting that the observation is not authoritative. @@ -187,7 +189,7 @@ type SkillRegistration = Omit & { ## Lookup and configuration -Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. +Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Registry reads additionally take the viewing scope — consumers pass the calling agent, which is its own scope key — through `SkillViewOptions`; the registry consumes `scope` for layer selection, and providers read only their `SkillLookupOptions` contract from the same borrowed options object. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. Full definitions are not cached by the registry. Each `get()` calls the winning provider with the selected candidate, so the local provider rereads the current body. A definition whose name no longer matches that candidate is rejected and invalidates the exact provider for rediscovery. @@ -201,6 +203,19 @@ interface SkillLookupOptions { } ``` +```ts type-equiv +/** + * Registry read options: provider lookup context plus the viewing scope. + * The registry consumes `scope` to select layers; providers receive the same + * borrowed options object and read only their {@link SkillLookupOptions} + * contract from it. + */ +interface SkillViewOptions extends SkillLookupOptions { + /** Viewing scope (the calling agent); omitted reads the global layer alone. */ + readonly scope?: ScopeKey | undefined +} +``` + The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`) plus watcher enablement, polling, stability, symlink, and project-capacity controls. The consumer owns its catalog description bound. Exact defaults and validation are in the generated [config catalog](../config-catalog.md). ```ts type-equiv @@ -231,13 +246,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.skills` — `SkillService` -Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. +Layered registry of skill providers, the host+per-scope shape the tools registry established. A registration files into the layer of its calling context's scope (scopeOf): host rows and repository plugins land in the global layer, while a plugin mounted by an agent preset's standing composition lands in that preset's layer. A read merges the global layer with the viewing scope's chain — the nearest layer's entry wins a duplicate name outright, and the rank order decides duplicates only within one layer. It exposes sorted invocation-neutral summaries and loads full skill bodies on demand. ```ts cordis-catalog /** - * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and - * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters - * the provider and invalidates catalog caches. + * Register a borrowed same-process provider synchronously during plugin + * apply, into the calling context's layer: a scoped context (an agent + * preset's standing mount) registers for that scope alone, an unscoped + * context registers globally. Duplicate names within one layer and reserved + * names throw; remote initialization belongs in `list()`. Fiber disposal + * unregisters the provider and invalidates catalog caches. * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. @@ -245,9 +263,11 @@ Registry of skill providers. It merges provider catalogs with stable first-wins registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** - * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which - * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and - * receives a no-op disposer so it cannot remove the winner. + * Register a borrowed readonly runtime skill into the calling context's + * layer. Project entries outrank runtime entries, which outrank user + * entries, within one layer. Same-name runtime entries in one layer are + * first-wins; a duplicate logs a warning and receives a no-op disposer so + * it cannot remove the winner. * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ @@ -258,32 +278,33 @@ register(skill: SkillRegistration): () => void * model or user invocation policy at their operational boundary. Lookup * options and provider candidates are readonly same-process values borrowed * throughout discovery. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns all sorted winning summaries. */ -async list(options: SkillLookupOptions = {}): Promise +async list(options: SkillViewOptions = {}): Promise /** * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns sorted summaries plus discovery-completeness state. */ -async snapshot(options: SkillLookupOptions = {}): Promise +async snapshot(options: SkillViewOptions = {}): Promise /** * Load and validate the winning candidate, passing its opaque discovery locator back to the * provider. Cancellation is rechecked after selection, including cache hits, and raced against * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. - * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @param options - view options; `scope` selects the viewing agent's layers, + * `cwd` selects workspace-sensitive skills, and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ -async get(name: string, options: SkillLookupOptions = {}): Promise +async get(name: string, options: SkillViewOptions = {}): Promise ``` -Source: [`packages/skill/skill/src/index.ts:305`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:357`](../../packages/skill/skill/src/index.ts) @@ -306,5 +327,5 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:284`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:297`](../../packages/skill/skill/src/index.ts) diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index d2244e86f0..f20c68596f 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -2,7 +2,7 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service provider([dsh-skill-local](../../packages/skill/skill-local))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service provider([dsh-skill-local](../../packages/skill/skill-local))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 @@ -10,7 +10,9 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名项依次按 rank、提供方顺序和本地顺序确定优先级;摘要按名称排序。提供方的 `list()` 被拒绝时,系统会记录日志,并从不完整观测中省略该提供方的结果;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +注册表采用宿主 + 按 scope 的分层结构,即[工具注册表](tools.md)在 [dsh-scope](../../packages/core/scope) 之上确立的形态:注册会落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,由 agent preset 常驻组合挂载的插件落入该 preset 的层——提供方名称在每层内唯一,而非进程级唯一。读取时将全局层与观察 scope 的链合并:最近层的条目直接赢得重名 skill,下文的 rank 顺序只在单层内裁决重名。发现缓存以解析后的 scope 链为键,因此重设 scope 父级(空会话重组)无需注册表变更即可被下一次读取看到。 + +在单层内,重名项依次按 rank、提供方顺序和本地顺序确定优先级;摘要按名称排序。提供方的 `list()` 被拒绝时,系统会记录日志,并从不完整观测中省略该提供方的结果;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 `SkillProvider.list()` 返回的数组是完整发现的简写形式。`SkillProviderObservation` 允许提供方公开仍可直接加载的候选项,同时报告该观测不具权威性。 @@ -187,7 +189,7 @@ type SkillRegistration = Omit & { ## 查找与配置 -skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收用于缓存标识和加载的同一个只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 +skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。注册表读取还通过 `SkillViewOptions` 携带观察 scope——消费方传入调用中的 agent,agent 本身就是自己的 scope key;注册表消费 `scope` 做层选择,提供方只从同一个借用的选项对象中读取其 `SkillLookupOptions` 契约。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。 @@ -201,6 +203,19 @@ interface SkillLookupOptions { } ``` +```ts type-equiv +/** + * Registry read options: provider lookup context plus the viewing scope. + * The registry consumes `scope` to select layers; providers receive the same + * borrowed options object and read only their {@link SkillLookupOptions} + * contract from it. + */ +interface SkillViewOptions extends SkillLookupOptions { + /** Viewing scope (the calling agent); omitted reads the global layer alone. */ + readonly scope?: ScopeKey | undefined +} +``` + 注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`),以及 watcher 启用、轮询、稳定性、符号链接和项目容量控制。消费方拥有其目录描述上限。确切的默认值和校验规则见自动生成的[插件配置目录](../config-catalog.md)。 ```ts type-equiv @@ -231,13 +246,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.skills` — `SkillService` -Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. +Layered registry of skill providers, the host+per-scope shape the tools registry established. A registration files into the layer of its calling context's scope (scopeOf): host rows and repository plugins land in the global layer, while a plugin mounted by an agent preset's standing composition lands in that preset's layer. A read merges the global layer with the viewing scope's chain — the nearest layer's entry wins a duplicate name outright, and the rank order decides duplicates only within one layer. It exposes sorted invocation-neutral summaries and loads full skill bodies on demand. ```ts cordis-catalog /** - * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and - * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters - * the provider and invalidates catalog caches. + * Register a borrowed same-process provider synchronously during plugin + * apply, into the calling context's layer: a scoped context (an agent + * preset's standing mount) registers for that scope alone, an unscoped + * context registers globally. Duplicate names within one layer and reserved + * names throw; remote initialization belongs in `list()`. Fiber disposal + * unregisters the provider and invalidates catalog caches. * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. @@ -245,9 +263,11 @@ Registry of skill providers. It merges provider catalogs with stable first-wins registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** - * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which - * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and - * receives a no-op disposer so it cannot remove the winner. + * Register a borrowed readonly runtime skill into the calling context's + * layer. Project entries outrank runtime entries, which outrank user + * entries, within one layer. Same-name runtime entries in one layer are + * first-wins; a duplicate logs a warning and receives a no-op disposer so + * it cannot remove the winner. * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ @@ -258,32 +278,33 @@ register(skill: SkillRegistration): () => void * model or user invocation policy at their operational boundary. Lookup * options and provider candidates are readonly same-process values borrowed * throughout discovery. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns all sorted winning summaries. */ -async list(options: SkillLookupOptions = {}): Promise +async list(options: SkillViewOptions = {}): Promise /** * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns sorted summaries plus discovery-completeness state. */ -async snapshot(options: SkillLookupOptions = {}): Promise +async snapshot(options: SkillViewOptions = {}): Promise /** * Load and validate the winning candidate, passing its opaque discovery locator back to the * provider. Cancellation is rechecked after selection, including cache hits, and raced against * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. - * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @param options - view options; `scope` selects the viewing agent's layers, + * `cwd` selects workspace-sensitive skills, and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ -async get(name: string, options: SkillLookupOptions = {}): Promise +async get(name: string, options: SkillViewOptions = {}): Promise ``` -Source: [`packages/skill/skill/src/index.ts:305`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:357`](../../packages/skill/skill/src/index.ts) @@ -306,5 +327,5 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:284`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:297`](../../packages/skill/skill/src/index.ts) diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index a7a5e5a6d0..91ff1b49d6 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -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/system-prompt.md -system-prompt.md: 94ce40f8bf98dd4efe3514879c2527c2a7bd3b21 -system-prompt.zh.md: c46ee6e2b70c6603500bd9061ed09b805ed61630 +system-prompt.md: 5397858ea9991efad06e045118b96a90386f2285 +system-prompt.zh.md: defd8fae73834ba543ae1f45d15ca4253a5abe40 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 94ce40f8bf..5397858ea9 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -139,7 +139,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index c46ee6e2b7..defd8fae73 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -139,7 +139,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 1384b3888b..ed9d60978f 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -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: 83389f39c3188fc251504ed5786249ff1921acae -tools.zh.md: 45cb85b3f2940f84b46bc58406bc8255cbe08be7 +tools.md: 692bafa02e37e1c1918fda31c766ad32f7c7cdba +tools.zh.md: 81aabddd20e2a0d09d904f4f8521d65c2622351f diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 83389f39c3..692bafa02e 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -478,6 +478,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Present this agent's tools in `mode` instead of the deployment default. + * + * 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 + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ +presentAs(mode: ToolPresentationMode): () => void + /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -554,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 45cb85b3f2..81aabddd20 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -478,6 +478,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Present this agent's tools in `mode` instead of the deployment default. + * + * 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 + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ +presentAs(mode: ToolPresentationMode): () => void + /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -554,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 20f05623ad..6d339a4512 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\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 */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 27f5c79ca4..b3d68bc8f3 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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/README.md -README.md: eff1d9522e3ca6e8a7efaa20463d73036101f8f5 -README.zh.md: cc2d37d3999e2e59095a8000feb3f963d0b4e4a1 +README.md: c18a46b7131f7782be68f3c96fa99b89615de471 +README.zh.md: 3cd766ed70b7847bef8229a48b65873540365851 diff --git a/packages/README.md b/packages/README.md index eff1d9522e..c18a46b713 100644 --- a/packages/README.md +++ b/packages/README.md @@ -34,6 +34,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | +| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | | [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index cc2d37d399..3cd766ed70 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -34,6 +34,7 @@ | [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 | +| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 | | [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 | diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index 71d7a76379..db765f3dde 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -22,8 +22,20 @@ export type ApiRemoteAgentResult = export interface ApiRemoteAgentOptions { /** Read the per-Agent defaults when a cold identity must resume. */ readonly agentOptions?: () => AgentOptions - /** Host-specific Agent-scope composition completed before publication. */ - readonly setup?: AgentSetup + /** + * Build the Host-specific Agent-scope composition completed before + * publication. Keyed by the resumed session itself because what a Host + * installs may depend on what that session recorded: an agent preset fixes + * the tools its history was produced under, so rebuilding it under another + * composition would replay tool calls the agent can no longer make. The + * events come along because a session's own record of such a choice may be + * an event rather than a header field. + * @param session - the resumed session's persisted header and event log. + * @returns the Agent-scope setup to run before publication. + */ + readonly setup?: ( + session: { meta: SessionHeader; events: readonly SessionEvent[] }, + ) => AgentSetup | Promise } /** Cold identity absent from the durable session store. */ @@ -136,6 +148,11 @@ export function createApiRemoteAgentResolver( if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) { throw new ApiRemoteSubagentSessionOwnership(sessionId) } + // Built from the inspected session before the published re-checks + // below, so those stay adjacent to `resume` and a Host setup that + // awaits (composing a preset, say) does not widen the collision + // window. + const setup = options.setup === undefined ? undefined : await options.setup(inspected) const publishedSession = ctx.sessions.get(sessionId) const publishedAgent = ctx.agents.get(sessionId) if (publishedSession !== undefined @@ -145,7 +162,7 @@ export function createApiRemoteAgentResolver( const handle = await ctx.agents.resume({ resumeSessionId: sessionId, ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() }, - ...options.setup === undefined ? {} : { setup: options.setup }, + ...setup === undefined ? {} : { setup }, }) return handle.agent } finally { diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 58ae933e12..5d51b559d4 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -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/boot/app-boot/README.md -README.md: 1bbd376121ae79bf37376b51f5ac3eb405af6dfd -README.zh.md: 15263d8de9b69fc976ce328a6056e1b35e9beda5 +README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0 +README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 1bbd376121..49c75bac1b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -15,7 +15,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | @@ -27,6 +27,8 @@ Loader settlement rejects import and lifecycle failures with the failing entry a The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. +`cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. + Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 15263d8de9..93adc52c11 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -15,7 +15,7 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | @@ -27,6 +27,8 @@ Loader 结算会在导入或生命周期失败时返回拒绝结果,并携带 Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先 dispose 部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前 dispose 整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间,处理函数保持注册并处于锁定状态:被报告的始终是第一个 rejection,后续拒绝(包括拆卸自身产生的拒绝)会被忽略,而不会变成未捕获错误、在拆卸中途杀死进程。 +`cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 + 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index e2d0ed2391..c33dc878b8 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -28,6 +28,7 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { + "@cordisjs/plugin-group": "^1.0.0", "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", @@ -43,6 +44,7 @@ } }, "devDependencies": { + "@cordisjs/plugin-group": "workspace:^", "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index c722f30f0f..256ea34299 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -14,6 +14,7 @@ import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' +import Group from '@cordisjs/plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type {} from '@cordisjs/plugin-hmr' @@ -485,6 +486,12 @@ export async function mountRootInclude( patches: readonly PatchOptions[] = [], ): Promise { ctx.loader.builtins.include = Include + // `cordis:group` alongside it: a group row is how a composition gives one + // `isolate` realm to a provider and its consumers together, and an agent + // preset living outside this workspace cannot resolve `@cordisjs/plugin-group` + // by name. Both builtins load through the ambient module pipeline, so neither + // depends on the included tree's own specifier resolution. + ctx.loader.builtins.group = Group // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 45eab9ea7d..9cbe3d1a58 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -8,9 +8,8 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import { Context } from 'cordis' import type { Include } from '@cordisjs/plugin-include' -import { Group } from '@cordisjs/plugin-loader' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -219,8 +218,10 @@ describe('loader tree replacement', () => { }) it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { + // No manual builtin registration: `boot()` supplies `cordis:group` beside + // `cordis:include`, which is what lets a composition give one `isolate` + // realm to a provider and its consumers together. const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') - ctx.loader.builtins.group = Group try { const config = (disabled: boolean) => [ '- id: parent', @@ -253,7 +254,6 @@ describe('loader tree replacement', () => { const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), }) - ctx.loader.builtins.group = Group try { const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) @@ -386,3 +386,46 @@ describe('include patches layered over one base', () => { } }) }) + +describe('shipped builtins', () => { + it('lets a booted composition share one isolate realm across a group of rows', async () => { + // The reason `boot()` registers `cordis:group`: a composition — notably an + // agent preset living outside this workspace, which cannot resolve + // `@cordisjs/plugin-group` by name — gives a provider and its consumer one + // named realm so the service stays out of the root realm while remaining + // visible to the rows that need it. + const { ctx } = await bootTree([ + '- id: realm', + ' name: cordis:group', + ' isolate:', + ' demoRealmSvc: true', + ' config:', + ' - id: provider', + ' name: ./provider.mjs', + ' - id: consumer', + ' name: ./consumer.mjs', + '', + ].join('\n'), { + 'provider.mjs': 'export const name = "provider"\n' + + 'export function apply(ctx) { ctx.effect(() => ctx.reflect.provide("demoRealmSvc", { tag: "realm" })) }\n', + 'consumer.mjs': 'export const name = "consumer"\n' + + 'export const inject = ["demoRealmSvc"]\n' + + 'export function apply(ctx) { globalThis.__REALM_SEEN__ = ctx.get("demoRealmSvc").tag }\n', + }) + try { + expect((globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__).toBe('realm') + // `provide` mints the root symbol unconditionally (cordis `reflect.ts`), + // so the name IS in the root realm — pinned here because it is the half + // that looks like the claim and is not. The claim is the other half: no + // implementation is stored under that symbol, so the root realm cannot + // resolve the service and a second composition mounting the same rows + // cannot collide with this one. + const rootKey = ctx.root[Context.isolate].demoRealmSvc + expect(rootKey).toBeDefined() + expect(ctx.reflect.store[rootKey!]).toBeUndefined() + } finally { + delete (globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__ + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index 18ddbedad3..e8a7b79612 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/group" + }, { "path": "../../../vendor/hmr" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c3a66ebbf4..e4c4935a2a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -183,6 +183,11 @@ - id: ui-permission name: '@deepseek-ai/dsh-client-ui-permission' + # The agent-preset row in General settings: the default preset for + # sessions created later. Absent a roster it renders nothing. + - id: ui-agent-preset + name: '@deepseek-ai/dsh-client-ui-agent-preset' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' @@ -192,3 +197,136 @@ - id: ui-trajectory name: '@deepseek-ai/dsh-client-ui-trajectory' + +# ── the agent plane moves behind agent presets ───────────────────────────── +# +# Every row below composes what ONE agent contributes to the host registries: +# its tools, its prompt sections, its delegation backends. The base keeps them +# for the TUI, which is single-session and composes its agent process-wide; the +# Web surface disables them here and lets each session mount a preset instead. +# +# Disabling rather than deleting is deliberate: the base is shared, and a row +# absent from a surface overlay would silently reappear the day someone reorders +# the composition. + +# `bash-env` STAYS in the host plane: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# would never reach the model's shell at all. + +- id: tool-bash + disabled: true + +- id: tool-tasks + disabled: true + +- id: tasks + disabled: true + +- id: tool-fs + disabled: true + +- id: tool-fs-search + disabled: true + +- id: tool-str-replace-editor + disabled: true + +# The `skill` REGISTRY stays in the host plane. It is host+per-scope layered +# (the tools-registry shape): deployment-level providers — repository plugins, +# a host skill-local row — register into its global layer, while a preset's +# `skill-local` registers into that preset's layer, and each agent reads the +# merged catalog its scope chain selects. Only the per-agent rows move behind +# presets: the base host `skill-local` row is disabled here (presets own local +# discovery), and `tool-skill` is what a preset mounts to give its agent the +# catalog and loader at all. + +- id: skill-local + disabled: true + +- id: tool-skill + disabled: true + +# The goal SERVICE, its session driver, and the `/goal` command STAY on the +# host plane; only the model-facing tool moves. The Gateway serves the goal +# domain as Remote endpoints, and a Remote method picks its receiver Service +# from a generated descriptor — it resolves `goals` on the host, so a +# per-session realm would answer `service-unavailable` for every browser call. +# That is the `bash-env` criterion read from the other side: injection is not +# the only host relationship a Service can have. The registry is keyed by +# session, so one host instance serves every session exactly as before presets. + +- id: tool-goal + disabled: true + +- id: plan-mode + disabled: true + +- id: token-meter + disabled: true + +- id: compact-basic + disabled: true + +- id: command-compact + disabled: true + +- id: tool-result-prune + disabled: true + +# The subagent registry and its backends STAY in the host plane. `subagents` is +# a process singleton with a cross-session query surface (`listChildren`, +# `followup`) that the host api-proxy serves to the browser, and a provider +# registers under a globally unique name, so a per-session copy would both +# starve that host row and collide on the second session. What a preset +# chooses is which delegation TOOLS its agent sees, below. + +- id: tool-subagent-control + disabled: true + +- id: tool-subagent-list-agents + disabled: true + +- id: tool-subagent + disabled: true + +- id: tool-subagent-fork + disabled: true + +# `tool-subagent-report` is host-plane for the same reason as the registry, not +# because a preset may not want it: it registers a CONTINUABLE SETUP on that +# singleton rather than a tool this agent calls, and the setup list is not +# scope-aware — one copy per mounted preset means every child gets `report` +# registered once per live session, which throws on the second. + +- id: workflow-workerthread + disabled: true + +- id: tool-workflow + disabled: true + +- id: tool-ralph + disabled: true + +- id: workspace-context + disabled: true + +- id: tool-todo + disabled: true + +- id: tool-web + disabled: true + +# The preset roster. `config/agent-presets/` ships with the deployment and is +# read-only (its entries carry `system` trust); +# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and +# carries the same trust as shell access because a preset IS a composition. +# `roots` is an assembly fact, not user config: the shipped preset directory +# ships beside this file, so AppCLIEntry resolves it and patches it in — the +# same treatment `distIndex` gets on the webserver row. +- insert: + - id: agent-presets + name: '@deepseek-ai/dsh-agent-presets' + config: + default: standard diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 9b8e15fd66..4f8b8d4318 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -32,12 +32,15 @@ } }, "dependencies": { + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 997063b3bb..816f8737e7 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: 56b9363cc724515ecbd11127ea4c13aba84283df -README.zh.md: a3fe1a978de7ab5935ec527d115278703cbebcd4 +README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd +README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 diff --git a/packages/client/README.md b/packages/client/README.md index 56b9363cc7..567e10f74a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -33,6 +33,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | | [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. | +| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. | | [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index a3fe1a978d..ad6a9fb199 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -33,6 +33,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | +| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 | | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 1ae9269cb8..a7dbd94e55 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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/connection/README.md -README.md: 07849f0728aee6076b15a8216ddcab08521994d6 -README.zh.md: a7996d0f7cc2948da82877c47f9acc805be2bba8 +README.md: 85ff46052ba2f032ee6a95b16c396d45e766d3ba +README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 07849f0728..85ff46052b 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md). +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md). ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a7996d0f7c..89cbb19a98 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index b863745d3c..70ce89677c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1357,6 +1357,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // DeepSeek route so unrelated GUI journeys do not enter first-run setup. ['DEEPSEEK_API_KEY', true], ]) + /** + * Preset compositions the fixture serves. Held as state rather than + * constants so the settings editor's save and delete are exercisable: the + * roster a GUI journey sees after writing is the text it wrote. + */ + const fixturePresets = new Map([ + ['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }], + ['minimal', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }], + ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], + ]) + let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -2444,6 +2455,88 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { matched: true as const, commandId }) }, }, + agentPresets: { + // Both trusts appear, because a surface must present a locally authored + // preset differently from one the deployment vetted. + list: request => ok(request, { + presets: [...fixturePresets].map(([id, preset]) => ({ + id, + trust: preset.trust, + isDefault: id === fixtureDefaultPreset, + })), + authorable: true, + hasDocument: true, + }), + select: (request) => { + fixtureDefaultPreset = request.payload.agentPreset + return ok(request, { agentPreset: request.payload.agentPreset }) + }, + read: (request) => { + const { agentPreset } = request.payload + const preset = fixturePresets.get(agentPreset) + if (preset === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${agentPreset}"`, + details: { agentPreset, available: [...fixturePresets.keys()] }, + }) + } + return ok(request, { + agentPreset, + trust: preset.trust, + content: preset.content, + }) + }, + copy: (request) => { + const { from, agentPreset } = request.payload + const source = fixturePresets.get(from) + if (source === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${from}"`, + details: { agentPreset: from, available: [...fixturePresets.keys()] }, + }) + } + if (fixturePresets.has(agentPreset)) { + return err(request, { + code: 'agent-preset-invalid', + message: `agent preset "${agentPreset}" already exists`, + details: { agentPreset, reason: 'already exists' }, + }) + } + fixturePresets.set(agentPreset, { trust: 'user', content: source.content }) + return ok(request, { agentPreset }) + }, + // Native opens are deterministic no-op successes in this fixture, so the + // open-directory affordance renders and the path-text fallback stays a + // component-test concern. + openDocument: (request) => { + const { agentPreset } = request.payload + const existing = fixturePresets.get(agentPreset) + if (existing === undefined || existing.trust === 'system') { + return err(request, { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }) + } + return ok(request, { opened: true as const }) + }, + remove: (request) => { + const { agentPreset } = request.payload + const existing = fixturePresets.get(agentPreset) + if (existing?.trust === 'system') { + return err(request, { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }) + } + fixturePresets.delete(agentPreset) + return ok(request, {}) + }, + }, + skills: { list: (request) => { const missing = requireSession(request) @@ -2764,6 +2857,12 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) + case 'agentPreset.list': return this.api.agentPresets.list(request) + case 'agentPreset.select': return this.api.agentPresets.select(request) + case 'agentPreset.read': return this.api.agentPresets.read(request) + case 'agentPreset.copy': return this.api.agentPresets.copy(request) + case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) + case 'agentPreset.remove': return this.api.agentPresets.remove(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index aefdcdadf4..f865653b9f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -66,6 +66,24 @@ export const Config: z = z.object({ * keys, or key state — and a LAN client's model picker legitimately needs it. */ const PRIVILEGED_METHODS = new Set([ + // A preset composition names the plugins a session runs, so reading one is + // reconnaissance; copy and remove rearrange what the deployment offers, and + // openDocument drives the host desktop — all more than the roster beside + // them. (Authoring is copy-only, so no method here accepts composition text + // or a path; the pin is about who may manage the roster at all.) + // + // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a + // preset looks like escalation — one of them mounts the toolset that edits the + // live runtime — but `session.create` already takes an `agentPreset`, so + // pinning only the switch would leave the same capability one method over. + // The deeper reason is that the capability is not the preset's to grant: the + // deployment's own default already carries `bash` and the filesystem tools, so + // any caller that may start a session at all can already run commands as this + // process. Pinning the switch would be a fence beside an open gate. + 'agentPreset.read', + 'agentPreset.copy', + 'agentPreset.openDocument', + 'agentPreset.remove', 'host.pickDirectory', 'host.openPath', 'settings.describe', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index c61b97833b..fc6ba9a57d 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -172,6 +172,22 @@ export class FakeApiClient implements IApiClient { execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), } + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + read: (payload: { agentPreset: string }) => + this.record('agentPreset.read', payload, Promise.resolve(ok({ + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', + }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), + } + readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 59ab8e6102..e3a4d6cb26 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -159,6 +159,10 @@ describe('connection node half', () => { 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', + // A composition names the plugins a session runs: reading one is + // reconnaissance, and copy/remove/openDocument manage the roster and + // drive the host desktop. + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { const denied = fakeResponse() await routes[0]!.handler( @@ -452,13 +456,19 @@ describe('connection node half over a real HTTP server', () => { // Carries a draft credential and turns the host into a fetcher for a // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) } // The model catalog stays reachable for the same authority: a LAN // client's model picker needs it, and it carries no key or endpoint // state (404 is the empty proxy's carrier answer — the fence passed). - for (const method of ['llm.providers', 'llm.models']) { + // `agentPreset.list` joins the model catalog for the same reason: ids and + // trust only, and a LAN client's preset picker needs it. `select` is + // reachable too: `session.create` already takes an `agentPreset`, and the + // deployment's own default already carries bash, so pinning the switch + // would be a fence beside an open gate. + for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404]) } // Loopback reaches everything, configuration included. diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 3e74510dd8..1560f131d2 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -62,6 +62,15 @@ export interface ISessions { * @returns completion of the current or newly started refresh. */ refreshSubagents(parentSessionId: SessionId): Promise + + /** + * Record the composition one session now runs. The agent-preset seat calls + * this after a successful blank-session switch, so the header label moves + * with the composition instead of waiting for the next full list refresh. + * @param sessionId - the switched session. + * @param agentPreset - the preset id the host confirmed. + */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void /** Clear the current selection into the no-session view state. */ clear(): void /** diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 69094f2964..cf8fa0834d 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -25,6 +25,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string + /** Agent preset the session's agent was composed from (summary passthrough). */ + agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> /** User interaction currently blocking this session, derived from live mux frames. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b251ed8ed5..ab25781353 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -536,6 +536,7 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary: { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), } }) } else { const publishedSessionId = workspaceAttachSessionId(result.error) @@ -601,6 +602,17 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary }) } + /** + * Record a host-confirmed composition switch (see ISessions.noteAgentPreset). + * @param sessionId - the switched session. + * @param agentPreset - the preset id the host confirmed. + */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.recordMutation({ kind: 'upsert', summary: { + sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset, + } }) + } + /** Apply immediately and retain for replay when a list response is in flight. */ private recordMutation(mutation: SessionListMutation): void { this.listMutations?.push(mutation) @@ -756,6 +768,7 @@ export class SessionManager { ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), + ...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}), }) this.sessions.get(frame.sessionId)?.handleBlank(frame.blank) if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) { @@ -1040,9 +1053,15 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ? { parentSessionId: mutation.summary.parentSessionId } : {}), ...(existing.origin === undefined && mutation.summary.origin !== undefined ? { origin: mutation.summary.origin } : {}), + // Newest wins, not fill-only: a blank-session preset switch replaces + // the creation-time value, and every producer of this field (the + // create echo, the select echo, a list row) reports the CURRENT one. + ...(mutation.summary.agentPreset !== undefined + ? { agentPreset: mutation.summary.agentPreset } : {}), } if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId - && filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries] + && filled.origin === existing.origin && filled.blank === existing.blank + && filled.agentPreset === existing.agentPreset) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } case 'remove': diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index e137d9597b..72edcea1c3 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -45,6 +45,12 @@ export interface SessionSummary { /** Human-facing label: durable title, project basename, then session id. */ displayTitle: string cwd?: string + /** + * Agent preset this session's agent was composed from; absent when the + * deployment composes no presets. The session header labels what the + * session actually runs rather than the deployment's current default. + */ + agentPreset?: string parentId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' @@ -392,6 +398,10 @@ export class SessionsService implements ISessions { return this.manager.refreshSubagents(parentSessionId) } + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.manager.noteAgentPreset(sessionId, agentPreset) + } + /** * Clear the current selection so the layout shows the no-session empty * state (new-session affordance and the workspace preselection flow). @@ -662,6 +672,7 @@ export class SessionsService implements ISessions { ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + ...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}), } } if (current !== undefined && currentAddress !== undefined) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 80f45db067..860936bd77 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -208,6 +208,22 @@ export class FakeApiClient implements IApiClient { execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), } + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + read: (payload: { agentPreset: string }) => + this.record('agentPreset.read', payload, Promise.resolve(ok({ + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', + }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), + } + readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index f700cb4ba1..424015f1f6 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -430,6 +430,14 @@ export class TestSessions implements ISessions { return Promise.resolve() } + /** Apply a confirmed preset switch into the fixture list, as production does. */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.list.update((draft) => { + const summary = draft.byId[sessionId] + if (summary !== undefined) draft.byId[sessionId] = { ...summary, agentPreset } + }) + } + /** Clear the current selection (recorded; the production no-session flow). */ clear(): void { this.calls.push({ method: 'clear', args: [] }) diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 08a82ee251..0aeede27ac 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -221,6 +221,13 @@ describe('sessions', () => { .toMatchObject({ displayTitle: 'renamed', running: true }) runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true) await runtime.sessions.refreshSubagents('s2' as SessionId) + // The confirmed-switch write-back lands on the row it names and ignores + // one the fixture never added, exactly as production's list upsert does. + runtime.sessions.noteAgentPreset('s1' as SessionId, 'minimal') + runtime.sessions.noteAgentPreset('missing' as SessionId, 'minimal') + await runtime.flush() + expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId]) + .toMatchObject({ agentPreset: 'minimal' }) runtime.sessions.open('s1' as SessionId) await runtime.flush() expect(runtime.sessions.list.getSnapshot().current).toBe('s1') diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml new file mode 100644 index 0000000000..6943e47673 --- /dev/null +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -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 packages/client/ui-agent-preset/README.md +README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c +README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md new file mode 100644 index 0000000000..32a4e7d9e2 --- /dev/null +++ b/packages/client/ui-agent-preset/README.md @@ -0,0 +1,67 @@ +# dsh-client-ui-agent-preset + +English | [中文](README.zh.md) + +The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. + +## Why it is a new-session preference + +A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with. + +## The new-session chip + +A second surface, beside the workspace picker on the new-session screen. It sits there rather than in the composer because that is where the choice is still open: a control that spends most of its life disabled belongs on the screen where it still works. + +The chip opens on the deployment default and its pick is *staged* — the screen precedes the session it would apply to. The stage reaches a session when one becomes current and is still blank, which covers both the session the workspace connect created and the blank one it reused; riding along on `sessions.create` would miss the second. It is spent on first use, so the next new session opens on the default again, exactly like the workspace picker beside it. + +A session that has started is refused rather than queued: the host answers `agent-preset-locked`, and the stage is dropped instead of waiting for a session that will never accept it. + +## The session-header label + +A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. + +## What it reads and writes + +Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. + +A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted. + +The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it. + +## The management section + +A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions. + +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 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. + +The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure. + +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. + +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). + +`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it. + +## When the surfaces are absent + +A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails. + +## Model Experience + +Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model. + +#### KV Cache effect + +No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition. + +## Known Limitations and Deferred Work + +- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source. +- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself. +- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md new file mode 100644 index 0000000000..b65a1bdf92 --- /dev/null +++ b/packages/client/ui-agent-preset/README.zh.md @@ -0,0 +1,67 @@ +# dsh-client-ui-agent-preset + +[English](README.md) | 中文 + +agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。 + +## 为什么它是"新建会话"的偏好设置 + +会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。 + +## 新建会话 chip + +第二个表层,位于新建会话界面上、工作区选择器旁边。它落在这里而非 composer,是因为这里才是选择仍然成立的地方:一个大部分时间处于禁用状态的控件,属于它仍然可用的那个界面。 + +chip 以部署默认值打开,其选择是**暂存**的——该界面先于它要应用到的会话存在。暂存值会在某个会话成为当前会话且仍为空白时抵达该会话;这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。暂存值一经使用即被清空,因此下一个新会话重新以默认值打开——与它旁边的工作区选择器完全一致。 + +已经开始的会话会被直接拒绝而非排队:宿主返回 `agent-preset-locked`,暂存值随之丢弃,而不是去等一个永远不会接受它的会话。 + +## 会话标题旁的标签 + +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 + +## 它读什么、写什么 + +选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。 + +本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。 + +本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。 + +## 管理分区 + +第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示。 + +浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。 + +随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + +复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。 + +对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。 + +删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 + +名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。 + +设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 + +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。 + +## 何时不显示这些表层 + +未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框。 + +## Model Experience + +Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model. + +#### KV Cache effect + +没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。 + +## Known Limitations and Deferred Work + +- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。 +- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。 +- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json new file mode 100644 index 0000000000..6b42c14ec2 --- /dev/null +++ b/packages/client/ui-agent-preset/package.json @@ -0,0 +1,74 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-agent-preset", + "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-web-react": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css new file mode 100644 index 0000000000..5468f0d592 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -0,0 +1,23 @@ +/* Session-header agent-preset label: static chrome, never a control. */ + +.label { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 180px; + padding: 0 8px; + height: 22px; + border-radius: 6px; + background: var(--dsw-alias-fill-tsp-secondary); + font-size: 12px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.icon { + flex: none; + opacity: 0.7; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx new file mode 100644 index 0000000000..82688dd7c2 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -0,0 +1,62 @@ +/** + * The session header's agent-preset label. + * + * Read-only by construction: a session's composition is fixed once its + * conversation starts, and a header is only worth reading after that. Offering + * a control here would promise a switch the host refuses; naming what the + * session runs is the honest affordance, and the choice itself lives on the + * new-session screen ({@link AgentPresetSeat}). + */ + +import { useEffect } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: pulls the ui-conversation SlotMap merge (the header actions). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { AgentPresetSettingsState } from './settings-store.ts' +import css from './AgentPresetLabel.module.css' + +/** Registration-side business face for the header label. */ +export interface AgentPresetLabelInjected { + hooks: { + /** Roster snapshot bound by the renderer as useAgentPresets. */ + agentPresets: SnapshotStore + } + /** Read the roster, so the label can show a name rather than an id. */ + load: () => Promise +} + +/** Full component props. */ +export type AgentPresetLabelProps = + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * Render this session's agent-preset name beside its title. + * @param props - composed slot props. + * @returns the label, or null when the session records no preset. + */ +export function AgentPresetLabel({ + sessionId, useSessions, useAgentPresets, load, t, +}: AgentPresetLabelProps) { + const preset = useSessions(state => state.byId[sessionId]?.agentPreset) + const options = useAgentPresets(state => state.options) + + useEffect(() => { + // Deployments that compose no presets never label anything, so the roster + // is only worth a request once a session reports one. + if (preset !== undefined) void load() + }, [preset, load]) + + if (preset === undefined) return null + + const option = options.find(entry => entry.id === preset) + return ( + + + {option?.name ?? preset} + + ) +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css new file mode 100644 index 0000000000..d0f7134329 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css @@ -0,0 +1,60 @@ +/* Agent-preset row: title/description plus the preset selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx new file mode 100644 index 0000000000..ba875b0b95 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -0,0 +1,89 @@ +/** + * Agent-preset preference row: the preset new sessions are composed from. + * A running session keeps the composition it began with, so this row never + * disturbs work in progress. + */ + +import { useEffect, useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { AgentPresetSettingsState } from './settings-store.ts' +import type { AgentPresetSettingsKey } from './locales.ts' +import { PresetMenu } from './PresetMenu.tsx' +import css from './AgentPresetRow.module.css' + +/** Registration-side business face for the host-backed preference. */ +export interface AgentPresetRowInjected { + hooks: { + /** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */ + agentPreset: SnapshotStore + } + /** Load the roster when the row first renders. */ + load: () => Promise + /** Persist one preset as the default for later sessions. */ + select: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * Render the new-session agent-preset selector. + * @param props - composed slot props. + * @returns the row, or null when the deployment composes no presets. + */ +export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) { + const state = useAgentPreset(snapshot => snapshot) + const [open, setOpen] = useState(false) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (state.writable && state.status !== 'unavailable') return + setOpen(false) + }, [state.status, state.writable]) + + // A deployment that composes no presets has nothing to choose between, and + // every session shares the host composition — the row simply does not exist. + if (state.status === 'unavailable') return null + const busy = state.status === 'loading' || state.status === 'saving' + // The metadata name is what every other surface shows — the id is the + // addressing, not the label. A preset that names itself nothing falls back + // to its id, which is then all there is to say about it. + const chosen = state.options.find(option => option.id === state.currentValue) + const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue) + const description: string = state.error ?? t('description') + + return ( +
+
+
{t('title')}
+
{description}
+
+ { void select(id) }} + /> +
+ ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent-preset row copy. */ + 'settings.agentPreset': AgentPresetSettingsKey + } +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css new file mode 100644 index 0000000000..a4e4c50309 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -0,0 +1,64 @@ +/* Agent-preset chip on the new-session screen, beside the workspace picker. + Geometry mirrors HeroShell's .workspace so the two read as one row. */ + +.seat { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: min(100%, 240px); + min-height: 28px; + padding: 0 8px; + border: none; + border-radius: 12px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.seat:not(:disabled):hover, +.seat[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); +} + +.seat:disabled { + cursor: default; + color: var(--dsw-alias-label-quaternary); +} + +.seatIcon { + flex: none; + color: var(--dsw-alias-label-primary); +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-caption); +} + +/* Menu rows carry the name over its description: the id alone never said what + a preset does, which is why the metadata exists. */ +.item { + display: flex; + flex-direction: column; + gap: 2px; + max-width: 280px; +} + +.itemName { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.itemDesc { + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-caption); + white-space: normal; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx new file mode 100644 index 0000000000..8e18471fbc --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -0,0 +1,100 @@ +/** + * The agent-preset chip on the new-session screen, beside the workspace + * picker. + * + * It lives here rather than in the composer because the choice is only + * available before a conversation starts: once a turn has run, the session's + * history was produced under that preset's tools and the host refuses to swap + * them. A control that spends most of its life disabled belongs on the screen + * where it still works. + * + * The menu opens on the staged choice, which starts as the deployment default. + * Picking stages; the choice reaches a session when one becomes current. + */ + +import { useEffect, useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: pulls the ui-conversation SlotMap merge (the hero seat). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { AgentPresetSeatState } from './seat-store.ts' +import css from './AgentPresetSeat.module.css' + +/** Registration-side business face for the hero chip. */ +export interface AgentPresetSeatInjected { + hooks: { + /** Seat snapshot bound by the renderer as useAgentPresetSeat. */ + agentPresetSeat: SnapshotStore + } + /** Read the roster when the chip first renders. */ + load: () => Promise + /** Stage one preset for the next session. */ + select: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetSeatProps = + PropsRuntime<'conversation.hero.agentPreset'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * Render the new-session agent-preset chip. + * @param props - composed slot props. + * @returns the chip, or null when the deployment composes no presets. + */ +export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { + const state = useAgentPresetSeat(snapshot => snapshot) + const [open, setOpen] = useState(false) + + useEffect(() => { + void load() + }, [load]) + + // Nothing to choose between: the deployment composes no presets and every + // session shares the host composition. + if (state.options.length === 0 || state.current === '') return null + + const chosen = state.options.find(option => option.id === state.current) + + return ( + { setOpen(false) }} + items={state.options.map(option => ({ + id: option.id, + // Name and description together: the id alone never said what a + // preset does, which is the whole reason the metadata exists. + label: ( + + {option.name ?? option.id} + {option.description ?? t('noDescription')} + + ), + }))} + selectedId={state.current} + onSelect={(id) => { + setOpen(false) + void select(id) + }} + align="start" + portal + anchor={( + + )} + /> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css new file mode 100644 index 0000000000..f29bf7cdf5 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -0,0 +1,388 @@ +.section { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 720px; + color: var(--dsw-alias-label-primary); +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.intro { + margin: 0; + font-size: 13px; + color: var(--dsw-alias-label-tertiary); +} + +/* Cards, not rows: a preset is a thing you pick, and the description is the + part that tells them apart — a row would bury it beside the actions. */ +.group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.groupHead { + margin: 0; + font-size: 12px; + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--dsw-alias-label-tertiary); +} + +.cards { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); + /* Every row the same height, so a short description does not make its card + shorter than the one beside it. */ + grid-auto-rows: 1fr; + gap: 12px; +} + +.card { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + display: flex; + flex-direction: column; + background: var(--dsw-alias-bg-layer-3); + transition: border-color .16s, background .16s; +} + + +.card:hover:not(.cardActive) { + border-color: var(--dsw-alias-label-dimmed); +} + +/* The default preset reads as selected, not merely badged. */ +.cardActive { + background: var(--dsw-alias-bg-layer-2); + border-color: var(--dsw-alias-label-primary); +} + +/* A broken preset reads as damaged before anything else: the card cannot be + picked, so its border carries the warning the disabled body cannot. */ +.cardBroken { + border-color: var(--dsw-alias-state-error-primary); +} + +.cardBroken:hover { + border-color: var(--dsw-alias-state-error-primary); +} + +.brokenBadge { + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 17px; + white-space: nowrap; + font-weight: 500; + background: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-bg-layer-3); +} + +/* The discovery-reported reason, verbatim: it names the file and the fix. */ +.cardBrokenReason { + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} + +/* The card body is the control that picks the preset. */ +.cardMain { + flex: 1; + appearance: none; + border: 0; + background: none; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px 12px; + border-radius: 12px 12px 0 0; +} + +.cardMain:disabled { + cursor: default; +} + +.cardMain:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + +.cardHead { + display: flex; + align-items: center; + gap: 8px; +} + +.cardName { + font-size: 15px; + font-weight: 600; + line-height: 1.4; +} + +.badge, +.inUse { + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 17px; + white-space: nowrap; + font-weight: 500; +} + +.badge { + border: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-tertiary); +} + +.inUse { + margin-left: auto; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.cardDesc { + font-size: 13px; + line-height: 1.55; + color: var(--dsw-alias-label-secondary); + flex: 1; + min-height: 42px; +} + +.cardId { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; + color: var(--dsw-alias-label-dimmed); +} + +.cardFoot { + display: flex; + justify-content: flex-end; + gap: 2px; + padding: 6px 10px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + +/* Icon-only actions: the label rides `title` so the row stays quiet until + someone reaches for it. */ +.iconButton { + position: relative; + appearance: none; + border: 0; + border-radius: 7px; + padding: 6px; + background: none; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.iconButton:disabled { + opacity: 0.4; + cursor: default; +} + +.iconButton:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.iconButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -1px; +} + +.iconButton::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + line-height: 17px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.iconButton:hover::after, +.iconButton:focus-visible::after { + opacity: 1; +} + +.iconDanger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); +} + +/* Where the host has no desktop opener, the row answers with the directory + itself — text to copy, not a control that would spawn into nothing. */ +.revealedPath { + margin: 0; + padding: 6px 16px 10px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); + display: flex; + gap: 6px; + align-items: baseline; +} + +.revealedPath code { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + color: var(--dsw-alias-label-secondary); + user-select: all; + overflow-wrap: anywhere; +} + +.revealedPathLabel { + white-space: nowrap; +} + +.secondaryButton { + border: none; + border-radius: 7px; + padding: 5px 8px; + background: none; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + + +.secondaryButton:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); +} + +.secondaryButton:disabled { + opacity: 0.5; + cursor: default; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fieldLabel { + font-size: 12px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.input { + box-sizing: border-box; + padding: 9px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + font: inherit; + font-size: 13px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.input:focus { + outline: none; + border-color: var(--dsw-alias-brand-primary); +} + +.input::placeholder { + color: var(--dsw-alias-label-dimmed); +} + +.dialog { + width: min(560px, 100%); +} + +.dialogFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* A shipped composition can be long; the dialog scrolls it rather than grow. */ +.viewerCode { + margin: 0; + padding: 12px; + max-height: min(52vh, 480px); + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + white-space: pre; + tab-size: 2; + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.error { + margin: 0; + font-size: 12px; + color: var(--dsw-alias-state-error-primary); +} + +.deleteDialog { + width: min(480px, 100%); +} + +.deleteConfirm:not(:disabled) { + border-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* The conversational authoring entry, after the card grid in the spot the + create button vacated. Dashed like the Models page's add affordances: it + reads as a place a preset will appear, not a command. */ +.creatorButton { + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 44px; + border: 1px dashed var(--dsw-alias-border-l3); + border-radius: 12px; + font: inherit; + font-size: 13px; + background: none; + color: inherit; + cursor: pointer; +} + +.creatorButton:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); +} + +.creatorButton:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx new file mode 100644 index 0000000000..3a9d0b960a --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -0,0 +1,372 @@ +/** + * Agent-presets settings section: the roster as cards, a copy dialog as the + * only way a preset is created, and a read-only viewer over the shipped + * compositions. + * + * The browser edits no composition text — a shipped preset opens read-only to + * be READ (it is the known-good composition a copy starts from), and a custom + * preset is edited in its own files, which is what the location action leads + * to. Deleting a preset leaves running sessions alone: a composition is + * mounted once at session creation and nothing re-reads the file. + */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { + Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, +} 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' +import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' +import type { AgentPresetSettingsKey } from './locales.ts' +import css from './AgentPresetSection.module.css' + +/** Registration-side business face for the management section. */ +export interface AgentPresetSectionInjected { + hooks: { + /** Page snapshot bound by the renderer as useAgentPresetSection. */ + agentPresetSection: SnapshotStore + } + /** Read the roster; called once when the section first renders. */ + load: () => Promise + /** Open one shipped preset's composition in the read-only viewer. */ + view: (id: string) => Promise + /** Close the read-only viewer. */ + closeView: () => void + /** Open the copy dialog over one preset. */ + beginCopy: (from: string) => void + /** Close the copy dialog, discarding the draft. */ + cancelCopy: () => void + /** Name the preset the copy creates. */ + setCopyId: (id: string) => void + /** Name the copy's display name. */ + setCopyName: (name: string) => void + /** Submit the copy. */ + confirmCopy: () => Promise + /** Open one preset's directory, or reveal its path where there is no desktop. */ + openLocation: (id: string) => Promise + /** + * Stage the self-referential preset and start a new session on it — the + * guided way to author a preset, beside copying. Absent when the surface + * is composed without the conversation flow to land the session in. + */ + startCreatorDraft?: () => void + /** Ask for delete confirmation, or dismiss it with null. */ + confirmDelete: (id: string | null) => void + /** Delete the preset awaiting confirmation. */ + remove: () => Promise + /** Make one preset the default for sessions created later. */ + makeDefault: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */ +interface CopyDialogProps { + state: AgentPresetSectionState + t: (key: AgentPresetSettingsKey) => string + actions: Pick +} + +function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { + const draft = state.copy + const blocker = draft === null ? undefined : draftBlocker(draft, state.rows) + const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker)) + return ( + { actions.cancelCopy() }} + title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`} + closeLabel={t('close')} + description={t('copyIntro')} + className={css.dialog as string} + footer={( + <> + + + + )} + > + {draft === null + ? null + : ( +
+ + + {message === null ? null :

{message}

} +
+ )} +
+ ) +} + +/** + * Render the Agent presets section content column. + * @param props - composed slot props. + * @returns the section, or null when the deployment composes no presets. + */ +export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { + const { useAgentPresetSection, t, load } = props + const state = useAgentPresetSection(snapshot => snapshot) + + useEffect(() => { + void load() + }, [load]) + + // A deployment that composes no presets has nothing to manage: every + // session shares the host composition and the page would be an empty list. + if (state.status === 'unavailable') return null + if (state.status === 'error') { + /* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */ + const detail = state.error ?? '' + return ( +
+

{`${t('error')} ${detail}`}

+ +
+ ) + } + + return ( +
+

{t('nav')}

+

{t('sectionIntro')}

+ {state.error === null ? null :

{state.error}

} + {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => { + const group = state.rows.filter(row => row.trust === trust) + if (group.length === 0) return null + return ( +
+

{heading}

+
    + {group.map(row => ( +
  • + {/* The card body IS the control: picking a preset is the + common act, so it should not hide behind a small button. + The action row sits outside it — nesting buttons is + invalid, and these act on the card rather than select it. + A broken preset cannot compose a session, so its body is + disabled and the card says why instead of offering it. */} + +
    + {/* Shipped presets are the compositions a copy starts + from, so READING one is the point; a custom preset is + edited in its files instead, which the location action + leads to. A broken shipped preset has no readable + composition to offer, so its viewer is withheld; a + broken custom one keeps the location action — the + files are where it gets fixed. */} + {row.trust === 'system' + ? row.broken === undefined + ? ( + + ) + : null + : ( + + )} + + {row.trust === 'user' + ? ( + + ) + : null} +
    + {state.revealedPaths[row.id] === undefined + ? null + : ( +

    + {t('revealedPathLabel')} + {state.revealedPaths[row.id]} +

    + )} +
  • + ))} +
+
+ ) + })} + {/* The guided alternative to copying: the self-referential preset can + read this very composition and author a new one in conversation. + Offered only where that preset is actually on the roster and a + session can be landed; without a writable root the draft could + never be discovered, so the reason rides the disabled button. */} + {props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') + ? ( + + ) + : null} + + { props.closeView() }} + title={state.view === null ? '' : `${t('view')} · ${state.view.title}`} + closeLabel={t('close')} + description={t('composition')} + className={css.dialog as string} + footer={( + + )} + > + {state.view === null + ? null + :
{state.view.content}
} +
+ { props.confirmDelete(null) }} + title={t('deleteTitle')} + closeLabel={t('close')} + description={t('deleteDescription')} + className={css.deleteDialog as string} + footer={( + <> + + + + )} + /> +
+ ) +} diff --git a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx new file mode 100644 index 0000000000..2a6bc6ea28 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx @@ -0,0 +1,83 @@ +/** + * The preset picker both surfaces render: a menu of presets over a button + * naming the current one. + * + * The settings row and the composer seat differ in where they sit, what they + * call the current value, and when they refuse a pick — not in how the picker + * itself behaves. Trust is the one thing the list always says: a locally + * authored preset is exactly as privileged as the plugins it names, so the + * label marks it rather than presenting every preset as shipped and vetted. + */ + +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { AgentPresetOption } from './settings-store.ts' + +/** What one surface passes to the shared picker. */ +export interface PresetMenuProps { + /** Presets to offer, in roster order. */ + options: readonly AgentPresetOption[] + /** The preset the button names and the menu marks selected. */ + selectedId: string + /** Text on the button; the surfaces word a pending roster differently. */ + label: string + /** Suffix marking a locally authored preset in the menu. */ + userTrustLabel: string + /** Class for the trigger button, owned by the calling surface. */ + buttonClassName: string | undefined + /** Class for the chevron, owned by the calling surface. */ + chevronClassName: string | undefined + /** Whether the trigger refuses interaction. */ + disabled: boolean + /** Whether the menu is open — the surface owns this so it can force it shut. */ + open: boolean + /** Report the menu's next open state. */ + onOpenChange: (open: boolean) => void + /** Called with the picked preset once the menu has closed. */ + onSelect: (id: string) => void +} + +/** + * Render the preset picker. + * @param props - the calling surface's copy, styling, and handlers. + * @returns the menu and its trigger. + */ +export function PresetMenu({ + options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName, + disabled, open, onOpenChange, onSelect, +}: PresetMenuProps) { + return ( + { onOpenChange(false) }} + items={options.map(option => ({ + id: option.id, + // The metadata name is what every surface shows; the id is addressing, + // not a label. A preset that names itself nothing falls back to its id, + // which is then all there is to say about it. + label: option.trust === 'user' + ? `${option.name ?? option.id} · ${userTrustLabel}` + : option.name ?? option.id, + }))} + selectedId={selectedId} + onSelect={(id) => { + onOpenChange(false) + onSelect(id) + }} + align="end" + portal + anchor={( + + )} + /> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts new file mode 100644 index 0000000000..97a7f66ce2 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -0,0 +1,209 @@ +/** + * Agent-preset surface plugin, browser half — four surfaces over one roster: + * a General-settings row for the default preset, a chip on the new-session + * screen for the session about to start, a read-only label in the session + * header, and a settings section that manages the roster (copy, delete, + * default, and the way into a preset's own files). + * + * A running session keeps the composition it began with (the host refuses to + * adopt an existing session under a different preset). That is what splits + * the choice from the display: the General row and the hero chip are both + * before-the-fact, while the header only reports what a session already runs. + */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetLabel } from './AgentPresetLabel.tsx' +import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx' +import { AgentPresetRow } from './AgentPresetRow.tsx' +import type { AgentPresetRowInjected } from './AgentPresetRow.tsx' +import { AgentPresetSeat } from './AgentPresetSeat.tsx' +import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' +import { AgentPresetSection } from './AgentPresetSection.tsx' +import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx' +import { AgentPresetSeatController } from './seat-store.ts' +import type { SeatSessionSummary } from './seat-store.ts' +import { AgentPresetSectionController } from './section-store.ts' +import { en, zh } from './locales.ts' +import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' + +export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx' +export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' +export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' +export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' +export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts' +export { + draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView, +} from './section-store.ts' +export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' +export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'connection'] + +/** + * Mount the General-settings row. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const { api } = ctx.get('connection') as ConnectionHandle + const controller = new AgentPresetSettingsController(api) + // One roster, four surfaces. The chip is registered in a later scope, so it + // subscribes here rather than being reached from this one. + const rosterReaders = new Set<() => void>() + const section = new AgentPresetSectionController(api, () => { + void controller.load() + for (const read of rosterReaders) read() + }) + + ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') + + const injected = (): AgentPresetRowInjected => ({ + hooks: { agentPreset: controller.store }, + load: () => controller.load(), + select: (id: string) => controller.select(id), + }) + + ctx.effect(() => { + // The roster is a live directory and the default is a settings field, so + // both an external settings edit and a reconnect can move this row. + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return + void controller.load() + // The section reads the same roster and marks the same default, so a + // change made from either surface converges both. + if (section.store.getSnapshot().status !== 'idle') void section.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-agent-preset: settings refresh') + + // The settings section's conversational authoring entry: stage the + // self-referential preset and land a new session on it. Bound inside the + // conversation scope below (the seat and the session flow live there) and + // unbound with it, so the section's face reads the current binding per + // render and simply hides the button while no flow exists. + let creatorDraft: (() => void) | undefined + + // The new-session chip and the header label: one controller, because the + // staged choice belongs to the flow rather than to any one session. + ctx.inject(['slots', 'conversation', 'sessions', 'workspaces'], (scope: ClientContext) => { + const api = (scope.get('connection') as ConnectionHandle).api + const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => { + const state = scope.sessions.list.getSnapshot() + const summary = state.current === undefined ? undefined : state.byId[state.current] + return summary === undefined + ? undefined + : { + id: summary.id, + blank: summary.blank, + ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }, + } + }, (sessionId, agentPreset) => { + scope.sessions.noteAgentPreset(sessionId as never, agentPreset) + }) + + const seatInjected = (): AgentPresetSeatInjected => ({ + hooks: { agentPresetSeat: seat.store }, + load: () => seat.load(), + select: (id: string) => seat.select(id), + }) + + const labelInjected = (): AgentPresetLabelInjected => ({ + hooks: { agentPresets: controller.store }, + load: () => controller.load(), + }) + + scope.effect(() => { + // Connecting a workspace either creates a blank session or reuses one, + // and either way the chip's pick predates it — so the stage is applied + // when the session arrives, not when it was made. + const stop = scope.sessions.list.subscribe(() => { void seat.apply() }) + // The chip opens on the deployment default, so a default changed from + // the settings surface moves it too — otherwise the screen that starts + // the next session keeps offering the previous default until a reload, + // which is exactly the session the setting claims to govern. A staged + // pick survives: `load()` prefers it over the refreshed fallback. + const settingsMoved = scope.on('settings/changed', (ns?: string) => { + if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return + void seat.load() + }) + // Authoring writes a FILE, not a setting, so nothing on the wire + // announces it — without this the screen that starts the next session + // keeps offering the roster as it stood when the chip first loaded, and + // a preset authored to be used is missing from the one place it is used. + const readRoster = (): void => { void seat.load() } + rosterReaders.add(readRoster) + // Stage WITHOUT applying — the still-current running session would + // refuse the swap and drop the stage — then start the session it lands + // on: the chip's list-change applier composes the blank session the + // workspace connect produces or reuses. + creatorDraft = () => { + seat.stage('cordis') + scope.workspaces.startSession() + } + const chip = scope.slots.register({ + name: 'conversation.hero.agentPreset', + locale: 'settings.agentPreset', + inject: seatInjected, + }, AgentPresetSeat) + const label = scope.slots.register({ + name: 'conversation.session.header.actions', + id: 'agent-preset', + order: 20, + locale: 'settings.agentPreset', + inject: labelInjected, + }, AgentPresetLabel) + return () => { + stop() + settingsMoved() + rosterReaders.delete(readRoster) + creatorDraft = undefined + chip() + label() + } + }, 'ui-agent-preset: new-session chip and header label') + }) + + const sectionInjected = (): AgentPresetSectionInjected => ({ + hooks: { agentPresetSection: section.store }, + load: () => section.load(), + view: (id: string) => section.view(id), + closeView: () => { section.closeView() }, + beginCopy: (from: string) => { section.beginCopy(from) }, + cancelCopy: () => { section.cancelCopy() }, + setCopyId: (id: string) => { section.setCopyId(id) }, + setCopyName: (name: string) => { section.setCopyName(name) }, + confirmCopy: () => section.confirmCopy(), + openLocation: (id: string) => section.openLocation(id), + ...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft }, + confirmDelete: (id: string | null) => { section.confirmDelete(id) }, + remove: () => section.remove(), + makeDefault: (id: string) => section.makeDefault(id), + }) + + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'agent-preset', + order: -25, + locale: 'settings.agentPreset', + inject: injected, + }, AgentPresetRow)) + // Ordered after Models: choosing a model is routine, and composing an + // agent is the deployment-shaping act behind it. + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'agent-presets', + order: 20, + label: () => ctx.locale.bind('settings.agentPreset')('nav'), + locale: 'settings.agentPreset', + inject: sectionInjected, + }, AgentPresetSection)) +} diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts new file mode 100644 index 0000000000..50a4e36138 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -0,0 +1,118 @@ +/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */ + +/** Locale keys these surfaces render. */ +export type AgentPresetSettingsKey = + | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' + | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' + | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' + | 'displayName' | 'displayNamePlaceholder' + | 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' + | 'brokenBadge' | 'brokenNoCopy' + | 'composition' | 'cancel' | 'close' | 'retry' + | 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft' + | 'openLocation' | 'showLocation' | 'revealedPathLabel' + | 'idRequired' | 'idInvalid' | 'idTaken' + | 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting' + +/** English copy. */ +export const en: Record = { + title: 'Agent preset', + description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.', + loading: 'Loading presets…', + error: 'Could not load agent presets.', + userTrust: 'Custom', + seatHint: 'Agent preset for the session you are about to start', + headerHint: 'The agent preset this session runs, fixed when it started', + nav: 'Agent presets', + sectionIntro: + 'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. ' + + 'Duplicate an existing one and make it yours, or let the agent draft one for you in Creator mode.', + builtIn: 'Built-in', + setDefault: 'Set as default', + view: 'View', + duplicate: 'Duplicate', + duplicateUnavailable: 'This deployment has no writable preset directory', + delete: 'Delete', + presetId: 'Identifier', + presetIdPlaceholder: 'my-agent', + displayName: 'Name', + displayNamePlaceholder: 'Shown in the picker; defaults to the identifier', + inUse: 'In use', + builtInGroup: 'Built-in', + customGroup: 'Custom', + noDescription: 'No description.', + brokenBadge: 'Broken', + brokenNoCopy: 'Broken presets cannot be duplicated', + copyOf: 'Copied from', + composition: 'Composition (agent.cordis.yml)', + cancel: 'Cancel', + close: 'Close', + retry: 'Retry', + copyTitle: 'Duplicate preset', + copyIntro: + 'The whole preset is copied on this machine. The identifier becomes its directory name and cannot ' + + 'be changed later; everything else is edited in the preset\'s own files.', + create: 'Create', + creating: 'Creating…', + creatorDraft: 'Draft a custom preset with Creator mode', + openLocation: 'Open folder', + showLocation: 'Show location', + revealedPathLabel: 'Preset files:', + idRequired: 'Give the preset an identifier.', + idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.', + idTaken: 'A preset with this identifier already exists.', + deleteTitle: 'Delete this preset?', + deleteDescription: + 'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.', + deleteConfirm: 'Delete', + deleting: 'Deleting…', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: 'Agent 预设', + description: '对此后新建的会话生效。运行中的会话保持它开始时的预设。', + loading: '正在加载预设…', + error: '无法加载 Agent 预设。', + userTrust: '自定义', + seatHint: '即将开始的这个会话所用的 Agent 预设', + headerHint: '本会话运行的 Agent 预设,开始时即固定', + nav: 'Agent 预设', + sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。', + builtIn: '内置', + setDefault: '设为默认', + view: '查看', + duplicate: '复制', + duplicateUnavailable: '此部署未配置可写的预设目录', + delete: '删除', + presetId: '标识符', + presetIdPlaceholder: 'my-agent', + displayName: '名称', + displayNamePlaceholder: '选择器中显示的名字,缺省用标识符', + inUse: '当前使用', + builtInGroup: '内置', + customGroup: '自定义', + noDescription: '暂无描述。', + brokenBadge: '已损坏', + brokenNoCopy: '预设已损坏,无法复制', + copyOf: '复制自', + composition: '组装(agent.cordis.yml)', + cancel: '取消', + close: '关闭', + retry: '重试', + copyTitle: '复制预设', + copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。', + create: '创建', + creating: '正在创建…', + creatorDraft: '用「创造模式」创作自定义预设', + openLocation: '打开目录', + showLocation: '查看路径', + revealedPathLabel: '预设文件:', + idRequired: '请填写标识符。', + idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。', + idTaken: '该标识符已被占用。', + deleteTitle: '删除该预设?', + deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。', + deleteConfirm: '删除', + deleting: '正在删除…', +} diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts new file mode 100644 index 0000000000..27a414e4a3 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -0,0 +1,163 @@ +/** + * Hero-chip controller: which preset the NEXT session gets. + * + * The new-session screen has no session, so a pick is staged rather than + * applied. It reaches a session when one becomes current and is still blank — + * whether the workspace connect created it or reused an existing blank one, + * which is why staging cannot simply ride along on `sessions.create`. + * + * The stage is forgotten once applied: the next new session starts from the + * deployment default again, matching the workspace picker beside it. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { + createSnapshotStore, type SessionId, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import { messageOf, presetOptions } from './settings-store.ts' +import type { AgentPresetOption } from './settings-store.ts' + +/** Hero-chip snapshot. */ +export interface AgentPresetSeatState { + /** Presets the deployment supplies; empty means the chip renders nothing. */ + options: readonly AgentPresetOption[] + /** The staged choice, empty until the roster loads. */ + current: string + /** A rejected apply's message, cleared by the next attempt. */ + error: string | null + busy: boolean +} + +const INITIAL: AgentPresetSeatState = { + options: [], current: '', error: null, busy: false, +} + +/** One session's identity and whether it has started. */ +export interface SeatSessionSummary { + /** The session the chip would apply its staged choice to. */ + id: SessionId + /** False once a turn has run — applying is refused from then on. */ + blank: boolean + /** The preset the session already runs, when the summary reports one. */ + agentPreset?: string +} + +/** Stages the next session's preset and applies it when one appears. */ +export class AgentPresetSeatController { + /** Chip snapshot the renderer subscribes to. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + /** + * The deployment default, so a consumed stage can fall back to it without + * re-reading the roster. + */ + private fallback = '' + + /** Set while a pick is waiting for a session; cleared once applied. */ + private staged: string | undefined + + constructor( + private readonly api: Pick, + /** The session the hero is about to hand over to, when there is one. */ + private readonly currentSession: () => SeatSessionSummary | undefined, + /** + * Publish an applied switch into the session list, so the header label + * moves with the composition instead of waiting for the next full list + * refresh. Optional: a harness that renders no list omits it. + */ + private readonly onApplied?: (sessionId: string, agentPreset: string) => void, + ) {} + + private set(patch: Partial): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + /** + * Read the roster and open the chip on the deployment default. + * @returns once the snapshot reflects the host. + */ + async load(): Promise { + try { + const response = await this.api.agentPresets.list({}) + if (!response.result.ok) { + this.set({ error: response.result.error.message }) + return + } + const { presets } = response.result.value + this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' + this.set({ + options: presetOptions(presets), + // Staged pick first, then the composition the current session + // already carries, then the deployment default. The middle term is + // what keeps a late-landing load from regressing the display after + // an applied stage was consumed — the chip mounts (and loads) only + // once the flow's session is current, so the reply can arrive after + // apply() already composed it. + current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback, + error: null, + }) + } catch (error) { + this.set({ error: messageOf(error) }) + } + } + + /** + * Stage one preset for the next session, applying it immediately when a + * blank session is already current. + * @param id - the preset to stage. + * @returns once the stage settled, and the apply too when one happened. + */ + async select(id: string): Promise { + if (this.store.getSnapshot().busy) return + this.stage(id) + await this.apply() + } + + /** + * Stage a pick WITHOUT the immediate apply, for a flow that starts the + * receiving session after the pick (the settings section's creator entry). + * `select()`'s immediate apply would meet the still-current running session + * and drop the stage as unservable; staging alone leaves it for the + * list-change applier, which fires when the started session becomes + * current. + * @param id - the preset to stage. + */ + stage(id: string): void { + this.staged = id + this.set({ current: id, error: null }) + } + + /** + * Hand the staged choice to the current session, if there is one to take it. + * + * Called both by `select()` and by whoever observes the current session + * changing, because the session may appear either before or after the pick. + * @returns once the switch settled, or immediately when there is nothing to do. + */ + async apply(): Promise { + const staged = this.staged + const session = this.currentSession() + if (staged === undefined || session === undefined) return + // A started session's history was produced under its own composition; the + // host refuses the swap, so the stage is no longer meaningful. + if (!session.blank || session.agentPreset === staged) { + this.staged = undefined + return + } + this.set({ busy: true, error: null }) + try { + const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged }) + this.staged = undefined + if (!response.result.ok) { + this.set({ busy: false, error: response.result.error.message, current: this.fallback }) + return + } + // Consumed: the next new session opens on the deployment default again. + this.set({ busy: false, current: response.result.value.agentPreset }) + this.onApplied?.(session.id, response.result.value.agentPreset) + } catch (error) { + this.staged = undefined + this.set({ busy: false, error: messageOf(error), current: this.fallback }) + } + } +} diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts new file mode 100644 index 0000000000..df430f6db4 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -0,0 +1,347 @@ +/** + * Agent-preset management controller: the roster as a list, a copy dialog as + * the only way a preset is created, and a read-only viewer over the shipped + * compositions. + * + * The browser edits no composition text. A new preset is a host-side copy of + * an existing one (`{ from, id, name? }` is all that crosses the wire), and + * everything after creation happens in the preset's own files — which is why + * the page's other job is getting the user TO those files: open the directory + * where the host has a desktop, show its path where it does not. + * + * The host stays the single fact source. Every mutation writes through the + * wire and the page re-reads the roster afterwards, because a copy changes + * more than the row it targeted. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' + +/** Ids a preset directory may be named, mirroring the host's own rule. */ +const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ + +/** One preset row the page renders. */ +export interface PresetRow { + /** Preset id and directory name; the display name falls back to it. */ + id: string + /** Display name the preset published, absent when it published none. */ + name?: string + /** One sentence on what the preset is for. */ + description?: string + /** Whether the preset ships with the deployment or was authored locally. */ + trust: 'system' | 'user' + /** Whether a session that names no preset gets this one. */ + isDefault: boolean + /** + * Why the preset cannot compose a session, absent when it can. A broken + * row renders marked and unselectable — its directory still occupies the + * id, so deleting it (or fixing the files) is the way out, and this page + * is where both of those live. + */ + broken?: string +} + +/** The copy dialog: a new id and optional display name over a fixed source. */ +export interface CopyDraft { + /** The preset being copied. */ + from: string + /** Display name of the source, for the dialog title. */ + fromTitle: string + /** New preset id being typed; the directory name, so it is required. */ + id: string + /** Display name being typed; empty falls back to the id. */ + name: string + /** Whether the copy is in flight. */ + saving: boolean + /** The last copy failure, cleared by the next edit. */ + error: string | null +} + +/** The read-only composition viewer over one shipped preset. */ +export interface PresetView { + /** The preset whose composition is shown. */ + id: string + /** Display name, for the dialog title. */ + title: string + /** Composition text exactly as stored. */ + content: string +} + +/** Page snapshot. */ +export interface AgentPresetSectionState { + status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error' + /** Whole-load failure text; a copy failure stays on the dialog. */ + error: string | null + /** Whether the deployment configures a root new presets can be written to. */ + authorable: boolean + /** Whether the host can open a preset directory on a native desktop. */ + hasDocument: boolean + /** Every preset the deployment currently supplies. */ + rows: readonly PresetRow[] + /** The open copy dialog, or null. */ + copy: CopyDraft | null + /** The open read-only viewer, or null. */ + view: PresetView | null + /** The preset awaiting delete confirmation. */ + pendingDelete: string | null + /** Whether a delete is in flight. */ + deleting: boolean + /** + * Preset directories shown as text because the host has no desktop opener + * — the answer `openDocument` gives instead of opening. + */ + revealedPaths: Readonly> +} + +const INITIAL: AgentPresetSectionState = { + status: 'idle', + error: null, + authorable: false, + hasDocument: false, + rows: [], + copy: null, + view: null, + pendingDelete: null, + deleting: false, + revealedPaths: {}, +} + +/** + * Why this copy cannot be submitted yet, as a locale key, or undefined when + * it can. Client-side only: the host re-checks the id and its answer is what + * the dialog reports on failure. + * @param draft - the open copy dialog. + * @param rows - the roster, for the collision check. + * @returns the blocking reason's locale key, or undefined when submittable. + */ +export function draftBlocker( + draft: CopyDraft, + rows: readonly PresetRow[], +): 'idRequired' | 'idInvalid' | 'idTaken' | undefined { + if (draft.id === '') return 'idRequired' + if (!PRESET_ID.test(draft.id)) return 'idInvalid' + // A copy never overwrites: landing on a name already in use would replace + // something the user did not open. + if (rows.some(row => row.id === draft.id)) return 'idTaken' + return undefined +} + +/** Reads the roster and drives the copy dialog, viewer, and location reveals. */ +export class AgentPresetSectionController { + /** Page snapshot the renderer subscribes to. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + constructor( + private readonly api: Pick, + /** + * Called after this page changes the roster DIRECTORY, so the other + * surfaces reading the same roster re-read it. A settings field moving is + * already announced by the host through `settings/changed`; a directory + * copied or deleted here is not, and the new-session chip has no other + * way to learn a preset it should offer now exists. + */ + private readonly rosterChanged: () => void = () => {}, + ) {} + + private set(patch: Partial): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + private patchCopy(patch: Partial): void { + const { copy } = this.store.getSnapshot() + if (copy === null) return + this.set({ copy: { ...copy, ...patch } }) + } + + /** + * Load the roster. An empty roster means the deployment composes no + * presets, which is a valid deployment rather than a failure — the section + * reports `unavailable` and renders nothing. + * @returns once the snapshot reflects the host. + */ + async load(): Promise { + const roster = await beginRosterRead(this.api, this.store) + if (roster === undefined) return + const { presets, authorable, hasDocument } = roster + if (presets.length === 0) { + // Nothing to manage leaves nothing to keep a dialog open over. + this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) + return + } + // A reveal outlives a reload but not its preset: a path for a row the + // roster no longer lists would be a claim about a directory that is gone. + const revealed = this.store.getSnapshot().revealedPaths + const kept = Object.fromEntries( + Object.entries(revealed).filter(([id]) => presets.some(preset => preset.id === id))) + this.set({ + status: 'ready', + error: null, + authorable, + hasDocument, + rows: presets.map(preset => ({ ...preset })), + revealedPaths: kept, + }) + } + + /** + * Open one shipped preset's composition in the read-only viewer. + * @param id - the preset to view. + * @returns once the composition loaded or the failure is on the page. + */ + async view(id: string): Promise { + this.set({ error: null }) + try { + const response = await this.api.agentPresets.read({ agentPreset: id }) + if (!response.result.ok) { + this.set({ error: response.result.error.message }) + return + } + const { name, content } = response.result.value + this.set({ view: { id, title: name ?? id, content } }) + } catch (error) { + this.set({ error: messageOf(error) }) + } + } + + /** Close the read-only viewer. */ + closeView(): void { + this.set({ view: null }) + } + + /** + * Open the copy dialog over one preset. + * @param from - the preset the copy will start from. + */ + beginCopy(from: string): void { + const row = this.store.getSnapshot().rows.find(candidate => candidate.id === from) + this.set({ + error: null, + copy: { from, fromTitle: row?.name ?? from, id: '', name: '', saving: false, error: null }, + }) + } + + /** Close the copy dialog, discarding whatever was typed. */ + cancelCopy(): void { + this.set({ copy: null }) + } + + /** + * Name the preset the copy creates. + * @param id - the id typed into the dialog. + */ + setCopyId(id: string): void { + this.patchCopy({ id, error: null }) + } + + /** + * Name the copy's display name. + * @param name - the display name typed into the dialog. + */ + setCopyName(name: string): void { + this.patchCopy({ name, error: null }) + } + + /** + * Submit the copy, re-read the roster, then take the user to the new + * preset's files — the directory opens where the host has a desktop, and + * its path appears on the new row where it does not. + * @returns once the copy settled and the page reflects it. + */ + async confirmCopy(): Promise { + const draft = this.store.getSnapshot().copy + if (draft === null || draft.saving) return + if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return + this.patchCopy({ saving: true, error: null }) + try { + const name = draft.name.trim() + const response = await this.api.agentPresets.copy({ + from: draft.from, + agentPreset: draft.id, + ...name === '' ? {} : { name }, + }) + if (!response.result.ok) { + this.patchCopy({ saving: false, error: response.result.error.message }) + return + } + this.set({ copy: null }) + await this.load() + this.rosterChanged() + // A preset is its files from here on (the dialog collected nothing + // else), so landing in them is the completion, not a follow-up. + await this.openLocation(draft.id) + } catch (error) { + this.patchCopy({ saving: false, error: messageOf(error) }) + } + } + + /** + * Open one preset's directory on the host desktop, or reveal its path on + * the row where the deployment has no opener to hand it to. + * @param id - the preset whose files the user wants. + * @returns once the host answered and the page reflects it. + */ + async openLocation(id: string): Promise { + try { + const response = await this.api.agentPresets.openDocument({ agentPreset: id }) + if (!response.result.ok) { + this.set({ error: response.result.error.message }) + return + } + if (response.result.value.opened) return + const { path } = response.result.value + this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) + } catch (error) { + this.set({ error: messageOf(error) }) + } + } + + /** + * Ask for confirmation before deleting one preset. + * @param id - the preset to delete, or null to dismiss the confirmation. + */ + confirmDelete(id: string | null): void { + if (this.store.getSnapshot().deleting) return + this.set({ pendingDelete: id }) + } + + /** + * Delete the preset awaiting confirmation, then re-read the roster. + * + * A session already composed from it keeps running: its composition was + * mounted at creation and nothing re-reads the file. + * @returns once the delete settled and the page reflects it. + */ + async remove(): Promise { + const { pendingDelete, deleting } = this.store.getSnapshot() + if (pendingDelete === null || deleting) return + this.set({ deleting: true, error: null }) + try { + const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete }) + if (!response.result.ok) { + this.set({ deleting: false, pendingDelete: null, error: response.result.error.message }) + return + } + this.set({ deleting: false, pendingDelete: null }) + await this.load() + this.rosterChanged() + } catch (error) { + this.set({ deleting: false, pendingDelete: null, error: messageOf(error) }) + } + } + + /** + * Make one preset the default for sessions created later. Running sessions + * keep the composition they began with, so this never disturbs work. + * @param id - the preset to make default. + * @returns once the write settled and the roster was re-read. + */ + async makeDefault(id: string): Promise { + const failure = await writeDefaultPreset(this.api, id) + if (failure !== undefined) { + this.set({ error: failure }) + return + } + await this.load() + } +} diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts new file mode 100644 index 0000000000..c9499c40a9 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -0,0 +1,255 @@ +/** + * Agent-preset default-settings controller. + * + * Options and the current default both come from one `agentPreset.list` call: + * the roster already reports which id a session with no explicit choice gets, + * so the row needs no schema introspection. Writes target the settings + * namespace's `default` field, which is what the host resolves at creation. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** The agent-preset settings namespace on the host wire. */ +export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' + +/** + * Human text for a rejected wire call. A transport failure rejects with an + * Error; a host or a runtime can reject with anything, and the surface still + * has to say something. + * @param error - the rejection value. + * @returns the message to show. + */ +export function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Persist one preset as the default for sessions created later. + * + * The default is a settings field rather than a preset property, so both the + * General row and the management section write it here — one home for which + * namespace and field the host resolves at session creation. + * @param api - the settings wire face. + * @param id - the preset to make default. + * @returns the failure message, or undefined once the write landed. + */ +export async function writeDefaultPreset( + api: Pick, + id: string, +): Promise { + let response + try { + response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } }) + } catch (error) { + // The transport rejected rather than answering; the caller must be able to + // say so instead of the row silently snapping back. + return messageOf(error) + } + return response.result.ok ? undefined : response.result.error.message +} + +/** One selectable preset. */ +export interface AgentPresetOption { + /** Preset id, written to Settings and the label's fallback. */ + id: string + /** Whether the preset ships with the deployment or was authored locally. */ + trust: 'system' | 'user' + /** Display name the preset published, absent when it published none. */ + name?: string + /** One sentence on what the preset is for. */ + description?: string +} + +/** One roster entry exactly as the host reports it. */ +export interface RosterPreset { + /** Preset id and directory name. */ + id: string + /** Whether the preset ships with the deployment or was authored locally. */ + trust: 'system' | 'user' + /** Whether a session that names no preset gets this one. */ + isDefault: boolean + /** Display name the preset published, absent when it published none. */ + name?: string + /** One sentence on what the preset is for. */ + description?: string + /** Why the preset cannot compose a session, absent when it can. */ + broken?: string +} + +/** The roster the host answered with. */ +export interface RosterValue { + /** Every preset the deployment composes, in the order the host lists them. */ + presets: readonly RosterPreset[] + /** Whether this browser may author presets at all. */ + authorable: boolean + /** Whether the host can open a preset directory on a native desktop. */ + hasDocument: boolean +} + +/** The roster, or the message to show in its place. */ +export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: string } + +/** + * Read the roster, folding both refusal shapes into one message. + * + * The wire refuses in two ways — the transport rejects, or it answers an + * `ok: false` envelope — and every surface treats them identically. Folding + * them here keeps each store's `load` about what it does with a roster rather + * than about how the call can fail. + * @param api - the agent-preset wire face. + * @returns the roster, or the message to show in its place. + */ +export async function readRoster(api: Pick): Promise { + try { + const response = await api.agentPresets.list({}) + return response.result.ok + ? { ok: true, value: response.result.value } + : { ok: false, error: response.result.error.message } + } catch (error) { + return { ok: false, error: messageOf(error) } + } +} + +/** + * The opening move every roster-backed surface makes: refuse a read that is + * already in flight, mark the store loading, then read. + * + * A surface that gets `undefined` returns without touching its snapshot + * further — either another read owns it, or this one already wrote the + * failure. What differs between surfaces starts after this. + * @param api - the agent-preset wire face. + * @param store - the surface's own snapshot store. + * @returns the roster, or undefined when the caller should return. + */ +export async function beginRosterRead( + api: Pick, + store: SnapshotStore, +): Promise { + const before = store.getSnapshot() + if (before.status === 'loading') return undefined + store.set({ ...before, status: 'loading', error: null }) + const roster = await readRoster(api) + if (roster.ok) return roster.value + store.set({ ...store.getSnapshot(), status: 'error', error: roster.error }) + return undefined +} + +/** + * The roster entries as the pickers render them: healthy presets only. + * + * The chip and the row exist to choose the NEXT session's composition, and a + * broken preset cannot compose one — offering it would defer the discovery + * of that fact to a failed session start. The management section renders the + * full roster (broken rows included) from its own store instead. + * + * The chip, the row, and the management section all show the same facts, and + * `exactOptionalPropertyTypes` makes "absent" and "present as undefined" + * different shapes — so the spread dance belongs in one place rather than + * once per store. + * @param presets - the roster the host answered with. + * @returns one option per selectable preset, in roster order. + */ +export function presetOptions( + presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string; broken?: string }[], +): AgentPresetOption[] { + return presets.filter(preset => preset.broken === undefined).map(preset => ({ + id: preset.id, + trust: preset.trust, + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + })) +} + +/** Agent-preset settings-row snapshot. */ +export interface AgentPresetSettingsState { + status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error' + error: string | null + /** + * Whether this browser may persist the choice at all. `settings.describe` is + * loopback-only and reports a read-only provider as `writable: false`; the + * row then shows the current default and disables the control rather than + * offering a write the gateway will refuse. + */ + writable: boolean + currentValue: string + options: readonly AgentPresetOption[] +} + +const INITIAL: AgentPresetSettingsState = { + status: 'idle', + error: null, + // Assumed until `load()` asks; a row that has not read yet renders nothing + // interactive anyway (status 'idle'). + writable: true, + currentValue: '', + options: [], +} + +/** Reads the roster and persists the chosen default. */ +export class AgentPresetSettingsController { + /** Row snapshot the renderer subscribes to. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + constructor(private readonly api: IApiClient) {} + + private set(patch: Partial): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + /** + * Load the roster. An empty roster means the deployment composes no + * presets, which is a valid deployment rather than a failure — the row + * reports `unavailable` and renders nothing. + * @returns once the snapshot reflects the host. + */ + async load(): Promise { + const roster = await beginRosterRead(this.api, this.store) + if (roster === undefined) return + const { presets } = roster + const [first] = presets + if (first === undefined) { + this.set({ status: 'unavailable', options: [], currentValue: '' }) + return + } + try { + // The roster says what may be chosen; `settings.describe` says whether + // this browser may write the choice down. A non-loopback browser reaches + // neither method, so a refused describe leaves the row read-only rather + // than offering a control whose write answers `settings-not-exposed`. + const described = await this.api.settings.describe({}) + this.set({ + status: 'ready', + error: null, + writable: described.result.ok && described.result.value.writable, + options: presetOptions(presets), + // A roster can mark nothing default: settings can name a preset that + // was since deleted, and the picker still has to show something. + currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id, + }) + } catch (error) { + this.set({ status: 'error', error: messageOf(error) }) + } + } + + /** + * Persist one preset as the default for sessions created later. Running + * sessions keep the composition they were created with, so this never + * disturbs work in progress. + * @param id - the preset to make default. + * @returns once the write settled and the roster was re-read. + */ + async select(id: string): Promise { + const before = this.store.getSnapshot() + if (before.status === 'saving' || id === before.currentValue) return + this.set({ status: 'saving', error: null, currentValue: id }) + const failure = await writeDefaultPreset(this.api, id) + if (failure !== undefined) { + this.set({ status: 'ready', currentValue: before.currentValue, error: failure }) + return + } + // Re-read rather than trust the patch: the host resolves the default + // through the same roster the row displays. + await this.load() + } +} diff --git a/packages/client/ui-agent-preset/src/css-modules.d.ts b/packages/client/ui-agent-preset/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-agent-preset/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-agent-preset/src/index.ts b/packages/client/ui-agent-preset/src/index.ts new file mode 100644 index 0000000000..c145962f1d --- /dev/null +++ b/packages/client/ui-agent-preset/src/index.ts @@ -0,0 +1,9 @@ +/** + * Agent-preset surface plugin, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half ships the + * General-settings row through exports["./client"], discovered from the + * package.json dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-agent-preset/src/invariant.ts b/packages/client/ui-agent-preset/src/invariant.ts new file mode 100644 index 0000000000..1794763066 --- /dev/null +++ b/packages/client/ui-agent-preset/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`. + * @module @deepseek-ai/dsh-client-ui-agent-preset/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-agent-preset-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream + * or mutable runtime data; the roster and the settings write are host contracts covered there. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts new file mode 100644 index 0000000000..6272501183 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -0,0 +1,546 @@ +/** + * Registration: the General row, the settings section, the new-session chip, + * and the header label all come from one apply, and each defers until the slot + * it fills has been declared. A pushed settings change refreshes the surfaces + * that are already showing, so a default set from one converges the other. + */ + +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' +import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' +import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' +import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' +import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx' +import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' +import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx' +import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' +import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' + +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +usePinnedBrowserLanguages('zh-CN') + +const ROSTER_ONE = { + rpcId: 'r', + result: { + ok: true as const, + value: { + presets: [{ id: 'standard', trust: 'system', isDefault: true }], + authorable: true, + hasDocument: true, + }, + }, +} + +/** The roster after this browser copied one preset of its own. */ +const ROSTER_AUTHORED = { + rpcId: 'r', + result: { + ok: true as const, + value: { + presets: [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ], + authorable: true, + hasDocument: true, + }, + }, +} + +/** The same roster with a second preset carrying the default. */ +const ROSTER_MOVED = { + rpcId: 'r', + result: { + ok: true as const, + value: { + presets: [ + { id: 'standard', trust: 'system', isDefault: false }, + { id: 'minimal', trust: 'system', isDefault: true }, + ], + authorable: true, + hasDocument: true, + }, + }, +} + +async function bench() { + const ctx = new Context() + // The host's answer, mutable so a spec can move the default the way the + // settings surface does and watch who re-reads it. + let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE + const moveDefault = (): void => { ROSTER = ROSTER_MOVED } + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + const calls: string[] = [] + ctx.provide('connection', { + api: { + agentPresets: { + list: () => { calls.push('list'); return Promise.resolve(ROSTER) }, + read: () => Promise.resolve({ + rpcId: 'r', + result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } }, + }), + copy: (payload: { from: string; agentPreset: string }) => { + calls.push(`copy:${payload.agentPreset}`) + // The host's roster now contains it, which is the whole point of the + // copy and what every surface must converge on. + ROSTER = ROSTER_AUTHORED + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) + }, + openDocument: (payload: { agentPreset: string }) => { + calls.push(`openDocument:${payload.agentPreset}`) + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } }) + }, + remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }), + select: (payload: { agentPreset: string }) => { + calls.push(`select:${payload.agentPreset}`) + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) + }, + }, + settings: { + // The row reads this to learn whether this browser may write at all. + describe: () => Promise.resolve({ + rpcId: 'r', + result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }, + }), + update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) }, + }, + }, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, calls, moveDefault } +} + +function declareRoot(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { + 'settings.general.item': { kind: 'list', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + conversation: { kind: 'single', scope: 'root' }, + }, + } as never, () => null) +} + +/** The conversation's own declarations, which the chip and label wait for. */ +function declareConversation(slots: SlotsService): () => void { + return slots.register({ + name: 'conversation', + children: { + 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) +} + +/** A workspaces double recording new-session starts. */ +function workspacesDouble() { + const starts: unknown[] = [] + return { + starts, + startSession: (workspaceId?: unknown) => { starts.push(workspaceId ?? null) }, + } +} + +/** A sessions double whose list can be moved and whose changes are pushed. */ +function sessionsDouble(state: { + current?: string + byId: Record +}) { + const listeners = new Set<() => void>() + return { + list: { + getSnapshot: () => state, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + }, + /** Push a list change the way the runtime's store does. */ + notify: () => { for (const fn of listeners) fn() }, + } +} + +describe('ui-agent-preset apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'connection']) + }) + + it('registers the General row and the settings section', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + const row = slots.entries('settings.general.item')[0]! + expect(row.component).toBe(AgentPresetRow) + expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 }) + const section = slots.entries('settings.section')[0]! + expect(section.component).toBe(AgentPresetSection) + expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 }) + // The nav label is a locale-following thunk; owners resolve it at read time. + expect(resolveSlotLabel(section.options.label)).toBe('Agent 预设') + }) + + it('registers into a declaration that arrives after apply', async () => { + const { ctx, slots } = await bench() + await ctx.plugin({ inject: [...inject], apply }).await() + + declareRoot(slots) + + await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) + }) + + it('hands each surface its own store and actions', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + + const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + + expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection) + // Each thunk reaches its own controller: the row's load fills the row's + // store, and the section's default write does not go through the row. + await row.load() + await row.select('standard') + await section.makeDefault('standard') + expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) + expect(section.hooks.agentPresetSection.getSnapshot().rows) + .toEqual([{ id: 'standard', trust: 'system', isDefault: true }]) + }) + + it('routes the section actions to one controller', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + + await section.load() + section.beginCopy('standard') + section.cancelCopy() + section.beginCopy('standard') + section.setCopyId('mine') + section.setCopyName('我的模式') + await section.confirmCopy() + await section.view('standard') + section.closeView() + section.confirmDelete('mine') + await Promise.all([section.openLocation('mine'), section.remove()]) + + // One controller behind every action: the copy the dialog named is the + // one the roster re-read reflects, and the delete the section confirmed + // is the one its remove() sees. + expect(calls).toContain('copy:mine') + expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0) + expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2) + }) + + it('refreshes a showing surface when its namespace changes, and ignores others', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + await section.load() + const before = calls.length + + ctx.emit('settings/changed', 'agent-presets') + await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) + const afterRelevant = calls.length + + ctx.emit('settings/changed', 'llm-deepseek') + await Promise.resolve() + + // Both surfaces re-read on their own namespace; an unrelated one moves + // neither, so this rules out a blanket refresh on every settings write. + expect(calls.length).toBe(afterRelevant) + }) + + it('re-reads both surfaces when the connection comes back', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + await section.load() + const before = calls.length + + ctx.emit('connection/reset') + + // A reconnect can land on a host whose roster changed under the browser. + await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) + }) + + it('leaves the section alone until it has been opened once', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const before = calls.length + + ctx.emit('settings/changed', 'agent-presets') + await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) + + // Only the General row reloads: a section nobody opened has nothing to + // converge, and reading the roster for it would be a wasted round trip. + expect(calls.length - before).toBe(1) + }) + + it('registers the new-session chip and the header label, and drops both on disposal', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }) + await fiber.await() + + const chip = slots.entries('conversation.hero.agentPreset')[0]! + expect(chip.component).toBe(AgentPresetSeat) + const label = slots.entries('conversation.session.header.actions')[0]! + expect(label.component).toBe(AgentPresetLabel) + expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 }) + await fiber.dispose() + expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0) + expect(slots.entries('conversation.session.header.actions')).toHaveLength(0) + expect(slots.entries('settings.section')).toHaveLength(0) + conversation() + }) + + it('moves the chip when the default changes on the settings surface', async () => { + const { ctx, slots, moveDefault } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + + const chip = slots.entries('conversation.hero.agentPreset')[0]! + const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() + await seat.load() + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') + + // The chip opens on the deployment default, and the setting it comes from + // lives on another screen: without this the next session — the very one + // the setting governs — would be composed from the previous default until + // a reload. + // An unrelated namespace moves nothing: the chip re-reads on its own + // setting, not on every settings write in the process. + moveDefault() + ctx.emit('settings/changed', 'llm-deepseek') + await Promise.resolve() + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') + + ctx.emit('settings/changed', 'agent-presets') + await vi.waitFor(() => { + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal') + }) + conversation() + }) + + it('offers a just-authored preset on the new-session chip', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + + const chip = slots.entries('conversation.hero.agentPreset')[0]! + const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() + await seat.load() + expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard']) + + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + await section.load() + section.beginCopy('standard') + section.setCopyId('mine') + section.setCopyName('我的模式') + await section.confirmCopy() + + // Authoring copies a directory rather than writing a setting, so nothing + // on the wire announces it: a preset created to be used must appear on + // the one screen that starts sessions, without a reload. + await vi.waitFor(() => { + expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine']) + }) + conversation() + }) + + it('applies the staged choice to the blank session the flow lands on', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const state: { + current?: string + byId: Record + } = { byId: {} } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + // Picked on the hero screen, where there is no session yet. + await chip.select('minimal') + expect(calls).not.toContain('select:minimal') + + state.current = 's1' + state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' } + sessions.notify() + + // Connecting a workspace produced the session; the stage reaches it there. + await vi.waitFor(() => { expect(calls).toContain('select:minimal') }) + }) + + it('applies the stage to a session that records no preset of its own', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const sessions = sessionsDouble({ + current: 's1', + byId: { s1: { id: 's1', blank: true } }, + }) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + await chip.select('minimal') + + // A session created before the deployment composed presets records none; + // reading that as "already runs it" would drop the pick on the floor. + expect(calls).toContain('select:minimal') + }) + + it('forgets the stage once it has been spent', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const state = { + current: 's1', + byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, + } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + await chip.select('minimal') + const spent = calls.filter(call => call === 'select:minimal').length + sessions.notify() + sessions.notify() + + // Every later list movement would re-apply a stage that was not cleared, + // switching sessions the user never picked for. + await Promise.resolve() + expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent) + }) + + it('gives the header label the same roster the General row reads', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const label = (slots.entries('conversation.session.header.actions')[0]! + .inject as unknown as () => AgentPresetLabelInjected)() + const row = (slots.entries('settings.general.item')[0]! + .inject as unknown as () => AgentPresetRowInjected)() + + await label.load() + + // One roster behind both: the label resolves a name the settings row's own + // load already fetched, rather than issuing a second read per session. + expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset) + expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) + }) + + it('stages the creator preset and starts a session from the section', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + const workspaces = workspacesDouble() + ctx.provide('workspaces', workspaces as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + const seat = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + section.startCreatorDraft?.() + + // The pick is staged on the chip's own controller — the session the + // workspace start produces is what the stage lands on — and exactly one + // new-session flow began. + expect(section.startCreatorDraft).toBeDefined() + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') + expect(workspaces.starts).toHaveLength(1) + conversation() + }) + + it('keeps the applied composition when the roster load lands late', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + const state: { + current?: string + byId: Record + } = { byId: {} } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + const seat = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + section.startCreatorDraft?.() + state.current = 's1' + state.byId['s1'] = { id: 's1', blank: true } + sessions.notify() + await vi.waitFor(() => { expect(calls).toContain('select:cordis') }) + + // The chip mounts with the flow's session, so its roster load can land + // AFTER the stage was consumed; the session's own composition is what + // the display must keep — not the deployment default. + state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'cordis' } + await seat.load() + + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') + conversation() + }) + + it('offers no creator draft while the conversation flow is absent', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + // No conversation scope mounted: the face omits the affordance and the + // section hides its button rather than staging into nowhere. + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + expect(section.startCreatorDraft).toBeUndefined() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx new file mode 100644 index 0000000000..7bc3d59e04 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -0,0 +1,300 @@ +// @vitest-environment jsdom +/** + * The three conversation-adjacent surfaces: the General-settings row naming the + * default for later sessions, the new-session chip naming the next one's, and + * the session header's read-only label. The split is the host's rule — a + * session's history is produced under its preset's tools, so the choice is + * only ever offered before one starts. + */ + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, 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 { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' +import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx' +import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' +import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx' +import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' +import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx' +import type { AgentPresetSettingsState } from '../src/client/settings-store.ts' +import type { AgentPresetSeatState } from '../src/client/seat-store.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const ROW_READY: AgentPresetSettingsState = { + status: 'ready', + error: null, + writable: true, + currentValue: 'standard', + // `mine` deliberately names itself nothing: the row must fall back to the + // id for a preset whose author wrote no metadata. + options: [{ id: 'standard', trust: 'system', name: '标准模式' }, { id: 'mine', trust: 'user' }], +} + +const SEAT_READY: AgentPresetSeatState = { + current: 'standard', + options: [ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + { id: 'mine', trust: 'user' }, + ], + busy: false, + error: null, +} + +function renderRow(state: Partial = {}) { + const store = createSnapshotStore({ ...ROW_READY, ...state }) + const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + render( en[key], + } as unknown as AgentPresetRowProps)} />) + return actions +} + +function renderSeat(state: Partial = {}) { + const store = createSnapshotStore({ ...SEAT_READY, ...state }) + const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + render( en[key], + } as unknown as AgentPresetSeatProps)} />) + return actions +} + +function renderLabel( + summary: { blank: boolean; agentPreset?: string } | undefined, + roster: Partial = {}, +) { + // The chip and the label read the same roster, metadata included. + const store = createSnapshotStore({ + ...ROW_READY, options: SEAT_READY.options, ...roster, + }) + const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } }) + const load = vi.fn(() => Promise.resolve()) + const view = render( en[key], + } as unknown as AgentPresetLabelProps)} />) + return { load, view } +} + +describe('the General-settings row', () => { + it('reads the roster once and shows the current default', async () => { + const actions = renderRow() + + await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) + expect(screen.getByRole('button').textContent).toContain('标准模式') + }) + + it('marks a locally authored option as local', () => { + renderRow() + + fireEvent.click(screen.getByRole('button')) + + // A local preset is exactly as privileged as the plugins it names, so the + // list says which rows are local rather than presenting all as vetted. + expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() + // The shipped one carries no marker; only local rows are called out. + expect(screen.getAllByText('标准模式')).toHaveLength(2) + }) + + it('falls back to the id for a preset that published no name', () => { + renderRow({ + currentValue: 'mine', + options: [ + { id: 'standard', trust: 'system', name: '标准模式' }, + { id: 'bare', trust: 'system' }, + { id: 'mine', trust: 'user' }, + { id: 'ours', trust: 'user', name: '团队模式' }, + ], + }) + + // The trigger names the preset; with no metadata the id is all there is. + expect(screen.getByRole('button').textContent).toContain('mine') + + fireEvent.click(screen.getByRole('button')) + + // A locally authored preset is marked whether or not it named itself. + expect(screen.getByText(`团队模式 · ${en.userTrust}`)).toBeTruthy() + expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() + // A shipped preset with no metadata is listed by id and carries no mark. + expect(screen.getByText('bare')).toBeTruthy() + }) + + it('writes the picked preset and closes the menu', () => { + const actions = renderRow() + fireEvent.click(screen.getByRole('button')) + + fireEvent.click(screen.getByText(`mine · ${en.userTrust}`)) + + expect(actions.select).toHaveBeenCalledWith('mine') + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) + + it('closes on an outside dismissal', () => { + renderRow() + fireEvent.click(screen.getByRole('button')) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) + + it('says it is loading before the roster answers', () => { + renderRow({ status: 'loading', currentValue: '' }) + + expect(screen.getByRole('button').textContent).toContain(en.loading) + expect(screen.getByRole('button')).toHaveProperty('disabled', true) + }) + + it('shows a failure in place of the description', () => { + renderRow({ error: 'roster unavailable' }) + + expect(screen.getByRole('alert').textContent).toBe('roster unavailable') + }) + + it('renders nothing when the deployment composes no presets', () => { + const { container } = render( Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + useAgentPreset: bindSnapshotSelector( + createSnapshotStore({ ...ROW_READY, status: 'unavailable', options: [] })), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetRowProps)} />) + + expect(container.firstChild).toBeNull() + }) + + it('closes and locks the menu when the settings turn read-only', () => { + const store = createSnapshotStore(ROW_READY) + render( Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + useAgentPreset: bindSnapshotSelector(store), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetRowProps)} />) + fireEvent.click(screen.getByRole('button')) + + act(() => { store.set({ ...ROW_READY, writable: false }) }) + + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button')).toHaveProperty('disabled', true) + }) +}) + +describe('the new-session chip', () => { + it('reads the roster once and shows the staged preset by name', async () => { + const actions = renderSeat() + + await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) + expect(screen.getByRole('button').textContent).toContain('标准模式') + expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint) + }) + + it('offers each preset with what it is for', () => { + renderSeat() + + fireEvent.click(screen.getByRole('button')) + + // The id alone never said what a preset does; the description is the + // whole reason a preset can publish metadata at all. + expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + // A preset that published none still reads as a row, with its id standing + // in for the name. + expect(screen.getByText(en.noDescription)).toBeTruthy() + expect(screen.getByText('mine')).toBeTruthy() + }) + + it('falls back to the id when the staged preset published no name', () => { + renderSeat({ current: 'mine' }) + + expect(screen.getByRole('button').textContent).toContain('mine') + }) + + it('stages the picked preset and closes the menu', () => { + const actions = renderSeat() + fireEvent.click(screen.getByRole('button')) + + fireEvent.click(screen.getByText('mine')) + + expect(actions.select).toHaveBeenCalledWith('mine') + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) + + it('disables the trigger while a switch is in flight', () => { + renderSeat({ busy: true }) + + expect(screen.getByRole('button')).toHaveProperty('disabled', true) + }) + + it('shows a refused switch on the trigger', () => { + renderSeat({ error: 'session has already started' }) + + expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started') + }) + + it('renders nothing before the roster arrives or when there is none', () => { + const empty = renderSeat({ options: [] }) + expect(empty).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() + cleanup() + + renderSeat({ current: '' }) + expect(screen.queryByRole('button')).toBeNull() + }) + + it('closes on an outside dismissal', () => { + renderSeat() + fireEvent.click(screen.getByRole('button')) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) +}) + +describe('the session-header label', () => { + it('names the preset the session runs, and never offers a switch', async () => { + const { load } = renderLabel({ blank: false, agentPreset: 'standard' }) + + await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) + // A control here would promise a switch the host refuses outright. + expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式') + }) + + it('falls back to the id, and to the generic hint, when metadata is absent', () => { + renderLabel({ blank: true, agentPreset: 'mine' }) + + expect(screen.getByTitle(en.headerHint).textContent).toBe('mine') + }) + + it('shows the id until the roster resolves it', () => { + renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] }) + + // The session's own summary is the authority on which preset it runs; the + // roster only supplies the display name, and its arrival is a later frame. + expect(screen.getByTitle(en.headerHint).textContent).toBe('standard') + }) + + it('renders nothing, and reads no roster, when the session records no preset', async () => { + const absent = renderLabel({ blank: true }) + expect(absent.view.container.firstChild).toBeNull() + cleanup() + + // A session the list has not caught up to is the same answer: a deployment + // that composes no presets must not pay for a roster read per header. + const unknown = renderLabel(undefined) + expect(unknown.view.container.firstChild).toBeNull() + await act(async () => { await Promise.resolve() }) + expect(absent.load).not.toHaveBeenCalled() + expect(unknown.load).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/invariant.spec.ts b/packages/client/ui-agent-preset/tests/invariant.spec.ts new file mode 100644 index 0000000000..300e561856 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/invariant.spec.ts @@ -0,0 +1,25 @@ +/** The package's node half: an empty host body and an explained empty invariant companion. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant' + +describe('invariant companion', () => { + it('reserves package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(AgentPresetInvariant).await()).resolves.toBeDefined() + }) + + it('has an empty node half', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-agent-preset') + + // The host body exists only so the plugin appears in the host cordis.yml; + // every surface this package ships lives in the browser half. + apply() + + expect(typeof apply).toBe('function') + }) +}) diff --git a/packages/client/ui-agent-preset/tests/section-store.spec.ts b/packages/client/ui-agent-preset/tests/section-store.spec.ts new file mode 100644 index 0000000000..805a4ca8ae --- /dev/null +++ b/packages/client/ui-agent-preset/tests/section-store.spec.ts @@ -0,0 +1,580 @@ +/** + * The agent-preset management controller: a copy dialog is the only way a + * preset is created, the shipped compositions open in a read-only viewer, and + * the way into a custom preset's files is the location action — opened on a + * desktop, revealed as a path where the host has none. Every mutation + * re-reads the roster because a copy changes more than the row it targeted. + */ + +import { describe, expect, it } from 'vitest' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' +import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' + +interface FakePreset { trust: 'system' | 'user'; content: string; name?: string } +interface Recorded { method: string; payload: unknown } + +interface FakeOptions { + /** Every call the controller made, in order. */ + calls?: Recorded[] + /** Reject `list` with this message. */ + failList?: string + /** Reject `read` with this message. */ + failRead?: string + /** Reject `copy` with this message. */ + failCopy?: string + /** Reject `openDocument` with this message. */ + failOpen?: string + /** Reject `remove` with this message. */ + failRemove?: string + /** Reject `settings.update` with this message. */ + failSettings?: string + /** Throw from `list` rather than answering, as a dead transport does. */ + throwList?: boolean + /** Throw from `read`, as a dead transport does. */ + throwRead?: boolean + /** Throw from `copy`, as a dead transport does. */ + throwCopy?: boolean + /** Throw from `openDocument`, as a dead transport does. */ + throwOpen?: boolean + /** Whether the deployment configures a writable root. */ + authorable?: boolean + /** Whether the host can open a preset directory on a desktop. */ + hasDocument?: boolean + /** Hold `remove` until this resolves, to observe the in-flight state. */ + holdRemove?: Promise +} + +const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } }) +const fail = (message: string) => + Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } }) + +/** + * A wire face over an in-memory preset store: copies land, so the roster the + * controller re-reads after a copy is the one the copy produced. + * @param presets - the starting compositions by id. + * @param defaultId - the preset a session with no choice gets. + * @param options - failure injection and call recording. + * @returns the fake client. + */ +function fakeApi( + presets: Map, + defaultId: { id: string }, + options: FakeOptions = {}, +): Pick { + const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } + return { + agentPresets: { + list: () => { + record('list', {}) + if (options.throwList === true) return Promise.reject(new Error('socket closed')) + if (options.failList !== undefined) return fail(options.failList) + return ok({ + presets: [...presets].map(([id, preset]) => ({ + id, trust: preset.trust, isDefault: id === defaultId.id, + ...preset.name === undefined ? {} : { name: preset.name }, + })), + authorable: options.authorable ?? true, + hasDocument: options.hasDocument ?? true, + }) + }, + read: (payload: { agentPreset: string }) => { + record('read', payload) + if (options.throwRead === true) return Promise.reject(new Error('socket closed')) + if (options.failRead !== undefined) return fail(options.failRead) + const preset = presets.get(payload.agentPreset) + /* v8 ignore next -- every test reads an id the fake store holds */ + if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`) + return ok({ + agentPreset: payload.agentPreset, + trust: preset.trust, + content: preset.content, + ...preset.name === undefined ? {} : { name: preset.name }, + }) + }, + copy: (payload: { from: string; agentPreset: string; name?: string }) => { + record('copy', payload) + if (options.throwCopy === true) return Promise.reject(new Error('socket closed')) + if (options.failCopy !== undefined) return fail(options.failCopy) + const source = presets.get(payload.from) + /* v8 ignore next -- every test copies a source the fake store holds */ + if (source === undefined) return fail(`unknown preset ${payload.from}`) + presets.set(payload.agentPreset, { + trust: 'user', + content: source.content, + ...payload.name === undefined ? {} : { name: payload.name }, + }) + return ok({ agentPreset: payload.agentPreset }) + }, + openDocument: (payload: { agentPreset: string }) => { + record('openDocument', payload) + if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) + if (options.failOpen !== undefined) return fail(options.failOpen) + return (options.hasDocument ?? true) + ? ok({ opened: true }) + : ok({ opened: false, path: `/presets/${payload.agentPreset}` }) + }, + remove: async (payload: { agentPreset: string }) => { + record('remove', payload) + await options.holdRemove + if (options.failRemove !== undefined) return await fail(options.failRemove) + presets.delete(payload.agentPreset) + return await ok({}) + }, + }, + settings: { + update: (payload: { ns: string; patch: { default?: string } }) => { + record('settings.update', payload) + if (options.failSettings !== undefined) return fail(options.failSettings) + /* v8 ignore next -- the controller only ever patches `default` */ + defaultId.id = payload.patch.default ?? defaultId.id + return ok({}) + }, + }, + } as unknown as Pick +} + +function seed(): Map { + return new Map([ + ['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }], + ['mine', { trust: 'user', content: '- id: tool-read\n' }], + ]) +} + +function harness(options: FakeOptions = {}) { + const presets = seed() + const defaultId = { id: 'standard' } + const calls: Recorded[] = [] + let rosterChanges = 0 + const controller = new AgentPresetSectionController( + fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }), + () => { rosterChanges += 1 }, + ) + return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges } +} + +function copyOf(controller: AgentPresetSectionController): CopyDraft { + const { copy } = controller.store.getSnapshot() + if (copy === null) throw new Error('expected an open copy dialog') + return copy +} + +describe('loading the roster', () => { + it('maps the roster onto rows with the capability flags', async () => { + const { controller } = harness({ authorable: true, hasDocument: false }) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('ready') + expect(state.authorable).toBe(true) + expect(state.hasDocument).toBe(false) + expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine']) + expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' }) + }) + + it('reports an empty roster as unavailable, not as an error', async () => { + const { controller, presets } = harness() + presets.clear() + + await controller.load() + + expect(controller.store.getSnapshot().status).toBe('unavailable') + }) + + it('keeps one load in flight rather than stacking reads', async () => { + const { controller, calls } = harness() + + await Promise.all([controller.load(), controller.load()]) + + expect(calls.filter(call => call.method === 'list')).toHaveLength(1) + }) + + it('surfaces a refusal as the page error', async () => { + const { controller } = harness({ failList: 'not for you' }) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('error') + expect(state.error).toBe('not for you') + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller } = harness({ throwList: true }) + + await controller.load() + + expect(controller.store.getSnapshot().status).toBe('error') + expect(controller.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('the read-only viewer', () => { + it('opens a shipped composition under its display name', async () => { + const { controller } = harness() + await controller.load() + + await controller.view('standard') + + expect(controller.store.getSnapshot().view).toEqual({ + id: 'standard', title: '标准模式', content: '- id: tool-bash\n', + }) + }) + + it('falls back to the id when the preset published no name', async () => { + const { controller } = harness() + await controller.load() + + await controller.view('mine') + + expect(controller.store.getSnapshot().view?.title).toBe('mine') + }) + + it('closes without touching the list', async () => { + const { controller } = harness() + await controller.load() + await controller.view('standard') + + controller.closeView() + + expect(controller.store.getSnapshot().view).toBeNull() + expect(controller.store.getSnapshot().rows).toHaveLength(2) + }) + + it('puts a read refusal on the page rather than opening empty', async () => { + const { controller } = harness({ failRead: 'no peeking' }) + await controller.load() + + await controller.view('standard') + + expect(controller.store.getSnapshot().view).toBeNull() + expect(controller.store.getSnapshot().error).toBe('no peeking') + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller } = harness({ throwRead: true }) + await controller.load() + + await controller.view('standard') + + expect(controller.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('the copy dialog', () => { + it('opens over the source with its display name in the title', async () => { + const { controller } = harness() + await controller.load() + + controller.beginCopy('standard') + + expect(copyOf(controller)).toMatchObject({ + from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, + }) + }) + + it('falls back to the source id when it published no name', async () => { + const { controller } = harness() + await controller.load() + + controller.beginCopy('mine') + + expect(copyOf(controller).fromTitle).toBe('mine') + }) + + it('cancel discards whatever was typed', async () => { + const { controller } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('half-typed') + + controller.cancelCopy() + + expect(controller.store.getSnapshot().copy).toBeNull() + }) + + it('ignores field edits and submits with no dialog open', async () => { + const { controller, calls } = harness() + await controller.load() + + controller.setCopyId('typed-into-nothing') + controller.setCopyName('nameless') + await controller.confirmCopy() + + expect(controller.store.getSnapshot().copy).toBeNull() + expect(calls.some(call => call.method === 'copy')).toBe(false) + }) + + it('typing clears the previous failure', async () => { + const { controller } = harness({ failCopy: 'disk full' }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + await controller.confirmCopy() + expect(copyOf(controller).error).toBe('disk full') + + controller.setCopyName('renamed') + + expect(copyOf(controller).error).toBeNull() + }) +}) + +describe('the copy blocker', () => { + const rows: PresetRow[] = [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ] + const draft = (id: string): CopyDraft => + ({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null }) + + it('requires an id, a containable shape, and a free name', () => { + expect(draftBlocker(draft(''), rows)).toBe('idRequired') + expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid') + expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid') + expect(draftBlocker(draft('mine'), rows)).toBe('idTaken') + expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined() + }) +}) + +describe('submitting a copy', () => { + it('copies, re-reads the roster, announces the change, and opens the files', async () => { + const { controller, calls, rosterChanges } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + controller.setCopyName('我的模式') + + await controller.confirmCopy() + + const state = controller.store.getSnapshot() + expect(state.copy).toBeNull() + expect(state.rows.map(row => row.id)).toContain('my-copy') + expect(rosterChanges()).toBe(1) + expect(calls.find(call => call.method === 'copy')?.payload) + .toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' }) + // A preset is its files from here on, so landing in them completes the + // copy rather than following it. + expect(calls.find(call => call.method === 'openDocument')?.payload) + .toEqual({ agentPreset: 'my-copy' }) + }) + + it('omits an empty name so the copy falls back to its id', async () => { + const { controller, calls } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + controller.setCopyName(' ') + + await controller.confirmCopy() + + expect(calls.find(call => call.method === 'copy')?.payload) + .toEqual({ from: 'standard', agentPreset: 'my-copy' }) + }) + + it('reveals the new directory as text where the host has no desktop', async () => { + const { controller } = harness({ hasDocument: false }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + + await controller.confirmCopy() + + expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy') + }) + + it('keeps the dialog open with the refusal on it', async () => { + const { controller, rosterChanges } = harness({ failCopy: 'id already exists' }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + + await controller.confirmCopy() + + expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' }) + expect(rosterChanges()).toBe(0) + }) + + it('folds a dead transport into the dialog error', async () => { + const { controller } = harness({ throwCopy: true }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + + await controller.confirmCopy() + + expect(copyOf(controller).error).toContain('socket closed') + }) + + it('refuses to submit while blocked or already saving', async () => { + const { controller, calls } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('mine') + + await controller.confirmCopy() + + expect(calls.some(call => call.method === 'copy')).toBe(false) + }) +}) + +describe('the location action', () => { + it('opens the directory and leaves the page alone on a desktop host', async () => { + const { controller, calls } = harness() + await controller.load() + + await controller.openLocation('mine') + + expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' }) + expect(controller.store.getSnapshot().revealedPaths).toEqual({}) + }) + + it('reveals the path on the row where the host has none', async () => { + const { controller } = harness({ hasDocument: false }) + await controller.load() + + await controller.openLocation('mine') + + expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' }) + }) + + it('drops a revealed path once its preset leaves the roster', async () => { + const { controller, presets } = harness({ hasDocument: false }) + await controller.load() + await controller.openLocation('mine') + presets.delete('mine') + + await controller.load() + + expect(controller.store.getSnapshot().revealedPaths).toEqual({}) + }) + + it('surfaces a refusal as the page error', async () => { + const { controller } = harness({ failOpen: 'not yours' }) + await controller.load() + + await controller.openLocation('mine') + + expect(controller.store.getSnapshot().error).toBe('not yours') + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller } = harness({ throwOpen: true }) + await controller.load() + + await controller.openLocation('mine') + + expect(controller.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('deleting', () => { + it('asks first, then deletes, re-reads, and announces the change', async () => { + const { controller, rosterChanges } = harness() + await controller.load() + + controller.confirmDelete('mine') + expect(controller.store.getSnapshot().pendingDelete).toBe('mine') + await controller.remove() + + const state = controller.store.getSnapshot() + expect(state.pendingDelete).toBeNull() + expect(state.rows.map(row => row.id)).not.toContain('mine') + expect(rosterChanges()).toBe(1) + }) + + it('dismisses the confirmation without deleting', async () => { + const { controller, calls } = harness() + await controller.load() + controller.confirmDelete('mine') + + controller.confirmDelete(null) + await controller.remove() + + expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine') + expect(calls.some(call => call.method === 'remove')).toBe(false) + }) + + it('ignores a second confirmation while one delete is in flight', async () => { + let release = (): void => {} + const gate = new Promise((resolve) => { release = resolve }) + const { controller, calls } = harness({ holdRemove: gate }) + await controller.load() + controller.confirmDelete('mine') + const removal = controller.remove() + + controller.confirmDelete('standard') + await controller.remove() + release() + await removal + + expect(calls.filter(call => call.method === 'remove')).toHaveLength(1) + }) + + it('surfaces a refusal and clears the confirmation', async () => { + const { controller } = harness({ failRemove: 'shipped preset' }) + await controller.load() + controller.confirmDelete('mine') + + await controller.remove() + + const state = controller.store.getSnapshot() + expect(state.error).toBe('shipped preset') + expect(state.pendingDelete).toBeNull() + expect(state.deleting).toBe(false) + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller, presets } = harness() + await controller.load() + presets.clear() + const broken = new AgentPresetSectionController({ + agentPresets: { + list: () => Promise.reject(new Error('gone')), + remove: () => Promise.reject(new Error('socket closed')), + }, + settings: {}, + } as unknown as Pick) + broken.confirmDelete('mine') + + await broken.remove() + + expect(broken.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('a controller with no roster listener', () => { + it('completes a delete without anyone to notify', async () => { + // The rosterChanged callback is optional wiring, not a requirement: a + // page composed without sibling surfaces still deletes cleanly. + const presets = seed() + const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' })) + await alone.load() + alone.confirmDelete('mine') + + await alone.remove() + + expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine') + }) +}) + +describe('the default preset', () => { + it('writes the setting and re-reads the roster', async () => { + const { controller, defaultId } = harness() + await controller.load() + + await controller.makeDefault('mine') + + expect(defaultId.id).toBe('mine') + expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true) + }) + + it('surfaces a settings refusal as the page error', async () => { + const { controller } = harness({ failSettings: 'read-only settings' }) + await controller.load() + + await controller.makeDefault('mine') + + expect(controller.store.getSnapshot().error).toContain('read-only settings') + }) +}) diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx new file mode 100644 index 0000000000..36e34067b3 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +/** + * The management section's rendering rules: which actions a row offers depends + * on its trust, a shipped composition opens in a read-only viewer, creation is + * a copy dialog that collects an id and an optional name, and the location + * 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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' +import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' +import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const READY: AgentPresetSectionState = { + status: 'ready', + error: null, + authorable: true, + hasDocument: true, + rows: [ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + { id: 'mine', trust: 'user', isDefault: false }, + ], + copy: null, + view: null, + pendingDelete: null, + deleting: false, + revealedPaths: {}, +} + +/** + * Render the section over a fixed snapshot, with every action a spy. + * @param state - the snapshot to render. + * @returns the spies, so a test can assert what a click reached. + */ +function renderSection( + state: Partial = {}, + options: { creator?: boolean } = {}, +) { + const store = createSnapshotStore({ ...READY, ...state }) + const actions = { + load: vi.fn(() => Promise.resolve()), + // The shell-owned section affordance (SettingsSectionOwnerProps.close). + close: vi.fn(), + ...options.creator === false ? {} : { startCreatorDraft: vi.fn() }, + view: vi.fn(() => Promise.resolve()), + closeView: vi.fn(), + beginCopy: vi.fn(), + cancelCopy: vi.fn(), + setCopyId: vi.fn(), + setCopyName: vi.fn(), + confirmCopy: vi.fn(() => Promise.resolve()), + openLocation: vi.fn(() => Promise.resolve()), + confirmDelete: vi.fn(), + remove: vi.fn(() => Promise.resolve()), + makeDefault: vi.fn(() => Promise.resolve()), + } + const props = { + ...actions, + useAgentPresetSection: bindSnapshotSelector(store), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetSectionProps + render() + return actions +} + +/** Locate a card by the id it prints, not by its display name. */ +function rowFor(id: string): HTMLElement { + const key = screen.getAllByText(id).find(node => node.tagName === 'CODE') + const row = key?.closest('li') ?? null + /* v8 ignore next -- every rendered card prints its id */ + if (row === null) throw new Error(`no card for ${id}`) + return row +} + +describe('the preset list', () => { + it('reads the roster once when it first renders', async () => { + const actions = renderSection() + + await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) + }) + + it('shows the published name and description, falling back to the id', () => { + renderSection() + + // The name is what a picker reads; the id stays visible as the key the + // composition and the session header actually carry. + expect(screen.getByText('标准模式')).toBeTruthy() + expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + const mine = rowFor('mine') + expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0) + expect(within(mine).getByText(en.noDescription)).toBeTruthy() + }) + + it('marks trust and the one in use, and offers no "set default" on it', () => { + renderSection() + + const standard = rowFor('standard') + expect(within(standard).getByText(en.builtIn)).toBeTruthy() + expect(within(standard).getByText(en.inUse)).toBeTruthy() + expect(within(standard).queryByText(en.setDefault)).toBeNull() + expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy() + }) + + it('separates built-in presets from custom ones', () => { + renderSection() + + // Two different things: one set ships with the deployment and is + // read-only, the other is the user's own. + expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy() + }) + + it('shows no group heading for a set nobody has', () => { + renderSection({ rows: [{ id: 'standard', trust: 'system', isDefault: true }] }) + + expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull() + }) + + it('leads with the two ways a preset is created', () => { + renderSection() + + // The page has no create button: the intro is what tells a first-time + // reader that copying an existing preset — or drafting one in Creator + // mode — IS the way to make one. + expect(screen.getByText(new RegExp('Creator mode'))).toBeTruthy() + }) + + it('picks a preset by clicking its card, and the one in use is inert', () => { + const actions = renderSection() + + const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: 标准模式` }) + expect(inUse).toHaveProperty('disabled', true) + fireEvent.click(inUse) + + // Clicking the card IS the choice; the preset already in use cannot be + // re-picked, so the click reaches nothing. + expect(actions.makeDefault).not.toHaveBeenCalled() + }) + + it('offers View on a shipped row and the location on a custom one', () => { + renderSection() + + // A shipped preset is the composition a copy starts from — reading it is + // the point. A custom preset is edited in its files, so its row leads + // there instead; there is no editor for either. + const standard = rowFor('standard') + expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy() + expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull() + const mine = rowFor('mine') + expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy() + expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull() + }) + + it('offers Delete only for a locally authored preset', () => { + renderSection() + + expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy() + expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull() + }) + + it('disables duplication when nothing is writable, and says why', () => { + renderSection({ authorable: false }) + + const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` }) + expect(duplicate).toHaveProperty('disabled', true) + expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable) + }) + + it('marks a broken custom preset: unselectable, uncopyable, still deletable', () => { + const actions = renderSection({ + rows: [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', broken: 'the composition file agent.cordis.yml is missing' }, + ], + }) + + const ghost = rowFor('ghost') + // The reason is on the card, and the body cannot pick what cannot mount. + expect(within(ghost).getByText(en.brokenBadge)).toBeTruthy() + expect(within(ghost).getByRole('alert').textContent).toContain('is missing') + const body = within(ghost).getByRole('button', { name: `${en.brokenBadge}: 幽灵预设` }) + expect(body).toHaveProperty('disabled', true) + fireEvent.click(body) + expect(actions.makeDefault).not.toHaveBeenCalled() + // Copying a broken preset would only mint another broken one; deleting + // and the location remain — the files are where it gets fixed. + const duplicate = within(ghost).getByRole('button', { name: `${en.duplicate}: 幽灵预设` }) + expect(duplicate).toHaveProperty('disabled', true) + expect(duplicate.getAttribute('data-tip')).toBe(en.brokenNoCopy) + expect(within(ghost).getByRole('button', { name: `${en.delete}: 幽灵预设` })).toBeTruthy() + expect(within(ghost).getByRole('button', { name: `${en.openLocation}: 幽灵预设` })).toBeTruthy() + }) + + it('withholds the viewer on a broken shipped preset', () => { + renderSection({ + rows: [{ id: 'standard', trust: 'system', isDefault: false, name: '标准模式', broken: 'the composition is not valid YAML' }], + }) + + // There is no readable composition to offer; the reason on the card is + // the whole story a shipped row can tell. + const standard = rowFor('standard') + expect(within(standard).queryByRole('button', { name: `${en.view}: 标准模式` })).toBeNull() + expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML') + }) + + it('labels the location by what it will do without a desktop', () => { + renderSection({ hasDocument: false }) + + expect(within(rowFor('mine')).getByRole('button', { name: `${en.showLocation}: mine` })).toBeTruthy() + }) + + it('shows a revealed directory on its row', () => { + renderSection({ revealedPaths: { mine: '/home/user/.dsh/.agent-presets/mine' } }) + + const mine = rowFor('mine') + expect(within(mine).getByText('/home/user/.dsh/.agent-presets/mine')).toBeTruthy() + expect(within(mine).getByText(en.revealedPathLabel)).toBeTruthy() + // The reveal belongs to its row alone. + expect(within(rowFor('standard')).queryByText(en.revealedPathLabel)).toBeNull() + }) + + it('routes the row actions to the controller', () => { + const actions = renderSection() + + // The card body is the control that picks a preset. + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` })) + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` })) + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` })) + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` })) + + expect(actions.makeDefault).toHaveBeenCalledWith('mine') + expect(actions.openLocation).toHaveBeenCalledWith('mine') + expect(actions.beginCopy).toHaveBeenCalledWith('mine') + expect(actions.view).toHaveBeenCalledWith('standard') + }) + + it('starts a creator-mode draft session and leaves settings', () => { + const actions = renderSection({ + rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], + }) + + fireEvent.click(screen.getByRole('button', { name: en.creatorDraft })) + + expect(actions.startCreatorDraft).toHaveBeenCalledTimes(1) + // Leaving settings is part of the gesture: the flow lands in the new + // session, not behind the modal. + expect(actions.close).toHaveBeenCalledTimes(1) + }) + + it('hides the creator entry without the flow or the preset, disables it without a root', () => { + renderSection() + expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() + cleanup() + + renderSection({ + rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], + }, { creator: false }) + expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() + cleanup() + + const actions = renderSection({ + authorable: false, + rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], + }) + const disabled = screen.getByRole('button', { name: en.creatorDraft }) + expect(disabled).toHaveProperty('disabled', true) + fireEvent.click(disabled) + expect(actions.startCreatorDraft).not.toHaveBeenCalled() + }) + + it('shows a page-level failure without hiding the list', () => { + renderSection({ error: 'settings are read-only' }) + + expect(screen.getByRole('alert').textContent).toBe('settings are read-only') + expect(rowFor('mine')).toBeTruthy() + }) + + it('renders nothing when the deployment composes no presets', () => { + const { container } = render(({ ...READY, status: 'unavailable', rows: [] })), + t: (key: keyof typeof en) => en[key], + load: vi.fn(() => Promise.resolve()), + } as unknown as AgentPresetSectionProps)} />) + + expect(container.firstChild).toBeNull() + }) + + it('offers a retry when the roster could not be read', () => { + const actions = renderSection({ status: 'error', error: 'roster unavailable' }) + + expect(screen.getByRole('alert').textContent).toContain('roster unavailable') + fireEvent.click(screen.getByText(en.retry)) + + expect(actions.load).toHaveBeenCalledTimes(2) + }) +}) + +describe('the copy dialog', () => { + const draft: CopyDraft = { + from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null, + } + + it('names its source and collects only an id and a display name', () => { + const actions = renderSection({ copy: draft }) + + const dialog = screen.getByRole('dialog') + expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`) + expect(within(dialog).getByText(en.copyIntro)).toBeTruthy() + fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } }) + fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } }) + + expect(actions.setCopyId).toHaveBeenCalledWith('my-agent') + expect(actions.setCopyName).toHaveBeenCalledWith('我的模式') + // Nothing else is collected: the description and the composition are + // edited in the preset's own files. + expect(within(dialog).queryByRole('textbox', { name: /description/i })).toBeNull() + }) + + it('creates and cancels through the controller', () => { + const actions = renderSection({ copy: { ...draft, id: 'my-agent' } }) + + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByText(en.create)) + fireEvent.click(within(dialog).getByText(en.cancel)) + + expect(actions.confirmCopy).toHaveBeenCalledTimes(1) + expect(actions.cancelCopy).toHaveBeenCalledTimes(1) + }) + + it('blocks a copy the host would refuse, and says why', () => { + const actions = renderSection({ copy: { ...draft, id: 'Upper Case' } }) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByRole('alert').textContent).toBe(en.idInvalid) + fireEvent.click(within(dialog).getByText(en.create)) + + // Disabled rather than round-tripping: the id is a directory name and the + // rule is the host's own. + expect(actions.confirmCopy).not.toHaveBeenCalled() + }) + + it('shows the host\'s refusal instead of the local blocker', () => { + renderSection({ copy: { ...draft, id: 'my-agent', error: 'already exists' } }) + + expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toBe('already exists') + }) + + it('reports a copy in flight and blocks a second click', () => { + const actions = renderSection({ copy: { ...draft, id: 'my-agent', saving: true } }) + + fireEvent.click(within(screen.getByRole('dialog')).getByText(en.creating)) + + expect(actions.confirmCopy).not.toHaveBeenCalled() + }) + + it('dismisses on Escape', () => { + const actions = renderSection({ copy: draft }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(actions.cancelCopy).toHaveBeenCalledTimes(1) + }) +}) + +describe('the read-only viewer', () => { + it('shows the composition text under the preset\'s name', () => { + renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } }) + + const dialog = screen.getByRole('dialog') + expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`) + expect(within(dialog).getByText(en.composition)).toBeTruthy() + expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n') + }) + + it('closes through the controller', () => { + const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) + + fireEvent.click(within(screen.getByRole('dialog')).getByText(en.close)) + + expect(actions.closeView).toHaveBeenCalledTimes(1) + }) + + it('dismisses on Escape', () => { + const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(actions.closeView).toHaveBeenCalledTimes(1) + }) +}) + +describe('deleting a preset', () => { + it('asks before deleting', () => { + const actions = renderSection() + + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })) + + expect(actions.confirmDelete).toHaveBeenCalledWith('mine') + }) + + it('confirms and dismisses through the controller', () => { + const actions = renderSection({ pendingDelete: 'mine' }) + + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByText(en.deleteConfirm)) + fireEvent.click(within(dialog).getByText(en.cancel)) + + expect(actions.remove).toHaveBeenCalledTimes(1) + expect(actions.confirmDelete).toHaveBeenLastCalledWith(null) + }) + + it('dismisses the confirmation on Escape', () => { + const actions = renderSection({ pendingDelete: 'mine' }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(actions.confirmDelete).toHaveBeenCalledWith(null) + }) + + it('reports a delete in flight', () => { + const actions = renderSection({ pendingDelete: 'mine', deleting: true }) + + fireEvent.click(within(screen.getByRole('dialog')).getByText(en.deleting)) + + expect(actions.remove).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts new file mode 100644 index 0000000000..fc36dde066 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/settings-store.spec.ts @@ -0,0 +1,458 @@ +/** + * The agent-preset settings controller: it derives both the options and the + * current default from one roster call, writes only the `default` field, and + * treats an empty roster as "this deployment composes no presets" rather than + * as a failure. + */ + +import { describe, expect, it } from 'vitest' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { + AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, +} from '../src/client/settings-store.ts' +import { AgentPresetSeatController } from '../src/client/seat-store.ts' +import type { SeatSessionSummary } from '../src/client/seat-store.ts' + +interface Recorded { ns: string; patch: unknown } + +/** A client whose roster and write outcome the test controls. */ +function fakeApi( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + options: { + writes?: Recorded[] + failWrite?: string + failList?: string + failWriteWith?: Error + readOnly?: boolean + } = {}, +): IApiClient { + return { + agentPresets: { + list: () => Promise.resolve(options.failList === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }), + }, + settings: { + // Loopback-only in production; a read-only provider answers writable:false + // and the row disables its control instead of offering a refused write. + describe: () => Promise.resolve({ + rpcId: 'r', + result: { + ok: true as const, + value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, + }, + }), + update: (payload: { ns: string; patch: unknown }) => { + options.writes?.push({ ns: payload.ns, patch: payload.patch }) + if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith) + if (options.failWrite !== undefined) { + return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } }) + } + // A committed write moves the roster's default, exactly as the host does. + for (const preset of presets) { + preset.isDefault = preset.id === (payload.patch as { default?: string }).default + } + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) + }, + }, + } as unknown as IApiClient +} + +describe('the agent-preset settings controller', () => { + it('disables the control when this browser may not write settings', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + ], { readOnly: true })) + + await controller.load() + + // `settings.describe` is loopback-only and reports a read-only provider; + // offering a control whose write answers `settings-not-exposed` would + // promise a switch the host refuses. + expect(controller.store.getSnapshot().writable).toBe(false) + expect(controller.store.getSnapshot().currentValue).toBe('standard') + }) + + it('derives options and the current default from one roster call', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ])) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('ready') + expect(state.currentValue).toBe('standard') + expect(state.options).toEqual([ + { id: 'standard', trust: 'system' }, + { id: 'mine', trust: 'user' }, + ]) + }) + + it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' }, + ] as never)) + + await controller.load() + + // A broken preset cannot compose a session; listing it here would defer + // that discovery to a failed session start. The management section shows + // (and deletes) it from its own store instead. + expect(controller.store.getSnapshot().options.map(option => option.id)).toEqual(['standard']) + }) + + it('carries the display metadata a preset published', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + ] as never)) + + await controller.load() + + // Surfaces beyond this row read the same options; the id alone never said + // what a preset does. + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + ]) + }) + + it('reports an empty roster as unavailable, not as an error', async () => { + const controller = new AgentPresetSettingsController(fakeApi([])) + + await controller.load() + + // A deployment composing no presets is valid: every session shares the + // host composition and the row renders nothing. + expect(controller.store.getSnapshot().status).toBe('unavailable') + expect(controller.store.getSnapshot().error).toBeNull() + }) + + it('writes only the default field, into the agent-presets namespace', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ], { writes })) + await controller.load() + + await controller.select('minimal') + + expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'minimal' } }]) + expect(controller.store.getSnapshot().currentValue).toBe('minimal') + }) + + it('restores the previous value and surfaces the message when the write fails', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ], { failWrite: 'read-only settings' })) + await controller.load() + + await controller.select('minimal') + + const state = controller.store.getSnapshot() + expect(state.currentValue).toBe('standard') + expect(state.error).toBe('read-only settings') + expect(state.status).toBe('ready') + }) + + it('ignores a pick that is already the default', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + ], { writes })) + await controller.load() + + await controller.select('standard') + + expect(writes).toEqual([]) + }) + + it('surfaces a roster failure without claiming the deployment has no presets', async () => { + const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' })) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('error') + expect(state.error).toBe('host down') + }) + + it('shows the first preset when the roster marks none default', async () => { + // Settings can name a preset that was since deleted; the picker still has + // to show something rather than an empty control. + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: false }, + { id: 'mine', trust: 'user', isDefault: false }, + ])) + + await controller.load() + + expect(controller.store.getSnapshot().currentValue).toBe('standard') + }) + + it('ignores a load while one is already in flight', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi( + [{ id: 'standard', trust: 'system', isDefault: true }], { writes })) + + await Promise.all([controller.load(), controller.load()]) + + expect(controller.store.getSnapshot().status).toBe('ready') + }) + + it('reads an Error\'s message and stringifies anything else', () => { + // A transport rejects with an Error, but a host or a runtime can reject + // with anything and the surface still has to say something. + expect(messageOf(new Error('boom'))).toBe('boom') + expect(messageOf({ code: 7 })).toBe('[object Object]') + }) + + it('reports a transport that rejects rather than answering', async () => { + const controller = new AgentPresetSettingsController({ + agentPresets: { list: () => Promise.reject(new Error('socket closed')) }, + } as unknown as IApiClient) + + await controller.load() + + expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) + }) + + it('reports a transport that rejects mid-write and keeps the old default showing', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ], { failWriteWith: new Error('socket closed') })) + await controller.load() + + await controller.select('mine') + + // The value snaps back because the host never took it; a picker still + // showing "mine" would be claiming a default that does not exist. + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' }) + }) +}) + +describe('the new-session chip controller', () => { + /** A chip over a current session the test can move. */ + function chip( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + current: { id: string; blank: boolean; agentPreset?: string } | undefined, + options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {}, + ): AgentPresetSeatController { + const api = { + agentPresets: { + list: () => { + if (options.throwOn === 'list') return Promise.reject(new Error('socket closed')) + return Promise.resolve(options.failList === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }) + }, + select: (payload: { agentPreset: string }) => { + if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) + options.writes?.push({ ns: 'select', patch: payload.agentPreset }) + return Promise.resolve(options.failSelect === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } }) + }, + }, + } as unknown as IApiClient + return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined) + } + + const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ] + + it('opens on the deployment default', async () => { + const controller = chip(ROSTER, undefined) + + await controller.load() + + // The chip names the session about to start, and nothing about it is + // decided yet — the default is the honest opening value. + expect(controller.store.getSnapshot().current).toBe('standard') + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system' }, + { id: 'minimal', trust: 'system' }, + ]) + }) + + it('shows the first preset when the roster marks none default', async () => { + const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined) + + await controller.load() + + // Settings can name a preset that was since deleted; the chip still has + // to open on something rather than render nothing. + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('carries the display metadata into the menu rows', async () => { + const controller = chip([ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + ] as never, undefined) + + await controller.load() + + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + ]) + }) + + it('opens on nothing when the deployment composes no presets', async () => { + const controller = chip([], undefined) + + await controller.load() + + // An empty roster is a valid deployment: every session shares the host + // composition, and the chip renders nothing rather than an empty control. + expect(controller.store.getSnapshot().current).toBe('') + }) + + it('stages a pick made before any session exists', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, undefined, { writes }) + await controller.load() + + await controller.select('minimal') + + // Nothing to switch yet: the new-session screen precedes the session. + expect(writes).toEqual([]) + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('applies the stage to the blank session the flow lands on', async () => { + const writes: Recorded[] = [] + const current = { id: 's1', blank: true, agentPreset: 'standard' } + const controller = chip(ROSTER, current, { writes }) + await controller.load() + await controller.select('minimal') + + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('spends the stage exactly once', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + await controller.select('minimal') + + await controller.apply() + await controller.apply() + + // Every later list movement calls apply(); an unspent stage would keep + // switching sessions the user never picked for. + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + }) + + it('drops the stage against a session that already started', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes }) + await controller.load() + + await controller.select('minimal') + + // The host enforces the same rule; the chip simply never asks. + expect(writes).toEqual([]) + }) + + it('drops the stage when the session already runs it', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes }) + await controller.load() + + await controller.select('minimal') + + expect(writes).toEqual([]) + }) + + it('falls back to the default when the host refuses the switch', async () => { + const controller = chip( + ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' }) + await controller.load() + + await controller.select('minimal') + + // Showing `minimal` after a refusal would claim a composition the session + // never got. + expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' }) + }) + + it('falls back to the default when the switch never reaches the host', async () => { + const controller = chip( + ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' }) + await controller.load() + + await controller.select('minimal') + + expect(controller.store.getSnapshot()) + .toMatchObject({ current: 'standard', busy: false, error: 'socket closed' }) + }) + + it('ignores a pick while a switch is in flight', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + + const first = controller.select('minimal') + await controller.select('standard') + await first + + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + }) + + it('keeps a staged pick across a roster refresh', async () => { + const controller = chip(ROSTER, undefined) + await controller.load() + await controller.select('minimal') + + await controller.load() + + // A settings push re-reads the roster; it must not silently discard what + // the user picked for the session they are about to start. + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('reports a refused roster read without emptying the chip', async () => { + const controller = chip(ROSTER, undefined, { failList: 'host down' }) + + await controller.load() + + expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] }) + }) + + it('reports a transport that rejects the roster read', async () => { + const controller = chip(ROSTER, undefined, { throwOn: 'list' }) + + await controller.load() + + expect(controller.store.getSnapshot().error).toBe('socket closed') + }) + + it('reports a refused describe as a failure rather than a half-read row', async () => { + const api = { + agentPresets: { + list: () => Promise.resolve({ + rpcId: 'r', + result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } }, + }), + }, + // The roster answered; `settings.describe` is what rejected, and the row + // cannot claim a writable default it never confirmed. + settings: { describe: () => Promise.reject(new Error('socket closed')) }, + } as unknown as IApiClient + const controller = new AgentPresetSettingsController(api) + + await controller.load() + + expect(controller.store.getSnapshot().status).toBe('error') + expect(controller.store.getSnapshot().error).toBe('socket closed') + }) + + +}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json new file mode 100644 index 0000000000..2b21a7e1e2 --- /dev/null +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../test-runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts new file mode 100644 index 0000000000..3ede4df6a8 --- /dev/null +++ b/packages/client/ui-agent-preset/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 401d9883a6..3e415d7e76 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -192,6 +192,7 @@ export function apply(ctx: Context): void { 'conversation.input.left': { kind: 'list', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, + 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 8d292abd53..fe6a14244e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -79,6 +79,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * reads the global workspace list. */ 'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } + /** + * The agent-preset chip beside the workspace picker on the new-session + * screen. Root scope: no session exists yet, so the choice is staged for + * the next one rather than applied to a current one. + */ + 'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps } // 'conversation.input.overlay' merges in ui-slash (the dependency // direction is the hard constraint — ui-slash cannot import // this package, while this package's input contract already imports @@ -141,6 +147,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } +/** Owner share of the hero agent-preset chip: the shell supplies nothing. */ +export interface HeroAgentPresetOwnerProps { + /** Marker field: the chip owns its own roster, staging, and menu state. */ + children?: never +} + +/** Owner share of the strict session content seat. */ +export interface ConversationSessionOwnerProps { + /** + * Wrap the view ring in the transcript scrollport that also hosts the + * sticky composer seat (whole `'conversation.composer'` chain output). + * Supplied for every real session (hero/settling/active) so the composer + * keeps one tree seat across the blank → active flip; the header stays + * outside that wrapper as ordinary column chrome (`flex: none`), while + * active CSS sticks the seat to the bottom of the same scrollport so wheel + * over the footer scrolls the flow. + * @param view - the session view-ring content (null while blank chrome is hidden). + * @returns the scrollport containing `view` and the sticky composer seat. + */ + wrapActiveBody?: (view: ReactNode) => ReactNode +} + /** Header actions derive their state from the standard session/global kit. */ export interface ConversationHeaderActionOwnerProps {} @@ -430,6 +458,7 @@ export type ConversationSlotProps = | 'conversation.input.dock' | 'conversation.composer.dock' | 'conversation.input.left' | 'conversation.input.right' | 'conversation.hero.workspace' + | 'conversation.hero.agentPreset' > & InjectFace & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index f9c2eb0968..10f757d15e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -119,6 +119,7 @@ export function ConversationRoot({ }, onClose: () => { setPickerOpen(false) }, })} + {renderSlot('conversation.hero.agentPreset', {})} ) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 41ffd329b8..9a3e5e2908 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,9 +84,11 @@ describe('apply wiring', () => { expect(conversationHeader?.store).toBe(conversationSession?.store) expect(details?.store).toBe(conversationSession?.store) expect(chatView?.store).toBe(conversationSession?.store) - // The hero workspace picker hole rides the conversation entry's children - // declaration (the empty-state occupant is gone). + // The hero holes ride the conversation entry's children declaration (the + // empty-state occupant is gone). Both are root-scoped: the new-session + // screen precedes the session either would belong to. expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter']) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index aaed8153da..172b6c2967 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -721,13 +721,15 @@ describe('strips and variants', () => { }) describe('command launcher chrome and control seats', () => { - it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('命令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() - // Both seats dispatched, nothing rendered. - expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) + // Every seat dispatched, nothing rendered. + expect(slotCalls.map(c => c.key)).toEqual([ + 'conversation.input.plan', 'conversation.input.model', + ]) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index bbabbaeb0c..e7c9729685 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -453,6 +453,9 @@ describe('ConversationRoot resident composer', () => { const chip = b.view.getByRole('button', { name: '选择工作区' }) expect((chip as HTMLButtonElement).disabled).toBe(false) expect(b.slotCalls).toContain('conversation.hero.workspace') + // The agent-preset chip sits in the same row, for the same reason: both + // choices are only open before the first message. + expect(b.slotCalls).toContain('conversation.hero.agentPreset') }) it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => { diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 5b99a0e71c..02f4913751 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -584,6 +584,17 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) => ) +/** + * folder_open_16, outline layer only: the duotone original above reads a rung + * heavier than the …Outline16 family, so an icon-button row mixing them looks + * mismatched — this is the same geometry without the 20%-opacity inner fill. + */ +export const IconFolderOpenOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) + /** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 5c2fe88608..fd15671b73 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 17 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(66) + it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(67) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index bca51d908b..a00cd9bb55 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/README.i18n.yaml @@ -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-question/README.md -README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c -README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691 +README.md: d31ceb62c46cb7a720b52d9e2a6c92e98d1c7e42 +README.zh.md: 9f9ad01c3f1f661f60fe11ec072f487cb18c170a diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 72d9439677..d31ceb62c4 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. +Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets). The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 6344327d26..9f9ad01c3f 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 +Web 提问功能插件:其浏览器侧把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent,无论它由哪个 preset 组装,于是一个"两工具"的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。 组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 7416596284..7e129c9731 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -40,7 +40,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "clsx": "^2.0.0", "react": "^18.2.0" }, diff --git a/packages/client/ui-question/src/index.ts b/packages/client/ui-question/src/index.ts index 901e832c14..4ceb9e0bd1 100644 --- a/packages/client/ui-question/src/index.ts +++ b/packages/client/ui-question/src/index.ts @@ -1,17 +1,14 @@ /** - * Web question plugin, node half: enabling this UI feature also exposes the - * model-facing ask_user_question tool on the host composition. + * Web question plugin, node half. + * + * Deliberately empty. Mounting `ask_user_question` here put it in the tools + * registry's GLOBAL layer, so every agent saw it no matter which preset + * composed it — a two-tool benchmark preset actually presented three, and a + * locally authored `bash-only` preset presented two. Rendering a question is + * a host UI capability; having the tool is an agent capability, and only a + * preset decides that. The `tool-ask-user` row belongs in the presets that + * want it (and in the TUI composition, which has no presets). */ -import type { Context } from 'cordis' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -/** Host services required by the model-facing tool. */ -export const inject = ['tools', 'userInteraction'] - -/** - * Mount ask_user_question for hosts that selected the Web question plugin. - * @param ctx - Host plugin context carrying tools and userInteraction. - */ -export function apply(ctx: Context): void { - toolAskUser.apply(ctx) -} +/** Host plugin body — the model-facing tool is composed per preset, not here. */ +export function apply(): void {} diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.spec.ts index 9bc34e9599..4602ef0bed 100644 --- a/packages/client/ui-question/tests/node-plugin.spec.ts +++ b/packages/client/ui-question/tests/node-plugin.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest' import ToolRegistry from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { apply, inject } from '../src/index.ts' +import { apply } from '../src/index.ts' let ctx: Context | undefined @@ -13,16 +13,19 @@ afterEach(async () => { }) describe('ui-question node plugin', () => { - it('exposes ask_user_question only for the selected Web feature lifecycle', async () => { + it('mounts no model-facing tool', async () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(UserInteractionService) - const feature = ctx.plugin({ inject: [...inject], apply }) - await feature.await() - expect(ctx.tools.get('ask_user_question')).toBeDefined() - await feature.dispose() + await ctx.plugin({ apply }).await() + + // Selecting the Web question FEATURE must not hand every agent the tool. + // `ctx.tools.register` on an unscoped host context files into the global + // layer, which merges into every agent's view regardless of the preset + // that composed it — so a two-tool benchmark preset would really present + // three. The `tool-ask-user` row belongs to the presets that want it. expect(ctx.tools.get('ask_user_question')).toBeUndefined() }) }) diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index 1b920ce207..a6400f7c84 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../ui-slots" }, - { - "path": "../../interaction/tool-ask-user" - }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 447dd7e9c7..874bd44630 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -46,7 +46,7 @@ describe('GeneralSection', () => { const renderSlot = vi.fn( ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], ) - const props: GeneralSectionComponentProps = { ...kit, renderSlot } + const props: GeneralSectionComponentProps = { ...kit, renderSlot, close: vi.fn() } const view = render() return { view, renderSlot } } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index e70558081a..9163e68ba8 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -63,15 +63,18 @@ } /* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects - match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */ + match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800 wide. + One height for every section, taken from the viewport rather than the + content: sections differ by hundreds of pixels (a settings list against the + composition editor), and a content-sized panel would resize under the + pointer on every nav click. Whatever does not fit scrolls in `.options`. */ .panel { position: relative; z-index: 1; display: flex; width: 800px; - height: 600px; + height: min(800px, calc(100vh - 48px)); max-width: calc(100vw - 48px); - max-height: calc(100vh - 48px); border-radius: 24px; overflow: hidden; background: var(--dsw-alias-bg-layer-2); diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index d6b2e8ef5a..54e0e0dbb7 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -13,13 +13,16 @@ */ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' -import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return + if (id === 'agent-presets') return return } @@ -84,7 +87,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
- {active !== undefined && renderSlot('settings.section', {}, { only: active })} + {active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })}
diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index aeaafe8232..4158ee6c66 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -83,12 +83,14 @@ export interface SettingsHeaderOwnerProps { /** * Owner share of a settings section entry. The shell owns modal visibility - * and navigation; sections receive nothing but the render site (their data - * arrives through their own inject faces and stores). + * and navigation; a section's data arrives through its own inject faces and + * stores. `close` is the one shell affordance a section receives, for flows + * that leave settings altogether (starting a session from a section) — the + * onboarding coordinator's `openSection`/`complete` precedent, inverted. */ export interface SettingsSectionOwnerProps { - /** Marker field: section owner props are intentionally empty. */ - children?: never + /** Close the settings panel (the shell owns the open state). */ + close: () => void } /** Owner share of the currently active settings-backed onboarding step. */ diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 40b4dc29c2..1f34a47cf5 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -24,6 +24,7 @@ function mount({ rows = [ { id: 'general', order: 0, label: 'General' }, { id: 'models', order: 10, label: 'Models' }, + { id: 'agent-presets', order: 20, label: 'Agent presets' }, ], steps = [ { id: 'welcome', order: -100 }, diff --git a/packages/core/agent-tool-mode/README.i18n.yaml b/packages/core/agent-tool-mode/README.i18n.yaml new file mode 100644 index 0000000000..0799e0e547 --- /dev/null +++ b/packages/core/agent-tool-mode/README.i18n.yaml @@ -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 packages/core/agent-tool-mode/README.md +README.md: 0ef7f32c0890e5ef1071368571bd78b400b656e2 +README.zh.md: 974fc4ed574e44244f8d97682e2451440c8267ce diff --git a/packages/core/agent-tool-mode/README.md b/packages/core/agent-tool-mode/README.md new file mode 100644 index 0000000000..0ef7f32c08 --- /dev/null +++ b/packages/core/agent-tool-mode/README.md @@ -0,0 +1,31 @@ +# dsh-agent-tool-mode + +English | [中文](README.zh.md) + +The row an [agent preset](../../preset/agent-presets/README.md) carries to say which form of its tools the model sees: `native` (every schema), `code` (only `run_code` plus a generated TypeScript SDK), or `both`. + +## Why a row rather than a registry + +The tool registry cannot move into a preset. Its consumers are all host-plane — [`dsh-agent-loop`](../agent-loop/README.md) reads its scheduler, [`dsh-apiproxy`](../../host/apiproxy/README.md) reads its presenters to render tool cards, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. + +What a preset can own is the **presentation** of that registry. `ctx.tools.presentAs()` declares it for the mounting agent alone, so a Code Mode session runs beside native ones in one process, each seeing its own catalog. The deployment's `mode` on the [`dsh-tools`](../tools/README.md) row remains the default that agents declaring nothing get. + +## What it does + +`native` applies immediately. A code mode instead waits for `ctx.codeRuntime`, which is a host-plane service ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)): a preset selecting Code Mode against a deployment composing no runtime then holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. The alternative — applying optimistically — moves the failure to the session's first request, where the operator can act on neither the preset nor the composition. + +`mode` is required rather than defaulted, because a preset without this row already gets the deployment default; an omitted value would mean the row was composed for nothing. + +One agent declares one presentation. A second declaration in the same composition is refused rather than merged: two answers to "which form does the model see" is a contradiction, not an override. + +## Model Experience + +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. + +#### KV Cache effect + +No direct invalidation; the presentation is fixed when the agent is composed, so its request prefix is stable for the session's life. + +## Known Limitations and Deferred Work + +- **The runtime stays host-plane** — a preset can select Code Mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no code-mode preset. diff --git a/packages/core/agent-tool-mode/README.zh.md b/packages/core/agent-tool-mode/README.zh.md new file mode 100644 index 0000000000..974fc4ed57 --- /dev/null +++ b/packages/core/agent-tool-mode/README.zh.md @@ -0,0 +1,31 @@ +# dsh-agent-tool-mode + +[English](README.md) | 中文 + +[agent preset](../../preset/agent-presets/README.md) 用来声明「模型看到的工具是哪一种形态」的那一行:`native`(全部 schema)、`code`(只有 `run_code` 加一份生成的 TypeScript SDK)或 `both`。 + +## 为什么是一行插件,而不是把注册表搬下来 + +工具注册表搬不进 preset。它的消费者全在宿主平面——[`dsh-agent-loop`](../agent-loop/README.md) 读它的调度器,[`dsh-apiproxy`](../../host/apiproxy/README.md) 读它的 presenter 来渲染工具卡,每个工具插件都往里注册——而一个服务只有在**所有**消费者一起下沉时才能下沉。 + +preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs()` 只为正在挂载的那个 agent 声明,于是一个 Code Mode 会话可以和多个 native 会话同进程并存,各自看到各自的清单。[`dsh-tools`](../tools/README.md) 那一行上的 `mode` 仍然是默认值,供未作声明的 agent 使用。 + +## 它做什么 + +`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)):若某个 preset 在未组装运行时的部署上选择 Code Mode,本行就停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。另一种做法——先乐观应用——会把失败推迟到该会话的第一次请求,那时操作者对 preset 和组装都已无从下手。 + +`mode` 是必填而非有默认值:不带这一行的 preset 本来就会拿到部署默认值,省略它等于这一行白组装了。 + +一个 agent 只声明一次呈现方式。同一份组装里的第二次声明会被拒绝而不是合并:对「模型看到哪种形态」给出两个答案是矛盾,不是覆盖。 + +## Model Experience + +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. + +#### KV Cache effect + +没有直接的失效影响;呈现方式在 agent 组装时即固定,因此其请求前缀在该会话的整个生命周期内保持稳定。 + +## Known Limitations and Deferred Work + +- **运行时仍在宿主平面** —— preset 可以选择 Code Mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 code 模式的 preset。 diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json new file mode 100644 index 0000000000..236c9e5891 --- /dev/null +++ b/packages/core/agent-tool-mode/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-agent-tool-mode", + "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/core/agent-tool-mode/src/index.ts b/packages/core/agent-tool-mode/src/index.ts new file mode 100644 index 0000000000..d2f1e8fd49 --- /dev/null +++ b/packages/core/agent-tool-mode/src/index.ts @@ -0,0 +1,70 @@ +/** + * Agent-plane presentation selector: the row an agent preset carries to say + * which form of its tools the model sees. + * + * 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. + * + * 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)). + * This row therefore waits for it rather than assuming it: a preset selecting + * Code Mode against a deployment that composes no runtime fails at mount, named + * in the preset's own activation audit, instead of at the first prompt. + * @module @deepseek-ai/dsh-agent-tool-mode + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools' +// Type-only: brings the `ctx.tools` Context merge into this program. +import type {} from '@deepseek-ai/dsh-tools' + +/** Cordis plugin name. */ +export const name = 'tool-mode' + +/** + * Required services. `codeRuntime` is NOT listed: a `native` row must mount in + * a deployment that composes no runtime, and the mode-dependent wait is + * declared inside {@link apply} instead. + */ +export const inject = ['tools'] + +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a preset + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} + +/** Runtime schema. */ +export const Config: z = z.object({ + mode: z.union(['native', 'code', 'both'] as const).required(), +}) + +/** + * Declare this agent's tool presentation. + * @param ctx - the mounting agent's scope context. + * @param config - the selected presentation. + */ +export function apply(ctx: Context, config: Config): void { + // `presentAs` is itself the effect — it registers through the calling + // context and hands back that exact disposer — so the declaration unwinds + // with this row without a second wrapper owning it. + if (config.mode === 'native') { + ctx.tools.presentAs('native') + return + } + // The wait is the loud failure: an entry still pending on `codeRuntime` is + // what `dsh-agent-presets` reports as an unusable row, naming this id. + ctx.inject(['codeRuntime'], (runtimeCtx: Context) => { + runtimeCtx.tools.presentAs(config.mode) + }) +} diff --git a/packages/core/agent-tool-mode/src/invariant.ts b/packages/core/agent-tool-mode/src/invariant.ts new file mode 100644 index 0000000000..bd576cb943 --- /dev/null +++ b/packages/core/agent-tool-mode/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-tool-mode`. + * @module @deepseek-ai/dsh-agent-tool-mode/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode' + +/** Cordis companion plugin name. */ +export const name = 'tool-mode-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package makes exactly one scoped call into + * `ctx.tools` and owns no event or snapshot of its own; the relation it + * establishes — which presentation one agent's assembly uses — is the tool + * registry's to hold, and `dsh-tools` observes it there. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts new file mode 100644 index 0000000000..ba9b9972ff --- /dev/null +++ b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts @@ -0,0 +1,129 @@ +/** + * The row an agent preset carries to pick its tool presentation. What it owes + * its caller: the choice reaches THIS agent and no other, it unwinds with the + * agent, and a code mode composed against a deployment with no code runtime + * stops at mount — where a preset's activation audit can name it — rather + * than at the first prompt assembly. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-mode' + +/** A runtime that never runs anything: presentation never dispatches. */ +class StubRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'stub' + + run(_request: CodeRunRequest): Promise { + return Promise.resolve({ logs: [] }) + } +} + +/** A host plane with one tool, optionally carrying a code runtime. */ +async function host(options: { runtime?: boolean } = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(ToolRegistry, {}) + if (options.runtime !== false) await ctx.plugin(StubRuntime) + ctx.tools.register(defineTool({ + name: 'echo', + description: 'Echo tool.', + parameters: { value: { type: 'string', required: true } }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: args => Promise.resolve(args.value), + })) + return ctx +} + +/** Mount the row under one agent's scope, as a preset subtree does. */ +async function mount(ctx: Context, config: Config, id = 'agent') { + const agent = { id: SessionId(id) } as Agent + let inner!: Context + const fiber = ctx.plugin(Object.assign((host: Context) => { + inner = createScope(host, agent).ctx + }, { inject: ['tools', 'systemPrompt'] })) + await fiber.await() + const row = inner.plugin({ name, inject: [...inject], Config, apply }, config) + await row.await() + return { agent, fiber, row } +} + +describe('the tool-mode row', () => { + it('declares the services it uses without holding a code runtime hostage', () => { + // A `native` row must mount where no runtime is composed, so the wait is + // conditional inside apply rather than static metadata. + expect(inject).toEqual(['tools']) + }) + + it('gives its own agent Code Mode and leaves the rest native', async () => { + const ctx = await host() + const coded = await mount(ctx, { mode: 'code' }, 'coded') + const plain = await mount(ctx, { mode: 'native' }, 'plain') + + const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) + const plainAssembly = await ctx.systemPrompt.assemble({ scope: plain.agent }) + + expect(codedAssembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(codedAssembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('echo') + expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('presents both forms when asked for both', async () => { + const ctx = await host() + const { agent } = await mount(ctx, { mode: 'both' }) + + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME]) + }) + + it('restores the deployment default when the agent unloads', async () => { + const ctx = await host() + const { agent, row } = await mount(ctx, { mode: 'code' }) + + await row.dispose() + + // HMR safety: the preset subtree is torn down with its agent, and the + // presentation must go with it rather than outliving the composition. + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) + + it('waits for a code runtime the deployment does not compose', async () => { + const ctx = await host({ runtime: false }) + + const { agent, row } = await mount(ctx, { mode: 'code' }) + + // Pending, not applied: `dsh-agent-presets` rejects a mount holding a row + // that never reached a usable state, naming this id — so the preset fails + // where the operator can act, instead of at the first request. + expect(row.ctx.get('codeRuntime')).toBeUndefined() + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('applies once the runtime arrives', async () => { + const ctx = await host({ runtime: false }) + const { agent } = await mount(ctx, { mode: 'code' }) + + await ctx.plugin(StubRuntime) + + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + }) + + it('requires a mode rather than defaulting one', () => { + // An omitted value would mean the row was composed for nothing: a preset + // without this row already gets the deployment default. + expect(() => Config({} as never)).toThrow() + }) +}) diff --git a/packages/core/agent-tool-mode/tsconfig.json b/packages/core/agent-tool-mode/tsconfig.json new file mode 100644 index 0000000000..3b0445c30a --- /dev/null +++ b/packages/core/agent-tool-mode/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 4df0056c1a..0f2fcabf3a 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -96,6 +96,7 @@ export interface CreateAgentOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } /** * Initial replay/fork history. A fork supplies a balanced completed-turn diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index b42a9e11c7..7df5df403c 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/README.i18n.yaml @@ -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/core/scope/README.md -README.md: ecb442e39e40d5b97a07ccf8a71a190c4009ede8 -README.zh.md: 019e4c59dd788866e26b3b8a20b0023999f35ed2 +README.md: a8fbe97ae3b59f223bb52e44860439803fda420c +README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8 diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index ecb442e39e..a8fbe97ae3 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -2,20 +2,21 @@ English | [中文](README.zh.md) -Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents. +Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either. ## Public API -- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). +- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal. +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first. - `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. - `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. -- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics). +- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff its tag is the key or an ancestor of it; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics). - `Scoped` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. - `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation. -- `ScopedLayers` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer. +- `ScopedLayers` Own one eager global layer and lazy exact-scope layers. `peek()` never creates and stays chain-blind (a scope's OWN contributions — restrictions, guards — must not silently pick up an ancestor's), `chainLayers()` returns existing overlays farthest-ancestor-first, `merge()` materializes insertion-ordered named shadows along the chain, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer. - `NamedEntries` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo. - `AnonymousEntries` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo. @@ -32,5 +33,5 @@ Handing out a scoped context hands out the minting plugin's service-resolution s ## Known Limitations and Deferred Work - **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context. -- **A context carries one nearest scope key** — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets. +- **A context carries one nearest scope key** — the hierarchy lives in the key-level parent relation, not in context tags; nested scope CONTEXTS still shadow to a single tag, and multi-membership policy sets remain unsupported. - **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder. diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index 019e4c59dd..af23823298 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -2,11 +2,12 @@ [English](README.md) | 中文 -带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。agent loop(智能体循环)为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包无需依赖 agent 即可使用。 +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 ## 公开 API -- `createScope(ctx: Context, key: ScopeKey): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。 +- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。 +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。 - `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。 - `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。 - `Scope.dispose(): Promise`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。 @@ -15,7 +16,7 @@ - `Scoped`:编译期不透明载体 brand。按作用域筛选的事件要求它作为 `this` 类型,因此使用裸主体分发会产生编译错误。类型参数记录主体类型,但不公开其属性。 - `isScopeCarrier(value)`/`carrierKeyOf(value)`:运行时载体标记,开发不变式使用它们断言每次按作用域筛选的分发都携带载体,而且载体键与参数所指名的主体一致。 - `ScopeLayer`:一个注册表的完整全局贡献或精确作用域贡献的聚合约定;`isEmpty()` 控制带作用域层的回收。 -- `ScopedLayers`:拥有一个立即创建的全局层和按需创建的精确作用域层。`peek()` 从不创建;`merge()` 物化按插入顺序排列的具名遮蔽项;`effect()` 从同一上下文推导可见性与所有权,同时返回原样 Cordis disposer。 +- `ScopedLayers`:持有一个立即构造的全局层与惰性的精确作用域层。`peek()` 从不创建且刻意不看链(某作用域**自己**的贡献——限制、守卫——不得悄悄继承祖先的),`chainLayers()` 按最远祖先在前返回已存在的各层,`merge()` 沿链物化按插入序的具名遮蔽,`effect()` 从同一上下文推导可见性与所有权,并返回精确的 Cordis disposer。 - `NamedEntries`:按插入顺序排列的具名存储,调用方拥有重复项诊断、查找,以及一个非空表世代内的实时迭代。表清空后,现有迭代器与后续插入项脱离;`insert()` 返回幂等的精确条目撤销函数。 - `AnonymousEntries`:按插入顺序排列的匿名存储;唯一内部键使相同值仍作为独立注册存在。它使用相同的清空世代迭代器边界;`append()` 返回幂等的精确条目撤销函数。 @@ -32,5 +33,5 @@ ## 已知限制与暂缓事项 - **只有感知作用域的表层才会隔离状态**:注册表必须按 `scopeOf()` 归档,事件必须通过 `scopeTarget()` 分发;仅仅通过带作用域的上下文调用任意 Cordis 服务,并不会改变该服务仍为上下文全局这一事实。 -- **一个上下文只携带一个最近的作用域键**:嵌套作用域会遮蔽父作用域的标签,而不会形成层级策略集或多成员策略集。 +- **一个上下文只携带一个最近的作用域键**:层级关系存在于键级父关系中而非上下文标签里;嵌套作用域**上下文**仍遮蔽为单一标签,多成员策略集仍不受支持。 - **服务可达性来自作用域创建者**:交出 `Scope.ctx` 也会交出创建插件注入的服务表层,因此,若作用域创建者提供的服务范围较宽,持有者之后也无法将其收窄。 diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index fc5b1fa5fa..b5f58dbdf0 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -29,6 +29,78 @@ export type Scoped = object & { readonly [ScopedBrand]: T } /** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */ const carrierKeys = new WeakMap() +/** + * The enclosing scope of each key. One relation powers both directions of + * scope nesting: registration views inherit DOWN the chain (a child scope + * sees its ancestors' layers — {@link ScopedLayers}), and event admission + * extends UP it (a listener tagged with an ancestor receives events dispatched + * to a descendant key — {@link scopeTarget}). + */ +const scopeParents = new WeakMap() + +/** The privileged handle to move one scope key's parent link. */ +export interface ScopeParentBinding { + /** + * Re-link the bound key to a different parent, with the same cycle check as + * the bind. Valid only while nothing produced under the old parent is + * retained — the blank-session recompose contract, which the holder upholds + * because this relation cannot see what a session logged. + * @param parent - the new enclosing scope key. + */ + rebind(parent: ScopeKey): void +} + +/** Cycle-checked write shared by the bind and every rebind. */ +function linkScopeParent(key: ScopeKey, parent: ScopeKey): void { + for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) { + if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle') + } + scopeParents.set(key, parent) +} + +/** + * Bind `parent` as `key`'s enclosing scope, once. + * + * A key that already has a parent throws: there is no open re-link path, so a + * scope's ancestry cannot be moved by anyone but the original binder, who + * alone receives the {@link ScopeParentBinding}. A link that would close a + * cycle is rejected, because every chain consumer walks parents to the root. + * @param key - the child scope key. + * @param parent - its enclosing scope key. + * @returns the binding that alone may re-link this key. + */ +export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding { + if (scopeParents.has(key)) { + throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind') + } + linkScopeParent(key, parent) + return { + rebind(next: ScopeKey): void { + linkScopeParent(key, next) + }, + } +} + +/** + * Read one key's enclosing scope. + * @param key - the scope key to inspect. + * @returns its parent key, or `undefined` for a root scope. + */ +export function scopeParentOf(key: ScopeKey): ScopeKey | undefined { + return scopeParents.get(key) +} + +/** + * The chain from a key to its root ancestor. + * @param key - the starting key, or `undefined` for the empty chain. + * @returns keys nearest-first: `[key, parent, grandparent, …]`. + */ +export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] { + const chain: ScopeKey[] = [] + for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor) + return chain +} + /** A minted registration scope and its quiescent disposal boundaries. */ export interface Scope { /** Context through which scope-owned registrations are made. */ @@ -48,14 +120,22 @@ async function quiesceFiber(fiber: Fiber): Promise { /** Shared no-op plugin used as the backing scope fiber. */ function scope(): void {} +/** Options accepted by {@link createScope}. */ +export interface CreateScopeOptions { + /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */ + parent?: ScopeKey +} + /** * Mint a scope under `ctx`. The scoped context inherits the minting plugin's * dependency surface and owns every registration made through it. * @param ctx - active context whose dependency surface the scope inherits. * @param key - opaque identity used for listener routing. + * @param options - optional scope-chain placement. * @returns the scoped context and exact/shared disposal boundaries. */ -export function createScope(ctx: Context, key: ScopeKey): Scope { +export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { + if (options?.parent !== undefined) bindScopeParent(key, options.parent) const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined @@ -77,7 +157,12 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { /** * Build an opaque receiver that preserves the base filter, admits untagged - * listeners globally, and admits tagged listeners only for a matching key. + * listeners globally, and admits tagged listeners for a matching key or any + * of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing + * scope receives every descendant scope's events, which is what lets one + * standing composition observe each of the agents composed under it. A tag + * BELOW the dispatch key stays excluded — events flow up the chain, never + * down. * @param base - subject or service whose existing Cordis filter is preserved. * @param key - routed scope identity, or `undefined` for an unscoped subject. * @returns a carrier whose subject remains available only through event arguments. @@ -88,7 +173,11 @@ export function scopeTarget(base: T, key: ScopeKey | undefined [CordisContext.filter](ctx: Context): boolean { if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false const tag = scopeOf(ctx) - return tag === undefined || tag === key + if (tag === undefined) return true + for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) { + if (cursor === tag) return true + } + return false }, } carrierKeys.set(carrier, key) diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts index cdb34b50ce..a9e1468ccd 100644 --- a/packages/core/scope/src/store.ts +++ b/packages/core/scope/src/store.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' -import { scopeOf } from './index.ts' +import { scopeChainOf, scopeOf } from './index.ts' import type { ScopeKey } from './index.ts' /** One scope's aggregate contribution to a registry. */ @@ -170,7 +170,10 @@ export class ScopedLayers { } /** - * Read an existing exact-scope overlay. + * Read an existing exact-scope overlay. Deliberately chain-blind: callers + * addressing one scope's OWN contributions (its restrictions, its guards) + * must not silently pick up an ancestor's — use {@link chainLayers} where + * inheritance is the point. * @param scope - exact scope key; `undefined` denotes no overlay. * @returns the existing scoped layer, or `undefined` without creating one. */ @@ -180,8 +183,25 @@ export class ScopedLayers { } /** - * Materialize global named entries followed by exact-scope shadows. - * @param scope - exact viewing scope, or `undefined` for the global view. + * Existing overlays along the scope's parent chain ({@link scopeChainOf}), + * farthest ancestor first and the exact scope last, so a caller layering + * them in order gives the nearest scope the final word. + * @param scope - viewing scope, or `undefined` for no overlays. + * @returns the existing layers, nearest last; absent overlays are skipped. + */ + chainLayers(scope: ScopeKey | undefined): L[] { + const layers: L[] = [] + for (const key of scopeChainOf(scope).reverse()) { + const layer = this.scoped.get(key) + if (layer !== undefined) layers.push(layer) + } + return layers + } + + /** + * Materialize global named entries followed by scope-chain shadows, + * farthest ancestor first, so the nearest scope's entry wins a name. + * @param scope - viewing scope, or `undefined` for the global view. * @param pick - select the named table from a layer. * @returns an insertion-ordered effective map. */ @@ -190,9 +210,9 @@ export class ScopedLayers { pick: (layer: L) => NamedEntries, ): Map { const merged = new Map(pick(this.global).entries()) - const layer = this.peek(scope) - if (layer === undefined) return merged - for (const [name, value] of pick(layer).entries()) merged.set(name, value) + for (const layer of this.chainLayers(scope)) { + for (const [name, value] of pick(layer).entries()) merged.set(name, value) + } return merged } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 0b7bbef348..7007624d53 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { @@ -153,3 +153,70 @@ describe('scopeTarget', () => { expectTypeOf(carrier).toEqualTypeOf>() }) }) + +describe('scope parent chain', () => { + it('links at mint, walks to the root, and rejects cycles', () => { + const ctx = new Context() + const preset = { kind: 'preset' } + const agent = { kind: 'agent' } + createScope(ctx, preset) + createScope(ctx, agent, { parent: preset }) + + expect(scopeParentOf(agent)).toBe(preset) + expect(scopeParentOf(preset)).toBeUndefined() + expect(scopeChainOf(agent)).toEqual([agent, preset]) + expect(scopeChainOf(undefined)).toEqual([]) + expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/) + expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/) + }) + + it('re-links only through the binding held by the original binder', () => { + const ctx = new Context() + const presetA = { id: 'a' } + const presetB = { id: 'b' } + const agent = { id: 'agent' } + createScope(ctx, presetA) + createScope(ctx, presetB) + const binding = bindScopeParent(agent, presetA) + createScope(ctx, agent) + + // A bound key cannot be re-bound from the outside; only the binding moves it. + expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/) + binding.rebind(presetB) + + expect(scopeChainOf(agent)).toEqual([agent, presetB]) + // The rebind keeps the cycle check: a parent may not adopt its ancestor. + const child = { id: 'child' } + const childBinding = bindScopeParent(child, agent) + void childBinding + expect(() => { binding.rebind(child) }).toThrow(/cycle/) + }) + + it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => { + const ctx = new Context() + const preset = { kind: 'preset' } + const agent = { kind: 'agent' } + const other = { kind: 'other-preset' } + const presetScope = createScope(ctx, preset) + const agentScope = createScope(ctx, agent, { parent: preset }) + const otherScope = createScope(ctx, other) + + const seen: string[] = [] + ctx.on('probe/event' as never, ((): void => { seen.push('untagged') }) as never) + presetScope.ctx.on('probe/event' as never, ((): void => { seen.push('preset') }) as never) + agentScope.ctx.on('probe/event' as never, ((): void => { seen.push('agent') }) as never) + otherScope.ctx.on('probe/event' as never, ((): void => { seen.push('other') }) as never) + + const emit = ctx as unknown as { emit: (carrier: object, type: string) => void } + // Dispatch at the AGENT key: its own tag and its ancestor's admit; a + // sibling root does not. + emit.emit(scopeTarget({}, agent), 'probe/event') + expect(seen.sort()).toEqual(['agent', 'preset', 'untagged']) + + // Dispatch at the PRESET key: the agent-tagged listener sits BELOW the + // dispatch key and stays excluded — events flow up the chain, not down. + seen.length = 0 + emit.emit(scopeTarget({}, preset), 'probe/event') + expect(seen.sort()).toEqual(['preset', 'untagged']) + }) +}) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 2e9bf49271..df32eaf80c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -149,6 +149,9 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { throw new Error('session header delegationDepth must be a non-negative safe integer') } + if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') { + throw new Error('session header agentPreset must be a string') + } return deepFreeze(record as unknown as SessionHeader) } @@ -898,6 +901,7 @@ export class SessionStore extends Service { ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, ...meta?.origin === undefined ? {} : { origin: meta.origin }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, + ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset }, } return Session.create(sessionId, seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 6074b51c02..35dd9d1dab 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -69,6 +69,13 @@ export interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } /** @@ -90,6 +97,7 @@ export interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 39f0530874..b0302b9d4d 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1319,6 +1319,7 @@ describe('SessionStore', () => { { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ }, + { meta: { agentPreset: 1 }, error: /agentPreset must be a string/ }, ] for (const [index, { meta, error }] of cases.entries()) { diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 16fc1394fa..6f1757ff28 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -110,6 +110,17 @@ export interface PromptAssembly { variables: Record } +/** + * The deployment persona's section name and order. Exported because a + * composition can replace this slot — an agent preset shadows the + * deployment's persona with its own — and both sides naming the same section + * is what makes the replacement work rather than duplicate. + */ +export const PERSONA_SECTION = 'deployment:persona' + +/** Prompt order of the persona slot; the first section a model reads. */ +export const PERSONA_ORDER = 0 + /** Valid variable names: how they are written between the braces. */ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ @@ -337,8 +348,8 @@ export class SystemPrompt extends Service { }) } this.section({ - name: 'deployment:persona', - order: 0, + name: PERSONA_SECTION, + order: PERSONA_ORDER, // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', }) @@ -429,9 +440,11 @@ export class SystemPrompt extends Service { for (const [name, provider] of this.layers.global.variables.entries()) { variables[name] = provider(context) } - const scopedVariables = this.layers.peek(scope)?.variables - for (const [name, provider] of scopedVariables?.entries() ?? []) { - variables[name] = provider(context) + // Scope-chain variables, farthest first, so the nearest scope wins a name. + for (const layer of this.layers.chainLayers(scope)) { + for (const [name, provider] of layer.variables.entries()) { + variables[name] = provider(context) + } } // Scoped sections shadow globals before the stable order sort. const sectionByName = this.layers.merge(scope, layer => layer.sections) @@ -439,7 +452,7 @@ export class SystemPrompt extends Service { // Validate order against pre-restriction names while collecting visible schemas. const providers = [ ...this.layers.global.toolProviders.values(), - ...(this.layers.peek(scope)?.toolProviders.values() ?? []), + ...this.layers.chainLayers(scope).flatMap(layer => [...layer.toolProviders.values()]), ] const collected: ToolSchema[] = [] const knownNames = new Set() diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 9d9ed71d8d..38a9c2ede3 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -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/core/tools/README.md -README.md: b1de96293f8ec823ec52d6142a46de877f7fc5e6 -README.zh.md: fa42f7c02a09579bd7b1c995246696d8808889de +README.md: f3d1b4741c7fde64669794d079c36a18e633c0c1 +README.zh.md: d3054372095ef0cfdabc6cf80e0faa41a3b12d4c diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index b1de96293f..f3d1b4741c 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both. +Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both, and one agent shadows that default for itself with `presentAs`. ## Service: `ToolRegistry` (ctx key: `tools`) @@ -13,11 +13,12 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. +- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. - `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). @@ -190,6 +191,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. -- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own). +- **Code Mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (TypeScript or Python); scoped restrictions/shadows and `presentAs` choose each agent's visible bindings and their form, but within one agent no tool can be native-only while another is code-only. - **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index fa42f7c02a..d305437209 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者。 +工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者;单个 agent 可用 `presentAs` 为自己遮蔽该默认值。 ## 服务:`ToolRegistry`(ctx 键:`tools`) @@ -13,11 +13,12 @@ tools: mode: native # native (default) | code | both ``` -`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 +`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 ### 公开 API - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 +- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。 - `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 @@ -190,6 +191,6 @@ The available tools: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。 - **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。 -- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责这次查表,以及注册表为何读取所加载的运行时而不自带 language 字段)。 +- **Code Mode 的 SDK 语言跟随已加载的那个运行时,且呈现方式按 agent 而非按工具**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK renderer(TypeScript 或 Python);作用域限制/遮蔽与 `presentAs` 会选择每个 agent 的可见绑定及其形态,但在同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。 - **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 - **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 75c0751ad9..a44df0863f 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -623,11 +623,16 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a `ctx.codeRuntime` whose `language` has a registered - * SDK renderer (TypeScript or Python) and fail prompt assembly when it is - * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * `ctx.codeRuntime` whose `language` has a registered SDK renderer + * (TypeScript or Python) and fail prompt assembly when it is absent or has + * no renderer. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** @@ -682,6 +687,12 @@ class ToolLayer implements ScopeLayer { readonly tools: NamedEntries readonly restrictions = new AnonymousEntries() readonly guards = new AnonymousEntries() + /** + * Presentation this scope's agent declared for itself, shadowing the + * deployment default. One cell rather than an entry table: two answers to + * "which form does the model see" is a contradiction, not a merge. + */ + mode: ToolPresentationMode | undefined constructor(scope: ScopeKey | undefined) { this.tools = new NamedEntries(name => new Error(scope === undefined @@ -692,6 +703,7 @@ class ToolLayer implements ScopeLayer { /** Whether every contribution table in this aggregate layer is empty. */ isEmpty(): boolean { return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() + && this.mode === undefined } /** Whether every compiled restriction in this layer admits a global tool name. */ @@ -772,60 +784,143 @@ export class ToolRegistry extends Service { scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }, ) - private readonly mode: ToolPresentationMode - /** Reserved presentation transport, kept outside the filterable registration layers. */ - private readonly codeTransport: ToolDefinition | undefined + /** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */ + private readonly defaultMode: ToolPresentationMode + private readonly maxParallelSubCalls: number + /** + * Reserved presentation transport, kept outside the filterable registration + * layers. Built on first need rather than at construction: which agents run + * a code mode is no longer known when the service is constructed, and the + * transport is stateless beyond its closures over `this`. + */ + private codeTransport: ToolDefinition | undefined constructor(ctx: Context, config: Config = {}) { super(ctx, 'tools') // The schema already defaulted an omitted mode; the ?? narrows the // optional-input type for direct (non-Loader) construction in tests. - this.mode = config.mode ?? 'native' - // `run_code` is presentation infrastructure, not an end capability. It - // therefore does not enter the global layer: per-agent restrictions must - // not remove it, and a scoped registration must not shadow it. The - // visibility resolver appends this reserved definition after resolving - // the filterable global/scoped capability layers. - this.codeTransport = this.mode === 'native' - ? undefined - : createRunCodeTool(this, { - requireRuntime: () => this.requireCodeRuntime(), - peekRuntime: () => this.ctx.get('codeRuntime'), - maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls), - shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), - }) + this.defaultMode = config.mode ?? 'native' + this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) - if (this.mode !== 'native') { - ctx.systemPrompt.section({ - name: 'tools:sdk', - order: SDK_SECTION_ORDER, - // Regenerate from the calling scope's visible tools in stable order, - // picking the renderer that matches the loaded runtime's language. - // `requireCodeRuntime` already validated the language is in the table, - // so the guard below is defense-in-depth against a caller that bypassed - // it (impossible under normal composition). - text: (context) => { - const runtime = this.requireCodeRuntime() - // Own-property read: a language like `toString`/`constructor` would - // otherwise resolve an inherited Object.prototype member as a renderer. - const render = SDK_RENDERERS[runtime.language] - /* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */ - if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) { - throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')})`) - } - return render(this.sdkSchemas(context.scope)) - }, - }) + if (this.defaultMode !== 'native') { + ctx.systemPrompt.section(this.sdkSection()) } } + /** + * The generated-SDK prompt section, registered globally by a code-mode + * deployment and per agent 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 + * deployment still sees the global registration, and an empty section is + * dropped from the rendered prompt. + * @returns the section registration. + */ + private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + return { + name: 'tools:sdk', + order: SDK_SECTION_ORDER, + // Regenerate from the calling scope's visible tools in stable order. + text: (context) => { + const mode = this.modeFor(context.scope) + if (mode === 'native') return '' + const runtime = this.requireCodeRuntime(mode) + // Own-property read: a language like `toString`/`constructor` would + // otherwise resolve an inherited Object.prototype member as a renderer. + const render = SDK_RENDERERS[runtime.language] + /* v8 ignore next -- requireCodeRuntime rejects an unknown language before this runs. */ + if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`) + return render(this.sdkSchemas(context.scope)) + }, + } + } + + /** + * The presentation one scope's agent sees: its own declaration, else the + * deployment default. + * @param scope - the calling agent, or undefined for the global view. + * @returns the resolved presentation mode. + */ + private modeFor(scope?: ScopeKey): ToolPresentationMode { + // Nearest scope wins along the chain: a preset's standing declaration + // covers every agent parented under it, and an agent's own (were one ever + // declared) would override its preset's. The mode decides what the model + // SEES, which is exactly the class of fact the chain inherits. + const layers = this.layers.chainLayers(scope) + for (let index = layers.length - 1; index >= 0; index -= 1) { + const mode = layers[index]?.mode + if (mode !== undefined) return mode + } + return this.defaultMode + } + + /** + * The reserved `run_code` transport, built on first need. + * + * It never enters the global layer: per-agent restrictions must not remove + * it, and a scoped registration must not shadow it. The visibility resolver + * appends it after resolving the filterable global/scoped capability layers, + * and only for scopes whose mode actually presents it. + * @returns the shared transport definition. + */ + private requireCodeTransport(): ToolDefinition { + this.codeTransport ??= createRunCodeTool(this, { + requireRuntime: () => this.requireCodeRuntime(this.defaultMode), + // The language-aware description/parameters getters read the runtime + // without demanding one, so a native-default process can still project + // the transport for an agent that chose code. + peekRuntime: () => this.ctx.get('codeRuntime'), + maxParallel: this.maxParallelSubCalls, + shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), + }) + return this.codeTransport + } + + /** + * Present this agent's tools in `mode` instead of the deployment default. + * + * 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 + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ + presentAs(mode: ToolPresentationMode): () => void { + const ctx = this.ctx + if (scopeOf(ctx) === undefined) { + throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row') + } + const dispose = ctx.effect(function* (this: ToolRegistry) { + yield this.layers.effect( + 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`) + } + 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 + // 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. + if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection()) + }.bind(this), 'tools.presentAs()') + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity + return dispose + } + /** * Build one scope's wire schemas and names for prompt-order validation. * Restrictions do not make known tools invalid, but a mode collapse does. */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) - if (this.mode === 'native') { + const mode = this.modeFor(scope) + if (mode === 'native') { const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) return { schemas, knownNames: [...view.knownNames] } } @@ -834,9 +929,9 @@ export class ToolRegistry extends Service { // flavor-table guard would otherwise surface first. This keeps the // renderer-table rejection the canonical assembly-time error for a // language with no SDK renderer. - this.requireCodeRuntime() + this.requireCodeRuntime(mode) const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - if (this.mode === 'code') { + if (mode === 'code') { return { schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME], @@ -861,10 +956,10 @@ export class ToolRegistry extends Service { * point it is testable); rationale in the * [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md). */ - private requireCodeRuntime(): CodeRuntime { + private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime { const runtime = this.ctx.get('codeRuntime') if (!runtime) { - throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) + throw new Error(`dsh-tools: mode "${mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) } if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) { const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ') @@ -893,7 +988,10 @@ export class ToolRegistry extends Service { && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`) } - if (this.codeTransport !== undefined && name === RUN_CODE_NAME) { + // Reserved unconditionally: any agent may select a code mode for itself, + // so a name free to take under the deployment default would become a + // collision the moment a preset mounted. + if (name === RUN_CODE_NAME) { throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) } return this.layers.effect( @@ -924,8 +1022,7 @@ export class ToolRegistry extends Service { ...allow !== undefined ? { allow: new Set(allow) } : {}, ...deny !== undefined ? { deny: new Set(deny) } : {}, } - if (this.codeTransport !== undefined - && [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) { + if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) { throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`) } const known = this.view(scope).restrictableNames @@ -958,11 +1055,16 @@ export class ToolRegistry extends Service { ) } - /** First monotonic denial from the global then matching scoped guard layers. */ + /** First monotonic denial from the global then the scope chain's guard layers, farthest first. */ private guardReason(exec: ToolExecution): string | undefined { const globalReason = this.layers.global.guardReason(exec) if (globalReason !== undefined) return globalReason - return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec) + if (exec.agent === undefined) return undefined + for (const layer of this.layers.chainLayers(exec.agent)) { + const reason = layer.guardReason(exec) + if (reason !== undefined) return reason + } + return undefined } /** @@ -974,26 +1076,34 @@ export class ToolRegistry extends Service { * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { - const layer = this.layers.peek(scope) + // Scope-chain layers, farthest ancestor first, the exact scope last. + const layers = this.layers.chainLayers(scope) const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() for (const [name, definition] of this.layers.global.tools.entries()) { knownNames.add(name) restrictableNames.add(name) - if (layer?.admits(name) ?? true) visible.set(name, definition) + // Restrictions intersect across the whole chain: any scope on it may + // mask a global-surface name for everything nested inside it. + if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // Scoped layer second: same-name entries REPLACE (shadow) the global ones, - // and scope-local registrations are never part of the global filter above. - for (const [name, definition] of layer?.tools.entries() ?? []) { - knownNames.add(name) - visible.set(name, definition) + // Chain layers second, nearest last: same-name entries REPLACE (shadow) + // the global and farther-scope ones, and scope-local registrations are + // never part of the global filter above. + for (const layer of layers) { + for (const [name, definition] of layer.tools.entries()) { + knownNames.add(name) + visible.set(name, definition) + } } // Presentation infrastructure is resolved last and outside capability // filtering. Registration rejects this reserved name, so the insertion is - // an invariant assertion as well as protection against future layer changes. - if (this.codeTransport !== undefined) { - visible.set(RUN_CODE_NAME, this.codeTransport) + // an invariant assertion as well as protection against future layer + // changes. Per scope: a native agent must not find `run_code` in its + // dispatch table because some other agent in the process presents it. + if (this.modeFor(scope) !== 'native') { + visible.set(RUN_CODE_NAME, this.requireCodeTransport()) } return { visible, knownNames, restrictableNames } } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 2f3454b03c..34758b840f 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1562,3 +1562,127 @@ describe('the run_code dispatch bridge', () => { expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) }) + +/** + * Presentation is per agent, because an agent preset composes it: one + * deployment runs a Code Mode agent beside native ones, and neither may see + * the other's catalog. The deployment `mode` is the default those agents + * shadow, not a process-wide fact. + */ +describe('per-agent presentation', () => { + it('gives one agent Code Mode while the deployment stays native', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'native' }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + + scope.ctx.tools.presentAs('code') + + const coded = await systemPrompt.assemble({ scope: agent }) + expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(coded.sections.find(section => section.name === 'tools:sdk')?.text) + .toContain('echo') + // The deployment default is untouched: an agent that declared nothing — + // and the global view behind it — still sees the native catalog. + const native = await systemPrompt.assemble() + expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + expect(native.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) + + it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { + const { bindScopeParent } = await import('@deepseek-ai/dsh-scope') + const { ctx, systemPrompt } = await setup({ mode: 'native' }) + registerEcho(ctx) + // The preset's standing scope declares once; the agent only PARENTS to it + // (the per-preset standing-mount shape — no per-agent declaration at all). + const standing = await mintAgentScope(ctx, 'preset:code-like') + standing.scope.ctx.tools.presentAs('code') + const joined = await mintAgentScope(ctx, 'joined-agent') + bindScopeParent(joined.agent, standing.agent) + const loner = await mintAgentScope(ctx, 'loner-agent') + + expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() + const coded = await systemPrompt.assemble({ scope: joined.agent }) + expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + // A sibling that never parented stays native, as does the global view. + expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() + const native = await systemPrompt.assemble({ scope: loner.agent }) + expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('keeps run_code out of a native agent\'s dispatch table', async () => { + const { ctx } = await setup({ mode: 'native' }) + registerEcho(ctx) + const coded = await mintAgentScope(ctx, 'coded') + const plain = await mintAgentScope(ctx, 'plain') + coded.scope.ctx.tools.presentAs('code') + + // Not merely hidden from the prompt: the transport one agent presents must + // not be dispatchable by another that never presented it. + expect(ctx.tools.get(RUN_CODE_NAME, coded.agent)).toBeDefined() + expect(ctx.tools.get(RUN_CODE_NAME, plain.agent)).toBeUndefined() + expect(ctx.tools.get(RUN_CODE_NAME)).toBeUndefined() + }) + + it('lets an agent opt out of a code-mode deployment', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code' }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + + scope.ctx.tools.presentAs('native') + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + // The deployment's global section still reaches this scope; rendering it + // empty is what keeps the opted-out agent's prompt free of an SDK. + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toBe('') + }) + + it('restores the deployment default when the agent unloads', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'native' }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + const dispose = scope.ctx.tools.presentAs('code') + + dispose() + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) + + it('refuses a second declaration for the same agent', async () => { + const { ctx } = await setup({ mode: 'native' }) + const { scope } = await mintAgentScope(ctx) + scope.ctx.tools.presentAs('code') + + // Two answers to "which form does the model see" is a contradiction, and + // silently keeping either one would make the composition unreadable. + expect(() => scope.ctx.tools.presentAs('both')) + .toThrow('conflicts with "code" already declared') + }) + + it('refuses an unscoped declaration', async () => { + const { ctx } = await setup({ mode: 'native' }) + + expect(() => ctx.tools.presentAs('code')) + .toThrow('requires a scoped context') + }) + + it('reserves run_code even where no agent presents it', async () => { + const { ctx } = await setup({ mode: 'native' }) + + // The name must stay free under a native deployment too: an agent preset + // mounting later would otherwise collide with whatever took it. + expect(() => registerEcho(ctx, RUN_CODE_NAME)).toThrow('is reserved') + }) + + it('reports the missing runtime against the agent\'s own mode', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + scope.ctx.tools.presentAs('both') + + await expect(systemPrompt.assemble({ scope: agent })) + .rejects.toThrow('mode "both" requires a code runtime') + }) +}) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e6ea7dd50d..f449e05a28 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: 44b8ee76b0da67ef1aaf24fb3d54e91d20fc064a -README.zh.md: 06b05aa64727c8bce08c7935040a1766f30b98ed +README.md: 98fcdda155286feb23aaf294cab76529ce31cfc1 +README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 44b8ee76b0..98fcdda155 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,6 +46,10 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. +The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. + +`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. + The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 06b05aa647..8cfa7e527a 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,6 +46,10 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 +`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 + +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 + `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 511fc25dba..06749f8f35 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -65,15 +65,17 @@ "zod": "^4.4.3" }, "peerDependencies": { - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "^0.0.1" + "@deepseek-ai/dsh-agent-presets": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "workspace:^" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e417ca12b2..1fbcfadd2d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import type { Context } from 'cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' @@ -26,6 +26,11 @@ import { WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). +import { + InvalidPresetIdError, PresetExistsError, PresetMountError, + PresetNotWritableError, resolveSessionPreset, + SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, @@ -58,6 +63,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and // `ctx.get('approval')` without a value dependency on the seam (optional composition). @@ -79,7 +85,7 @@ import { hasApiRemoteSubagentOwner, inspectApiRemoteSession, } from '@deepseek-ai/dsh-api-remotes' -import { openNativePath, openNativeTextFile } from './native-path-opener.ts' +import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -96,8 +102,15 @@ const COLD_SUMMARY_BATCH_SIZE = 16 /** Conversation message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Product settings intentionally exposed beside model-provider namespaces. */ -const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding']) +/** + * Product settings intentionally exposed beside model-provider namespaces. + * + * The agent-preset namespace carries one field — which preset a session with + * no explicit choice is composed from — and both browser surfaces that offer + * that choice write it through `settings.update`, so it has to cross the + * configuration boundary or the pickers silently fail to persist. + */ +const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE]) /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { @@ -206,6 +219,35 @@ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } +/** + * The RPC refusal a preset failure becomes, or undefined when the failure is + * about something else. + * + * Both the session-create path and the switch path can be handed the same two + * failures, and a client that has to branch on the code needs them worded the + * same from either. + * @param request - the request being answered. + * @param error - the thrown value. + * @returns the refusal, or undefined when the caller should keep handling. + */ +function presetFailure(request: RpcRequest, error: unknown): RpcResponse | undefined { + if (error instanceof UnknownPresetError) { + return err(request, { + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + }) + } + if (error instanceof PresetMountError) { + return err(request, { + code: 'agent-preset-invalid', + message: error.message, + details: { agentPreset: error.presetId, reason: error.reason }, + }) + } + return undefined +} + /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */ class FrameQueue { private buffer: F[] = [] @@ -266,15 +308,21 @@ function sessionBlank(session: Session): boolean { } /** Shared Session-header projection for list baselines and creation frames. */ -function sessionListFields(header: SessionHeader): { +function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): { parentSessionId?: SessionId origin?: 'subagent' cwd?: string + agentPreset?: string } { + // The preset comes from the log, not the header: a session that switched + // while blank ran its turns under the newer composition, and a picker + // showing the creation-time value would contradict what the model saw. + const agentPreset = resolveSessionPreset({ header, events }) return { ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }, ...header.origin === undefined ? {} : { origin: header.origin }, ...header.cwd === undefined ? {} : { cwd: header.cwd }, + ...agentPreset === undefined ? {} : { agentPreset }, } } @@ -287,7 +335,7 @@ function summarize(session: Session, running: boolean): SessionSummary { updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, running, blank: sessionBlank(session), - ...sessionListFields(session.header), + ...sessionListFields(session.header, session.events), } } @@ -321,12 +369,10 @@ async function summarizeCold( // a cold log to check for turns would defeat the index read, so a listed // cold session is served as not-blank (its log holds its conversation). blank: false, - ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession }, - ...meta.origin === undefined ? {} : { origin: meta.origin }, - /* v8 ignore next -- the empty arm needs a cwd-less meta, but list() - filters those out (legacy logs are not served); the conditional mirrors - summarize() shape. */ - ...meta.cwd === undefined ? {} : { cwd: meta.cwd }, + // Header-only: reading the log for a blank-window preset switch would + // defeat the same index read, and attaching the session replaces this row + // with `summarize()`, which resolves the switch from the events. + ...sessionListFields(meta), } } @@ -363,6 +409,14 @@ export interface ApiProxyDefaults { openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ openTextFile?: (path: string, signal: AbortSignal) => Promise + /** + * Whether handing a path to the native opener can work at all — the + * `hasDocument` capability the preset roster reports, and the switch + * between opening a preset directory and answering its path as text. + * Absent, an injected `openPath` counts as openable and everything else + * falls back to platform detection ({@link canOpenNativePath}). + */ + canOpenPath?: () => boolean } /** The tool/call payload fields the presenter path reads. */ @@ -437,11 +491,21 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: * the client's documented default (generic JSON card) covers every miss. */ -function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { +function viewFor( + ctx: Context, + event: SessionEvent, + argsFor: (callId: string) => unknown, + // Presenters live with the definitions, and definitions live in the scope + // chain: a preset registers its tools into its standing layer. A live agent + // is a scope whose chain passes through its preset; a cold read passes the + // preset's standing key directly — no agent, no resume. An undefined scope + // sees only the global layer, which is the pre-preset deployment shape. + scope?: ScopeKey, +): ToolEventView | undefined { try { if (event.type === 'tool/call') { const { name, arguments: raw } = event.data as ToolCallData - const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw)) + const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw)) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { @@ -450,7 +514,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => const callId = message.source.callId const call = argsFor(callId) as { name: string; args: unknown } | undefined if (call === undefined) return undefined - const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { + const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, { content: result.content, isError: result.isError === true, ...meta === undefined ? {} : { meta }, @@ -493,11 +557,12 @@ function historyPage( events: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number | undefined, + scope?: ScopeKey, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) + const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope) return { event, ...view === undefined ? {} : { view } } }), hasMore: page.hasMore, @@ -665,6 +730,57 @@ async function catalogChild( } } +/** + * The requested preset differs from the one this session already runs. + * + * A session's composition is fixed at creation: its history was produced under + * that preset's tools, so adopting the identity under a different one would + * replay tool calls the rebuilt agent cannot make. Naming a different preset + * is therefore a caller error rather than a switch. + */ +/** The roster is absent: this deployment composes no agent presets at all. */ +function noRoster(agentPreset: string): RpcError { + return { + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + } +} + +/** Map one authoring/roster failure onto its wire code. */ +function presetError(agentPreset: string, error: unknown): RpcError { + if (error instanceof UnknownPresetError) { + return { + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + } + } + if (error instanceof PresetNotWritableError) { + return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } } + } + if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { + return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } } + } + return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} } +} + +class AgentPresetConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedPreset: string, + readonly existingPreset: string | undefined, + ) { + super( + existingPreset === undefined + ? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; ` + + 'a deployment composing no roster records none on any session — ' + : `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; ` + + `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`, + ) + } +} + /** Requested identity already belongs to a session with another project cwd. */ class SessionCwdConflict extends Error { constructor( @@ -738,6 +854,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection } const selections = new WeakMap() + /** + * Serializes `agentPreset.select` per session. Two concurrent selects both + * pass the blank check, and the second `unmountPresetFor` then finds nothing + * to unmount because the first already removed the record — leaving two + * compositions registered into one agent layer. The client's `busy` flag is + * not enforcement: the wire is reachable directly. + */ + const presetSwitches = new Map>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ const sessionCreations = new Map>() /** Serializes path ownership and explicit title checks with Workspace mutations. */ @@ -794,6 +918,64 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro selectionFor(agent) } + /** + * Reject an attempt to run an existing session under a different preset. + * + * A caller that names no preset always adopts the session as it is, so the + * common paths — reconnecting, resuming, retrying a create — are unaffected. + * @param sessionId - the identity being adopted. + * @param requested - the preset the request named, if any. + * @param existing - the preset the session was created under, if any. + * @throws when both are present and differ. + */ + function assertPresetUnchanged( + sessionId: SessionId, + requested: string | undefined, + existing: string | undefined, + ): void { + if (requested === undefined || requested === existing) return + throw new AgentPresetConflict(sessionId, requested, existing) + } + + /** + * Resolve the preset an agent will be composed from, and the setup that + * installs it. + * + * The id is resolved BEFORE the session exists because the session boundary + * snapshots `meta` before asynchronous setup begins — a preset discovered + * during setup could never reach the header. Mounting still happens in + * setup, where a failure rolls the whole creation back rather than leaving a + * published session whose capabilities are half-installed. + * + * A deployment with no preset roster composes nothing and every session + * shares the host composition, which is the behavior before presets existed. + * @param presetId - the requested preset, or `undefined` for the default. + * @returns the id to record on the header (absent without a roster) and the setup callback. + * @throws when the roster supplies no such preset. + */ + async function composeAgent(presetId: string | undefined): Promise<{ + agentPreset?: string + setup: (agentCtx: Context) => Promise + }> { + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return { + setup: (agentCtx: Context) => { + installSelection(agentCtx) + return Promise.resolve() + }, + } + } + const resolvedId = (await presets.resolve(presetId)).id + return { + agentPreset: resolvedId, + setup: async (agentCtx: Context) => { + installSelection(agentCtx) + await presets.mount(agentCtx, resolvedId) + }, + } + } + const hasSubagentOwner = ( session: Pick, agent: Agent | undefined, @@ -802,7 +984,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro apiRemoteSubagentOwnershipError(sessionId) const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => inspectApiRemoteSession(ctx, sessionId) - const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installSelection }) + // Cold resume composes the preset the session recorded, for the same reason + // `session.create` does: its history was produced under that composition. + // Every generic entry point — prompt, models, commands — arrives here, so + // leaving it out meant a session opened after a restart ran on host tools + // and the deployment persona. Resolved from the LOG, not the header: a + // session that switched while blank ran its turns under the newer + // composition, and the header is written once at creation. Reading the + // header here would silently undo the switch on the next restart and + // restore that history under the old tool set. + const agentFor = createApiRemoteAgentResolver(ctx, { + agentOptions, + setup: async ({ meta, events }) => + (await composeAgent(resolveSessionPreset({ header: meta, events }))).setup, + }) /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { @@ -1023,23 +1218,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async function historyStateFor( sessionId: SessionId, includeProjections: boolean, - ): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> { const attached = ctx.sessions.get(sessionId) if (attached !== undefined) { const events = [...attached.events] const projections = includeProjections ? projectionsFor(ctx, attached) : undefined - return { events, ...projections === undefined ? {} : { projections } } + return { header: attached.header, events, ...projections === undefined ? {} : { projections } } } const inspected = await inspectServable(sessionId) const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined return { + header: inspected.meta, events: inspected.events, ...projections === undefined ? {} : { projections }, } } + /** + * The registry view scope a transcript's presenters resolve in. + * + * A live agent is that scope itself (its chain passes through its preset's + * standing layer). A cold session names its preset on the header, and the + * preset's STANDING key serves without resuming anything — ensuring the + * mount composes plugins but starts no agent, session, or turn. No roster, + * no recorded preset, or a preset the roster no longer supplies all fall + * back to the global layer: the transcript still serves, with the generic + * cards a viewless entry renders. + * @param sessionId - the transcript being read. + * @param header - that session's header (attached or inspected). + * @returns the scope to pass to presenter lookups, or undefined for global. + */ + async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise { + const live = ctx.get('agents')?.get(sessionId) + if (live !== undefined) return live + const presets = ctx.get('agentPresets') + if (presets === undefined) return undefined + try { + // An unrecorded preset (a log from before the roster existed) renders + // through the DEFAULT preset's standing layer: that is the composition + // an unnamed session composes today, and presenters are pure display, + // so the worst a mismatch produces is the generic card it had anyway. + return await presets.standingKeyFor(header.agentPreset) + } catch { + // Swallows only the unknown/unusable-preset rejection from the roster: + // a deleted or broken preset must degrade this read, never fail it. + return undefined + } + } + /** Resolve one requested identity to a live agent, creating or resuming it once. */ - async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise { + async function ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise { let creation = sessionCreations.get(sessionId) if (creation === undefined) { creation = (async () => { @@ -1065,10 +1298,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (inspected.meta.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) } + // Resolved from the log, not the header: a session that switched + // while blank ran every turn under the newer composition. + const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) + assertPresetUnchanged(sessionId, presetId, storedPreset) + // The stored preset wins over anything the request names: a resumed + // session's history was produced under that composition, and + // rebuilding it differently would replay tool calls the model can no + // longer make. return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: agentOptions(), - setup: installSelection, + setup: (await composeAgent(storedPreset)).setup, })).agent } @@ -1077,11 +1318,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } + const composition = await composeAgent(presetId) return (await ctx.agents.create({ sessionId, agentOptions: agentOptions(), - meta: { cwd }, - setup: installSelection, + meta: { + cwd, + ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }, + }, + setup: composition.setup, })).agent })().catch((error: unknown) => { // Another Host entry path may have published the same identity while @@ -1103,6 +1348,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } const agent = await creation if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId) + // Beside the cwd check for the same reason, and after the await so it + // covers every path that yields a live agent — freshly created, adopted + // live, resumed from disk, or recovered by the concurrent-creation catch. + assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset) if (agent.session.header.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } @@ -1194,11 +1443,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return items } - /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */ - function goalService(): NonNullable>> | { error: RpcError } { - const goals = ctx.get('goals') + /** + * Resolve the goal service THIS agent runs. + * + * The service is per session: an agent preset mounts it behind an `isolate` + * realm, which no host context resolves. Reading it from the root would + * answer "absent" for a session whose composition mounts it — so the lookup + * is keyed by the agent, and only a deployment composing it nowhere is + * genuinely absent. + */ + function goalServiceFor(agent: Agent): NonNullable>> | { error: RpcError } { + const presets = ctx.get('agentPresets') + const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals') if (goals === undefined) { - return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } } + return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } } } return goals } @@ -1214,10 +1472,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro request: RpcRequest<{ sessionId: SessionId }>, mutation: (goals: NonNullable>>, agent: Agent) => CoreGoalRef, ): Promise> { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { const ref = mutation(goals, found.agent) return ok(request, { ref: { id: ref.id, revision: ref.revision } }) @@ -1314,6 +1572,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return openTarget(request, path, signal, open) } + /** Whether this deployment can hand a path to a native opener at all. */ + function canOpenPaths(): boolean { + if (defaults.canOpenPath !== undefined) return defaults.canOpenPath() + // An injected opener is by definition usable; otherwise ask the platform. + return defaults.openPath !== undefined || canOpenNativePath() + } + /** Missing-service report shared by the credentials domain. */ function credentialsAbsent(): RpcError { return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} } @@ -1572,9 +1837,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd + const requestedPreset = request.payload.agentPreset try { - await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined) + await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) } catch (error: unknown) { + if (error instanceof AgentPresetConflict) { + return err(request, { + code: 'agent-preset-conflict', + message: error.message, + details: { + sessionId: error.sessionId, + requestedPreset: error.requestedPreset, + ...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }, + }, + }) + } + const refused = presetFailure(request, error) + if (refused !== undefined) return refused if (error instanceof SessionCwdConflict) { return err(request, { code: 'session-conflict', @@ -1606,12 +1885,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } - return ok(request, { sessionId }) + // Echo the RESOLVED composition so a client can label the session it + // just created without waiting for the next list refresh — the create + // is the commit point that knows it (a caller that named none gets + // the default the header recorded). + const created = ctx.agents.get(sessionId) + const createdPreset = created?.session.header.agentPreset + return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) }, async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock } + let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock } try { state = await historyStateFor(sessionId, beforeSeq === undefined) } catch (error: unknown) { @@ -1624,7 +1909,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header)) return ok(request, { events: page.events, hasMore: page.hasMore, @@ -1766,6 +2051,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } const childId = `session-${randomUUID()}` as SessionId + // The child inherits the parent's composition for the same reason a + // resumed session keeps its own: the seeded history was produced under + // those tools, and composing anything else would strand the tool calls + // it already carries. Now that no model-facing row sits in the host + // plane, composing nothing would leave the child with no tools at all. + const forkComposition = await composeAgent(resolveSessionPreset(source)) try { await ctx.agents.create({ sessionId: childId, @@ -1774,9 +2065,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd }, parentSession: source.id, seedLength: cut, + ...forkComposition.agentPreset === undefined + ? {} + : { agentPreset: forkComposition.agentPreset }, }, agentOptions: agentOptions(), - setup: installSelection, + setup: forkComposition.setup, }) } catch (error: unknown) { return err(request, { @@ -2349,10 +2643,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async clear(request) { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { goals.clear(found.agent, request.payload.ref) return ok(request, { cleared: true as const }) @@ -2362,10 +2656,154 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + agentPresets: { + // A deployment with no roster answers with an empty list rather than an + // error: composing no presets is a valid deployment, and the browser + // simply offers no choice. + async list(request) { + const presets = ctx.get('agentPresets') + if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false }) + const defaultId = presets.defaultId + return ok(request, { + presets: (await presets.list()).map(preset => ({ + id: preset.id, + trust: preset.trust, + isDefault: preset.id === defaultId, + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + ...preset.broken === undefined ? {} : { broken: preset.broken }, + })), + authorable: presets.authorable, + hasDocument: canOpenPaths(), + }) + }, + + // Recomposing is limited to a blank session because a started + // conversation's history was produced under its preset's tools; the + // agent and the session survive, only the composition is swapped. + async select(request) { + const { sessionId, agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + }) + } + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const { agent } = found + const swap = async (): Promise> => { + // Re-read inside the queue: an earlier switch may have run, and a + // conversation may have started, since this request arrived. + if (!sessionBlank(agent.session)) { + return err(request, { + code: 'agent-preset-locked', + message: `session "${sessionId}" has already started; its agent preset is fixed`, + details: { sessionId, agentPreset }, + }) + } + try { + const preset = await presets.recompose(agent.ctx, agentPreset) + // Recorded only after the swap committed: the log states what the + // agent runs, and a rejected mount leaves the previous composition. + agent.session.append('agent-preset/selected', { agentPreset: preset.id }) + return ok(request, { agentPreset: preset.id }) + } catch (error: unknown) { + const refused = presetFailure(request, error) + if (refused !== undefined) return refused + return err(request, { + code: 'internal', + message: `failed to select agent preset "${agentPreset}": ${String(error)}`, + details: {}, + }) + } + } + const queued = presetSwitches.get(sessionId) ?? Promise.resolve() + const turn = queued.then(swap) + presetSwitches.set(sessionId, turn.catch(() => undefined)) + try { + return await turn + } finally { + if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId) + } + }, + + // Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection): + // a composition names the plugins a session runs, so reading one is + // reconnaissance, and copy/remove/openDocument manage the roster and + // drive the host desktop. + async read(request) { + const { agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + const preset = await presets.resolve(agentPreset) + return ok(request, { + agentPreset: preset.id, + trust: preset.trust, + content: await presets.read(preset.id), + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + }) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + + async copy(request) { + const { from, agentPreset, name } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + await presets.copy(from, agentPreset, name) + return ok(request, { agentPreset }) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + + async openDocument(request, signal) { + const { agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + const preset = await presets.resolve(agentPreset) + // Same line as copy/remove draw: the shipped install is not the + // user's to manage, and pointing an editor into it invites edits an + // upgrade will silently overwrite. + if (preset.trust !== 'user') { + throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + } + // The id resolved against the Host's own roots is what selects the + // directory — no browser payload carries a path in either direction + // unless the deployment has no opener to hand it to. + const directory = dirname(preset.path) + if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory }) + return await openPath(request, directory, signal) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + + async remove(request) { + const { agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + await presets.remove(agentPreset) + return ok(request, {}) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + }, + skills: { - // Skill lookup never touches the Agent registry: the session address - // resolves to a canonical cwd from the host-resident session header, so - // listing skills cannot create or resume an agent as a side effect. + // Skill lookup never creates or resumes an agent: the session address + // resolves to a canonical cwd from the host-resident session header, and + // the view scope is the live agent or the preset's standing key. async list(request) { const { sessionId } = request.payload const session = ctx.sessions.get(sessionId) @@ -2382,17 +2820,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) } const cwd = session.header.cwd - // Same stance as the commands domain: a missing service means the - // deployment omitted dsh-skill from its composition, not an empty - // catalog. ctx.get also keeps this handler independent of the gateway - // plugin's inject list (an undeclared `ctx.skills` property read - // fails the reflect proxy). - const skillRegistry = ctx.get('skills') + // The host registry is layered per scope and serves every session. A + // composition may still realm-mount its own registry instead; that + // instance is invisible to host contexts, so address it through the + // live agent (`agents.get` keeps the no-side-effect stance above). + const live = ctx.agents.get(sessionId) + const presets = ctx.get('agentPresets') + const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') + // Same stance as the commands domain: a missing service means no + // composition mounts dsh-skill, not an empty catalog. `ctx.get` also + // keeps this handler independent of the gateway plugin's inject list + // (an undeclared `ctx.skills` property read fails the reflect proxy). + const skillRegistry = scoped ?? ctx.get('skills') if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) + return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) } + // The scope presenters resolve in — the live agent, else the recorded + // preset's standing key, else the global layer — so a cold session's + // '/' popup lists the catalog its composition actually serves. + const scope = await presenterScopeFor(sessionId, session.header) try { - const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable) + const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, @@ -2622,8 +3070,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } else if (event.type === 'turn/end') { openCalls.delete(session.id) } - const view = viewFor(ctx, event, callId => - openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId)) + const view = viewFor( + ctx, event, + callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId), + ctx.agents.get(session.id), + ) queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) }), ctx.on('session/created', (session: Session) => { @@ -2657,7 +3108,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // has run no turn yet, so this is constantly true in practice. blank: sessionBlank(session), // Including cwd lets the client group the new session without refreshing the list. - ...sessionListFields(session.header), + ...sessionListFields(session.header, session.events), })) }), ctx.on('session/disposed', (session: Session) => { diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts new file mode 100644 index 0000000000..da3da6f9be --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -0,0 +1,88 @@ +/** + * agent-presets domain zod schemas (names derived from map keys: + * agentPresetListRequestSchema / agentPresetListValueSchema). + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import { sessionIdSchema } from './sessions.schema.ts' +import type { AgentPresetEntry } from './agent-presets.ts' + +/** AgentPresetEntry row of agentPreset.list. */ +export const agentPresetEntrySchema = z.object({ + id: z.string().min(1), + trust: z.union([z.literal('system'), z.literal('user')]), + isDefault: z.boolean(), + name: z.string().optional(), + description: z.string().optional(), + broken: z.string().min(1).optional(), +}) satisfies z.ZodType> + +/** agentPreset.list request payload. */ +export const agentPresetListRequestSchema = z.object({ +}) satisfies z.ZodType>> + +/** agentPreset.list response value. */ +export const agentPresetListValueSchema = z.object({ + presets: z.array(agentPresetEntrySchema), + authorable: z.boolean(), + hasDocument: z.boolean(), +}) satisfies z.ZodType>> + +/** agentPreset.select request payload. */ +export const agentPresetSelectRequestSchema = z.object({ + sessionId: sessionIdSchema, + agentPreset: z.string().min(1), +}) satisfies z.ZodType>> + +/** agentPreset.select response value. */ +export const agentPresetSelectValueSchema = z.object({ + agentPreset: z.string(), +}) satisfies z.ZodType>> + +/** agentPreset.read request payload. */ +export const agentPresetReadRequestSchema = z.object({ + agentPreset: z.string().min(1), +}) satisfies z.ZodType>> + +/** agentPreset.read response value. */ +export const agentPresetReadValueSchema = z.object({ + agentPreset: z.string(), + trust: z.union([z.literal('system'), z.literal('user')]), + content: z.string(), + name: z.string().optional(), + description: z.string().optional(), +}) satisfies z.ZodType>> + +/** agentPreset.copy request payload. */ +export const agentPresetCopyRequestSchema = z.object({ + from: z.string().min(1), + agentPreset: z.string().min(1), + name: z.string().optional(), +}) satisfies z.ZodType>> + +/** agentPreset.copy response value. */ +export const agentPresetCopyValueSchema = z.object({ + agentPreset: z.string(), +}) satisfies z.ZodType>> + +/** agentPreset.openDocument request payload. */ +export const agentPresetOpenDocumentRequestSchema = z.object({ + agentPreset: z.string().min(1), +}) satisfies z.ZodType>> + +/** agentPreset.openDocument response value. */ +export const agentPresetOpenDocumentValueSchema = z.union([ + z.object({ opened: z.literal(true) }), + z.object({ opened: z.literal(false), path: z.string() }), +]) satisfies z.ZodType>> + +/** agentPreset.remove request payload. */ +export const agentPresetRemoveRequestSchema = z.object({ + agentPreset: z.string().min(1), +}) satisfies z.ZodType>> + +/** agentPreset.remove response value. */ +export const agentPresetRemoveValueSchema = z.object({ +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts new file mode 100644 index 0000000000..76f5c05355 --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -0,0 +1,116 @@ +/** + * agent-presets domain contract: the roster a browser offers when starting a + * session, plus the authoring calls behind it. + * + * `list` is ordinary: it carries ids and trust, and every preset picker needs + * it. The authoring calls are privileged and loopback-pinned — a composition + * names the plugins a session runs, so reading one is reconnaissance, and + * although authoring is copy-only (no caller supplies composition text or a + * path), copying and deleting still rearrange what the deployment offers. + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RpcRequest, RpcResponse } from './rpc.ts' + +/** One preset the deployment can compose a session's agent from. */ +export interface AgentPresetEntry { + /** Stable identifier, also the display name until presets carry metadata. */ + readonly id: string + /** + * Whether the preset ships with the deployment or was authored locally. + * A `user` preset is exactly as privileged as the plugins it names, so a + * surface offering one should say so rather than present it as vetted. + */ + readonly trust: 'system' | 'user' + /** Whether a session that names no preset gets this one. */ + readonly isDefault: boolean + /** + * Display name the preset published, absent when it published none. A + * surface falls back to {@link id}; it is never a second identity, and it + * never decides trust — a locally authored preset cannot name itself into + * the shipped set. + */ + readonly name?: string + /** One sentence on what the preset is for, when it published one. */ + readonly description?: string + /** + * Why this preset cannot compose a session, absent when it can. A broken + * preset stays listed — its directory still occupies the id, so a surface + * must be able to show and delete it — but offering it for selection would + * only defer this reason to a failed session start. + */ + readonly broken?: string +} + +/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */ +export interface AgentPresetsApi { + /** + * Lists every preset the deployment currently supplies, in root-precedence + * order — the roots as configured, each root's own presets sorted by id, + * and the first root to supply an id wins. The order is not globally + * sorted: a user root's preset sits in that root's block, not among the + * shipped ids. + * An empty roster means the deployment composes no presets at all, and + * every session shares the host composition. `authorable` reports whether + * the deployment configures a root new presets can be written to, and + * `hasDocument` whether `openDocument` can hand a preset directory to a + * native opener — both deployment facts rather than per-preset ones, and + * neither exposes a Host path. + */ + list(request: RpcRequest<{}>): + Promise> + + /** + * Recompose one session's agent from a different preset. + * + * Allowed only while the session is blank — no turn has run. Once a + * conversation starts, its history was produced under that preset's tools, + * and swapping them would leave logged tool calls the new composition cannot + * make; the attempt answers `agent-preset-locked`. + */ + select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>): + Promise> + + /** + * Read one preset's composition text, for the read-only viewer. + * + * Privileged: a composition names the plugins a session runs, so reading + * one is reconnaissance. + */ + read(request: RpcRequest<{ agentPreset: string }>): + Promise> + + /** + * Create a locally authored preset by copying an existing one whole. + * + * The only authoring write. No composition text and no path crosses the + * wire: `from` and `agentPreset` are ids the Host resolves against its own + * roots, so a copy is exactly as loadable as its source and grants nothing + * the roster did not already carry. The copy keeps the source's description + * (the file is the author's to edit afterwards) but not its name — `name` + * here or the id fallback is what distinguishes the rows. + */ + copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>): + Promise> + + /** + * Hand one locally authored preset's DIRECTORY to the platform opener, for + * editing the files that are now the only composition editor. The request + * carries an id, never a path — the Host resolves it — so no browser + * payload can select an arbitrary filesystem target. Where the deployment + * has no native opener (`hasDocument: false` on `list`), the reply carries + * the resolved directory for the surface to show as text instead. Shipped + * presets are refused: their install is not the user's to manage. + */ + openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal): + Promise> + + /** Delete a locally authored preset. Shipped presets are refused. */ + remove(request: RpcRequest<{ agentPreset: string }>): Promise> +} diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 13ead7d08d..b432880810 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -73,6 +73,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), cwd: z.string().optional(), + agentPreset: z.string().optional(), }), z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }), z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index bf2f694eca..bbf895625f 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -116,6 +116,7 @@ export type HostFrame = parentSessionId?: SessionId origin?: 'subagent' cwd?: string + agentPreset?: string } | { type: 'host/session-removed'; sessionId: SessionId } | { type: 'host/session-status'; sessionId: SessionId; running: boolean } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 8e35c62514..83ca08c0a6 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' +import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' import type { EventsApi } from './events.ts' @@ -25,6 +26,7 @@ export interface ApiProxy { workspace: WorkspaceApi commands: CommandsApi skills: SkillsApi + agentPresets: AgentPresetsApi events: EventsApi goals: GoalsApi settings: SettingsApi @@ -48,6 +50,7 @@ export type { export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' +export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index deb963db07..f81f19ec19 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' +import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' @@ -51,6 +52,12 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'agentPreset.list': AgentPresetsApi['list'] + 'agentPreset.select': AgentPresetsApi['select'] + 'agentPreset.read': AgentPresetsApi['read'] + 'agentPreset.copy': AgentPresetsApi['copy'] + 'agentPreset.openDocument': AgentPresetsApi['openDocument'] + 'agentPreset.remove': AgentPresetsApi['remove'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 2733c6e940..5a758ee56f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -46,6 +46,11 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), + z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), + z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), + z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), + z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54bbb5a8cc..f799074c88 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,11 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-read-only': { agentPreset: string; reason: string } + 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } + 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } + 'agent-preset-not-found': { agentPreset: string; available: string[] } + 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } 'queue-item-not-found': { itemId: MessageId } 'steer-unavailable': { itemId: MessageId } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a1cc88dace..a33ab20f8f 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -55,6 +55,7 @@ export const sessionSummarySchema = z.object({ parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), cwd: z.string().optional(), + agentPreset: z.string().optional(), projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), }) as unknown as z.ZodType> @@ -100,6 +101,7 @@ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), cwd: z.string().optional(), sessionId: sessionIdSchema.optional(), + agentPreset: z.string().optional(), }).refine( payload => payload.workspaceId === undefined || payload.cwd === undefined, { message: 'session.create accepts workspaceId or cwd, not both' }, @@ -108,6 +110,7 @@ export const sessionCreateRequestSchema = z.object({ /** session.create response value. */ export const sessionCreateValueSchema = z.object({ sessionId: sessionIdSchema, + agentPreset: z.string().optional(), }) satisfies z.ZodType>> /** session.rename request payload (raw title; host-side normalization decides acceptance). */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 0a4da455a2..f2a34e62c8 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -165,6 +165,13 @@ export interface SessionSummary { origin?: 'subagent' /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string + /** + * Agent preset this session's agent was composed from (header passthrough); + * absent when the deployment composes no presets. A surface offering a + * switch reads this to show what the session actually runs rather than what + * the deployment currently defaults to. + */ + agentPreset?: string /** * Projection baseline for this row, with zero log loads: attached sessions * read the registry's live watermark cut; cold sessions read the persisted @@ -208,9 +215,16 @@ export interface SessionsApi { * session, while a different cwd fails with `session-conflict`. Workspace * creation attaches the session after publication; an attach failure * returns `workspace-attach-failed` with the published session id. + * + * `agentPreset` names the composition the new session's agent is built + * from; omitted, the effective default applies — the user's stored choice + * where one exists, else the deployment's own. The resolved id is stored on + * the session header, so a later resume rebuilds the same agent. An unknown + * id fails with `agent-preset-not-found`, and a preset whose composition + * cannot be mounted fails with `agent-preset-invalid`. */ - create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>): - Promise> + create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>): + Promise> /** * Reads a window of history events; page boundaries align to append-origin message diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0ce935809f..bbb8b3872a 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -40,6 +40,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' +import { + agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, + agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema, +} from '../api/agent-presets.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -121,6 +125,14 @@ export interface IApiClient { skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } + agentPresets: { + list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> + select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise>> + read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise>> + copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise>> + openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise>> + remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise>> + } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -188,6 +200,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), } + // Annotated like every sibling, and load-bearing rather than cosmetic: + // inferring this member inlines `AgentPresetEntry` into the emitted + // declaration by the specifier TS picks — the host `index.ts` — which drags + // the whole gateway, and with it the host `Context` merges, into every + // Client program that imports this carrier. + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal), + select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal), + read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal), + copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal), + openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal), + remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal), + } + readonly goals: IApiClient['goals'] = { create: (payload, signal) => this.callUnary('goal.create', payload, signal), edit: (payload, signal) => this.callUnary('goal.edit', payload, signal), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index bd3bc3827a..7474f371f1 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -42,6 +42,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' +import { + agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, + agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema, +} from '../api/agent-presets.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -111,6 +115,12 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, + 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, + 'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) }, + 'agentPreset.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) }, + 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, + 'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 8004f952e4..bd060e7d19 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -38,6 +38,14 @@ declare module 'cordis' { export interface Config { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } /** @@ -53,6 +61,7 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ workspaceRoot: z.string(), + nativeOpen: z.boolean(), }) readonly sessions: ApiProxy['sessions'] @@ -62,6 +71,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] + readonly agentPresets: ApiProxy['agentPresets'] readonly settings: ApiProxy['settings'] readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] @@ -76,6 +86,7 @@ export class ApiProxyService extends Service implements ApiProxy { saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd, workspaceRoot: resolve(config.workspaceRoot ?? cwd), + ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, }) this.sessions = api.sessions this.subagents = api.subagents @@ -84,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.commands = api.commands this.goals = api.goals this.skills = api.skills + this.agentPresets = api.agentPresets this.settings = api.settings this.credentials = api.credentials this.llm = api.llm diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index 6d8d3e0170..f8a065c8e2 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -152,6 +152,25 @@ async function openNativePathWithIntent( throw new Error(`native path opener is unsupported on ${platform}`) } +/** + * Whether {@link openNativePath} plausibly reaches a desktop on this host. + * + * macOS and Windows always carry a desktop opener; Linux does when it is WSL + * (the Windows desktop takes the path) or a display server is announced. + * A headless or containerised Linux host answers false, which is what lets a + * surface show a path as text instead of offering a button that would spawn + * `xdg-open` into nothing. + * @param internals - platform and environment seam for deterministic tests. + * @returns true when handing a path to the native opener can work at all. + */ +export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean { + const platform = internals.platform ?? process.platform + if (platform === 'darwin' || platform === 'win32') return true + if (platform !== 'linux') return false + const env = internals.env ?? process.env + return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY) +} + /** * Open a filesystem path with the operating system's default application, or * with the default browser when the path names a document a browser renders. diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts new file mode 100644 index 0000000000..24f08bae21 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -0,0 +1,652 @@ +/** + * A session's agent preset is fixed at creation. The gateway records the + * resolved id on the header and refuses to adopt the identity under a different + * one, because the session's history was produced under that preset's tools: + * rebuilding it differently would replay tool calls the new agent cannot make. + */ + +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { RpcId, type RpcRequest } from '../src/api/rpc.ts' +import { + InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' +import { GoalId } from '@deepseek-ai/dsh-goal' +import { createApiProxy } from '../src/api-proxy.ts' +import { describe, expect, it } from 'vitest' + +let nextRpc = 0 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload } +} + +/** Minimal live agent; the gateway only needs identity and its session. */ +function stubAgent(session: Session): Agent { + return { id: session.id, session, status: 'idle' } as unknown as Agent +} + +/** + * A roster whose `mount` is a no-op: this spec is about the gateway's identity + * rules, and the composition itself is covered by the real-composition test in + * `apps/cli`. Ids listed in `userIds` present as locally authored; the rest + * ship with the deployment. + */ +function roster(ids: readonly string[], userIds: readonly string[] = []): unknown { + const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system') + const presetOf = (id: string): object => + ({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` }) + return { + defaultId: ids[0], + list: () => Promise.resolve(ids.map(presetOf)), + resolve: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + return Promise.resolve(presetOf(wanted)) + }, + mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), + // What a real mount leaves behind: a service instance only the agent that + // mounted it can be used to address. The doubles are per agent so a test + // can tell "this session's" from "some session's". + serviceFor: (agent: { id: unknown }, name: string) => { + const perAgent = services.get(String(agent.id)) + return perAgent?.[name] + }, + authorable: true, + read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`), + copy: (from: string, id: string) => { + if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids)) + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id)) + if (ids.includes(id)) return Promise.reject(new PresetExistsError(id)) + return Promise.resolve() + }, + remove: (id: string) => { + if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) + return Promise.resolve() + }, + recompose: (_ctx: Context, id: string) => { + if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) + return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` }) + }, + // The standing scope key a cold transcript read resolves presenters in. + standingKeyFor: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + standingKeyRequests.push(wanted) + if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) { + return Promise.reject(new UnknownPresetError(wanted, ids)) + } + let key = standingKeys.get(wanted) + if (key === undefined) { + key = { agentPreset: wanted } + standingKeys.set(wanted, key) + } + return Promise.resolve(key) + }, + } +} + +/** Standing keys the roster double minted, and the ids readers asked for. */ +const standingKeys = new Map() +const standingKeyRequests: string[] = [] +/** Preset ids whose standing mount the double reports as unusable. */ +const failingStandingKeys = new Set() + +/** Per-agent service instances a mounted preset would own, keyed by session id. */ +const services = new Map>() + +async function harness( + presets?: readonly string[], + persistence?: unknown, + options: { userIds?: readonly string[]; defaults?: Record } = {}, +) { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never) + if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never) + + const factory: AgentFactory = { + async createAgent(_ownerCtx, options) { + const session = ctx.sessions.create( + options.sessionId, + options.meta === undefined ? {} : { meta: options.meta }, + ) + const agent = stubAgent(session) + // Setup runs before publication against a context that carries the + // agent, and the agent reaches back through `agent.ctx` — the pair the + // gateway's own `installTarget` relies on. + const agentCtx = ctx.extend({ agent }) + ;(agent as { ctx?: Context }).ctx = agentCtx + await options.setup?.(agentCtx) + const unregister = ctx.agents.register(agent) + return { agent, dispose: () => { unregister(); return Promise.resolve() } } + }, + async resume() { + throw new Error('test harness has no persisted sessions') + }, + } + ctx.agents.setFactory(factory) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), + cwd, + workspaceRoot: cwd, + ...options.defaults, + }) + return { api, ctx, cwd } +} + +describe('session.create with an agent preset', () => { + it('records the resolved preset on the session header', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + + const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' })) + + expect(created.result.ok).toBe(true) + expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal') + }) + + it('records the default when the caller names none', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + + await api.sessions.create(request({ sessionId: SessionId('s2') })) + + expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') + }) + + it('rejects an unknown preset and names the ones that exist', async () => { + const { api } = await harness(['standard']) + + const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('refuses to adopt a live session under a different preset', async () => { + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' })) + + const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-conflict') + expect(response.result.error.details).toEqual({ + sessionId: 's4', + requestedPreset: 'standard', + existingPreset: 'minimal', + }) + }) + + it('adopts a live session unchanged when the caller names no preset', async () => { + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' })) + + // Reconnecting and retrying a create must stay ordinary operations. + const response = await api.sessions.create(request({ sessionId: SessionId('s5') })) + + expect(response.result.ok).toBe(true) + }) + + it('leaves the header preset-less when no roster is composed', async () => { + const { api, ctx } = await harness() + + await api.sessions.create(request({ sessionId: SessionId('s6') })) + + expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() + }) + + it('says why a preset-less session cannot be adopted under one', async () => { + // Two callers reach this: a deployment that composes no roster, and a + // session created before one existed. Both record no preset, so naming + // any is a conflict rather than an adoption — the history was produced + // under a composition this roster cannot name. The message has to say + // that, because "already runs agent preset undefined" reads as a bug. + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('s7') })) + + const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-conflict') + expect(response.result.error.message).toContain('records no agent preset') + expect(response.result.error.details).toEqual({ + sessionId: 's7', + requestedPreset: 'standard', + existingPreset: undefined, + }) + }) +}) + +/** + * A capability a preset mounts is reachable from nowhere the host normally + * looks: an `isolate` realm is what makes it per session. The gateway serves + * requests that are ABOUT a session from OUTSIDE it, so it addresses the + * instance through the agent instead of reading a root-realm singleton. + */ +describe('a capability the session\'s preset mounts', () => { + it('serves the goal RPC from the session\'s own goal service', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' })) + const ref = { id: GoalId('goal-1'), revision: 1 } + const paused: unknown[] = [] + services.set('g1', { + goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } }, + }) + + const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref })) + + expect(response.result).toMatchObject({ ok: true, value: { ref } }) + // Reached the instance this session mounted, and was handed its own agent. + expect(paused).toEqual([['g1', ref]]) + services.delete('g1') + }) + + it('serves the skill catalog from the session\'s own registry', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' })) + services.set('k1', { + skills: { + list: () => Promise.resolve([{ + name: 'preset-owned', + description: 'ships inside the preset directory', + invocation: { modelInvocable: true, userInvocable: true }, + }]), + }, + }) + + const response = await api.skills.list(request({ sessionId: SessionId('k1') })) + + // A preset ships its own skill directory, so the catalog IS the + // session's; reading a host singleton would answer for the wrong one. + expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } }) + services.delete('k1') + }) + + it('says so when no composition mounts the capability at all', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' })) + + const response = await api.skills.list(request({ sessionId: SessionId('n1') })) + + // Absent means absent — not "this session has none", which is what a + // root-realm read used to report for every presetd session. + expect(response.result.ok).toBe(false) + const failure = response.result as { ok: false; error: { message: string } } + expect(failure.error.message).toContain('neither this session') + }) +}) + +describe('agentPreset.list', () => { + it('marks the default and carries each preset\'s trust', async () => { + const { api } = await harness(['standard', 'minimal']) + + const response = await api.agentPresets.list(request({})) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.presets).toEqual([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ]) + expect(response.result.value.authorable).toBe(true) + }) + + it('answers with an empty roster when the deployment composes no presets', async () => { + const { api } = await harness() + + const response = await api.agentPresets.list(request({})) + + // Composing no presets is a valid deployment, not an error: every session + // then shares the host composition and the browser offers no choice. + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.presets).toEqual([]) + // Nothing to write to either, so a surface offering "new preset" knows to + // stay hidden rather than offering a button whose save always fails. + expect(response.result.value.authorable).toBe(false) + }) +}) + +describe('agentPreset.select', () => { + it('recomposes a blank session', async () => { + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.agentPreset).toBe('minimal') + }) + + it('records the switch in the log, and the list reads it back', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) + + await api.agentPresets.select( + request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' })) + + // The header is written once at creation, so the switch lives in the log — + // this is what a restart replays and what every projection resolves from. + // Asserting only the RPC's echo would miss a switch that never persisted. + const session = ctx.sessions.get(SessionId('sel-log')) + if (session === undefined) throw new Error('unreachable') + expect(session.header.agentPreset).toBe('standard') + expect(resolveSessionPreset(session)).toBe('core-web') + const listed = await api.sessions.list(request({})) + if (!listed.result.ok) throw new Error('unreachable') + expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) + .toBe('core-web') + }) + + it('serializes two concurrent selects on one session', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) + + // Both pass the blank check; unserialized, the second unmount finds no + // record because the first already removed it, and two compositions end up + // in one agent layer. The client's busy flag is not enforcement. + const [first, second] = await Promise.all([ + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })), + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })), + ]) + + expect(first.result.ok).toBe(true) + expect(second.result.ok).toBe(true) + const session = ctx.sessions.get(SessionId('sel-race')) + if (session === undefined) throw new Error('unreachable') + // One winner, and the log agrees with it: the last committed switch. + expect(resolveSessionPreset(session)).toBe('standard') + }) + + it('refuses once the conversation has started', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' })) + // One turn is enough: the history from here on was produced under + // `standard`'s tools, and a swap would strand those tool calls. + ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 }) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-locked') + }) + + it('reports an unknown preset without disturbing the session', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('sel-3') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports a deployment that composes no presets', async () => { + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('sel-4') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) +}) + +describe('authoring over the wire', () => { + it('reads a composition with its trust', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.read(request({ agentPreset: 'standard' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + // The shipped set is readable: it is the known-good composition a copy + // starts from, and trust is what tells a surface to say so. + expect(response.result.value.trust).toBe('system') + expect(response.result.value.content).toContain('- id: x') + }) + + it('copies a preset under a new id', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.copy( + request({ from: 'standard', agentPreset: 'mine', name: '我的模式' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.agentPreset).toBe('mine') + }) + + it('rejects a copy target that could escape the preset root', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-invalid') + }) + + it('rejects a copy target the roster already supplies', async () => { + const { api } = await harness(['standard', 'minimal']) + + const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-invalid') + expect(response.result.error.message).toMatch(/already exists/) + }) + + it('rejects a copy whose source is unknown', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports a deployment that composes no presets', async () => { + const { api } = await harness() + + const response = await api.agentPresets.read(request({ agentPreset: 'anything' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports an unknown id on delete rather than succeeding silently', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) +}) + +describe('opening a preset directory', () => { + it('hands the resolved directory to the native opener', async () => { + const opened: string[] = [] + const { api } = await harness(['standard', 'my-preset'], undefined, { + userIds: ['my-preset'], + defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, + }) + + const response = await api.agentPresets.openDocument( + request({ agentPreset: 'my-preset' }), new AbortController().signal) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value).toEqual({ opened: true }) + // The id selected the directory; the browser supplied no path. + expect(opened).toEqual(['/presets/my-preset']) + }) + + it('answers the path as text where the deployment has no opener', async () => { + const { api } = await harness(['standard', 'my-preset'], undefined, { + userIds: ['my-preset'], + defaults: { canOpenPath: () => false }, + }) + + const response = await api.agentPresets.openDocument( + request({ agentPreset: 'my-preset' }), new AbortController().signal) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' }) + }) + + it('refuses a preset that ships with the deployment', async () => { + const opened: string[] = [] + const { api } = await harness(['standard'], undefined, { + defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, + }) + + const response = await api.agentPresets.openDocument( + request({ agentPreset: 'standard' }), new AbortController().signal) + + // Pointing an editor into the install invites edits an upgrade will + // silently overwrite; the refusal mirrors copy/remove. + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-read-only') + expect(opened).toEqual([]) + }) + + it('reports the roster capability on list', async () => { + const openable = await harness(['standard'], undefined, { + defaults: { canOpenPath: () => true }, + }) + const headless = await harness(['standard'], undefined, { + defaults: { canOpenPath: () => false }, + }) + + const yes = await openable.api.agentPresets.list(request({})) + const no = await headless.api.agentPresets.list(request({})) + + expect(yes.result.ok && yes.result.value.hasDocument).toBe(true) + expect(no.result.ok && no.result.value.hasDocument).toBe(false) + }) + + it('counts an injected opener as openable', async () => { + const { api } = await harness(['standard'], undefined, { + defaults: { openPath: () => Promise.resolve() }, + }) + + const response = await api.agentPresets.list(request({})) + + expect(response.result.ok && response.result.value.hasDocument).toBe(true) + }) +}) + +describe('skills over the layered host registry', () => { + it('passes the live agent as the view scope to the host registry', async () => { + const { api, ctx } = await harness(['standard']) + const seen: unknown[] = [] + ctx.provide('skills', { + list: (options: { scope?: unknown }) => { + seen.push(options.scope) + return Promise.resolve([]) + }, + } as never) + await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' })) + + const response = await api.skills.list(request({ sessionId: SessionId('h1') })) + + expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) + expect(seen).toEqual([ctx.agents.get(SessionId('h1'))]) + }) + + it('resolves a cold session to its recorded preset standing key', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + const seen: unknown[] = [] + ctx.provide('skills', { + list: (options: { scope?: unknown }) => { + seen.push(options.scope) + return Promise.resolve([]) + }, + } as never) + ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } }) + + const response = await api.skills.list(request({ sessionId: SessionId('h2') })) + + expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) + expect(seen).toEqual([standingKeys.get('core-web')]) + }) + + it('serves the global view when the roster no longer supplies the recorded preset', async () => { + const { api, ctx } = await harness(['standard']) + const seen: unknown[] = [] + ctx.provide('skills', { + list: (options: { scope?: unknown }) => { + seen.push(options.scope) + return Promise.resolve([]) + }, + } as never) + ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } }) + + const response = await api.skills.list(request({ sessionId: SessionId('h3') })) + + expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) + expect(seen).toEqual([undefined]) + }) +}) + +describe('session.history presenter scope', () => { + it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' })) + // Cold: creation registered a live agent in this harness, so simulate the + // cold path by asking for a session only persistence knows... the harness + // has no persistence, so read the live one and assert no roster query. + standingKeyRequests.length = 0 + const live = await api.sessions.history(request({ sessionId: SessionId('p1') })) + expect(live.result.ok).toBe(true) + // A live agent IS the presenter scope; the roster is not consulted. + expect(standingKeyRequests).toEqual([]) + }) + + it('serves a COLD transcript whose standing mount is no longer usable', async () => { + // A genuinely cold session: persistence knows it, no live agent exists. + const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } + const { api } = await harness(['standard'], { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] }), + }) + // The preset broke after the session ran: the roster rejects the mount. + failingStandingKeys.add('standard') + try { + standingKeyRequests.length = 0 + const response = await api.sessions.history(request({ sessionId: SessionId('p3') })) + // Degraded, never failed: the roster WAS asked, and the transcript + // still serves — with the generic cards a viewless entry renders. + expect(standingKeyRequests).toEqual(['standard']) + expect(response.result.ok).toBe(true) + } finally { + failingStandingKeys.delete('standard') + } + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index c6cdc237b4..fee9c71b6f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -356,6 +356,21 @@ describe('settings domain', () => { expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) }) + it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { + const ctx = await harness() + ctx.settings.register(settingsNamespace('agent-presets'), z.object({ default: z.string() })) + const api = createApiProxy(ctx, DEFAULTS) + + expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'minimal' } }))) + + // Both browser surfaces that offer the choice — the General row and the + // management section — write the default through `settings.update`, so a + // namespace outside this boundary makes the picker move and then silently + // forget, which is worse than refusing the control. + expect(ctx.settings.describe().find(view => String(view.ns) === 'agent-presets')?.value) + .toEqual({ default: 'minimal' }) + }) + it('refuses even a model-provider namespace once its directory entry is gone', async () => { const ctx = await harness({ configurableProviders: false }) ctx.settings.register(NS, AdapterConfig) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ad95275e38..72654e868b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -23,6 +23,7 @@ function scriptedApi(overrides: { host?: Partial commands?: Partial skills?: Partial + agentPresets?: Partial events?: Partial goals?: Partial settings?: Partial @@ -88,6 +89,15 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + agentPresets: { + list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), + select: r => ok(r, { agentPreset: r.payload.agentPreset }), + read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }), + copy: r => ok(r, { agentPreset: r.payload.agentPreset }), + openDocument: r => ok(r, { opened: true as const }), + remove: r => ok(r, {}), + ...overrides.agentPresets, + }, goals: { create: err, edit: err, @@ -222,6 +232,18 @@ describe('unary round trip', () => { expect(appended.result.ok).toBe(true) }) + it('routes the agent-preset roster and switch through the wire', async () => { + const c = client(scriptedApi()) + + const listed = await c.agentPresets.list({}) + expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } }) + + // The switch carries the session it is about: the host refuses one whose + // conversation has started, and it can only know which by id. + const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' }) + expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } }) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 83ada22644..9160a61e8e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -197,6 +197,32 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } }, }, + agentPresets: { + list(request: RpcRequest<{}>) { + return Promise.resolve({ + rpcId: request.rpcId, + result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } }, + }) + }, + select(request: RpcRequest<{ agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, + read(request: RpcRequest<{ agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, + copy(request: RpcRequest<{ from: string; agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, + openDocument(request: RpcRequest<{ agentPreset: string }>) { + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } }) + }, + remove(request: RpcRequest<{ agentPreset: string }>) { + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } }) + }, + }, skills: { async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } @@ -340,6 +366,28 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) + it('round-trips every agent-preset method, authoring included', async () => { + const c = client() + + // The whole domain crosses the carrier: the roster a picker reads, the + // per-session switch, and the authoring calls the settings page makes. + // Each has its own request schema, so a registration missing from either + // half fails here rather than in the browser. + expect((await c.agentPresets.list({})).result).toEqual({ + ok: true, value: { presets: [], authorable: false, hasDocument: false }, + }) + expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result) + .toEqual({ ok: true, value: { agentPreset: 'minimal' } }) + expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({ + ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' }, + }) + expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result) + .toEqual({ ok: true, value: { agentPreset: 'mine' } }) + expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result) + .toEqual({ ok: true, value: { opened: true } }) + expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} }) + }) + it('round-trips the native picker without the default unary timeout', async () => { const api = fakeApi() api.host.pickDirectory = async (request) => { diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index cf57bf103f..e1904cbcf1 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -16,7 +16,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' +import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' const signal = () => new AbortController().signal @@ -287,3 +287,35 @@ describe('browser-renderable documents', () => { ]) }) }) + +describe('canOpenNativePath', () => { + it('always answers yes where the desktop is part of the platform', () => { + expect(canOpenNativePath({ platform: 'darwin', env: {} })).toBe(true) + expect(canOpenNativePath({ platform: 'win32', env: {} })).toBe(true) + }) + + it('requires a display server or WSL interop on linux', () => { + const linux = { platform: 'linux' as const, osRelease: '6.8.0-generic' } + // Headless is the case the capability exists for: `xdg-open` would spawn + // into nothing, so a surface should show the path as text instead. + expect(canOpenNativePath({ ...linux, env: {} })).toBe(false) + expect(canOpenNativePath({ ...linux, env: { DISPLAY: ':0' } })).toBe(true) + expect(canOpenNativePath({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-0' } })).toBe(true) + expect(canOpenNativePath({ + platform: 'linux', osRelease: '5.15.153.1-microsoft-standard-WSL2', env: {}, + })).toBe(true) + }) + + it('answers no on a platform the opener does not support', () => { + expect(canOpenNativePath({ platform: 'freebsd', env: {} })).toBe(false) + }) + + it('samples the ambient environment when no override is supplied', () => { + const env = process.env + const marked = (value: string | undefined): boolean => value !== undefined && value !== '' + const expected = marked(env.WSL_DISTRO_NAME) || marked(env.WSL_INTEROP) + || marked(env.DISPLAY) || marked(env.WAYLAND_DISPLAY) + + expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected) + }) +}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 6d2ae5b23c..9824a637bf 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -32,6 +32,9 @@ import { commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { + agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, +} from '../src/api/agent-presets.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -508,3 +511,27 @@ describe('respond payload schemas', () => { expect(payload.sessionId).toBe('s') }) }) + +describe('agent-preset schemas', () => { + it('accepts a roster row and rejects an unknown trust', () => { + expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true })) + .toEqual({ id: 'standard', trust: 'system', isDefault: true }) + expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow() + expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow() + }) + + it('accepts an empty roster', () => { + // A deployment composing no presets still reports its authoring and + // native-open capabilities, so a surface knows what to offer. + expect(agentPresetListValueSchema.parse({ presets: [], authorable: false, hasDocument: false })) + .toEqual({ presets: [], authorable: false, hasDocument: false }) + }) + + it('answers the open-document union by its discriminant', () => { + expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true }) + expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' })) + .toEqual({ opened: false, path: '/presets/mine' }) + // A closed reply must carry the path the surface shows instead. + expect(() => agentPresetOpenDocumentValueSchema.parse({ opened: false })).toThrow() + }) +}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index eb22348935..624951059e 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../core/agent-default-model" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../core/session" }, diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml new file mode 100644 index 0000000000..3690f87d6e --- /dev/null +++ b/packages/preset/README.i18n.yaml @@ -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 packages/preset/README.md +README.md: 5e7805eaa303b78c4d8a1a8972e1d031ee304fae +README.zh.md: 52f147f3c8128bce8377c20131d4312720f45bb6 diff --git a/packages/preset/README.md b/packages/preset/README.md new file mode 100644 index 0000000000..5e7805eaa3 --- /dev/null +++ b/packages/preset/README.md @@ -0,0 +1,16 @@ +# preset/ — per-session agent composition + +English | [中文](README.zh.md) + +An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it under an agent's scope context gives that session its own tools and prompt sections while every other live session keeps its own, so one process can run several differently composed agents at once. + +| Package | Role | ctx key | +|---|---|---| +| `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` | +| `persona/` | The agent persona as a composable row, so a preset can change identity and not only tools | — | + +The presets the deployment ships live in [`apps/cli/config/agent-presets/`](../../apps/cli/config/agent-presets) — one directory each, and that directory listing is the roster. Naming them here too would be a second list to keep in step, and the first one to fall behind. + +The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. + +Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md). diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md new file mode 100644 index 0000000000..52f147f3c8 --- /dev/null +++ b/packages/preset/README.zh.md @@ -0,0 +1,16 @@ +# preset/:按会话组装 agent + +[English](README.md) | 中文 + +**agent preset** 是一个目录,其中放置一份 `agent.cordis.yml`。把它挂载到某个 agent(智能体)的 scope 上下文之下,该会话就获得自己的工具与提示词段落,而其他在运行的会话各自保持不变,因此一个进程可以同时运行多个组装方式不同的 agent。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | +| `persona/` | 把 agent 人设做成可组装的行,使 preset 不止能改工具、也能改身份 | — | + +部署交付哪些 preset,看 [`apps/cli/config/agent-presets/`](../../apps/cli/config/agent-presets)——一个 preset 一个目录,那份目录列表就是清单。在这里再列一遍只会多出一份需要同步的名单,而且总是它先过时。 + +本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 + +设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。 diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml new file mode 100644 index 0000000000..cb80d89ad8 --- /dev/null +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -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 packages/preset/agent-presets/README.md +README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6 +README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md new file mode 100644 index 0000000000..ed640cf053 --- /dev/null +++ b/packages/preset/agent-presets/README.md @@ -0,0 +1,128 @@ +# dsh-agent-presets + +English | [中文](README.zh.md) + +Per-preset agent composition. A **preset** is a directory holding one `agent.cordis.yml`; the roster mounts it ONCE per process under a standing scope, and each session that names it joins by having its agent scope key parented to the mount's (`dsh-scope`'s parent chain). The mount's tools, prompt sections, and projection units exist exactly once and cover every joined agent — its plugins key their state by Session/Agent, so sessions stay apart inside one shared instance — and a host reader with no agent at all (a cold transcript read) resolves the same standing registrations by preset id. + +The mechanism is two seams. Entry contexts chain to the context a subtree was plugged into, and both [`dsh-tools`](../../core/tools/README.md) and [`dsh-system-prompt`](../../core/system-prompt/README.md) file registrations into the calling context's scope layer — so the standing mount's contributions land in the PRESET's layer. What carries them to each session is `dsh-scope`'s parent chain: an agent's views resolve `agent → preset → global` (nearest shadowing farthest), and the mount's listeners are admitted for every agent parented under it while a sibling preset's stay deaf. + +## Service: `AgentPresets` (ctx key: `agentPresets`) + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call, so a preset authored while the process runs is visible immediately and a deleted one disappears from the next read. Discovery also owns preset **health**: a directory whose composition is missing or unloadable (unparsable YAML — checked with the loader's own dialect, `!!js` included — or not a list of named plugin rows) is listed with a `broken` reason rather than skipped, because a skipped directory would still occupy its id on disk while every surface shows nothing to delete. A directory whose name is not a usable preset id (`[a-z0-9][a-z0-9-]*`) is skipped outright: no copy could ever claim it. + +- `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none. +- `ctx.agentPresets.list(): Promise` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. +- `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. +- `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. +- `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. +- `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. +- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. +- `ctx.agentPresets.read(id): Promise` One preset's composition text, exactly as stored. +- `ctx.agentPresets.copy(from, id, name?): Promise` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows. +- `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. + +`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), `path` (the absolute composition file), and — only when the preset cannot compose a session — `broken` (one human-readable reason, shown verbatim on roster surfaces). + +### Where to call `mount()` + +The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions. + +### Which preset a session runs + +The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. + +The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent. + +### Switching a blank agent + +`recompose()` unmounts the installed subtree and mounts the new one, because two compositions cannot coexist — both would register the same tool names into one layer. A failed mount restores the previous composition rather than leaving the agent with nothing, and an unknown id is rejected before anything is torn down. + +The restriction to a produced-nothing agent is a product rule, not a mechanical one: swapping tools mid-conversation would leave logged tool calls the new composition cannot make. The gateway enforces it at the wire ([`dsh-apiproxy`](../../host/apiproxy/README.md) answers `agent-preset-locked`), which is where session history is in hand. + +## Authoring + +Authoring is copy-only. A new preset is a whole-directory copy of an existing one — composition, metadata, skill directories, assets — landed under the first `user` root; the inputs are two ids the service resolves against its own roots plus an optional display name, so no caller ever supplies composition text and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files. `copy()` refuses three things before anything lands: + +- **An id that is not `[a-z0-9][a-z0-9-]*`.** The id becomes a directory name, so containment is a property of the id itself rather than of a path check after the fact — `../escape`, `a/b`, and an absolute path are all rejected as ids. +- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk refuses it too. Discovery lists such a directory as a broken preset, so the refusal's way out — delete it — is on the same page that reported it. +- **An unknown source.** The source may be any trust — copying a shipped preset is the primary case — but it must exist; a failed copy rolls its half-made directory back rather than leaving one discovery cannot see. + +The copied tree is re-tightened to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), symlinks are dereferenced so the copy is self-contained, and the root is created on first copy — a deployment configuring a user root that does not exist yet is the normal first-run state. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit in place, but its name and roster `order` are dropped — a copy presenting itself identically to its source, or sorted into the shipped set's declared order, would make the roster stop distinguishing them. `remove()` refuses a preset that ships with the deployment; the shipped set is the known-good compositions copies start from. + +### How a preset's rows resolve + +A row's **package name** resolves from the host composition, not from the preset directory. The Loader normally resolves an entry against its own tree's `baseUrl`, which for a preset is wherever the composition file sits; a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the harness, so every `@deepseek-ai/dsh-*` row would fail to import. The mount records the host base before plugging the subtree and sends bare specifiers there. + +A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it. + +### Display metadata + +A preset may publish display text in an optional `preset.yml` beside its composition: + +```yaml +name: 极简模式 +description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +``` + +It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load. + +Every read failure degrades to no metadata — absent, malformed, wrongly typed, or blank all mean the same thing, and a picker falls back to the id. Presentation is not capability: a preset with a broken name still mounts. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `default` | required | Preset id mounted when a caller names none | +| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) | + +An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. + +### The default preset is a user setting + +When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: + +```yaml +agent-presets: + default: minimal +``` + +The value is read per resolution rather than snapshotted, so a hot-reloaded document takes effect on the next session created and every running session stays on the preset it was composed from. Clearing the user field re-inherits the composition default. A default naming a preset no root supplies is stored without complaint and fails at the next `resolve()` — the roster is a live directory, so a name absent now may exist by the time a session asks for it. + +## What a mount rejects + +A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things. + +**An unscoped target.** Mounting into a context that carries no agent scope would register the preset's tools globally, for every agent in the process. + +**A row that never became usable.** The loader already rejects a row whose module failed to import or whose plugin threw; what remains is a row still waiting for a service the composition never supplies, which the audit names. + +**A row that published a service into the root realm.** Such a service is process-global, so the second preset publishing the same name collides with the first, and a host reader would resolve one preset's instance for every session. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local realms keep two presets' same-named services apart exactly as they once kept two sessions' apart — or the service belongs in the host composition instead. + +The package invariant re-checks that last rule on every service notification, because a row that publishes from a timer or an asynchronous continuation would escape the one-shot audit. + +## A preset file is an input, never a persistence target + +The Loader writes a tree back to its source file whenever it decides the config changed, and a row disposing its own fiber is enough to decide that: the entry is marked `disabled` and the tree is written. Inherited, that would burn one session's runtime state into a file every session shares — comments stripped by the YAML round trip, and a `writeFile` rejection inside a `setTimeout` for a read-only shipped preset. + +The mounted subtree therefore overrides `write()` as a no-op. Nothing in this package writes a composition; authoring one is a separate, explicit operation. + +## Trust + +Presets are compositions, so a preset is exactly as privileged as the plugins it names. A `user` preset — authored by a person or by an agent — carries the same trust as shell access; the `trust` field exists so consumers can present that difference, not to enforce it. + +## Model Experience + +Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it. + +#### KV Cache effect + +Prefix-stable for the life of an agent: a composition is installed once, before the agent is published and therefore before its first request, and is never re-read while the agent runs. Choosing a different preset for a new session establishes a different prefix for that session alone and cannot invalidate reuse for any session already running. + +## 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 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. +- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md new file mode 100644 index 0000000000..4e6fc0a4cf --- /dev/null +++ b/packages/preset/agent-presets/README.zh.md @@ -0,0 +1,128 @@ +# dsh-agent-presets + +[English](README.md) | 中文 + +按 preset 组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;roster 在整个进程内只把它挂载一次(常驻 scope),命名它的每个会话通过把自己 agent 的 scope key 认父到该挂载(`dsh-scope` 的父链)来加入。挂载的工具、提示词段落与投影单元只存在一份,覆盖所有已加入的 agent——其插件本就按 Session/Agent 分键存状态,会话在共享实例内互不串扰——而完全没有 agent 的宿主读取方(冷读记录)也能按 preset id 解析到同一份常驻注册。 + +其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。 + +## 服务:`AgentPresets`(ctx 键:`agentPresets`) + +发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。发现过程同时负责 preset 的**健康**:组装文件缺失或不可加载(YAML 无法解析——用加载器自己的方言检查,含 `!!js`——或不是由具名插件行组成的列表)的目录会作为携带 `broken` 原因的行列出而不是被跳过,因为被跳过的目录仍在磁盘上占着它的 id,而各个界面却没有任何可删的东西。目录名不是可用 preset id(`[a-z0-9][a-z0-9-]*`)的目录才被直接跳过:复制永远不可能占用那种名字。 + +- `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。 +- `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 +- `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 +- `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 +- `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 +- `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 +- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 +- `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容逐字一致。 +- `ctx.agentPresets.copy(from, id, name?): Promise` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。 +- `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。 + +`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)、`path`(组装文件的绝对路径),以及——仅当该 preset 无法组装会话时——`broken`(一条人类可读的原因,名单界面原样展示)。 + +### 应在何处调用 `mount()` + +agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。 + +### 会话实际运行的是哪个 preset + +创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 + +头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。 + +### 切换空白 agent + +`recompose()` 先卸载已装入的子树、再装入新的,因为两份组装无法共存——它们会把相同的工具名注册进同一个层。挂载失败会恢复先前的组装,而不是让 agent 一无所有;未知 id 则在任何东西被拆除之前就被拒绝。 + +"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。 + +## 创作 + +创作即复制。新 preset 是某个既有 preset 的整目录副本——组装、元数据、skill 目录、附带资产——落在首个 `user` 根目录之下;输入只有两个由服务对照自身根目录解析的 id 加一个可选显示名,因此调用方从不提供组装文本,一次复制不会授予 roster 尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。`copy()` 在任何内容落盘之前拒绝三种情况: + +- **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。 +- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字的目录同样拒绝。发现过程会把这样的目录列为损坏的 preset,所以这条拒绝的出路——删掉它——就在报告它的同一页面上。 +- **未知的来源。** 来源可以是任何信任级别——复制随附 preset 正是主要用途——但必须存在;复制失败会回滚做到一半的目录,而不是留下一个 discovery 看不见的目录。 + +复制出的目录树被收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),符号链接被解引用以保证副本自包含,且根目录在首次复制时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。复制出的 `preset.yml` 会被重写:保留来源的描述供作者就地编辑,但丢弃其名称与 roster `order`——副本若与来源呈现得一模一样、或按随附集合声明的顺序排序,roster 就不再能区分它们。`remove()` 拒绝随部署提供的 preset;随附集合正是副本的已知良好起点。 + +### preset 的各行如何解析 + +行的**包名**从宿主组装解析,而非从 preset 目录解析。Loader 通常按 entry 所属树的 `baseUrl` 解析,而对 preset 而言那就是组装文件所在之处;本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败。挂载在插入子树之前先记录宿主的基址,并把裸标识符送往那里。 + +**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。 + +### 展示用元信息 + +preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: + +```yaml +name: 极简模式 +description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +``` + +它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。 + +任何读取失败都退化为「没有元信息」——缺失、格式错误、类型不对、内容为空,含义相同,选择器回退到 id。展示不是能力:名字坏掉的 preset 依然能挂载。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `default` | 必填 | 调用方未指定时挂载的 preset id | +| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | + +根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 + +### 默认 preset 是一项用户设置 + +当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: + +```yaml +agent-presets: + default: minimal +``` + +该值在每次解析时读取而非快照,因此热重载的文档对**此后创建**的会话生效,而每个运行中的会话仍停留在它当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。若默认值指向没有任何根目录提供的 preset,写入时不会报错,而在下一次 `resolve()` 时失败——名单是一个活动目录,此刻不存在的名字,等到某个会话真正索取时可能已经存在。 + +## 挂载会拒绝什么 + +直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。 + +**目标上下文没有 scope。** 挂载到不带 agent scope 的上下文,会把该 preset 的工具注册成全局的,作用于进程内每一个 agent。 + +**某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。 + +**某一行把服务发布进了根 realm。** 这类服务是进程级全局的,因此第二个发布同名服务的 preset 会与第一个相撞,宿主读取方也会把某一个 preset 的实例当成所有会话的。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——entry 本地 realm 让两个 preset 的同名服务互不相干,正如它从前隔开两个会话——否则该服务应改放进宿主组装。 + +最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +## preset 文件是输入,不是持久化目标 + +只要 Loader 认为配置变了,它就会把树写回源文件——而一个行释放自己的 fiber 就足以让它这么认为:该 entry 被标记 `disabled`,随即触发写回。若继承该行为,一个会话的运行时状态就会被烧进所有会话共享的文件里:YAML 往返会抹掉注释,而对随附的只读 preset,`writeFile` 还会在 `setTimeout` 内抛出无人接管的 rejection。 + +因此被挂载的子树把 `write()` 覆写为空操作。本包不写任何组装;创作组装是另一件独立且显式的操作。 + +## 信任 + +preset 就是组装,因此一个 preset 的权限恰好等于它所引用的插件。`user` preset——无论由人还是由 agent 写出——与 shell 访问权限同级;`trust` 字段的存在是为了让消费方呈现这一差异,而不是用来强制隔离。 + +## Model Experience + +Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it. + +#### KV Cache effect + +在一个 agent 的整个生命周期内保持前缀稳定:组装只装入一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间不再重新读取。为新会话选择不同的 preset,只会为该会话建立不同的前缀,无法让任何已在运行的会话失去缓存复用。 + +## Known Limitations and Deferred Work + +- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 +- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。 +- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。 +- **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。 +- **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。 +- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json new file mode 100644 index 0000000000..c012dc3eda --- /dev/null +++ b/packages/preset/agent-presets/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-agent-presets", + "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-atomic-write": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-settings-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts new file mode 100644 index 0000000000..5ac4e55874 --- /dev/null +++ b/packages/preset/agent-presets/src/authoring.ts @@ -0,0 +1,195 @@ +/** + * Copying, reading, and deleting locally authored presets. + * + * Authoring is confined to a `user` root: the shipped `.system` set is part of + * the deployment, and letting a browser rewrite it would turn "reset to a known + * preset" into something the same caller could have broken first. + * + * The only authoring write is a whole-directory copy of an existing preset. + * No caller supplies composition text: the inputs are ids the host resolves + * against its own roots plus an optional display name, so authoring grants no + * capability the copied preset did not already carry. + * @module @deepseek-ai/dsh-agent-presets/authoring + */ + +import { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { expandHomePath } from '@deepseek-ai/dsh-paths' +import { METADATA_FILE, renderPresetMetadata } from './metadata.ts' +import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' + +/** A preset id that cannot be used as a directory name under a root. */ +export class InvalidPresetIdError extends Error { + constructor( + /** The rejected id. */ + readonly presetId: string, + ) { + super( + `agent-presets: preset id ${JSON.stringify(presetId)} must match ${String(PRESET_ID)} — ` + + 'the id is a directory name, so anything else could escape the preset root', + ) + } +} + +/** A copy target that is already occupied — a copy never overwrites. */ +export class PresetExistsError extends Error { + constructor( + /** The id that is already taken. */ + readonly presetId: string, + ) { + super( + `agent-presets: preset "${presetId}" already exists — ` + + 'a copy never overwrites; delete the existing preset first or choose another id', + ) + } +} + +/** Authoring was attempted where the deployment allows none. */ +export class PresetNotWritableError extends Error { + constructor( + /** What the caller tried to change, for the diagnostic. */ + readonly presetId: string, + reason: string, + ) { + super(`agent-presets: preset "${presetId}" cannot be written: ${reason}`) + } +} + +/** + * The root locally authored presets are written to. + * @param roots - the configured roots in precedence order. + * @returns the absolute path of the first `user` root. + * @throws when the deployment configured no writable root. + */ +export function writableRoot(roots: readonly PresetRoot[]): string { + const root = roots.find(candidate => candidate.trust === 'user') + if (root === undefined) { + throw new PresetNotWritableError('', 'this deployment configures no user-writable preset root') + } + return resolve(expandHomePath(root.path)) +} + +/** + * Read one preset's composition text. + * @param preset - the resolved preset. + * @returns the file's contents. + */ +export async function readComposition(preset: AgentPreset): Promise { + return await readFile(preset.path, 'utf8') +} + +/** Whether anything occupies the path (cp's own errorOnExist backstops races). */ +async function occupied(path: string): Promise { + let present = true + try { + await stat(path) + } catch { + // Every stat failure means the same thing here: nothing usable occupies + // the path, so the copy may claim it. + present = false + } + return present +} + +/** + * Re-tighten a copied tree to owner-only. A shipped preset is world-readable + * in its install and `cp` preserves that; the copy carries the same weight as + * the settings document beside it, so group/other access is stripped. A + * file's owner-execute bit survives — a preset may ship runnable helpers. + */ +async function tightenModes(dir: string): Promise { + await chmod(dir, 0o700) + for (const entry of await readdir(dir, { withFileTypes: true })) { + const target = join(dir, entry.name) + if (entry.isDirectory()) { + await tightenModes(target) + } else { + await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) + } + } +} + +/** + * Create a preset by copying an existing one's whole directory. + * + * The copy carries everything the source directory holds — composition, + * metadata, skill directories, assets — because a preset is its directory, + * not one file. Symlinks are dereferenced so the copy is self-contained + * rather than a set of links back into the install it was copied from. + * + * The copied metadata is then rewritten: the source's description is kept + * (the file is the author's to edit afterwards), but its name and roster + * `order` are not — a copy presenting itself identically to its source, or + * sorted into the shipped set's declared order, would make the roster stop + * distinguishing them. With no name given and no description to keep, the + * file is removed so the copy publishes nothing rather than a blank. + * @param roots - the configured roots; the first `user` one receives the copy. + * @param source - the resolved preset the copy starts from. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; omitted falls back to the id. + * @returns the absolute path of the new preset directory. + * @throws when the id is unusable or already occupied on disk, or the + * deployment configures no writable root. + */ +export async function copyComposition( + roots: readonly PresetRoot[], + source: AgentPreset, + id: string, + name?: string, +): Promise { + if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id) + const dir = join(writableRoot(roots), id) + // The roster check upstream only sees discovered presets; a directory with + // no composition file still occupies the name and deserves a readable + // refusal rather than a filesystem error code. + if (await occupied(dir)) throw new PresetExistsError(id) + try { + await cp(dirname(source.path), dir, { + recursive: true, dereference: true, force: false, errorOnExist: true, + }) + await tightenModes(dir) + const rendered = renderPresetMetadata({ + ...name === undefined ? {} : { name }, + ...source.description === undefined ? {} : { description: source.description }, + }) + const metadataPath = join(dir, METADATA_FILE) + if (rendered === undefined) { + await rm(metadataPath, { force: true }) + } else { + await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 }) + } + } catch (error) { + // A half-copied directory would be invisible to discovery at best and a + // mountable-but-incomplete preset at worst; a failed copy leaves nothing. + await rm(dir, { recursive: true, force: true }) + throw error + } + return dir +} + +/** + * Delete a locally authored preset. + * + * A shipped preset is refused: it belongs to the deployment. A preset a live + * session mounted is NOT refused — the composition was read at creation and is + * never re-read, so that session keeps running exactly as it was. + * @param roots - the configured roots. + * @param preset - the resolved preset to remove. + * @throws when the preset ships with the deployment or lies outside the writable root. + */ +export async function deleteComposition( + roots: readonly PresetRoot[], + preset: AgentPreset, +): Promise { + if (preset.trust !== 'user') { + throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + } + const dir = join(writableRoot(roots), preset.id) + // Belt and braces over the id pattern: the resolved directory must still be + // the one the writable root owns, whatever discovery reported. + if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) { + throw new PresetNotWritableError(preset.id, 'it does not live under the writable preset root') + } + await rm(dir, { recursive: true, force: true }) +} diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts new file mode 100644 index 0000000000..de8312893a --- /dev/null +++ b/packages/preset/agent-presets/src/discovery.ts @@ -0,0 +1,171 @@ +/** + * Filesystem discovery of agent presets. A preset is a directory holding + * {@link COMPOSITION_FILE}, optionally beside a {@link METADATA_FILE} carrying + * its display text; the directory name is the preset id. Discovery + * re-reads the roots on every call so a preset authored while the process is + * running is visible without a restart. + * + * Discovery also owns preset HEALTH: a directory whose composition is + * missing or unloadable is reported as a broken roster row rather than + * skipped. A skipped directory would still occupy its id on disk — the copy + * path refuses the name while no surface shows anything to delete — and a + * malformed composition would otherwise read as an ordinary preset until the + * first session fails to mount it. + * @module @deepseek-ai/dsh-agent-presets/discovery + */ + +import { readdir, readFile, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { load } from 'js-yaml' +import { entryListSchema } from '@cordisjs/plugin-include' +import { expandHomePath } from '@deepseek-ai/dsh-paths' +import { readPresetMetadata } from './metadata.ts' +import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' + +/** The composition file that makes a directory a preset. */ +export const COMPOSITION_FILE = 'agent.cordis.yml' + +/** + * Why `rows` cannot be an entry list, or undefined when it can. + * + * A shallow shape check, deliberately short of the loader's work: it does not + * resolve plugin names or apply configs. What it catches is the hand-edit + * that produces a file the loader cannot even begin with — and it must accept + * everything the loader accepts, which is why rows are only required to be + * maps carrying a plugin `name` (groups recurse into their own lists). + * @param rows - the parsed composition document. + * @param at - row-path prefix for nested diagnostics, empty at the top level. + * @returns one human-readable reason, or undefined when the shape holds. + */ +function entryListProblem(rows: unknown, at = ''): string | undefined { + if (!Array.isArray(rows)) { + return at === '' + ? 'the composition must be a top-level list of plugin rows' + : `group ${at} must hold a list of plugin rows` + } + for (const [index, row] of rows.entries()) { + const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}` + if (typeof row !== 'object' || row === null || Array.isArray(row)) { + return `${label} is not a plugin row (expected a map with a "name")` + } + const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown } + if (typeof name !== 'string' || name === '') { + return `${label} names no plugin (a "name" string is required)` + } + if (group === true) { + const nested = entryListProblem(config, label) + if (nested !== undefined) return nested + } + } + return undefined +} + +/** + * Why the composition at `path` cannot mount, or undefined when it looks + * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema}, + * the one carrying `!!js`), so health can never call a composition broken + * that the loader would accept. + * @param path - absolute path of the composition file. + * @returns one human-readable reason, or undefined when the file is loadable. + */ +async function compositionProblem(path: string): Promise { + let content: string + try { + content = await readFile(path, 'utf8') + } catch { + // The caller statted this file moments ago; any read failure now — + // deleted in between, permissions — is the same answer as unparsable. + return `the composition file ${COMPOSITION_FILE} cannot be read` + } + let rows: unknown + try { + rows = load(content, { schema: entryListSchema }) + } catch (error) { + /* v8 ignore next -- js-yaml throws YAMLException (an Error) for every parse failure; the fallback keeps a hostile value readable */ + const full = error instanceof Error ? error.message : String(error) + // First line only: js-yaml appends a multi-line code-frame snippet, and + // the reason is displayed on a roster card, not in a terminal. + return `the composition is not valid YAML: ${full.replace(/\n[\s\S]*$/, '')}` + } + return entryListProblem(rows) +} + +/** + * Whether `path` names an existing regular file. + * @param path - absolute path to test. + * @returns true when the path resolves to a file. + */ +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + // Any stat failure — absent, unreadable, a dangling link — means this + // directory does not present a composition, which is not an error: the + // directory simply is not a preset. + return false + } +} + +/** + * Scan one root for preset directories. + * + * An absent root yields no presets rather than throwing: the user root does + * not exist until the first locally authored preset, and naming a default + * that no root supplies already fails loud at resolution. + * + * Every directory whose name is a usable preset id is a roster row — broken + * when its composition is missing or unloadable. A directory named outside + * {@link PRESET_ID} is skipped instead: no copy could ever claim that name, + * so it blocks nothing, and reporting `.DS_Store`-grade residue as broken + * presets would teach users to ignore the marker. + * @param root - the directory and the trust its presets inherit. + * @returns the root's presets ordered by id. + */ +export async function scanRoot(root: PresetRoot): Promise { + const dir = resolve(expandHomePath(root.path)) + let children + try { + children = await readdir(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error }) + } + const found: AgentPreset[] = [] + for (const child of children) { + if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue + const directory = join(dir, child.name) + const path = join(directory, COMPOSITION_FILE) + const broken = await isFile(path) + ? await compositionProblem(path) + : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file` + // Display text only, and never fatal: a preset with unreadable metadata + // still mounts, it just shows its id. + const metadata = await readPresetMetadata(directory) + found.push({ + id: child.name, trust: root.trust, path, ...metadata, + ...broken === undefined ? {} : { broken }, + }) + } + // Declared order first so the shipped set reads by capability; everything + // else falls back to the id, which keeps authored presets stable. + return found.sort((left, right) => { + const byOrder = (left.order ?? Number.POSITIVE_INFINITY) - (right.order ?? Number.POSITIVE_INFINITY) + return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder + }) +} + +/** + * Scan every root in precedence order. + * @param roots - roots in precedence order; an earlier root wins a duplicate id. + * @returns every discovered preset, first-root-wins per id. + */ +export async function discoverPresets(roots: readonly PresetRoot[]): Promise { + const byId = new Map() + for (const root of roots) { + for (const preset of await scanRoot(root)) { + if (byId.has(preset.id)) continue + byId.set(preset.id, preset) + } + } + return [...byId.values()] +} diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts new file mode 100644 index 0000000000..58f523c4e3 --- /dev/null +++ b/packages/preset/agent-presets/src/index.ts @@ -0,0 +1,457 @@ +/** + * Agent presets: each session composes its model-facing plugin set from one + * preset `cordis.yml`, mounted ONCE per preset under a standing scope and + * joined by every agent that names it. + * + * The standing mount is what makes a preset one composition rather than one + * per session: its plugin instances, tool registrations, prompt sections, and + * projection units exist exactly once, keyed per session inside the plugins + * themselves (they predate presets and were written for a shared world). An + * agent joins by having its scope key parented to the mount's + * ({@link bindScopeParent}), which makes the mount's registrations visible to + * that agent's views and the mount's listeners receive that agent's events — + * and a host reader with no agent at all (a cold transcript read) resolves + * the same standing registrations by preset id. + * + * This package owns the preset vocabulary, filesystem discovery, and the + * guarded standing mount. It does not decide when an agent is created — the + * agent factory's `setup(agentCtx)` hook is the one supported call site, + * because only there is the join installed while the agent is still + * unpublished, so a rejected composition rolls the whole creation back. + * @module @deepseek-ai/dsh-agent-presets + */ + +import { stat } from 'node:fs/promises' +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +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' +import { mountPreset, serviceForAgent } from './mount.ts' +import { PresetExistsError } from './authoring.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts' + +/** Settings namespace carrying the user's chosen default preset. */ +export const SETTINGS_NAMESPACE = 'agent-presets' + +/** The user-writable slice of this plugin's config. */ +export interface AgentPresetSettings { + /** Preset mounted when a session names none. */ + default?: string +} + +/** Runtime schema for the user-writable slice. */ +export const AgentPresetSettingsSchema: z = z.object({ + default: z.string(), +}) + +export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { + METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, +} from './metadata.ts' +export { + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, + type PresetMount, +} from './mount.ts' +export { + copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, + PresetNotWritableError, readComposition, writableRoot, +} from './authoring.ts' +export { resolveSessionPreset, type PresetBearingSession } from './session.ts' +export { PresetMountError, UnknownPresetError } from './types.ts' +export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' + +declare module 'cordis' { + interface Context { + agentPresets: AgentPresets + } +} + +/** + * Registry over the deployment's agent presets. + * + * Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every + * call so a preset authored while the process runs is visible immediately, + * and a preset deleted underneath a picker disappears from the next read. + */ +export class AgentPresets extends Service { + static inject = ['loader'] + + /** Runtime schema for the preset roster. */ + static Config = z.object({ + default: z.string().required(), + roots: z.array(z.object({ + path: z.string().required(), + trust: z.union(['system', 'user'] as const).default('user'), + })).default([]), + }) as z + + /** + * The user layer over `config.default`, present only while a settings + * provider is composed. Held rather than snapshotted so a hot-reloaded + * document takes effect without a restart. + */ + private settings: SettingsScope | undefined + + /** + * The settings service behind {@link settings}, held for the one write this + * service makes: clearing a user default it has just deleted. + */ + private settingsService: SettingsService | undefined + + /** + * The service's own untraced context. Methods invoked through the traceable + * proxy see `this.ctx` rebound to the CALLER's context, which carries a + * shadow; a subtree minted from it resolves every service through that + * shadow's fiber instead of each entry's own inject store, so preset rows + * would fail on the very services they declare. Standing mounts must hang + * off the untraced original (the `tasks-local` selfCtx precedent). + */ + private readonly selfCtx: Context + + constructor(ctx: Context, public config: Config) { + super(ctx, 'agentPresets') + this.selfCtx = ctx + // Deliberately not `installSettingsSection`: that helper exists to re-judge + // what a consumer DERIVED from the source — memoized resolutions, + // registration-level facts — across attach, detach, and change. Nothing + // here is derived. `defaultId` reads through on every call, so both of its + // hooks would be no-ops and the source thunk would restate this field. + ctx.inject(['settings'], (settingsCtx) => { + this.settings = settingsCtx.settings.register( + settingsNamespace(SETTINGS_NAMESPACE), + AgentPresetSettingsSchema, + { base: { default: config.default } }, + ) + this.settingsService = settingsCtx.settings + settingsCtx.effect(() => () => { + this.settings = undefined + this.settingsService = undefined + }, 'agentPresets.settings()') + }) + } + + /** + * The preset id mounted when a caller names none. + * + * Read per call rather than cached: the settings document is hot-reloaded, so + * changing the default takes effect on the next session created and leaves + * every running session on the preset it was composed from. + */ + get defaultId(): string { + return this.settings?.get().default ?? this.config.default + } + + /** + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. + */ + async list(): Promise { + return await discoverPresets(this.config.roots) + } + + /** + * Resolve one preset by id. + * + * A broken preset resolves — deleting one, reading one, and reporting one + * all need the row — and the mounting paths refuse it AFTER resolution + * through {@link resolveMountable}. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. + * @throws when no configured root supplies that id. + */ + async resolve(id?: string): Promise { + const wanted = id ?? this.defaultId + const presets = await this.list() + const found = presets.find(preset => preset.id === wanted) + if (found === undefined) { + throw new UnknownPresetError(wanted, presets.map(preset => preset.id)) + } + return found + } + + /** + * Resolve one preset that is about to compose an agent, refusing a broken + * one with its discovery-reported reason. Failing here rather than inside + * the loader keeps the answer the same for every unloadable shape — ghost + * directory, unparsable YAML, rowless list — and spends no mount attempt + * on a composition discovery already read as unusable. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved, mountable preset. + * @throws when the preset is unknown or discovery reports it broken. + */ + private async resolveMountable(id?: string): Promise { + const preset = await this.resolve(id) + if (preset.broken !== undefined) { + throw new PresetMountError(preset.id, preset.broken) + } + return preset + } + + /** + * Standing mounts by preset id, single-flight so two agents racing the + * first use of one preset share one composition. A settled failure is + * removed so a later session retries a preset whose file has been fixed; a + * settled success serves until the composition FILE visibly changes — each + * generation records its file stamp, and a stale stamp starts the next + * generation for sessions created afterwards. Sessions already joined keep + * the generation they run on; a superseded one is never disposed while the + * process lives (reclaimed only by whole-tree teardown), so editing files + * is bounded by how often compositions change, not by session count. + */ + private readonly standing = new Map>() + + /** + * Parent bindings of the agents this roster composed, keyed by the agent's + * scope key. The binding is dsh-scope's only re-link capability; holding it + * here makes this service the sole authority that can move an agent between + * standing compositions. WeakMap: entries die with their agents. + */ + private readonly bindings = new WeakMap() + + /** + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken preset never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was composed, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ + async mount(agentCtx: Context, id?: string): Promise { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset') + } + const preset = await this.resolveMountable(id) + const standing = await this.ensureStanding(preset) + // The one bind of this agent's ancestry. The binding is the only re-link + // authority, held privately so nothing outside this roster can move a + // composed agent to another preset; a later recompose layer re-links + // through it under the caller-owned blank-session contract. + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + return preset + } + + /** Whether this deployment configures a root locally authored presets go to. */ + get authorable(): boolean { + return this.config.roots.some(root => root.trust === 'user') + } + + /** + * Read one preset's composition text. + * @param id - the preset id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ + async read(id: string): Promise { + return await readComposition(await this.resolve(id)) + } + + /** + * Create a locally authored preset by copying an existing one whole. + * + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. + */ + async copy(from: string, id: string, name?: string): Promise { + const source = await this.resolve(from) + // The roster check refuses ids any root supplies — shipped ones included, + // since a user directory named like a shipped preset is shadowed by it. + // The disk check inside copyComposition only sees the writable root. + if ((await this.list()).some(preset => preset.id === id)) { + throw new PresetExistsError(id) + } + await copyComposition(this.config.roots, source, id, name) + // A settled mount under this id can only be stale (its preset was deleted + // from disk outside `remove`); the new preset must not inherit it. Every + // session already joined keeps the generation it runs on regardless. + this.standing.delete(id) + } + + /** + * Delete a locally authored preset. + * @param id - the preset id. + * @throws when the preset is unknown or ships with the deployment. + */ + async remove(id: string): Promise { + await deleteComposition(this.config.roots, await this.resolve(id)) + // Sessions on the deleted preset keep their standing mount; only new + // sessions see the roster without it. + this.standing.delete(id) + // Storing a default that does not exist YET is deliberate — the roster is a + // live directory, so a name absent now may exist by the time a session asks + // for it, and `resolve` reports it then. A default this call just deleted is + // not that case: nothing will ever supply it again, and left in place every + // session created without an explicit pick would fail to start. Clearing it + // exposes the deployment's own default underneath, which is the layering. + if (this.settings?.get().default !== id) return + await this.settingsService?.mutate( + settingsNamespace(SETTINGS_NAMESPACE), + [{ op: 'unset', path: ['default'] }], + ) + } + + /** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ + serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { + return serviceForAgent(this.ctx, agent, name) + } + + /** + * Re-link one agent to a different preset's standing composition. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. + * + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable. + */ + async recompose(agentCtx: Context, id: string): Promise { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to recompose an unscoped context') + } + const preset = await this.resolveMountable(id) + const standing = await this.ensureStanding(preset) + const binding = this.bindings.get(agentKey) + if (binding === undefined) { + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + } else { + binding.rebind(standing.key) + } + return preset + } + + /** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ + async standingKeyFor(id?: string): Promise { + const preset = await this.resolveMountable(id) + return (await this.ensureStanding(preset)).key + } + + /** Resolve (or create, single-flight) the standing mount of one preset. */ + private async ensureStanding(preset: AgentPreset): Promise { + const pending = this.standing.get(preset.id) + if (pending !== undefined) { + const mounted = await pending + // Files are the only composition editor (authoring is copy/delete), so + // the stamp is what notices an edit: a changed file starts the next + // generation here, for this and later sessions. An unreadable stamp + // serves the current generation — a mount must survive its file + // 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 + // 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) + return this.ensureStanding(preset) + } + const created = (async (): Promise => { + const key: ScopeKey = { agentPreset: preset.id } + const scope = createScope(this.selfCtx, key) + try { + // Stamped before the file is read: an edit racing the mount makes the + // stamp stale rather than silently current, so the next session + // refreshes instead of trusting a composition older than its stamp. + const stamp = await compositionStamp(preset.path) + if (stamp === undefined) { + throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`) + } + await mountPreset(scope.ctx, preset) + return { key, scope, stamp } + } catch (error) { + this.standing.delete(preset.id) + await scope.dispose() + throw error + } + })() + this.standing.set(preset.id, created) + return created + } +} + +/** The composition file identity one standing generation was mounted from. */ +interface CompositionStamp { + /** Modification time in milliseconds, as `stat` reports it. */ + readonly mtimeMs: number + /** File size in bytes, the tiebreak for edits within one mtime tick. */ + readonly size: number +} + +/** Read one composition file's stamp, or undefined when it cannot be statted. */ +async function compositionStamp(path: string): Promise { + try { + const { mtimeMs, size } = await stat(path) + return { mtimeMs, size } + } catch { + // Deleted, replaced by an unreadable entry, or otherwise unstattable all + // mean the same to the caller: the file offers no identity to compare. + return undefined + } +} + +/** Whether two stamps name the same file state. */ +function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean { + return a.mtimeMs === b.mtimeMs && a.size === b.size +} + +/** One preset's standing composition. */ +interface StandingMount { + /** Scope key agents are parented to; also the mount's registration scope. */ + readonly key: ScopeKey + /** Disposal boundary; held for whole-tree teardown, never per-session. */ + readonly scope: Scope + /** Stamp of the composition file this generation was mounted from. */ + readonly stamp: CompositionStamp +} + +export default AgentPresets diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts new file mode 100644 index 0000000000..7a08eab7f1 --- /dev/null +++ b/packages/preset/agent-presets/src/invariant.ts @@ -0,0 +1,48 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-presets`. + * @module @deepseek-ai/dsh-agent-presets/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +// 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. +import { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-presets' + +/** Cordis companion plugin name. */ +export const name = 'agent-presets-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Assert that no installed preset composition reaches the root service realm. + * + * `mountPreset` proves this 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. + */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.on('internal/service', function (this: Context, name) { + for (const mount of livePresetMounts()) { + const leaked = leakedServices(ctx, mount.fiber) + if (leaked.length === 0) continue + fail( + `preset "${mount.presetId}" published process-global service(s) [${leaked.join(', ')}] ` + + `after its mount was audited (observed while notifying "${name}") — ` + + 'a preset service must sit behind an `isolate` realm or move to the host composition', + ) + } + }, { global: true }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/preset/agent-presets/src/metadata.ts b/packages/preset/agent-presets/src/metadata.ts new file mode 100644 index 0000000000..aa964e0ada --- /dev/null +++ b/packages/preset/agent-presets/src/metadata.ts @@ -0,0 +1,105 @@ +/** + * A preset's display metadata: the name and description a picker shows. + * + * It lives in its own file because the composition is a top-level list of + * plugin rows — YAML cannot carry sibling keys beside it, and faking a + * metadata row would hand the Loader something to load. Keeping it separate + * also keeps the composition exactly what its name says: a Cordis file the + * loader owns and the cordis preset can author. + * + * The file carries display text ONLY. `id` is the directory name and `trust` + * comes from the root a preset was discovered under, so neither is writable + * here — otherwise a locally authored preset could claim to be a shipped one. + * + * Every read failure degrades to no metadata. A preset whose display text is + * missing, malformed, or unreadable still mounts: presentation is not a + * capability, and a broken name must never become an agent that cannot start. + * @module @deepseek-ai/dsh-agent-presets/metadata + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import yaml from 'js-yaml' + +/** The optional display-metadata file beside a preset's composition. */ +export const METADATA_FILE = 'preset.yml' + +/** Display text a preset may publish about itself. */ +export interface PresetMetadata { + /** Human-facing name; falls back to the preset id when absent. */ + readonly name?: string + /** One sentence on what this preset is for. */ + readonly description?: string + /** + * Position within its group; lower comes first. A preset that declares + * none sorts after every preset that does, then by id — so the shipped set + * can read in capability order while authored ones stay alphabetical. + */ + readonly order?: number +} + +/** A non-empty trimmed string, or undefined for anything else. */ +function text(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed === '' ? undefined : trimmed +} + +/** + * Read one preset directory's display metadata. + * + * Absent, unparsable, and wrongly-shaped files are all the same answer — + * empty metadata — because the caller renders a picker, not a diagnostic. + * @param directory - the preset directory. + * @returns the display text the preset published, possibly empty. + */ +export async function readPresetMetadata(directory: string): Promise { + let raw: string + try { + raw = await readFile(join(directory, METADATA_FILE), 'utf8') + } catch { + // Absent is the common case: metadata is optional and most presets, + // including every one authored by duplicating another, carry none. + return {} + } + let parsed: unknown + try { + parsed = yaml.load(raw) + } catch { + // Malformed display text is not worth failing discovery over; the picker + // falls back to the id, and the composition still mounts. + return {} + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} + const record = parsed as Record + const name = text(record.name) + const description = text(record.description) + const order = typeof record.order === 'number' && Number.isFinite(record.order) + ? record.order + : undefined + return { + ...name === undefined ? {} : { name }, + ...description === undefined ? {} : { description }, + ...order === undefined ? {} : { order }, + } +} + +/** + * Render display metadata as the file's contents. + * + * Absent fields are omitted rather than written empty, so a preset with no + * description does not ship a key that reads as an intentional blank. + * @param metadata - the display text to store. + * @returns the YAML document, or undefined when there is nothing to store. + */ +export function renderPresetMetadata(metadata: PresetMetadata): string | undefined { + const name = text(metadata.name) + const description = text(metadata.description) + const { order } = metadata + if (name === undefined && description === undefined && order === undefined) return undefined + return yaml.dump({ + ...name === undefined ? {} : { name }, + ...description === undefined ? {} : { description }, + ...order === undefined ? {} : { order }, + }, { lineWidth: -1 }) +} diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts new file mode 100644 index 0000000000..fac3319fa1 --- /dev/null +++ b/packages/preset/agent-presets/src/mount.ts @@ -0,0 +1,356 @@ +/** + * Mount one preset composition under an agent's scope context, then prove the + * result is usable before the agent is published. + * + * The scope context is what makes the composition per-session: entry contexts + * chain to the context the subtree was plugged into, so every `ctx.tools` + * and `ctx.systemPrompt` registration inside the preset files into that + * agent's layer and unwinds with it. Two guards make that safe. A row that + * never reached a usable state is rejected, because a directly-plugged subtree + * is absent from `ctx.loader.entries()` and no boot audit covers it. A row that + * published a service into the ROOT realm is rejected, because such a service + * is process-global rather than per-session and the second session mounting the + * same preset collides with the first. + * @module @deepseek-ai/dsh-agent-presets/mount + */ + +import { pathToFileURL } from 'node:url' +import { Context, type Fiber } from 'cordis' +import { Include } from '@cordisjs/plugin-include' +import type { EntryTree } from '@cordisjs/plugin-loader' +import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { PresetMountError, type AgentPreset } from './types.ts' + +/** What one mounted subtree publishes about itself for the audit to read. */ +interface MountedTree { + /** The rows the composition created. */ + readonly tree: EntryTree + /** + * The subtree's own fiber. Captured here rather than taken from + * `ctx.plugin()`, which hands back a thenable `Object.create(fiber)` wrapper + * that is never identical to the fiber appearing in a parent chain. + */ + readonly fiber: Fiber +} + +/** + * Subtrees captured by config identity. A subtree plugged directly (rather than + * created as a loader entry) never links itself to an `Entry`, so this is the + * only handle to the rows it created; config objects are minted per mount, so + * concurrent mounts cannot collide. + */ +const mounted = new WeakMap() + +/** + * The base URL bare specifiers resolve against, per pending mount, keyed by the + * same config object. Recorded before the subtree is plugged, because `Include` + * rewrites its own context's `baseUrl` to the composition's directory and the + * pre-mount value is the only handle on where the harness itself lives. + */ +const harnessBase = new WeakMap() + +/** + * Include subclass that publishes its tree and fiber for the audit, and never + * writes to the file it read. + */ +class PresetTree extends Include { + constructor(ctx: Context, config: Include.Config) { + super(ctx, config) + mounted.set(config, { tree: this, fiber: ctx.fiber }) + } + + /** + * Resolve a bare specifier from the harness rather than from the preset. + * + * `EntryTree.import()` resolves against the tree's own `baseUrl`, which + * `Include` sets to the composition's directory. That is right for a + * relative specifier — a preset's own files travel with it — and wrong for + * a package name: a locally authored preset lives under the user's home, + * where Node's upward `node_modules` walk never reaches the harness's own + * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The + * mount records the host composition's base instead, which is inside the + * installed harness, and bare names resolve from there. + * @param name - the module specifier from the row. + * @param getOuterStack - the loader's stack composer for import diagnostics. + * @returns the imported module, or the `cordis:` builtin. + */ + override import(name: string, getOuterStack?: () => string[]): unknown { + const base = harnessBase.get(this.config) + /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */ + if (base === undefined) return super.import(name, getOuterStack) + if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack) + const internal = this.ctx.loader.internal + /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a + hypothetical embedder from losing the row's name in a resolution error. */ + if (internal === undefined) return super.import(name, getOuterStack) + return internal.import(name, base, {}) + } + + /** + * A preset is an input, never a persistence target. + * + * The Loader writes a tree back through this method whenever it decides the + * config changed — a plugin self-disposing is enough, and tearing an agent + * down disposes its whole subtree. Inherited, that rewrites the preset file + * with whatever the dying tree held, which in practice means truncating a + * shipped composition to `[]` the first time a session ends. Persisting a + * preset is also meaningless: nothing here is user state, and the same file + * backs every session that names it. + * + * Dropping the write drops the `loader/config-update` the inherited method + * emits with it. Nothing observes one for a preset subtree today, and a + * future "edit your preset while it runs" flow needs a deliberate + * persistence path rather than this method's return. + */ + override write(): void { + } +} + +/** One preset composition currently installed under some agent. */ +export interface PresetMount { + /** The preset the subtree was composed from. */ + readonly presetId: string + /** The mounted subtree's fiber. */ + readonly fiber: Fiber + /** The standing scope key agents are parented to (undefined only in torn-down records). */ + readonly key: ScopeKey | undefined +} + +const mounts = new Set() + +/** + * Drop every record whose subtree is gone. + * + * Records are pruned by observation rather than through a disposal hook + * because a subtree can be torn down by its owning agent, by a failed mount, or + * by the whole tree unloading, and a cleared `uid` is what all three share. + * + * Pruning therefore has to happen on a path this module owns. Reading is one + * such path, but not a reliable one: the only production reader is the + * invariant companion's service listener, and `dsh-invariants` is a + * development composition — a shipped host never loads it. Mounting is the + * other, and it is the one every session takes, which bounds the set at one + * generation of dead records rather than one per session ever composed. Each + * record would otherwise retain its whole disposed subtree: the fiber holds + * its config, and that config is the key its `EntryTree` is stored under. + */ +function pruneDisposedMounts(): void { + for (const mount of mounts) { + if (mount.fiber.uid === null) mounts.delete(mount) + } +} + +/** + * Every preset composition still installed, pruning fibers disposed since the + * last read. + * @returns the live mounts. + */ +export function livePresetMounts(): PresetMount[] { + pruneDisposedMounts() + return [...mounts] +} + +/** + * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. + * + * Membership is object identity. `uid` looks like a cheaper key but is a + * per-registry counter, so fibers in two different roots collide on it and a + * subtree in one runtime would be blamed for a service published in another. + * @param fiber - the fiber to locate. + * @param root - the subtree root to test membership against. + * @returns true when `fiber` belongs to `root`'s subtree. + */ +function withinFiber(fiber: Fiber, root: Fiber): boolean { + let current = fiber + while (true) { + if (current === root) return true + const parent = current.parent.fiber + if (parent === current) return false + current = parent + } +} + +/** + * Service names the mounted subtree published into the root realm. + * + * A provider without an `isolate` realm stores its implementation under the + * root's symbol for that name, which is exactly the comparison below; a + * provider inside an `isolate` realm stores under a realm-private symbol and + * is correctly absent here. + * @param ctx - any context of the runtime whose service store is inspected. + * @param mount - the mounted subtree's fiber. + * @returns the leaked service names in lexical order. + */ +export function leakedServices(ctx: Context, mount: Fiber): string[] { + const store = ctx.reflect.store + const rootIsolate = ctx.root[Context.isolate] + const leaked: string[] = [] + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than + clearing it, so an own symbol always resolves; the guard exists only + because the store's index signature is optional. */ + if (impl === undefined) continue + if (!withinFiber(impl.fiber, mount)) continue + if (rootIsolate[impl.name] === key) leaked.push(impl.name) + } + return leaked.sort((left, right) => left.localeCompare(right)) +} + +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes a service behind an `isolate` realm so two sessions + * cannot collide, and an entry-local realm is invisible to everything outside + * the group — including the agent's own scope context and the host. That is + * right for the rows inside the group and wrong for one caller: a request that + * is ABOUT a session but arrives from outside it, which is every browser RPC + * the api-proxy serves. + * + * Ownership is the same relation {@link leakedServices} reads, inverted: there + * it names implementations a subtree published into the ROOT realm, here it + * names the one this subtree published anywhere. Fiber membership is object + * identity for the reason stated on {@link withinFiber}. + * + * This is READ addressing for a caller that already holds the agent. It is not + * a general host handle on a session's internals: a host row that `inject`s a + * service cannot use it, because injection resolves before any session exists + * and has no agent to key by — such a service belongs on the host plane. + * @param ctx - any context of the runtime whose service store is inspected. + * @param agent - the agent whose mounted composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +export function serviceForAgent( + ctx: Context, + agent: { ctx: Context }, + name: K, +): Context[K] | undefined { + // The agent's own key is parented to its preset's standing key; the mount + // is no longer under the agent's fiber, so the search roots at the standing + // mount instead of walking up from the agent. + const agentKey = scopeOf(agent.ctx) + if (agentKey === undefined) return undefined + const standingKey = scopeParentOf(agentKey) + if (standingKey === undefined) return undefined + const mount = livePresetMounts().find(candidate => candidate.key === standingKey) + if (mount === undefined) return undefined + const store = ctx.reflect.store + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ + if (impl === undefined) continue + if (impl.name !== name) continue + if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K] + } + return undefined +} + +/** + * Rows that did not reach a usable state, each rendered as one diagnostic line. + * + * A row whose module failed to import or whose plugin threw already rejects the + * mount through the loader; what remains observable here is a row still waiting + * for a service the composition never supplies. + * @param tree - the mounted subtree. + * @returns one line per unusable row, empty when every enabled row is usable. + */ +export function inactiveRows(tree: EntryTree): string[] { + const lines: string[] = [] + for (const entry of tree.entries()) { + if (entry.disabled) continue + const fiber = entry.fiber + /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed, + so a settled tree never holds an enabled fiber-less entry; the branch exists + only because `Entry.fiber` is declared optional. */ + if (fiber === undefined) { + lines.push(`${entry.options.id} (${entry.options.name}): never started`) + continue + } + const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) + if (missing.length > 0) { + lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`) + } + } + return lines +} + +/** + * The reportable text of a mount failure. + * + * The loader reports several failed rows as one `AggregateError`, whose own + * message names none of them; without flattening, a composition that fails on + * two rows says only "loader entries failed to apply" and the operator has + * nothing to act on. + * @param error - the value the mount rejected with. + * @returns a single-line-per-cause description. + */ +function mountDetail(error: unknown): string { + /* v8 ignore next -- every path into the mount's catch throws an Error: the loader + wraps a row's thrown value before it propagates, and this module's own + rejections are Errors. The fallback keeps a hostile value readable. */ + if (!(error instanceof Error)) return String(error) + if (!(error instanceof AggregateError)) return error.message + return [error.message, ...error.errors.map(cause => `- ${mountDetail(cause)}`)].join('\n') +} + +/** + * Mount `preset` under `agentCtx` and return only once every row is usable. + * + * The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and + * the caller receives no disposer. A rejection leaves nothing mounted. + * @param agentCtx - the agent's scope context, from the agent factory's `setup`. + * @param preset - the resolved preset to compose the agent from. + * @throws when `agentCtx` carries no scope, a row is unusable, or a row + * published a service into the root realm. + */ +export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise { + const scope = scopeOf(agentCtx) + if (scope === undefined) { + throw new Error( + `agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; ` + + 'its registrations would apply to every agent in the process', + ) + } + const config: Include.Config = { path: pathToFileURL(preset.path).href } + // Captured before the subtree exists: the standing scope context still + // carries the host composition's base, which is inside the installed + // harness and is therefore where a row's package name has to resolve from. + /* v8 ignore next -- the Loader sets `baseUrl` on the root before any scoped context derives from it */ + if (agentCtx.baseUrl !== undefined) harnessBase.set(config, agentCtx.baseUrl) + // Before the record this mount is about to add: standing mounts are one per + // preset and live until whole-tree teardown, so pruning here only sweeps + // records of torn-down runtimes (tests; an HMR reload of the roster). + pruneDisposedMounts() + const handle = agentCtx.plugin(PresetTree, config) + try { + await handle.await() + const subtree = mounted.get(config) + /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */ + if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree') + const { tree, fiber } = subtree + const unusable = inactiveRows(tree) + if (unusable.length > 0) { + throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`) + } + const leaked = leakedServices(agentCtx, fiber) + if (leaked.length > 0) { + throw new Error( + `row(s) published process-global service(s) [${leaked.join(', ')}]; ` + + 'a preset service must sit behind an `isolate` realm or move to the host composition', + ) + } + mounts.add({ presetId: preset.id, fiber, key: scopeOf(agentCtx) }) + } catch (error) { + try { + await handle.dispose() + /* v8 ignore next 5 -- teardown of a subtree nothing else references has no + observed failure mode; the guard exists so a teardown error cannot + replace the mount diagnostic the caller needs. */ + } catch { + // Swallows only this subtree's teardown failure. The mount error below is + // the actionable one, and the discarded fiber is unreachable either way. + } + throw new PresetMountError(preset.id, `${mountDetail(error)} (${preset.path})`, { cause: error }) + } +} diff --git a/packages/preset/agent-presets/src/session.ts b/packages/preset/agent-presets/src/session.ts new file mode 100644 index 0000000000..ae3edada27 --- /dev/null +++ b/packages/preset/agent-presets/src/session.ts @@ -0,0 +1,54 @@ +/** + * The session-log record of which preset a session actually runs. + * + * The creation header names the preset a session STARTED with, and it is + * deep-frozen because that is a creation fact. A session may still change + * preset while it is blank, and the effect of that change outlives the blank + * window: the first turn — and every turn after it — runs under the newly + * mounted composition. Recording the change is what keeps the log honest, and + * it is required outright by the repo's model-visible ⟺ logged rule, since the + * preset decides the tool schemas and prompt sections the model sees. + * + * Reconstruction reads {@link resolveSessionPreset}, never the header alone. + * @module @deepseek-ai/dsh-agent-presets/session + */ + +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ + 'agent-preset/selected': { agentPreset: string } + } +} + +/** The minimum a caller must supply to resolve a session's preset. */ +export interface PresetBearingSession { + /** The session's creation header. */ + readonly header: SessionHeader + /** The session's event log, oldest first. */ + readonly events: readonly SessionEvent[] +} + +/** + * The preset a session actually runs, newest selection winning. + * + * The header supplies the creation-time value; every later selection is a + * logged event, so the last one is the answer. Reading the header alone + * rebuilds a switched session under the composition it was created with, not + * the one its history was produced under. + * @param session - the session's header and event log. + * @returns the preset id, or `undefined` when the deployment composes none. + */ +export function resolveSessionPreset(session: PresetBearingSession): string | undefined { + for (let index = session.events.length - 1; index >= 0; index -= 1) { + const event = session.events[index] + if (event?.type === 'agent-preset/selected') return event.data.agentPreset + } + return session.header.agentPreset +} diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts new file mode 100644 index 0000000000..f600d5ca01 --- /dev/null +++ b/packages/preset/agent-presets/src/types.ts @@ -0,0 +1,88 @@ +/** Agent-preset vocabulary shared by discovery, mounting, and consumers. @module @deepseek-ai/dsh-agent-presets/types */ + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' + +/** + * Ids a preset directory may use. + * + * The id becomes a path segment, so this is a containment boundary rather than + * a style rule: `..`, a separator, or an absolute-looking name would place the + * composition outside the root the deployment authorised. Discovery shares it: + * a directory whose name no copy could ever claim is not a preset slot. + */ +export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ + +/** One preset directory that carries a mountable agent composition. */ +export interface AgentPreset { + /** Stable identifier; the preset directory's name. */ + readonly id: string + /** Trust recorded from the root this preset was discovered under. */ + readonly trust: PresetTrust + /** Absolute path of the preset's agent composition file. */ + readonly path: string + /** Display name from the preset's own metadata; absent falls back to {@link id}. */ + readonly name?: string + /** One sentence on what this preset is for, when it published one. */ + readonly description?: string + /** Declared position within its group; absent sorts after those that declare one. */ + readonly order?: number + /** + * Why this preset cannot compose a session, absent when it can. A broken + * preset stays on the roster — hiding it would leave its directory blocking + * the id with nothing to see or delete — but every mounting path refuses it + * up front with this reason instead of failing deep inside the loader. + */ + readonly broken?: string +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** + * No configured root supplies the requested preset. + * + * Separate from a mount failure because the two mean different things to a + * caller: an unknown id is a bad request, while an unusable composition is a + * broken preset the deployment must fix. + */ +export class UnknownPresetError extends Error { + constructor( + /** The id that was requested. */ + readonly presetId: string, + /** Ids the roster does supply, for the caller to offer instead. */ + readonly available: readonly string[], + ) { + super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) + } +} + +/** A preset exists but its composition cannot be installed. */ +export class PresetMountError extends Error { + constructor( + /** The preset whose composition failed. */ + readonly presetId: string, + /** Why it failed, without this package's own message prefix. */ + readonly reason: string, + options?: ErrorOptions, + ) { + super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) + } +} diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts new file mode 100644 index 0000000000..b71776c264 --- /dev/null +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -0,0 +1,293 @@ +/** + * Authoring a preset copies an existing one's directory into the deployment's + * `user` root — copy is the only authoring write, so no caller ever supplies + * composition text. The id is a directory name, so its pattern is a + * containment boundary rather than a style rule; the shipped `.system` set + * stays read-only. + */ + +import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { + COMPOSITION_FILE, copyComposition, METADATA_FILE, +} from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' + +let ctx: Context +let userRoot: string + +/** Hand-craft a preset directory (tests cannot author text through the service). */ +async function seedPreset( + root: string, id: string, options: { composition?: string; metadata?: string; extras?: Record } = {}, +): Promise { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), options.composition ?? VALID) + if (options.metadata !== undefined) { + await writeFile(join(root, id, METADATA_FILE), options.metadata) + } + for (const [name, content] of Object.entries(options.extras ?? {})) { + await mkdir(dirname(join(root, id, name)), { recursive: true }) + await writeFile(join(root, id, name), content) + } +} + +beforeEach(async () => { + userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')) + ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: userRoot, trust: 'user' as const }, + ], + }) +}) + +describe('copying a preset', () => { + it('copies a shipped preset into the user root and lists it', async () => { + await ctx.agentPresets.copy('standard', 'mine') + + expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8')) + .toBe(await ctx.agentPresets.read('standard')) + const listed = await ctx.agentPresets.list() + expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user') + }) + + it('copies the whole directory, execute bits kept and group/other stripped', async () => { + await seedPreset(userRoot, 'source', { + extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' }, + }) + await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + + await ctx.agentPresets.copy('source', 'mine') + + expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n') + // A preset may ship runnable helpers; the copy keeps them runnable for the + // owner while withdrawing the world-readability of the install. + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) + expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + }) + + it('keeps the source description but never its name or order', async () => { + await seedPreset(userRoot, 'source', { metadata: 'name: 源模式\ndescription: 只做检索。\norder: 1\n' }) + + await ctx.agentPresets.copy('source', 'mine') + + // Two rows presenting identically is how a roster stops being a chooser, + // and the shipped set's declared order is not the copy's to claim. + const metadata = await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8') + expect(metadata).toContain('description: 只做检索。') + expect(metadata).not.toContain('name:') + expect(metadata).not.toContain('order:') + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')) + .toMatchObject({ description: '只做检索。' }) + }) + + it('stores the display name the author supplied', async () => { + await ctx.agentPresets.copy('standard', 'mine', '我的模式') + + expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8')).toContain('name: 我的模式') + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')) + .toMatchObject({ name: '我的模式' }) + }) + + it('publishes no metadata file when there is nothing to publish', async () => { + await seedPreset(userRoot, 'source') + + await ctx.agentPresets.copy('source', 'mine') + + // An empty metadata document would read as an intentional blank name; + // absence is what "this preset publishes no display text" looks like. + expect(existsSync(join(userRoot, 'mine', METADATA_FILE))).toBe(false) + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')?.name).toBeUndefined() + }) + + it('refuses an id that could escape the preset root', async () => { + for (const id of ['../escape', 'a/b', '/abs', '..', 'Upper']) { + await expect(ctx.agentPresets.copy('standard', id)).rejects.toThrow(/must match/) + } + // Nothing was created for any of them. + expect(existsSync(join(userRoot, 'escape'))).toBe(false) + }) + + it('refuses an id the roster already supplies, shipped ones included', async () => { + await ctx.agentPresets.copy('standard', 'mine') + + await expect(ctx.agentPresets.copy('standard', 'mine')).rejects.toThrow(/already exists/) + // A user directory named like a shipped preset would be shadowed by it. + await expect(ctx.agentPresets.copy('standard', 'minimal')).rejects.toThrow(/already exists/) + }) + + it('refuses a directory that occupies the name without being a preset', async () => { + await mkdir(join(userRoot, 'occupied'), { recursive: true }) + await writeFile(join(userRoot, 'occupied', 'README.txt'), 'nope\n') + + // Discovery does not list it (no composition file), so only the disk + // check can refuse it with a readable error instead of a filesystem code. + await expect(ctx.agentPresets.copy('standard', 'occupied')).rejects.toThrow(/already exists/) + expect(await readFile(join(userRoot, 'occupied', 'README.txt'), 'utf8')).toBe('nope\n') + }) + + it('reports an unknown source rather than creating anything', async () => { + await expect(ctx.agentPresets.copy('never-existed', 'mine')).rejects.toThrow(/not found/) + expect(existsSync(join(userRoot, 'mine'))).toBe(false) + }) + + it('leaves nothing behind when the copy itself fails', async () => { + const source = { + id: 'gone', + trust: 'user' as const, + path: join(userRoot, 'gone', COMPOSITION_FILE), + } + + // The source vanished between resolve and copy: the half-made target is + // rolled back rather than left invisible to discovery. + await expect(copyComposition( + [{ path: userRoot, trust: 'user' as const }], source, 'mine', + )).rejects.toThrow() + expect(existsSync(join(userRoot, 'mine'))).toBe(false) + }) +}) + +describe('deleting a preset', () => { + it('removes a locally authored one', async () => { + await ctx.agentPresets.copy('standard', 'mine') + + await ctx.agentPresets.remove('mine') + + expect(existsSync(join(userRoot, 'mine'))).toBe(false) + expect((await ctx.agentPresets.list()).some(preset => preset.id === 'mine')).toBe(false) + }) + + it('refuses to delete a shipped one', async () => { + await expect(ctx.agentPresets.remove('standard')) + .rejects.toThrow(/ships with the deployment/) + }) + + it('reports an unknown id rather than silently succeeding', async () => { + await expect(ctx.agentPresets.remove('never-existed')).rejects.toThrow(/not found/) + }) +}) + +describe('a deployment with more than one user root', () => { + it('refuses to delete a preset the writable root does not own', async () => { + const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-')) + await seedPreset(second, 'elsewhere') + const layered = new Context() + layered.baseUrl = pathToFileURL(FIXTURES).href + '/' + await layered.plugin(Loader) + layered.loader.builtins.include = Include + await layered.plugin(AgentPresets, { + default: 'standard', + roots: [ + { path: userRoot, trust: 'user' as const }, + { path: second, trust: 'user' as const }, + ], + }) + + // Writes go to the first user root, so a preset discovered from a later + // one is `user` trust yet outside what deletion is allowed to touch — + // `rm -r` on a directory this root does not own is the failure to avoid. + await expect(layered.agentPresets.remove('elsewhere')) + .rejects.toThrow(/does not live under the writable preset root/) + expect(existsSync(join(second, 'elsewhere'))).toBe(true) + }) +}) + +describe('a deployment with no writable root', () => { + it('says authoring is unavailable rather than guessing a directory', async () => { + const readOnly = new Context() + readOnly.baseUrl = pathToFileURL(FIXTURES).href + '/' + await readOnly.plugin(Loader) + readOnly.loader.builtins.include = Include + await readOnly.plugin(AgentPresets, { + default: 'standard', + roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + }) + + expect(readOnly.agentPresets.authorable).toBe(false) + await expect(readOnly.agentPresets.copy('standard', 'mine')) + .rejects.toThrow(/no user-writable preset root/) + }) +}) + +describe('a user root that does not exist yet', () => { + it('is created by the first copy', async () => { + const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset') + const fresh = new Context() + fresh.baseUrl = pathToFileURL(FIXTURES).href + '/' + await fresh.plugin(Loader) + fresh.loader.builtins.include = Include + await fresh.plugin(AgentPresets, { + default: 'standard', + roots: [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: absent, trust: 'user' as const }, + ], + }) + + await fresh.agentPresets.copy('standard', 'mine') + + expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8')) + .toBe(await fresh.agentPresets.read('standard')) + }) +}) + +describe('display metadata beside a composition', () => { + it('keeps a composition mountable when its metadata is unreadable', async () => { + await ctx.agentPresets.copy('standard', 'mine') + await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n') + + // Presentation is not capability: discovery still yields the preset. + const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine') + expect(listed?.name).toBeUndefined() + expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' }) + }) +}) + +describe('the on-disk occupancy backstop', () => { + it('refuses a directory the roster cannot see', async () => { + // The service's roster check sees every id-shaped directory now, so this + // is the race backstop: a directory appearing between the roster read and + // the copy still gets the readable refusal, not a filesystem error code. + await mkdir(join(userRoot, 'raced'), { recursive: true }) + const source = await ctx.agentPresets.resolve('standard') + + await expect(copyComposition( + [{ path: userRoot, trust: 'user' as const }], source, 'raced', + )).rejects.toThrow(/already exists/) + }) +}) + +describe('a ghost directory under the user root', () => { + it('lists broken, blocks its id, and clears through remove', async () => { + // The classic hand-edit: the composition file was deleted, the directory + // stayed. It must not vanish from the roster — its id is still taken, so + // there has to be something to see and delete. + await mkdir(join(userRoot, 'ghost'), { recursive: true }) + await writeFile(join(userRoot, 'ghost', 'README.txt'), 'composition deleted by hand\n') + + const ghost = (await ctx.agentPresets.list()).find(preset => preset.id === 'ghost') + expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/) + await expect(ctx.agentPresets.copy('standard', 'ghost')).rejects.toThrow(/already exists/) + + // remove is the way out the roster row offers; the id is claimable again. + await ctx.agentPresets.remove('ghost') + expect(existsSync(join(userRoot, 'ghost'))).toBe(false) + await ctx.agentPresets.copy('standard', 'ghost') + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')?.broken).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts new file mode 100644 index 0000000000..55845c2251 --- /dev/null +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -0,0 +1,197 @@ +import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } +const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } + +describe('display order', () => { + it('puts declared order first, then everything else by id', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) + for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') + await writeFile(join(root, id, 'preset.yml'), `order: ${String(order)}\n`) + } + for (const id of ['bravo', 'yankee']) { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') + } + + const found = await scanRoot({ path: root, trust: 'system' }) + + // The shipped set reads by capability; presets that declare nothing stay + // alphabetical behind them rather than interleaving unpredictably. + expect(found.map(preset => preset.id)).toEqual(['zulu', 'alpha', 'bravo', 'yankee']) + }) + + it('breaks a tie between equal declared orders by id', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-')) + for (const id of ['yankee', 'alpha']) { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') + await writeFile(join(root, id, 'preset.yml'), 'order: 1\n') + } + + const found = await scanRoot({ path: root, trust: 'system' }) + + // Two presets claiming the same slot must still list in a stable order: + // a directory-scan order would reshuffle the picker between reads. + expect(found.map(preset => preset.id)).toEqual(['alpha', 'yankee']) + }) +}) + +describe('preset discovery', () => { + it('reports one preset per directory holding a composition, ordered by id', async () => { + const found = await scanRoot(SYSTEM) + + expect(found.map(preset => preset.id)).toEqual(['minimal', 'standard']) + expect(found[0]).toEqual({ + id: 'minimal', + trust: 'system', + path: join(SYSTEM.path, 'minimal', COMPOSITION_FILE), + }) + }) + + it('reports a directory with no composition as a broken preset slot', async () => { + const found = await scanRoot(USER) + + // The directory still occupies its id — a copy to that name is refused — + // so hiding it would leave nothing to see or delete. It surfaces broken. + const ghost = found.find(preset => preset.id === 'not-a-preset') + expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/) + }) + + it('skips a directory whose name no preset id could ever claim', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-')) + await mkdir(join(root, '.hidden')) + await mkdir(join(root, 'Has_Caps')) + await mkdir(join(root, 'usable')) + await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n') + + const found = await scanRoot({ path: root, trust: 'user' }) + + // `.hidden` and `Has_Caps` cannot collide with any copy target, so + // reporting tool residue as broken presets would only train users to + // ignore the marker. + expect(found.map(preset => preset.id)).toEqual(['usable']) + }) + + it('records the root trust on every preset it discovers', async () => { + const found = await scanRoot(USER) + + expect(found.every(preset => preset.trust === 'user')).toBe(true) + }) + + it('lets the earlier root win a duplicate id', async () => { + const found = await discoverPresets([SYSTEM, USER]) + + const standard = found.filter(preset => preset.id === 'standard') + expect(standard).toHaveLength(1) + expect(standard[0]?.trust).toBe('system') + }) + + it('treats an absent root as supplying no presets', async () => { + const found = await scanRoot({ path: join(FIXTURES, 'no-such-root'), trust: 'user' }) + + expect(found).toEqual([]) + }) + + it('ignores a plain file sitting beside the preset directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + await writeFile(join(root, 'stray.yml'), '- id: x\n') + await mkdir(join(root, 'real')) + await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n') + + const found = await scanRoot({ path: root, trust: 'user' }) + + expect(found.map(preset => preset.id)).toEqual(['real']) + }) + + it('reports a root it cannot read rather than treating it as empty', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + const notADirectory = join(root, 'file-as-root') + await writeFile(notADirectory, 'not a directory\n') + + await expect(scanRoot({ path: notADirectory, trust: 'user' })) + .rejects.toThrow(/cannot read preset root/) + }) + + it('expands a leading tilde in a root path', async () => { + // `~` alone resolves to the home directory, which exists but holds no + // preset directories; the point is that it did not throw on a literal `~`. + const found = await scanRoot({ path: '~/.dsh-agent-presets-absent', trust: 'user' }) + + expect(found).toEqual([]) + }) +}) + +describe('composition health', () => { + /** One directory under a fresh root holding `composition`, scanned. */ + async function scanned(composition: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-')) + await mkdir(join(root, 'probe')) + await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) + const [preset] = await scanRoot({ path: root, trust: 'user' }) + return preset?.broken + } + + it('reports unparsable YAML with the parser\'s reason', async () => { + expect(await scanned('- id: x\n name: [unclosed\n')).toMatch(/not valid YAML/) + }) + + it('reports a composition that is not a list of rows', async () => { + expect(await scanned('name: not-a-list\n')).toMatch(/top-level list of plugin rows/) + }) + + it('reports the first row that names no plugin, by position', async () => { + expect(await scanned('- id: ok\n name: some-plugin\n- id: broken\n')) + .toMatch(/row 2 names no plugin/) + }) + + it('reports a row that is not a map at all', async () => { + expect(await scanned('- just-a-string\n')).toMatch(/row 1 is not a plugin row/) + }) + + it('descends into a group\'s own row list', async () => { + const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n' + expect(await scanned(composition)).toMatch(/row 1 row 1 names no plugin/) + }) + + it('reports a group whose config is not a list', async () => { + const composition = '- id: grp\n name: cordis:group\n group: true\n config: not-a-list\n' + expect(await scanned(composition)).toMatch(/group row 1 must hold a list/) + }) + + it('accepts a group whose own list is healthy', async () => { + const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n name: some-plugin\n' + expect(await scanned(composition)).toBeUndefined() + }) + + it('reports a composition that stats but cannot be read', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-')) + await mkdir(join(root, 'sealed')) + const path = join(root, 'sealed', COMPOSITION_FILE) + await writeFile(path, '[]\n') + await chmod(path, 0o000) + + const [preset] = await scanRoot({ path: root, trust: 'user' }) + + expect(preset?.broken).toMatch(/cannot be read/) + }) + + it('accepts the loader dialect, !!js scalars included', async () => { + // Health must never call a composition broken that the loader accepts: + // `!!js` is the loader's own extension, so it parses here too. + const composition = '- id: x\n name: some-plugin\n config:\n value: !!js "1 + 1"\n' + expect(await scanned(composition)).toBeUndefined() + }) + + it('accepts an empty list', async () => { + expect(await scanned('[]\n')).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js b/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js new file mode 100644 index 0000000000..b7b67be5d6 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js @@ -0,0 +1,20 @@ +// A preset row: registers one tool and one prompt section, both named from +// config. Import-free on purpose — the Loader resolves entry modules through +// Node's ESM resolver, which cannot see this workspace's TypeScript sources. +export const name = 'contribute' +export const inject = ['tools', 'systemPrompt'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) + ctx.effect(() => ctx.systemPrompt.section({ + name: `preset:${config.tool}`, + order: 10, + text: `section for ${config.tool}`, + })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js b/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js new file mode 100644 index 0000000000..b30e37356b --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js @@ -0,0 +1,5 @@ +// Publishes a service with no `isolate` realm, so it lands in the ROOT realm. +export const name = 'global-service' +export function apply(ctx, config) { + ctx.effect(() => ctx.reflect.provide(config.service, { label: config.label })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js b/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js new file mode 100644 index 0000000000..d0381a9979 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js @@ -0,0 +1,6 @@ +// Publishes into the ROOT realm only after its plugin body returned, escaping +// the one-shot mount audit. Exercises the package invariant. +export const name = 'late-service' +export function apply(ctx, config) { + globalThis.__PUBLISH_LATE__ = () => ctx.effect(() => ctx.reflect.provide(config.service, { label: 'late' })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js b/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js new file mode 100644 index 0000000000..b4f732eeac --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js @@ -0,0 +1,5 @@ +// Waits forever for a service the composition never supplies: the row stays +// pending rather than failing, which only the mount audit can catch. +export const name = 'needs-missing' +export const inject = ['serviceThatDoesNotExist'] +export function apply() {} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js b/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js new file mode 100644 index 0000000000..97c01f95a4 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js @@ -0,0 +1,9 @@ +// Disposes itself once active. The Loader treats a self-disposing entry as a +// config change and writes the tree back through `EntryTree.write()`, which is +// the exact path that once truncated a preset file to `[]`. +export const name = 'self-dispose' +export function apply(ctx) { + globalThis.__SELF_DISPOSED__ = new Promise((resolve) => { + setTimeout(() => { ctx.fiber.dispose(); resolve(undefined) }, 0) + }) +} diff --git a/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml new file mode 100644 index 0000000000..ebd0a74c33 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml @@ -0,0 +1,4 @@ +- id: beta + name: ../../plugins/contribute.js + config: + tool: beta diff --git a/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml new file mode 100644 index 0000000000..9a434aec6e --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml @@ -0,0 +1,12 @@ +# Shipped preset: one tool plus its guidance section. +- id: alpha + name: ../../plugins/contribute.js + config: + tool: alpha + +# A row switched off in the composition stays off without failing the mount. +- id: alpha-extra + name: ../../plugins/contribute.js + disabled: true + config: + tool: alpha-extra diff --git a/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml new file mode 100644 index 0000000000..ae9baeee11 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml @@ -0,0 +1,6 @@ +- id: ok + name: ../../plugins/contribute.js + config: + tool: ok +- id: missing + name: ../../plugins/does-not-exist.js diff --git a/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml new file mode 100644 index 0000000000..ccb3a9037c --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml @@ -0,0 +1,9 @@ +# Accepted: the same provider behind an entry-local realm never reaches the +# root realm, so it is per-session rather than process-global. +- id: svc + name: ../../plugins/global-service.js + isolate: + fixtureIsolatedSvc: true + config: + service: fixtureIsolatedSvc + label: ISOLATED diff --git a/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml new file mode 100644 index 0000000000..895268e67b --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml @@ -0,0 +1,6 @@ +# Publishes into the root realm only after the mount audit ran, which only the +# package invariant can catch. +- id: late + name: ../../plugins/late-service.js + config: + service: fixtureLateSvc diff --git a/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml new file mode 100644 index 0000000000..f95329ce46 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml @@ -0,0 +1,13 @@ +# Rejected: publishes services into the root realm, which would be +# process-global rather than per-session. Two rows, so the diagnostic has to +# order the names it reports. +- id: leak-z + name: ../../plugins/global-service.js + config: + service: zzzFixtureLeakedSvc + label: LEAKED-Z +- id: leak-a + name: ../../plugins/global-service.js + config: + service: aaaFixtureLeakedSvc + label: LEAKED-A diff --git a/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt b/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt new file mode 100644 index 0000000000..b4a2550351 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt @@ -0,0 +1 @@ +placeholder, not a preset diff --git a/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml new file mode 100644 index 0000000000..67f7ffb09a --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml @@ -0,0 +1,2 @@ +- id: waits + name: ../../plugins/needs-missing.js diff --git a/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml new file mode 100644 index 0000000000..4cfbbcb20c --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml @@ -0,0 +1,5 @@ +# Same id as the shipped preset: proves the earlier root wins. +- id: shadowed + name: ../../plugins/contribute.js + config: + tool: shadowed diff --git a/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml new file mode 100644 index 0000000000..1533565f58 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml @@ -0,0 +1,7 @@ +# Two rows that cannot load: the Loader reports several failed entries as one +# AggregateError whose own message names none of them, so this fixture is what +# proves the mount diagnostic flattens the causes. +- id: first-missing + name: ../../plugins/does-not-exist.js +- id: second-missing + name: ../../plugins/also-missing.js diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts new file mode 100644 index 0000000000..17d89813c3 --- /dev/null +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -0,0 +1,87 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +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 AgentLoop from '@deepseek-ai/dsh-agent-loop' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { describe, expect, it } from 'vitest' +import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: join(FIXTURES, 'user'), trust: 'user' as const }, +] + +async function harness(): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentPresetsInvariant) + return ctx +} + +describe('agent-presets invariants', () => { + it('keeps the standing composition alive across the agents that joined it', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + sessionId: SessionId('inv-live'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') + + // A standing mount survives its agents: the composition a session joined + // is shared, so one session ending must not strip it from the next. + await handle.dispose() + expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') + + // A second agent reuses the same mount rather than adding one. + await ctx.agents.create({ + sessionId: SessionId('inv-live-2'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(1) + + // Whole-tree teardown is the boundary that does reclaim it. + await ctx.fiber.dispose() + expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard') + }) + + it('rejects a composition that publishes a process-global service after its audit', async () => { + const ctx = await harness() + await ctx.agents.create({ + sessionId: SessionId('inv-late'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'late'), + }) + const publishLate = (globalThis as { __PUBLISH_LATE__?: () => void }).__PUBLISH_LATE__ + expect(publishLate).toBeTypeOf('function') + + expect(() => { publishLate?.() }).toThrow(/published process-global service\(s\) \[fixtureLateSvc\]/) + }) + + it('stays quiet while every composition keeps its services out of the root realm', async () => { + const ctx = await harness() + + await expect(ctx.agents.create({ + sessionId: SessionId('inv-isolated'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'), + })).resolves.toBeDefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts new file mode 100644 index 0000000000..8bdabe1c59 --- /dev/null +++ b/packages/preset/agent-presets/tests/metadata.spec.ts @@ -0,0 +1,114 @@ +/** + * Display metadata is presentation, never capability: every way of getting it + * wrong degrades to "this preset has no display text" rather than to a + * preset that cannot be discovered or mounted. It also cannot carry identity + * — `id` is the directory and `trust` is the root, so neither is readable + * from the file a user can write. + */ + +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts' + +/** A preset directory holding exactly the given metadata text. */ +async function presetDir(content?: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-')) + await mkdir(dir, { recursive: true }) + if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content) + return dir +} + +describe('reading display metadata', () => { + it('reads a name and a description', async () => { + const dir = await presetDir('name: 标准模式\ndescription: 完整的编码 agent。\n') + + expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', description: '完整的编码 agent。' }) + }) + + it('treats an absent file as no metadata', async () => { + // The common case: every preset authored by duplicating another starts + // without one, and a picker simply falls back to the id. + expect(await readPresetMetadata(await presetDir())).toEqual({}) + }) + + it('treats malformed YAML as no metadata', async () => { + const dir = await presetDir('name: [unclosed\n') + + // Display text is not worth failing discovery over — the composition + // beside it still mounts. + expect(await readPresetMetadata(dir)).toEqual({}) + }) + + it.each([ + ['a list', '- name: x\n'], + ['a scalar', 'just a string\n'], + ['an empty document', ''], + ])('treats %s as no metadata', async (_label, content) => { + expect(await readPresetMetadata(await presetDir(content))).toEqual({}) + }) + + it('ignores fields that are not text', async () => { + const dir = await presetDir('name: 42\ndescription:\n nested: true\n') + + expect(await readPresetMetadata(dir)).toEqual({}) + }) + + it('ignores blank text rather than showing an empty name', async () => { + const dir = await presetDir('name: " "\ndescription: ""\n') + + expect(await readPresetMetadata(dir)).toEqual({}) + }) + + it('trims surrounding whitespace', async () => { + const dir = await presetDir('name: " 极简模式 "\n') + + expect(await readPresetMetadata(dir)).toEqual({ name: '极简模式' }) + }) + + it('reads a declared order', async () => { + const dir = await presetDir('name: 标准模式\norder: 1\n') + + expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', order: 1 }) + }) + + it('ignores an order that is not a finite number', async () => { + expect(await readPresetMetadata(await presetDir('order: first\n'))).toEqual({}) + expect(await readPresetMetadata(await presetDir('order: .inf\n'))).toEqual({}) + }) + + it('cannot carry identity or trust', async () => { + const dir = await presetDir('name: mine\nid: standard\ntrust: system\n') + + // A locally authored preset writing `trust: system` must not become a + // shipped one; identity comes from the directory and the root it sits in. + expect(await readPresetMetadata(dir)).toEqual({ name: 'mine' }) + }) +}) + +describe('rendering display metadata', () => { + it('round-trips through a read', async () => { + const rendered = renderPresetMetadata({ name: '创造模式', description: '可以改自己的组装。' }) + const dir = await presetDir(rendered) + + expect(await readPresetMetadata(dir)).toEqual({ name: '创造模式', description: '可以改自己的组装。' }) + }) + + it('stores a declared order', () => { + expect(renderPresetMetadata({ name: '标准模式', order: 1 })).toBe('name: 标准模式\norder: 1\n') + }) + + it('omits an absent field rather than writing it blank', () => { + expect(renderPresetMetadata({ name: '极简模式' })).toBe('name: 极简模式\n') + // Description without a name is legal too: the picker falls back to the id. + expect(renderPresetMetadata({ description: '只做检索。' })).toBe('description: 只做检索。\n') + }) + + it('renders nothing when there is nothing to store', () => { + // Clearing both fields removes the file; an empty document would read as + // an intentional blank name. + expect(renderPresetMetadata({})).toBeUndefined() + expect(renderPresetMetadata({ name: ' ', description: '' })).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts new file mode 100644 index 0000000000..b4988331cc --- /dev/null +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -0,0 +1,574 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +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, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { + COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, +} from '@deepseek-ai/dsh-agent-presets' +import type { Config } from '@deepseek-ai/dsh-agent-presets' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' + +declare module 'cordis' { + interface Context { + /** Published by the `isolated` fixture preset behind an entry-local realm. */ + fixtureIsolatedSvc: { label: string } + } +} + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: join(FIXTURES, 'user'), trust: 'user' as const }, +] + +/** + * A composition carrying the registries a preset contributes to, plus the + * preset roster. + * @param roster - roster config, defaulting to the fixture roots. + * @returns the booted context. + */ +async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, roster) + return ctx +} + +/** Create one agent composed from `presetId`, exactly as a factory `setup` would. */ +async function agentOn(ctx: Context, id: string, presetId?: string): Promise { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId), + }) + return handle.agent +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +/** Every service registration in the runtime, regardless of which realm holds it. */ +function providedServiceNames(ctx: Context): string[] { + const store = ctx.reflect.store + return Object.getOwnPropertySymbols(store) + .map(key => store[key]?.name) + .filter((name): name is string => name !== undefined) +} + +/** Whether the root realm maps `name` to a live registration. */ +function rootResolves(ctx: Context, name: string): boolean { + const key = ctx.root[Context.isolate][name] + return key !== undefined && ctx.reflect.store[key] !== undefined +} + +let ctx: Context +beforeEach(async () => { + ctx = await harness() +}) + +describe('composing an agent from a preset', () => { + it('gives each session only its own preset\'s tools', async () => { + const alpha = await agentOn(ctx, 'sess-alpha', 'standard') + const beta = await agentOn(ctx, 'sess-beta', 'minimal') + + expect(toolNames(ctx, alpha)).toEqual(['alpha']) + expect(toolNames(ctx, beta)).toEqual(['beta']) + expect(toolNames(ctx)).toEqual([]) + }) + + it('scopes prompt sections and assembled schemas to the same session', async () => { + const alpha = await agentOn(ctx, 'sess-alpha', 'standard') + const beta = await agentOn(ctx, 'sess-beta', 'minimal') + + const alphaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(alpha)) + const betaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(beta)) + + expect(alphaPrompt.sections.map(section => section.name)).toContain('preset:alpha') + expect(alphaPrompt.sections.map(section => section.name)).not.toContain('preset:beta') + expect(betaPrompt.sections.map(section => section.name)).toContain('preset:beta') + expect(alphaPrompt.tools.map(schema => schema.name)).toEqual(['alpha']) + }) + + it('mounts the default preset when the caller names none', async () => { + const agent = await agentOn(ctx, 'sess-default') + + expect(toolNames(ctx, agent)).toEqual(['alpha']) + }) + + it('lets two sessions share one preset without colliding', async () => { + const first = await agentOn(ctx, 'sess-first', 'standard') + const second = await agentOn(ctx, 'sess-second', 'standard') + + expect(toolNames(ctx, first)).toEqual(['alpha']) + expect(toolNames(ctx, second)).toEqual(['alpha']) + }) + + it('unwinds one session\'s composition without touching another\'s', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-gone'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const survivor = await agentOn(ctx, 'sess-stays', 'minimal') + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + + await handle.dispose() + + expect(ctx.agents.get(SessionId('sess-gone'))).toBeUndefined() + expect(toolNames(ctx, survivor)).toEqual(['beta']) + expect(toolNames(ctx)).toEqual([]) + }) +}) + +describe('rejecting a composition that cannot be used', () => { + it('refuses to mount into a context that carries no agent scope', async () => { + await expect(ctx.agentPresets.mount(ctx, 'standard')) + .rejects.toThrow(/unscoped context/) + }) + + it('rolls the whole agent back when a row fails to load', async () => { + await expect(agentOn(ctx, 'sess-broken', 'broken')).rejects.toThrow(/failed to mount/) + + expect(ctx.agents.get(SessionId('sess-broken'))).toBeUndefined() + expect(toolNames(ctx)).toEqual([]) + }) + + it('names every failed row, not just the count', async () => { + // The Loader folds several failed rows into one AggregateError whose own + // message names none of them; unflattened, the operator is told only that + // "loader entries failed to apply" and has nothing to act on. + await expect(agentOn(ctx, 'sess-two-broken', 'two-broken')) + .rejects.toThrow(/first-missing[\s\S]*second-missing/) + }) + + it('names the unresolved service when a row never activates', async () => { + await expect(agentOn(ctx, 'sess-pending', 'pending')) + .rejects.toThrow(/waiting for serviceThatDoesNotExist/) + }) + + it('rejects a row that publishes a process-global service', async () => { + await expect(agentOn(ctx, 'sess-leaky', 'leaky')) + .rejects.toThrow(/process-global service\(s\) \[aaaFixtureLeakedSvc, zzzFixtureLeakedSvc\]/) + + // The rejected subtree is fully unwound, so its registrations are gone from + // the store rather than merely unreachable. + expect(providedServiceNames(ctx)).not.toContain('aaaFixtureLeakedSvc') + expect(providedServiceNames(ctx)).not.toContain('zzzFixtureLeakedSvc') + }) + + it('accepts the same provider behind an isolate realm', async () => { + const agent = await agentOn(ctx, 'sess-isolated', 'isolated') + + expect(agent.id).toBe(SessionId('sess-isolated')) + // The provider ran, but under a realm-private symbol the root cannot reach. + expect(providedServiceNames(ctx)).toContain('fixtureIsolatedSvc') + expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) + }) + + it('addresses the standing instance of a realm-private service through either agent', async () => { + const first = await agentOn(ctx, 'sess-reach-a', 'isolated') + const second = await agentOn(ctx, 'sess-reach-b', 'isolated') + + // The realm keeps the service out of every host context, so a caller + // holding the agent is how a request from OUTSIDE the session reads the + // instance it is about. + expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) + const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc') + const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc') + expect(mine).toBeDefined() + // ONE composition per preset: both agents joined the same standing mount, + // so they address the same instance — sessions stay apart inside it by + // the plugin's own Session/Agent keying, not by instance count. + expect(theirs).toBe(mine) + }) + + it('answers undefined for a service the agent\'s preset does not mount', async () => { + // The isolated preset's standing instance exists in the same runtime, so + // the lookup finds the NAME and must still refuse it: the instance lives + // under another mount's fiber, not this agent's composition. + await agentOn(ctx, 'sess-reach-other', 'isolated') + const agent = await agentOn(ctx, 'sess-reach-none', 'standard') + + expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined() + }) + + it('answers undefined for an agent outside the scope machinery', async () => { + // Unscoped, scoped-but-unparented, and parented to a key no live mount + // owns are the three ways a context can fail to name a standing mount; + // each is an answer, not a throw, because the caller asked a question. + expect(serviceForAgent(ctx, { ctx }, 'fixtureIsolatedSvc')).toBeUndefined() + const loner = createScope(ctx, { test: 'loner' }) + expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() + const orphan = createScope(ctx, { test: 'orphan' }) + bindScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' }) + expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() + }) + + it('refuses to mount a preset directly into an unscoped context', async () => { + // The service's own mount() guards this before delegating; the exported + // function is callable on its own, so the boundary holds there too. + const preset = await ctx.agentPresets.resolve('standard') + + await expect(mountPreset(ctx, preset)).rejects.toThrow(/unscoped context/) + }) + + it('reports the known ids when a preset is unknown', async () => { + await expect(ctx.agentPresets.resolve('nope')) + .rejects.toThrow(/preset "nope" not found \(available: .*standard/) + }) +}) + +describe('the preset roster', () => { + it('lists every root\'s presets with the earlier root winning', async () => { + const listed = await ctx.agentPresets.list() + + // `not-a-preset` is the fixture ghost: no composition file, listed broken. + expect(listed.map(preset => preset.id).sort()) + .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'not-a-preset', 'pending', 'standard', 'two-broken']) + expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system') + expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/) + }) + + it('exposes the configured default id', () => { + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) + +describe('composing from a broken preset', () => { + /** A roster whose only user preset carries `composition`. */ + async function rosterWith(composition: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) + await mkdir(join(root, 'damaged')) + await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] }) + } + + it('refuses the mount up front with the discovery-reported reason', async () => { + const scoped = await rosterWith('- id: x\n name: [unclosed\n') + + // The refusal happens before the loader ever sees the file, so every + // unloadable shape gets the same early PresetMountError — and a rejected + // setup rolls the whole agent creation back. + await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError) + await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/) + expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0) + }) + + it('refuses the standing key a cold reader would mount by', async () => { + const scoped = await rosterWith('rows: not-a-list\n') + + await expect(scoped.agentPresets.standingKeyFor('damaged')) + .rejects.toThrow(/top-level list of plugin rows/) + }) + + it('still resolves the broken row for the surfaces that manage it', async () => { + const scoped = await rosterWith('- id: x\n name: [unclosed\n') + + // Deleting and reporting need the row; only composing refuses it. + expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/) + }) +}) + +describe('a roster with nothing in it', () => { + it('says so instead of naming an empty list of candidates', async () => { + const bare = new Context() + await bare.plugin(Loader) + await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + + await expect(bare.agentPresets.resolve()) + .rejects.toThrow(/preset "standard" not found \(available: none\)/) + }) +}) + +describe('the preset file is an input, never a persistence target', () => { + it('survives a row that disposes itself, which makes the Loader persist a tree', async () => { + // The preset lives in a temp root, not under `fixtures/`: without the + // `write()` override the Loader REWRITES the composition it read, so a + // committed fixture would be mutated by the very run that proves the bug + // and every later run would compare against the damaged file and pass. + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-')) + const dir = join(root, 'self-disposing') + await mkdir(dir) + const path = join(dir, COMPOSITION_FILE) + const composition = [ + '- id: tool-kept', + ` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`, + ' config:', + ' tool: kept', + '- id: goes-away', + ` name: ${join(FIXTURES, 'plugins', 'self-dispose.js')}`, + '', + ].join('\n') + await writeFile(path, composition) + + const scoped = new Context() + scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' + await scoped.plugin(Loader) + scoped.loader.builtins.include = Include + await scoped.plugin(LlmService) + await scoped.plugin(SessionStore) + await scoped.plugin(SystemPrompt, { persona: '' }) + await scoped.plugin(ToolRegistry) + await scoped.plugin(AgentRegistry) + await scoped.plugin(AgentLoop, { agents: [] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + + await scoped.agents.create({ + sessionId: SessionId('sess-self-dispose'), + setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx), + }) + await (globalThis as { __SELF_DISPOSED__?: Promise }).__SELF_DISPOSED__ + // Slack past the deterministic signal above, not a race the number has to + // win. The write rides the Loader's fiber-unload listener, which stamps + // `disabled: true` and calls `write()` in the same synchronous step; once + // the self-dispose has settled, a regression has already written. Polling + // would not help — the assertion is an ABSENCE, and no amount of waiting + // proves one — so the wait only has to clear settlement. + await new Promise(resolve => setTimeout(resolve, 50)) + + // Inherited, `EntryTree.write()` persists the dying tree — stamping + // `disabled: true` onto the row and, in the shipped case, truncating the + // composition every session shares. + expect(await readFile(path, 'utf8')).toBe(composition) + }) +}) + +describe('attributing a service to a subtree', () => { + it('attributes nothing to a subtree that is already torn down', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-torn'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const [mount] = livePresetMounts().filter(entry => entry.presetId === 'standard') + expect(mount).toBeDefined() + + await handle.dispose() + + // A disposed subtree owns nothing, so it can never be blamed for a service + // some other subtree published under the same name afterwards. + expect(leakedServices(ctx, mount!.fiber)).toEqual([]) + }) +}) + +describe('replacing a composition', () => { + it('swaps the agent\'s tools without touching another session', async () => { + const keeper = await agentOn(ctx, 'sess-keeper', 'standard') + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-swap'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + expect(toolNames(ctx, keeper)).toEqual(['alpha']) + expect(toolNames(ctx)).toEqual([]) + }) + + it('leaves the agent on its previous composition when the new one is unknown', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-unknown'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'nope')) + .rejects.toThrow(/not found/) + + // Resolution happens before any teardown, so an unknown id is a no-op. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('restores the previous composition when the new one fails to mount', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-restore'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // The swap is unmount-then-mount, so a failure must put the old one back + // rather than leave the agent with no tools at all. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + 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 + // roster's kept binding can move it again. + const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') }) + + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + }) + + it('refuses a bare agent\'s broken composition without restoring anything', async () => { + const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // Nothing was installed, so there is nothing to put back. + expect(toolNames(ctx, handle.agent)).toEqual([]) + }) + + it('keeps the agent on its standing composition when a switch fails, even with the source deleted', async () => { + // A preset root this test owns, so removing the composition mid-flight + // cannot disturb the shipped fixtures. + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-')) + const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', '- id: nope\n name: ./does-not-exist.js\n']] + for (const [id, body] of seeded) { + await mkdir(join(root, id)) + await writeFile(join(root, id, COMPOSITION_FILE), body) + } + const scoped = new Context() + scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' + await scoped.plugin(Loader) + scoped.loader.builtins.include = Include + await scoped.plugin(LlmService) + await scoped.plugin(SessionStore) + await scoped.plugin(SystemPrompt, { persona: '' }) + await scoped.plugin(ToolRegistry) + await scoped.plugin(AgentRegistry) + await scoped.plugin(AgentLoop, { agents: [] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + const handle = await scoped.agents.create({ + sessionId: SessionId('sess-restore-gone'), + setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), + }) + + // The roster is a live directory: the composition the agent came from can + // be gone from DISK by the time a switch fails. The standing mount is not + // the file — it outlives deletion, so there is nothing to "restore". + await rm(join(root, 'first'), { recursive: true }) + + await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // The failed switch left the agent EXACTLY as it was: the new standing + // mount is ensured before the parent link moves, so a rejection never + // strips the old composition. + expect(toolNames(scoped, handle.agent)).toEqual(['only']) + }) + + it('refuses an unscoped context', async () => { + await expect(ctx.agentPresets.recompose(ctx, 'minimal')) + .rejects.toThrow(/unscoped context/) + }) +}) + +describe('editing a composition file', () => { + /** One-row composition whose single tool is named `tool`. */ + const rowFor = (tool: string): string => + `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: ${tool}\n` + + /** + * A context over a temp root holding one editable preset. The id is + * per-test because `livePresetMounts()` is a process-global registry. + */ + async function editable(id: string): Promise<{ scoped: Context; path: string }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-')) + await mkdir(join(root, id)) + const path = join(root, id, COMPOSITION_FILE) + await writeFile(path, rowFor('before')) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] }) + return { scoped, path } + } + + it('starts a new generation for later sessions while joined ones keep theirs', async () => { + const { scoped, path } = await editable('edited') + const first = await agentOn(scoped, 'sess-gen-first', 'edited') + expect(toolNames(scoped, first)).toEqual(['before']) + + // Files are the only composition editor now (authoring is copy/delete), + // so the standing mount notices the file's stamp changing on its own. + await writeFile(path, rowFor('afterwards')) + + const second = await agentOn(scoped, 'sess-gen-second', 'edited') + expect(toolNames(scoped, second)).toEqual(['afterwards']) + // The joined session keeps the generation it runs on. + expect(toolNames(scoped, first)).toEqual(['before']) + }) + + it('gives two sessions racing the refreshed file one shared new generation', async () => { + const { scoped, path } = await editable('raced') + await agentOn(scoped, 'sess-race-seed', 'raced') + + await writeFile(path, rowFor('afterwards')) + + // Whichever racer swaps the pointer first, the other must join it rather + // than fork a third generation off the same edit. + const [left, right] = await Promise.all([ + agentOn(scoped, 'sess-race-left', 'raced'), + agentOn(scoped, 'sess-race-right', 'raced'), + ]) + expect(toolNames(scoped, left)).toEqual(['afterwards']) + expect(toolNames(scoped, right)).toEqual(['afterwards']) + expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2) + }) + + it('hands a host reader the standing key without starting an agent', async () => { + const { scoped } = await editable('cold-read') + + const key = await scoped.agentPresets.standingKeyFor('cold-read') + + // The mount exists for the reader; no agent, session, or turn started. + expect(key).toEqual({ agentPreset: 'cold-read' }) + expect(livePresetMounts().filter(mount => mount.presetId === 'cold-read')).toHaveLength(1) + expect(scoped.agents.get(SessionId('cold-read'))).toBeUndefined() + // A second reader resolves the same generation, not a new mount. + expect(await scoped.agentPresets.standingKeyFor('cold-read')).toBe(key) + }) + + it('refuses to mount a generation it cannot stamp', async () => { + const { scoped, path } = await editable('unstampable') + await rm(path) + + // Discovery would refuse the preset too; a caller that resolved just + // before the deletion must get a mount failure, not an unstamped + // generation that no later edit could ever refresh. + const racer = scoped.agentPresets as unknown as { + ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise + } + await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path })) + .rejects.toThrow(PresetMountError) + expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0) + }) + + it('keeps serving the mounted generation when the file cannot be statted', async () => { + const { scoped, path } = await editable('stale') + await agentOn(scoped, 'sess-stale-served', 'stale') + expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1) + + await rm(path) + + // Discovery refuses a preset whose composition cannot be statted, so the + // public route cannot reach this state — but a caller that resolved just + // before the deletion still can, and it must be served the standing + // generation rather than failed over a stat. + const racer = scoped.agentPresets as unknown as { + ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise + } + await racer.ensureStanding({ id: 'stale', trust: 'user', path }) + + expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1) + }) +}) diff --git a/packages/preset/agent-presets/tests/session.spec.ts b/packages/preset/agent-presets/tests/session.spec.ts new file mode 100644 index 0000000000..d87c4d1937 --- /dev/null +++ b/packages/preset/agent-presets/tests/session.spec.ts @@ -0,0 +1,62 @@ +/** + * Which preset a session ran is a question about its LOG, not its header: the + * header records the creation-time choice, and a switch made during the blank + * window is an event. Every reconstruction — the list row, the header label, + * resume, fork — goes through this resolver, so a resolver that read the header + * alone would rebuild a switched session under a composition its own history + * contradicts. + */ + +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { resolveSessionPreset } from '../src/session.ts' + +/** A header carrying the creation-time preset, if any. */ +function header(agentPreset?: string): SessionHeader { + return { + version: 0, + id: SessionId('s'), + createdAt: 1, + delegationDepth: 0, + ...agentPreset === undefined ? {} : { agentPreset }, + } +} + +/** One logged selection, as `agentPreset.select` appends it. */ +function selected(agentPreset: string, seq: number): SessionEvent { + return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } } +} + +describe('resolving which preset a session ran', () => { + it('reads the creation-time value when nothing was switched', () => { + expect(resolveSessionPreset({ header: header('standard'), events: [] })).toBe('standard') + }) + + it('prefers a logged switch over the header', () => { + // The switch's effect outlives the blank window it was made in: the turns + // that follow run under the newer composition. + expect(resolveSessionPreset({ header: header('standard'), events: [selected('minimal', 0)] })) + .toBe('minimal') + }) + + it('takes the last switch when a session was moved twice', () => { + expect(resolveSessionPreset({ + header: header('standard'), + events: [selected('minimal', 0), selected('cordis', 1)], + })).toBe('cordis') + }) + + it('finds a switch behind later events', () => { + const later = { type: 'turn/end', seq: 2, time: 2, data: { turn: 1 } } as SessionEvent + + expect(resolveSessionPreset({ header: header(), events: [selected('minimal', 0), later] })) + .toBe('minimal') + }) + + it('reports none when the deployment composes no presets', () => { + // A valid deployment: every session shares the host composition, and no + // surface should invent a preset name for it. + expect(resolveSessionPreset({ header: header(), events: [] })).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts new file mode 100644 index 0000000000..081ffceba4 --- /dev/null +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -0,0 +1,163 @@ +/** + * The default preset is a user setting. `config.default` is the deployment's + * engineering default; the settings document overrides it and is hot-reloaded, + * so a person can change which preset new sessions get without a restart. + */ + +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +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 AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { describe, expect, it } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }] +const NS = settingsNamespace(SETTINGS_NAMESPACE) + +/** + * A composition with a real file-backed settings provider. `settingsFiber` is + * the provider's own handle, so a test can take it away the way a reload does. + */ +async function harness( + extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [], +): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-')) + const settingsFile = join(home, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) + await settingsFiber + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) + return { ctx, settingsFile, settingsFiber } +} + +const toolNames = (ctx: Context, agent?: unknown): string[] => + ctx.tools.schemas(agent as never).map(schema => schema.name).sort() + +describe('the default preset as a user setting', () => { + it('falls back to the composition default while the user set none', async () => { + const { ctx } = await harness() + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('takes the user default over the composition default', async () => { + const { ctx } = await harness() + + await ctx.settings.update(NS, { default: 'minimal' }) + + expect(ctx.agentPresets.defaultId).toBe('minimal') + }) + + it('composes a new session from the user default', async () => { + const { ctx } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + + const handle = await ctx.agents.create({ + sessionId: SessionId('settings-default'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), + }) + try { + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + } finally { + await handle.dispose() + } + }) + + it('leaves a running session on the preset it was composed from', async () => { + const { ctx } = await harness() + const running = await ctx.agents.create({ + sessionId: SessionId('settings-running'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), + }) + try { + expect(toolNames(ctx, running.agent)).toEqual(['alpha']) + + // Changing the default mid-flight must not reach an agent that already + // composed: its history was produced under `standard`'s tools. + await ctx.settings.update(NS, { default: 'minimal' }) + + expect(ctx.agentPresets.defaultId).toBe('minimal') + expect(toolNames(ctx, running.agent)).toEqual(['alpha']) + } finally { + await running.dispose() + } + }) + + it('re-inherits the composition default when the user setting is cleared', async () => { + const { ctx } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + + await ctx.settings.replace(NS, {}) + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('clears a user default it has just deleted', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-')) + await mkdir(join(root, 'mine')) + await writeFile( + join(root, 'mine', COMPOSITION_FILE), + `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`, + ) + const { ctx } = await harness([{ path: root, trust: 'user' as const }]) + await ctx.settings.update(NS, { default: 'mine' }) + expect(ctx.agentPresets.defaultId).toBe('mine') + + await ctx.agentPresets.remove('mine') + + // Nothing will ever supply that id again, so leaving the setting pointed at + // it would fail every session created without an explicit pick. Clearing it + // exposes the deployment's own default underneath. + expect(ctx.agentPresets.defaultId).toBe('standard') + expect((await ctx.agentPresets.resolve()).id).toBe('standard') + }) + + it('reports an unknown user default only when a session tries to use it', async () => { + const { ctx } = await harness() + + // Storing it succeeds — the roster is a live directory, so a name that is + // absent now may exist by the time a session asks for it. + await ctx.settings.update(NS, { default: 'no-such-preset' }) + + await expect(ctx.agentPresets.resolve()) + .rejects.toThrow(/preset "no-such-preset" not found/) + }) +}) + +describe('a settings provider that goes away', () => { + it('falls back to the composition default when the provider unloads', async () => { + const { ctx, settingsFiber } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + + // Unloading the provider takes the user layer with it; the roster keeps + // working on its composition default rather than holding a stale override. + await settingsFiber.dispose() + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json new file mode 100644 index 0000000000..47d5577207 --- /dev/null +++ b/packages/preset/agent-presets/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../core/session" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../util/atomic-write" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml new file mode 100644 index 0000000000..c4573b49f8 --- /dev/null +++ b/packages/preset/persona/README.i18n.yaml @@ -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 packages/preset/persona/README.md +README.md: 789776b32d907f7d217accccbca5508f88de0ed1 +README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md new file mode 100644 index 0000000000..789776b32d --- /dev/null +++ b/packages/preset/persona/README.md @@ -0,0 +1,39 @@ +# dsh-persona + +English | [中文](README.zh.md) + +The agent persona as a composable row. One config field, one prompt section. + +[`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row. + +## Scope-only + +Mounting this row outside an agent scope collides with the registry's own `deployment:persona` registration and fails loud. That is not a limitation to work around: the deployment persona already has an owner, and the whole point of this row is to shadow it for one agent. Mount it inside a preset composition, where the preset mount supplies the agent scope. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `text` | required | Persona prose rendered as the `deployment:persona` section | + +`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. + +## Model Experience + +### The persona section + +#### What the model sees + +The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. + +#### Token effect + +Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. + +#### KV Cache effect + +Prefix-stable for the life of an agent — the row mounts once, before the agent is published and therefore before its first request, and its text never changes while the agent runs. Two agents on different presets establish different prefixes from this section onward; neither can invalidate the other's reuse. + +## Known Limitations and Deferred Work + +- **No global mount** — the prompt registry owns the unscoped persona slot, so this row is usable only from a scoped composition. A deployment-wide persona change belongs in the `system-prompt` row's own config. diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md new file mode 100644 index 0000000000..4e28d75bbd --- /dev/null +++ b/packages/preset/persona/README.zh.md @@ -0,0 +1,39 @@ +# dsh-persona + +[English](README.md) | 中文 + +把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。 + +[`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 + +## 仅限 scope 内使用 + +在 agent scope 之外挂载本行,会与注册表自身的 `deployment:persona` 注册相撞并明确报错。这不是需要绕开的限制:部署级人设已经有归属,而本行存在的意义正是为某一个 agent 遮蔽它。请把它挂在 preset 组装内部,由 preset 的挂载过程提供 agent scope。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 | + +`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。 + +## Model Experience + +### 人设段落 + +#### What the model sees + +位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。 + +#### Token effect + +对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。 + +#### KV Cache effect + +在一个 agent 的整个生命周期内保持前缀稳定——本行只挂载一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间文本不再改变。两个使用不同 preset 的 agent 从该段落起建立各自不同的前缀,谁都无法让对方失去缓存复用。 + +## Known Limitations and Deferred Work + +- **不支持全局挂载** —— 提示词注册表拥有未加 scope 的人设槽位,因此本行只能从带 scope 的组装中使用。要改变部署级人设,应在 `system-prompt` 行自身的配置中修改。 diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json new file mode 100644 index 0000000000..5ec7678d16 --- /dev/null +++ b/packages/preset/persona/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-persona", + "description": "Composition-authored deployment persona section for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts new file mode 100644 index 0000000000..ec56bcc780 --- /dev/null +++ b/packages/preset/persona/src/index.ts @@ -0,0 +1,60 @@ +/** + * A per-agent persona as a composable row. + * + * `dsh-system-prompt` owns the global persona as its own config, and registers + * that section unconditionally — so this row is **scope-only**. Mounted inside + * an agent preset it shadows the deployment persona for that one session, + * exactly like the per-child persona `dsh-subagent` installs; mounted globally + * it collides with the registry's own registration and fails loud. + * + * That constraint is the reason the row exists. An agent preset cannot mount + * the prompt registry itself, so without a row of its own a preset could + * change an agent's tools but never its identity. + * @module @deepseek-ai/dsh-persona + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-system-prompt' + +// Imported rather than restated: the registry declares the slot this row +// replaces, and two hardcoded copies would drift into a preset whose persona +// silently lands beside the deployment's instead of shadowing it. +import { PERSONA_ORDER, PERSONA_SECTION } from '@deepseek-ai/dsh-system-prompt' + +export { PERSONA_ORDER, PERSONA_SECTION } + +/** Cordis plugin name. */ +export const name = 'persona' + +/** The prompt registry this row contributes to. */ +export const inject = ['systemPrompt'] + +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} + +/** Runtime schema for the persona row. */ +export const Config: z = z.object({ + text: z.string().required(), +}) + +/** + * Register the persona section for the mounting context's scope. + * @param ctx - an agent scope context; an unscoped context collides with the + * prompt registry's own persona registration and rejects. + * @param config - the persona text. + */ +export function apply(ctx: Context, config: Config): void { + ctx.effect(() => ctx.systemPrompt.section({ + name: PERSONA_SECTION, + order: PERSONA_ORDER, + text: config.text, + }), 'persona.section()') +} diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts new file mode 100644 index 0000000000..5f9068fe24 --- /dev/null +++ b/packages/preset/persona/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-persona`. + * @module @deepseek-ai/dsh-persona/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-persona' + +/** Cordis companion plugin name. */ +export const name = 'persona-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one + * prompt section and the prompt registry owns section identity, shadowing, and disposal. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts new file mode 100644 index 0000000000..bb7555df7c --- /dev/null +++ b/packages/preset/persona/tests/persona.spec.ts @@ -0,0 +1,88 @@ +import { Context } from 'cordis' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { describe, expect, it } from 'vitest' +import * as Persona from '@deepseek-ai/dsh-persona' +import { PERSONA_SECTION } from '@deepseek-ai/dsh-persona' + +async function harness(deploymentPersona: string): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: deploymentPersona }) + return ctx +} + +/** The rendered text of the persona slot as one scope sees it. */ +async function personaText(ctx: Context, scope?: ScopeKey): Promise { + const assembly = await ctx.systemPrompt.assemble(scope === undefined ? {} : { scope }) + return assembly.sections.find(section => section.name === PERSONA_SECTION)?.text +} + +describe('the persona row', () => { + it('rejects an unscoped mount, which would collide with the registry default', async () => { + const ctx = await harness('deployment identity') + + await expect(ctx.plugin(Persona, { text: 'composition identity' })) + .rejects.toThrow(/"deployment:persona" is already registered/) + }) + + it('shadows the deployment default for one scope only', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + + await scope.ctx.plugin(Persona, { text: 'preset identity' }) + + expect(await personaText(ctx, key)).toBe('preset identity') + expect(await personaText(ctx)).toBe('deployment identity') + }) + + it('gives two scopes independent personas', async () => { + const ctx = await harness('') + const first: ScopeKey = { agent: 'a1' } + const second: ScopeKey = { agent: 'a2' } + + await createScope(ctx, first).ctx.plugin(Persona, { text: 'first identity' }) + await createScope(ctx, second).ctx.plugin(Persona, { text: 'second identity' }) + + expect(await personaText(ctx, first)).toBe('first identity') + expect(await personaText(ctx, second)).toBe('second identity') + }) + + it('shadows the deployment persona away entirely when its text is empty', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + + await createScope(ctx, key).ctx.plugin(Persona, { text: '' }) + + // The slot is still occupied, so the deployment persona is gone for this + // agent; an empty section is dropped when the prompt renders. + expect(await personaText(ctx, key)).toBe('') + expect(await personaText(ctx)).toBe('deployment identity') + }) + + it('restores the shadowed default when its fiber unloads', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + const fiber = await scope.ctx.plugin(Persona, { text: 'preset identity' }) + expect(await personaText(ctx, key)).toBe('preset identity') + + await fiber.dispose() + + expect(await personaText(ctx, key)).toBe('deployment identity') + }) + + it('interpolates prompt variables strictly, like any other section', async () => { + const ctx = await harness('') + const key: ScopeKey = { agent: 'a1' } + ctx.systemPrompt.variable('model', () => 'deepseek-v4-pro') + + await createScope(ctx, key).ctx.plugin(Persona, { text: 'You run on {{model}}.' }) + + // `assemble()` keeps section text uninterpolated; `renderPrompt()` is the + // stage that resolves `{{…}}` against the assembly's variables. + expect(await personaText(ctx, key)).toBe('You run on {{model}}.') + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))) + .toContain('You run on deepseek-v4-pro.') + }) +}) diff --git a/packages/preset/persona/tsconfig.json b/packages/preset/persona/tsconfig.json new file mode 100644 index 0000000000..178bd54dbb --- /dev/null +++ b/packages/preset/persona/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 503736477c..3543d0fc98 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -94,6 +94,48 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'agentPresets', + summary: 'Registry over the deployment\'s agent presets.', + methods: [ + { + signature: 'async list(): Promise', + jsDoc: '/**\n * Every preset the configured roots currently supply.\n * @returns the presets, first-root-wins per id.\n */', + }, + { + signature: 'async resolve(id?: string): Promise', + jsDoc: '/**\n * Resolve one preset by id.\n *\n * A broken preset resolves — deleting one, reading one, and reporting one\n * all need the row — and the mounting paths refuse it AFTER resolution\n * through {@link resolveMountable}.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the resolved preset.\n * @throws when no configured root supplies that id.\n */', + }, + { + signature: 'async mount(agentCtx: Context, id?: string): Promise', + jsDoc: '/**\n * Compose one agent from a preset: ensure the preset\'s standing mount, then\n * parent the agent\'s scope key to it so the mount\'s registrations and\n * listeners cover this agent.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was composed, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', + }, + { + signature: 'async read(id: string): Promise', + jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */', + }, + { + signature: 'async copy(from: string, id: string, name?: string): Promise', + jsDoc: '/**\n * Create a locally authored preset by copying an existing one whole.\n *\n * Copy is the only authoring write. Composition text never crosses this\n * seam: the source is named by id and its directory is copied as it stands,\n * so the copy is exactly as loadable as its source and authoring grants no\n * capability the roster did not already carry. The copy is NOT mounted to\n * validate — a source that mounts today yields a copy that mounts today.\n * @param from - the preset the copy starts from; shipped presets are the\n * primary source, so any trust is accepted.\n * @param id - the new preset\'s id, which becomes its directory name.\n * @param name - display name for the copy; absent falls back to the id.\n * @throws when the source is unknown, the id is unusable or already taken,\n * or the deployment configures no writable root.\n */', + }, + { + signature: 'async remove(id: string): Promise', + jsDoc: '/**\n * Delete a locally authored preset.\n * @param id - the preset id.\n * @throws when the preset is unknown or ships with the deployment.\n */', + }, + { + signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined', + jsDoc: '/**\n * One agent\'s instance of a service its preset mounted.\n *\n * A preset publishes services behind `isolate` realms, which are invisible\n * outside the group that declares them — including to the host. This is how a\n * caller holding the agent reads one anyway: a request that is ABOUT a\n * session but arrives from outside it, which is every browser RPC.\n *\n * Read addressing only. A host row that `inject`s a service cannot use this,\n * because injection resolves before any session exists and has no agent to\n * key by; such a service belongs on the host plane instead.\n * @param agent - the agent whose composition to look inside.\n * @param name - the service name as the preset\'s rows resolve it.\n * @returns the agent\'s instance, or undefined when its preset mounts none.\n */', + }, + { + signature: 'async recompose(agentCtx: Context, id: string): Promise', + jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore. The re-link runs through the binding this roster kept\n * from the agent\'s mount — dsh-scope\'s only re-link authority. An agent\n * that never composed one has nothing to re-link: the switch is then the\n * agent\'s first bind, exactly a mount.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */', + }, + { + signature: 'async standingKeyFor(id?: string): Promise', + jsDoc: '/**\n * The standing scope key of one preset, for a host reader with no agent.\n *\n * A cold transcript read resolves tool presenters against the composition\n * the session recorded, and the standing mount makes that possible without\n * resuming anything: ensuring the mount composes plugins but starts no\n * agent, no session, and no turn.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the standing scope key readers pass as a registry view scope.\n * @throws when the preset is unknown or its composition is unusable.\n */', + }, + ], + }, { key: 'agents', summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.', @@ -886,27 +928,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'skills', - summary: 'Registry of skill providers.', + summary: 'Layered registry of skill providers, the host+per-scope shape the tools registry established.', methods: [ { signature: 'registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void', - jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', + jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin\n * apply, into the calling context\'s layer: a scoped context (an agent\n * preset\'s standing mount) registers for that scope alone, an unscoped\n * context registers globally. Duplicate names within one layer and reserved\n * names throw; remote initialization belongs in `list()`. Fiber disposal\n * unregisters the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', }, { signature: 'register(skill: SkillRegistration): () => void', - jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', + jsDoc: '/**\n * Register a borrowed readonly runtime skill into the calling context\'s\n * layer. Project entries outrank runtime entries, which outrank user\n * entries, within one layer. Same-name runtime entries in one layer are\n * first-wins; a duplicate logs a warning and receives a no-op disposer so\n * it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', }, { - signature: 'async list(options: SkillLookupOptions = {}): Promise', - jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */', + signature: 'async list(options: SkillViewOptions = {}): Promise', + jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - view options; `scope` selects the viewing agent\'s layers, `cwd` selects project roots, and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */', }, { - signature: 'async snapshot(options: SkillLookupOptions = {}): Promise', - jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', + signature: 'async snapshot(options: SkillViewOptions = {}): Promise', + jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - view options; `scope` selects the viewing agent\'s layers, `cwd` selects project roots, and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', }, { - signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise', - jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */', + signature: 'async get(name: string, options: SkillViewOptions = {}): Promise', + jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - view options; `scope` selects the viewing agent\'s layers,\n * `cwd` selects workspace-sensitive skills, and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */', }, ], }, @@ -1142,6 +1184,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'tools', summary: 'Tool registry and execution pipeline.', 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 */', + }, { signature: 'register(definition: ToolDefinition): () => void', jsDoc: '/**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */', @@ -1667,6 +1713,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', }, + { + name: 'AgentPreset', + declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', + }, { name: 'AgentSetup', declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', @@ -1913,7 +1963,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -1925,7 +1975,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n}', }, { name: 'CredentialInfo', @@ -2287,6 +2337,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, + { + name: 'PresetTrust', + declaration: 'export type PresetTrust = \'system\' | \'user\';', + }, { name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', @@ -2593,7 +2647,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', }, { name: 'SessionId', @@ -2811,6 +2865,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillSummary', declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', }, + { + name: 'SkillViewOptions', + declaration: 'export interface SkillViewOptions extends SkillLookupOptions {\n readonly scope?: ScopeKey | undefined;\n}', + }, { name: 'SpillLocator', declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;', @@ -3111,6 +3169,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolOutputDefinition', declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}', }, + { + name: 'ToolPresentationMode', + declaration: 'export type ToolPresentationMode = \'native\' | \'code\' | \'both\';', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}', diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index ef3a920878..dc67665f77 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -162,6 +162,7 @@ interface SessionHeaderRow { parent_session: string | null seed_length: number | null delegation_depth: number | null + agent_preset: string | null } interface SearchRow extends SessionHeaderRow { @@ -552,16 +553,10 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO persisted_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - entry.header.id, - entry.header.version, - entry.header.createdAt, - entry.header.cwd ?? null, - entry.header.parentSession ?? null, - entry.header.seedLength ?? null, - entry.header.delegationDepth ?? null, + ...headerBindings(entry.header), revision, generation, ) @@ -588,16 +583,10 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO temp.live_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - entry.header.id, - entry.header.version, - entry.header.createdAt, - entry.header.cwd ?? null, - entry.header.parentSession ?? null, - entry.header.seedLength ?? null, - entry.header.delegationDepth ?? null, + ...headerBindings(entry.header), entry.fingerprint, persisted ? 1 : 0, generation, @@ -692,7 +681,7 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() const live = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation FROM temp.live_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -702,7 +691,7 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistenceBinding.service !== undefined) { const persisted = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation FROM persisted_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -750,6 +739,25 @@ export class SessionQuerySqlite extends SessionQueryService { } } +/** + * The header columns both session upserts bind, in the order their INSERT + * lists them. The two statements differ only in what they append after these. + * @param header - the session header being written. + * @returns one bound value per header column. + */ +function headerBindings(header: SessionHeader): (string | number | null)[] { + return [ + header.id, + header.version, + header.createdAt, + header.cwd ?? null, + header.parentSession ?? null, + header.seedLength ?? null, + header.delegationDepth ?? null, + header.agentPreset ?? null, + ] +} + function selectedDocumentsSql(): { sql: string } { return { sql: `WITH candidates AS ( @@ -761,6 +769,7 @@ function selectedDocumentsSql(): { sql: string } { ps.parent_session AS parent_session, ps.seed_length AS seed_length, ps.delegation_depth AS delegation_depth, + ps.agent_preset AS agent_preset, 0 AS live, 1 AS persisted, CAST(pd.seq AS INTEGER) AS seq, @@ -783,6 +792,7 @@ function selectedDocumentsSql(): { sql: string } { ls.parent_session AS parent_session, ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, + ls.agent_preset AS agent_preset, 1 AS live, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, @@ -891,6 +901,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) + && a.agentPreset === b.agentPreset } function rowHeader(row: SessionHeaderRow): SessionHeader { @@ -902,6 +913,7 @@ function rowHeader(row: SessionHeaderRow): SessionHeader { ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, ...row.seed_length === null ? {} : { seedLength: row.seed_length }, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, + ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset }, } } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 59cec8819c..6ad031f77f 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 7 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 8 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -118,6 +118,7 @@ function ensurePersistentSchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + agent_preset TEXT, revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT @@ -147,6 +148,7 @@ function ensureTemporarySchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + agent_preset TEXT, fingerprint TEXT NOT NULL, persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index c5cbccbf24..8427ebedc4 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -280,7 +280,10 @@ describe('SQLite session search', () => { it('searches two-character Unicode61 tokens in live-only sessions', async () => { const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) const session = ctx.sessions.create(SessionId('live'), { - meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2 }, + // agentPreset rides along: the index rebuilds the header a caller reads, + // and a session listed under the wrong composition is a lie about what it + // ran. The full-header comparison below is what pins every column. + meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2, agentPreset: 'minimal' }, }) session.append( 'user/message', diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 1ccd0b5286..a1fcc59e7f 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -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-persistence-jsonl/README.md -README.md: b7fa8fc2918711dd24eeba67132a267452d401f9 -README.zh.md: cf044b937f7ae6d0464603a1b59ad9489423ebe0 +README.md: 628833513a8092280970230c8657a50d00db4527 +README.zh.md: 4eb2d4f2bebf9ed17190ef3cb21a2bc3c8d9123b diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index b7fa8fc291..628833513a 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence session.jsonl # only with compression: 'none' ``` -- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `agentPreset` is durable because it decides the resumed session's tools and prompt — restoring a different composition would replay history the model can no longer act on. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. - The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index cf044b937f..4eb2d4f2be 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`agentPreset` 必须持久化,因为它决定了被恢复会话的工具与提示词——恢复成另一套组装,就会重放模型已无法据以行动的历史。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 - 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 - 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 96e8221c65..fd62306b1a 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -39,6 +39,7 @@ export interface HeaderLine { seedLength?: number origin?: 'subagent' delegationDepth: number + agentPreset?: string } /** @@ -57,6 +58,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, ...header.origin !== undefined ? { origin: header.origin } : {}, delegationDepth: header.delegationDepth ?? 0, + ...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {}, } } @@ -78,6 +80,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, ...line.origin !== undefined ? { origin: line.origin } : {}, delegationDepth: line.delegationDepth, + ...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {}, } } @@ -98,6 +101,8 @@ function isHeaderLine(value: unknown): value is HeaderLine { && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) && ((value as { origin?: unknown }).origin === undefined || (value as { origin?: unknown }).origin === 'subagent') + && ((value as { agentPreset?: unknown }).agentPreset === undefined + || typeof (value as { agentPreset?: unknown }).agentPreset === 'string') ) } diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 636db0bc8c..c7b4ab8841 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -817,6 +817,27 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) }) + it('round-trips the agent preset a session was composed from', () => { + const line = toHeaderLine({ + version: 0, + id: SessionId('composed'), + createdAt: 1, + delegationDepth: 0, + agentPreset: 'minimal', + }) + const log = `${JSON.stringify(line)}\n` + + // The preset decides the resumed session's tools and prompt; dropping it + // on disk would restore a composition the logged history contradicts. + expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal') + }) + + it('rejects a session header whose agentPreset is not a string', () => { + const log = '{"type":"session","version":0,"id":"bad-preset","createdAt":1,"delegationDepth":0,"agentPreset":7}\n' + + expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) + }) + it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }), diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index fc2b10fa96..b26e273cf1 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -380,8 +380,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers private writeRow(meta: SessionHeader): void { this.db.prepare(` INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, @@ -389,7 +389,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers parent_session = excluded.parent_session, seed_length = excluded.seed_length, origin = excluded.origin, - delegation_depth = excluded.delegation_depth + delegation_depth = excluded.delegation_depth, + agent_preset = excluded.agent_preset `).run( meta.id, meta.version, @@ -399,6 +400,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.seedLength ?? null, meta.origin ?? null, meta.delegationDepth ?? null, + meta.agentPreset ?? null, randomUUID(), ) } diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index a9830316a8..c7a4de7233 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 13 +export const SCHEMA_VERSION = 14 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -42,6 +42,7 @@ export interface SessionRow { /** Monotonic log-change token incremented in each mutating transaction. */ revision: number delegation_depth: number | null + agent_preset: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -125,6 +126,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM seed_length INTEGER, origin TEXT, delegation_depth INTEGER, + agent_preset TEXT, incarnation TEXT NOT NULL, revision INTEGER NOT NULL ) STRICT; @@ -184,6 +186,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.origin !== null ? { origin: row.origin } : {}, ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, + ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, } } diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index ab602e1c4d..afaa060490 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -172,6 +172,7 @@ describe('rowToMeta', () => { incarnation: 'with-origin', revision: 1, delegation_depth: null, + agent_preset: null, })).toMatchObject({ id: 'with-origin', origin: 'subagent' }) }) @@ -187,8 +188,27 @@ describe('rowToMeta', () => { incarnation: 'fractional', revision: 1, delegation_depth: null, + agent_preset: null, })).toThrow('stored session createdAt must be a non-negative safe integer') }) + + it('restores the agent preset a session was composed from', () => { + // The preset decides the resumed session's tools and prompt; a row that + // dropped it would rebuild a composition the stored history contradicts. + expect(rowToMeta({ + id: 'composed', + version: 0, + created_at: 1, + cwd: null, + parent_session: null, + seed_length: null, + origin: null, + incarnation: 'composed', + revision: 1, + delegation_depth: null, + agent_preset: 'minimal', + })).toMatchObject({ agentPreset: 'minimal' }) + }) }) describe('SessionPersistenceSqlite: durability and crash semantics', () => { @@ -638,7 +658,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(13) + expect(SCHEMA_VERSION).toBe(14) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 208c850363..1d0364d1f2 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -134,10 +134,22 @@ interface UnitCell { observedSeq: number } -/** One live registration: the unit plus its per-session cells (dropped whole on disposal). */ +/** + * One live registration: the unit plus its per-session cells (dropped whole + * once the last registrant releases it). + * + * `refs` exists because one unit definition already serves every session — the + * cells are keyed by `Session` — while the registrants are now per-session: + * an agent preset mounts the same tool package once per agent, so N sessions + * on one preset register the same key N times. Without a count the first + * registrant would own the disposer, and its session ending would strip the + * projection from every other live session. + */ interface Registration { readonly def: ErasedDefinition readonly cells: WeakMap + /** Live registrants sharing this unit; the last one out removes the key. */ + refs: number } /** @@ -149,9 +161,12 @@ interface Registration { * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. Duplicate keys throw. Domain + * and clients read it as capability absence. Domain * plugins register under `ctx.inject(['sessionProjections'], …)` so headless - * assemblies without the registry stay unaffected. + * assemblies without the registry stay unaffected. Registrants sharing a key + * share one unit and are counted: the same tool package mounted in N agent + * presets registers N times, and the key survives until the last one + * unloads. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() @@ -182,12 +197,25 @@ export class SessionProjectionRegistry extends Service { } const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { const key = definition.key as string - if (this.registrations.has(key)) { - throw new Error(`session projection key ${JSON.stringify(key)} is already registered`) + const existing = this.registrations.get(key) + if (existing === undefined) { + this.registrations.set(key, { def: definition, cells: new WeakMap(), refs: 1 }) + } else { + // A differing `stateVersion` is the one incompatibility this can name: + // the versioned contract says the cached state shape differs, so the + // two registrants cannot share cells. Anything else about a definition + // is functions, which no runtime comparison can tell apart. + if (existing.def.stateVersion !== definition.stateVersion) { + throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(definition.stateVersion)}`) + } + existing.refs += 1 } - this.registrations.set(key, { def: definition, cells: new WeakMap() }) yield () => { - this.registrations.delete(key) + const live = this.registrations.get(key) + /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ + if (live === undefined) return + live.refs -= 1 + if (live.refs === 0) this.registrations.delete(key) } }.bind(this), 'sessionProjections.register()') return () => void dispose() diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index e54f1f2468..5d0f208743 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -127,14 +127,45 @@ describe('SessionProjectionRegistry drive', () => { expect(snapshot.values['test/marks']).toEqual({ marks: [] }) }) - it('rejects duplicate keys loud and keeps the first unit', async () => { + it('shares one unit between registrants of the same key', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) - expect(() => ctx.sessionProjections.register(marksUnit())).toThrow(/"test\/marks" is already registered/) + + // One definition already serves every session (cells are keyed by + // Session), and registrants are per-session now: an agent preset mounts + // the same tool package once per agent. + expect(() => ctx.sessionProjections.register(marksUnit())).not.toThrow() mark(session, ['kept']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) }) + it('keeps the unit until the last registrant releases it', async () => { + const { ctx, session } = await harness() + const first = ctx.sessionProjections.register(marksUnit()) + const second = ctx.sessionProjections.register(marksUnit()) + mark(session, ['kept']) + + first() + + // The regression this counts against: one session ending used to strip + // the projection from every other live session, because the first + // registrant owned the only disposer. + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) + second() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) + + it('refuses to share a key across a stateVersion change', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + + // The one incompatibility a runtime comparison can name: the versioned + // contract says the cached state shape differs, so the two cannot share + // cells. Everything else about a definition is functions. + expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) + .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) + }) + it('rejects a non-integer or negative stateVersion at register time', async () => { const { ctx } = await harness() expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/) diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 2c6f84ca3a..12d6de8dde 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -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/skill/README.md -README.md: 3dc2bcfa5775736717bdebcb92329d5655198234 -README.zh.md: e57e389f9080cfc763916111cf80ea97980f01e7 +README.md: 9c27a271f03f33d2b53984a6a5c18082ccc6169a +README.zh.md: 085dec3e342c2f42a39d28b995dcb4e2cf38f440 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 3dc2bcfa57..9c27a271f0 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -6,15 +6,17 @@ Pure agent skill provider registry. This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local). +The registry is host+per-scope layered over [`@deepseek-ai/dsh-scope`](../../core/scope), the shape the tools registry established: a registration files into the layer of its calling context's scope — host rows and repository plugins land in the global layer, a plugin mounted by an agent preset's standing composition lands in that preset's layer — and a read merges the global layer with the viewing scope's chain, the nearest layer winning a duplicate name outright while rank decides duplicates only within one layer. + ## Service: `SkillService` (ctx key: `skills`) ### Public API -- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. -- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached. -- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. -- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy. -- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. +- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by `provider.name`, unique within the calling context's layer. Duplicate names in one layer throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. +- `ctx.skills.snapshot({ cwd?, signal?, scope? })` Returns the invocation-neutral `{ skills, complete }` observation for the viewing scope's merged layers. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached. +- `ctx.skills.list({ cwd?, signal?, scope? })` Borrows the readonly view options, then returns every winning summary for the current workspace, merged across the global layer and the viewing scope's chain and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. +- `ctx.skills.get(name, { cwd?, signal?, scope? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy. +- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill into the calling context's layer, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations in one layer are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. ### Events @@ -49,7 +51,7 @@ A provider factory runs synchronously and receives one registration-scoped contr The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. +Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Within one layer, duplicate names resolve by rank, provider registration order, then provider-local order; across layers the nearest scope's entry wins the name. Summaries are sorted by skill name. Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog. @@ -74,4 +76,4 @@ No direct prompt effect. The named consumer owns the durable initial catalog and - **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism. - **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running. - **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics. -- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. +- **Duplicate resolution is first-wins** — later lower-priority candidates within a layer are logged and hidden, and a nearer layer shadows a farther one silently; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index e57e389f90..085dec3e34 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -6,15 +6,17 @@ 该包负责 `ctx.skills` 接口。它不知道 skill 来自本地文件、嵌入式插件数据、HTTP 还是其他后端;提供方通过 `ctx.skills.registerProvider(...)` 注册这些来源。已发布的本地实现是 [`@deepseek-ai/dsh-skill-local`](../skill-local)。 +注册表基于 [`@deepseek-ai/dsh-scope`](../../core/scope) 采用宿主 + 按 scope 的分层结构,即工具注册表确立的形态:注册落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,由 agent preset 常驻组合挂载的插件落入该 preset 的层——读取时将全局层与观察 scope 的链合并,最近层直接赢得重名,rank 只在单层内裁决重名。 + ## 服务:`SkillService`(ctx 键:`skills`) ### 公开 API -- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 -- `ctx.skills.snapshot({ cwd?, signal? })` 返回与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。 -- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 -- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。 -- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 +- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后以在调用方上下文所在层内唯一的 `provider.name` 注册其只读结果。同层重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 +- `ctx.skills.snapshot({ cwd?, signal?, scope? })` 返回观察 scope 各层合并后、与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。 +- `ctx.skills.list({ cwd?, signal?, scope? })` 借用只读视图选项,然后返回当前工作区中的全部胜出摘要;这些摘要在全局层与观察 scope 链之间合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 +- `ctx.skills.get(name, { cwd?, signal?, scope? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。 +- `ctx.skills.register(skill): () => void` 将只读运行时嵌入式 skill 注册进调用方上下文所在层,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同层同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 ### 事件 @@ -49,7 +51,7 @@ 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读约定。 -违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 +违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。单层内重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突;跨层则由最近 scope 的条目赢得名称。摘要按 skill 名称排序。 定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 @@ -74,4 +76,4 @@ - **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个响应取消但速度缓慢的提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不响应取消的提供方持续运行的工作。 - **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。 -- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 +- **重复解析使用先到先得**:系统会记录并隐藏层内较晚出现的低优先级候选项,较近的层会静默遮蔽较远的层;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index f77f56f6d1..da51610ec3 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -35,6 +36,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 18c878d74b..c013933547 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -12,6 +12,8 @@ import { Context, Service } from 'cordis' import { assertNever } from '@deepseek-ai/dsh-llm' +import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import z from 'schemastery' import type Schema from 'schemastery' @@ -106,6 +108,17 @@ export interface SkillLookupOptions { readonly signal?: AbortSignal | undefined } +/** + * Registry read options: provider lookup context plus the viewing scope. + * The registry consumes `scope` to select layers; providers receive the same + * borrowed options object and read only their {@link SkillLookupOptions} + * contract from it. + */ +export interface SkillViewOptions extends SkillLookupOptions { + /** Viewing scope (the calling agent); omitted reads the global layer alone. */ + readonly scope?: ScopeKey | undefined +} + /** * Return whether a skill may be advertised to and loaded by a model. * @param skill - skill metadata carrying resolved invocation controls. @@ -290,17 +303,56 @@ interface IndexedCandidate { provider: SkillProvider providerOrder: number localOrder: number + /** Owning layer, so a stale-definition invalidation can verify the exact registration is still live. */ + layer: SkillLayer } -interface CollectResult { +/** One provider registration retained by its layer. */ +interface RegisteredProvider { + provider: SkillProvider + /** Service-wide monotonic registration order, the within-layer rank tiebreak. */ + order: number +} + +interface LayerCollectResult { entries: IndexedCandidate[] cacheable: boolean } +interface CollectResult { + entries: Map + cacheable: boolean +} + +/** One scope's complete skill-registry contribution. */ +class SkillLayer implements ScopeLayer { + /** Providers registered through contexts carrying this scope, insertion-ordered. */ + readonly providers: NamedEntries + /** Runtime skills registered through contexts carrying this scope. */ + readonly runtime = new Map() + + constructor(scope: ScopeKey | undefined) { + this.providers = new NamedEntries(name => new Error(scope === undefined + ? `a skill provider named "${name}" is already registered` + : `a skill provider named "${name}" is already registered in this scope`)) + } + + /** Whether every contribution table in this aggregate layer is empty. */ + isEmpty(): boolean { + return this.providers.isEmpty() && this.runtime.size === 0 + } +} + /** - * Registry of skill providers. It merges provider catalogs with stable - * first-wins duplicate handling, exposes sorted invocation-neutral summaries, and - * loads full skill bodies on demand. + * Layered registry of skill providers, the host+per-scope shape the tools + * registry established. A registration files into the layer of its calling + * context's scope ({@link scopeOf}): host rows and repository plugins land in + * the global layer, while a plugin mounted by an agent preset's standing + * composition lands in that preset's layer. A read merges the global layer + * with the viewing scope's chain — the nearest layer's entry wins a duplicate + * name outright, and the rank order decides duplicates only within one layer. + * It exposes sorted invocation-neutral summaries and loads full skill bodies + * on demand. */ export class SkillService extends Service { static Config: Schema = z.object({ @@ -308,12 +360,16 @@ export class SkillService extends Service { }) private readonly collectCacheMaxEntries: number - private readonly providers = new Map() - private readonly runtime = new Map() - private readonly collectCache = new Map() - private providerRevision = 0 + private readonly layers = new ScopedLayers( + scope => new SkillLayer(scope), + () => { this.invalidateCache() }, + ) + private readonly collectCache = new Map>() + private revision = 0 private nextProviderOrder = 0 - private runtimeRevision = 0 + /** Stable identities for cache keys; scope keys are opaque identity-compared objects. */ + private readonly scopeIds = new WeakMap() + private nextScopeId = 1 constructor(ctx: Context, config: Config = {}) { super(ctx, 'skills') @@ -322,21 +378,27 @@ export class SkillService extends Service { } /** - * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and - * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters - * the provider and invalidates catalog caches. + * Register a borrowed same-process provider synchronously during plugin + * apply, into the calling context's layer: a scoped context (an agent + * preset's standing mount) registers for that scope alone, an unscoped + * context registers globally. Duplicate names within one layer and reserved + * names throw; remote initialization belongs in `list()`. Fiber disposal + * unregisters the provider and invalidates catalog caches. * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void { const lifecycle = new AbortController() - let active = false + let registration: { layer: SkillLayer; name: string } | undefined let provider: SkillProvider const control: SkillProviderControl = { signal: lifecycle.signal, invalidate: () => { - if (active) this.invalidateProvider(provider) + const active = registration + if (active !== undefined && active.layer.providers.get(active.name)?.provider === provider) { + this.invalidateCache() + } }, } try { @@ -345,26 +407,21 @@ export class SkillService extends Service { if (name === RUNTIME_PROVIDER) { throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) } - if (this.providers.has(name)) { - throw new Error(`a skill provider named "${name}" is already registered`) - } - const providers = this.providers const order = this.nextProviderOrder - const invalidateCache = (): void => { this.invalidateCache() } this.nextProviderOrder += 1 - const dispose = this.ctx.effect(function* () { - active = true - providers.set(name, { provider, order }) - invalidateCache() - yield () => { - active = false - providers.delete(name) - lifecycle.abort(new Error(`skill provider "${name}" disposed`)) - invalidateCache() - } - }, 'skills.registerProvider()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity - return dispose + return this.layers.effect( + this.ctx, + (layer) => { + const undo = layer.providers.insert(name, { provider, order }) + registration = { layer, name } + return () => { + registration = undefined + undo() + lifecycle.abort(new Error(`skill provider "${name}" disposed`)) + } + }, + { label: 'skills.registerProvider()' }, + ) } catch (error) { lifecycle.abort(error) throw error @@ -372,16 +429,19 @@ export class SkillService extends Service { } /** - * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which - * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and - * receives a no-op disposer so it cannot remove the winner. + * Register a borrowed readonly runtime skill into the calling context's + * layer. Project entries outrank runtime entries, which outrank user + * entries, within one layer. Same-name runtime entries in one layer are + * first-wins; a duplicate logs a warning and receives a no-op disposer so + * it cannot remove the winner. * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void { validateRuntimeSkill(skill) - const existing = this.runtime.get(skill.name) - if (existing !== undefined) { + const scope = scopeOf(this.ctx) + const existingLayer = scope === undefined ? this.layers.global : this.layers.peek(scope) + if (existingLayer !== undefined && existingLayer.runtime.has(skill.name)) { this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`) return () => {} } @@ -390,21 +450,14 @@ export class SkillService extends Service { invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true }, provider: skill.provider ?? RUNTIME_PROVIDER, } - const runtime = this.runtime - const updateRevision = (): void => { this.runtimeRevision += 1 } - const invalidateCache = (): void => { this.invalidateCache() } - const dispose = this.ctx.effect(function* () { - runtime.set(definition.name, definition) - updateRevision() - invalidateCache() - yield () => { - runtime.delete(definition.name) - updateRevision() - invalidateCache() - } - }, 'skills.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose + return this.layers.effect( + this.ctx, + (layer) => { + layer.runtime.set(definition.name, definition) + return () => { layer.runtime.delete(definition.name) } + }, + { label: 'skills.register()' }, + ) } /** @@ -412,10 +465,10 @@ export class SkillService extends Service { * model or user invocation policy at their operational boundary. Lookup * options and provider candidates are readonly same-process values borrowed * throughout discovery. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns all sorted winning summaries. */ - async list(options: SkillLookupOptions = {}): Promise { + async list(options: SkillViewOptions = {}): Promise { return (await this.snapshot(options)).skills } @@ -423,15 +476,14 @@ export class SkillService extends Service { * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns sorted summaries plus discovery-completeness state. */ - async snapshot(options: SkillLookupOptions = {}): Promise { + async snapshot(options: SkillViewOptions = {}): Promise { const collected = await this.collect(options) return { - skills: collected.entries - .map(entry => entry.candidate) - .map(toSummary) + skills: [...collected.entries.values()] + .map(entry => toSummary(entry.candidate)) .sort(compareSkillSummary), complete: collected.cacheable, } @@ -442,14 +494,15 @@ export class SkillService extends Service { * provider. Cancellation is rechecked after selection, including cache hits, and raced against * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. - * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @param options - view options; `scope` selects the viewing agent's layers, + * `cwd` selects workspace-sensitive skills, and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ - async get(name: string, options: SkillLookupOptions = {}): Promise { + async get(name: string, options: SkillViewOptions = {}): Promise { if (!isSkillName(name)) return undefined const collected = await this.collect(options) throwIfAborted(options.signal) - const match = collected.entries.find(entry => entry.candidate.name === name) + const match = collected.entries.get(name) if (match === undefined) return undefined const definition = await waitWithAbort( match.provider.get(match.candidate, options), @@ -458,25 +511,27 @@ export class SkillService extends Service { if (definition === undefined) return undefined validateDefinition(definition) if (definition.name !== match.candidate.name) { - this.invalidateProvider(match.provider) + this.invalidateEntry(match) return undefined } return definition } - private async collect(options: SkillLookupOptions): Promise { + private async collect(options: SkillViewOptions): Promise { throwIfAborted(options.signal) let attempt = 1 while (true) { - const providerRevision = this.providerRevision - const runtimeRevision = this.runtimeRevision - const key = collectCacheKey(options, providerRevision, runtimeRevision) + const revision = this.revision + // The chain is part of the key rather than assumed stable: a blank-session + // recompose re-parents an existing scope without touching this registry, + // and only a chain-bearing key makes the next read see the new preset. + const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision) const cached = this.collectCache.get(key) if (cached !== undefined) return { entries: cached, cacheable: true } const result = await this.collectFresh(options) throwIfAborted(options.signal) - if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) { + if (revision !== this.revision) { if (attempt < MAX_COLLECT_ATTEMPTS) { attempt += 1 continue @@ -494,8 +549,24 @@ export class SkillService extends Service { } } - private async collectFresh(options: SkillLookupOptions): Promise { - const collected = await this.listAllCandidates(options) + private async collectFresh(options: SkillViewOptions): Promise { + // Global first, then existing chain overlays farthest ancestor first and + // the exact scope last, so the nearest layer's same-name entry replaces + // the farther ones — the tools registry's shadowing rule. Rank decides + // duplicates only within one layer. + const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)] + const merged = new Map() + let cacheable = true + for (const layer of layers) { + const collected = await this.collectLayer(layer, options) + if (!collected.cacheable) cacheable = false + for (const entry of collected.entries) merged.set(entry.candidate.name, entry) + } + return { entries: merged, cacheable } + } + + private async collectLayer(layer: SkillLayer, options: SkillLookupOptions): Promise { + const collected = await this.listLayerCandidates(layer, options) collected.entries.sort(compareIndexedCandidates) const seen = new Set() const result: IndexedCandidate[] = [] @@ -511,21 +582,22 @@ export class SkillService extends Service { return { entries: result, cacheable: collected.cacheable } } - private async listAllCandidates(options: SkillLookupOptions): Promise { + private async listLayerCandidates(layer: SkillLayer, options: SkillLookupOptions): Promise { throwIfAborted(options.signal) const candidates: IndexedCandidate[] = [] let cacheable = true let runtimeOrder = 0 - for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) { + for (const skill of [...layer.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) { candidates.push({ candidate: runtimeCandidate(skill), provider: RUNTIME_SKILL_PROVIDER, providerOrder: -1, localOrder: runtimeOrder, + layer, }) runtimeOrder += 1 } - for (const { provider, order } of [...this.providers.values()]) { + for (const { provider, order } of [...layer.providers.values()]) { let localOrder = 0 let output: unknown try { @@ -540,7 +612,7 @@ export class SkillService extends Service { if (!observation.complete) cacheable = false for (const candidate of observation.candidates) { validateCandidate(candidate, provider.name) - candidates.push({ candidate, provider, providerOrder: order, localOrder }) + candidates.push({ candidate, provider, providerOrder: order, localOrder, layer }) localOrder += 1 } } @@ -548,14 +620,29 @@ export class SkillService extends Service { } private invalidateCache(): void { - this.providerRevision += 1 + this.revision += 1 this.collectCache.clear() this.notifyChange() } - private invalidateProvider(provider: SkillProvider): void { + /** Invalidate after a stale definition load, only while the exact registration that produced the entry is still live. */ + private invalidateEntry(entry: IndexedCandidate): void { /* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */ - if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache() + if (entry.layer.providers.get(entry.provider.name)?.provider === entry.provider) this.invalidateCache() + } + + private scopeId(key: ScopeKey): number { + let id = this.scopeIds.get(key) + if (id === undefined) { + id = this.nextScopeId + this.nextScopeId += 1 + this.scopeIds.set(key, id) + } + return id + } + + private collectCacheKey(cwd: string | undefined, chain: ScopeKey[], revision: number): string { + return JSON.stringify({ cwd, scopes: chain.map(key => this.scopeId(key)), revision }) } /** Notify catalog observers without making their refresh work load-bearing. */ @@ -729,10 +816,6 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { } } -function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string { - return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision }) -} - function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): Promise { if (signal === undefined) return promise throwIfAborted(signal) diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index d48263cfe0..a5e03b610a 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import SkillService, { isModelInvocable, isUserInvocable, @@ -49,6 +50,13 @@ function registerProvider(ctx: Context, provider: SkillProvider): () => void { return ctx.skills.registerProvider(() => provider) } +/** The skills service as a scoped caller resolves it (scope contexts declare no inject). */ +function scopedSkills(ctx: Context): SkillService { + const skills = ctx.get('skills') + if (skills === undefined) throw new Error('skills service missing') + return skills +} + describe('SkillService registry', () => { it('registers providers, resolves duplicates first-wins, and disposes providers', async () => { const ctx = new Context() @@ -894,6 +902,26 @@ describe('SkillService registry', () => { await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined() }) + it('propagates a load failure raced against an armed abort signal', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, { + name: 'failing-loader', + list: () => Promise.resolve([{ + name: 'failing-skill', + description: 'Failing', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'failing-loader', + source: 'test', + rank: 10, + locator: 'failing', + }]), + get: () => Promise.reject(new Error('load failed')), + }) + const controller = new AbortController() + await expect(ctx.skills.get('failing-skill', { signal: controller.signal })).rejects.toThrow('load failed') + }) + it('contains a provider rejection whose string coercion throws', async () => { const ctx = new Context() await ctx.plugin(SkillService) @@ -1076,3 +1104,168 @@ describe('renderSkillContent', () => { expect(text).toContain('Keep and as-is.') }) }) + +describe('SkillService scoped layers', () => { + it('files a scoped provider into its layer and merges it into that scope view only', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, new MemoryProvider([memorySkill('global-skill', 'Global', 100)])) + const preset = createScope(ctx, { preset: 'a' }) + const presetProvider: SkillProvider = { + name: 'preset-local', + async list() { + return [{ + name: 'preset-skill', + description: 'Preset', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'preset-local', + source: 'preset', + rank: 300, + locator: { content: 'Preset body.' }, + }] + }, + async get(candidate) { + return { ...candidate, content: (candidate.locator as { content: string }).content } + }, + } + scopedSkills(preset.ctx).registerProvider(() => presetProvider) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['global-skill']) + const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) }) + expect(scoped.map(skill => skill.name)).toEqual(['global-skill', 'preset-skill']) + expect((await ctx.skills.get('preset-skill', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.') + expect(await ctx.skills.get('preset-skill')).toBeUndefined() + await preset.dispose() + }) + + it('lets the nearest layer win a duplicate name regardless of rank', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, new MemoryProvider([memorySkill('shared-name', 'Global wins ranks', 10)])) + const preset = createScope(ctx, { preset: 'shadow' }) + scopedSkills(preset.ctx).registerProvider(() => ({ + name: 'preset-local', + async list() { + return [{ + name: 'shared-name', + description: 'Preset shadow', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'preset-local', + source: 'preset', + rank: 900, + locator: { content: 'Preset shadow body.' }, + }] + }, + async get(candidate: SkillCandidate) { + return { ...candidate, content: (candidate.locator as { content: string }).content } + }, + })) + + const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) }) + expect(scoped).toHaveLength(1) + expect(scoped[0]?.description).toBe('Preset shadow') + expect((await ctx.skills.get('shared-name', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset shadow body.') + expect((await ctx.skills.list())[0]?.description).toBe('Global wins ranks') + await preset.dispose() + }) + + it('resolves the scope chain so an agent key inherits its preset layer and recompose follows the new parent', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const presetA = createScope(ctx, { preset: 'a' }) + const presetB = createScope(ctx, { preset: 'b' }) + for (const [scope, label] of [[presetA, 'a'], [presetB, 'b']] as const) { + scopedSkills(scope.ctx).register({ + name: `skill-${label}`, + description: `Skill ${label}`, + source: 'preset', + content: `Body ${label}.`, + }) + } + const agentKey = {} + const binding = bindScopeParent(agentKey, scopeOf(presetA.ctx) as object) + expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-a']) + // A blank-session recompose re-links the same key through its binding + // without any registry write. + binding.rebind(scopeOf(presetB.ctx) as object) + expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-b']) + await presetA.dispose() + await presetB.dispose() + }) + + it('scopes provider-name uniqueness per layer and reports scoped duplicates distinctly', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, new MemoryProvider([])) + const presetA = createScope(ctx, { preset: 'a' }) + const presetB = createScope(ctx, { preset: 'b' }) + scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([memorySkill('a-only', 'A', 100)])) + scopedSkills(presetB.ctx).registerProvider(() => new MemoryProvider([memorySkill('b-only', 'B', 100)])) + expect(() => scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([]))) + .toThrow('a skill provider named "memory" is already registered in this scope') + expect((await ctx.skills.list({ scope: scopeOf(presetA.ctx) })).map(skill => skill.name)).toEqual(['a-only']) + expect((await ctx.skills.list({ scope: scopeOf(presetB.ctx) })).map(skill => skill.name)).toEqual(['b-only']) + await presetA.dispose() + await presetB.dispose() + }) + + it('keeps runtime duplicate handling per layer and shadows a global runtime name', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const warn = vi.fn() + ctx.logger.warn = warn as never + ctx.skills.register({ name: 'told-twice', description: 'Global runtime', source: 'runtime', content: 'Global body.' }) + const preset = createScope(ctx, { preset: 'runtime' }) + const disposeShadow = scopedSkills(preset.ctx).register({ + name: 'told-twice', + description: 'Preset runtime', + source: 'preset', + content: 'Preset body.', + }) + expect(warn).not.toHaveBeenCalled() + scopedSkills(preset.ctx).register({ name: 'told-twice', description: 'Ignored', source: 'preset', content: 'Ignored.' }) + expect(warn).toHaveBeenCalledWith('runtime skill "told-twice" ignored because it is already registered') + expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.') + expect((await ctx.skills.get('told-twice'))?.content).toBe('Global body.') + disposeShadow() + expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Global body.') + await preset.dispose() + }) + + it('drops a disposed scoped registration from its scope view and notifies change', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const changes = vi.fn() + ctx.on('skills/change', changes) + const preset = createScope(ctx, { preset: 'hmr' }) + const provider = new MemoryProvider([memorySkill('scoped-skill', 'Scoped', 100)]) + scopedSkills(preset.ctx).registerProvider(() => provider) + expect((await ctx.skills.list({ scope: scopeOf(preset.ctx) })).map(skill => skill.name)).toEqual(['scoped-skill']) + const notified = changes.mock.calls.length + await preset.dispose() + expect(changes.mock.calls.length).toBeGreaterThan(notified) + expect(await ctx.skills.list({ scope: scopeOf(preset.ctx) })).toEqual([]) + }) + + it('invalidates through a scoped provider control only while its exact registration is live', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const preset = createScope(ctx, { preset: 'invalidate' }) + const provider = new MemoryProvider([memorySkill('watched', 'Watched', 100)]) + let control: { invalidate: () => void } | undefined + const dispose = scopedSkills(preset.ctx).registerProvider((given) => { + control = given + return provider + }) + const scope = scopeOf(preset.ctx) + expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['watched']) + provider.replace([memorySkill('replaced', 'Replaced', 100)]) + control?.invalidate() + expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['replaced']) + dispose() + provider.replace([memorySkill('ignored', 'Ignored', 100)]) + control?.invalidate() + expect(await ctx.skills.list({ scope })).toEqual([]) + await preset.dispose() + }) +}) diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index 82e62d7c91..8fb99e3b59 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../core/scope" + }, { "path": "../../llm/llm" }, diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index f78825d1f7..3d40fd1610 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -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: fd2cb2dc00994a79856ad605ce126387c27a9f65 -README.zh.md: 3fb76e079033ef39e475e6795cf28407e52a83cc +README.md: 704eb7eb1f611c20f76ce79190326296ff63da42 +README.zh.md: 09fb5b954ea595b962f7670ce09db1ae22dce29c diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index fd2cb2dc00..704eb7eb1f 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -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 `` framing cannot decide whether a republish is needed and consumers never re-parse the `` 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. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 3fb76e0790..09fb5b954e 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -12,7 +12,7 @@ 每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `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) 负责定义持久初始目录和替换目录的生命周期。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index bc9a1634a1..634fb7ce02 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -128,7 +128,9 @@ export function apply(ctx: Context, config: Config = {}): void { if (!isSkillName(args.name)) { throw new Error(`invalid skill name "${args.name}"`) } - const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal } + // The agent is its own scope key, so the lookup resolves the layered + // registry exactly as this agent's composition sees it. + const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal, scope: exec.agent } const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name) if (!summary) { throw new Error(`skill "${args.name}" is unknown or no longer available`) @@ -157,11 +159,6 @@ 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') - } // User-explicit skill invocation: a claimed user message whose first line // starts with `/` naming a user-invocable skill is a deterministic @@ -186,7 +183,7 @@ export function apply(ctx: Context, config: Config = {}): void { const names = invokedSkillNames(messages) if (names.length === 0) return decision signal.throwIfAborted() - const lookup = { cwd: agent.session.header.cwd, signal } + const lookup = { cwd: agent.session.header.cwd, signal, scope: agent } const injections: UserMessage[] = [] for (const name of names) { const skill = await ctx.skills.get(name, lookup) @@ -208,6 +205,11 @@ export function apply(ctx: Context, config: Config = {}): void { // 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, @@ -215,9 +217,9 @@ 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 }) + ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal, scope: agent }) : { skills: [], complete: true } signal.throwIfAborted() if (!snapshot.complete) return decision diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 24d9c35591..496fe81d46 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -640,6 +640,43 @@ describe('dsh-tool-skill', () => { expect(JSON.stringify(result.content)).not.toContain('First body.') }) + it('resolves the layered registry as the calling agent sees it', async () => { + const home = await tempDir('tool-scoped-layer') + const ctx = await setup(home) + const { agent, scope } = await mintAgentScope(ctx, '/workspace/scoped') + const scopedSkills = scope.ctx.get('skills') + if (scopedSkills === undefined) throw new Error('skills service missing') + scopedSkills.register({ + name: 'preset-only-skill', + description: 'Visible to the scoped agent alone', + source: 'preset', + content: 'Preset-only body.', + }) + + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('preset-only-skill') + expect(JSON.stringify(await composePrefix(ctx, '/workspace/other'))).not.toContain('preset-only-skill') + + const scoped = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('scoped-load'), + name: 'skill', + arguments: { name: 'preset-only-skill' }, + agent, + }) + expect(scoped.isError).toBe(false) + expect(JSON.stringify(scoped.content)).toContain('Preset-only body.') + + const foreign = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('foreign-load'), + name: 'skill', + arguments: { name: 'preset-only-skill' }, + agent: agentForCwd('/workspace/other'), + }) + expect(foreign.isError).toBe(true) + await scope.dispose() + }) + it('retains the last-good catalog while any provider discovery is incomplete', async () => { const home = await tempDir('tool-incomplete-catalog') const ctx = await setup(home) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0475a32ee1..476730d593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,12 +134,36 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:* version: link:../../vendor/timer + '@deepseek-ai/dsh-agent-tool-mode': + specifier: workspace:^ + version: link:../../packages/core/agent-tool-mode '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot '@deepseek-ai/dsh-base': specifier: workspace:^ version: link:../../packages/bundle/base + '@deepseek-ai/dsh-client-ui-agent-preset': + specifier: workspace:^ + version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../packages/compact/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../packages/goal/command-goal + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../packages/goal/goal-session '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless @@ -149,6 +173,12 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-persona': + specifier: workspace:^ + version: link:../../packages/preset/persona + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../packages/plan/plan-mode '@deepseek-ai/dsh-pty': specifier: workspace:^ version: link:../../packages/pty/pty @@ -161,24 +191,81 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../packages/skill/skill-local + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../packages/tasks/tasks-local '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../packages/llm/token-meter '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../packages/interaction/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../packages/bash/tool-bash '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/self-modification/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/bash/tool-pwsh + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../packages/tasks/tool-tasks + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/tool-workflow '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../packages/workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../packages/context/workspace-context commander: specifier: ^15.0.0 version: 15.0.0 @@ -216,6 +303,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../packages/settings/settings '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -241,6 +331,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../vendor/group '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules @@ -971,6 +1064,9 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../../vendor/group '@cordisjs/plugin-hmr': specifier: workspace:^ version: link:../../../vendor/hmr @@ -1276,6 +1372,9 @@ importers: packages/bundle/web-app: dependencies: + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -1294,6 +1393,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-agent-preset': + specifier: workspace:^ + version: link:../../client/ui-agent-preset '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../../client/ui-command @@ -1631,6 +1733,48 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-agent-preset: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-command: dependencies: clsx: @@ -2114,9 +2258,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-tool-ask-user': - specifier: workspace:^ - version: link:../../interaction/tool-ask-user clsx: specifier: ^2.0.0 version: 2.1.1 @@ -3039,6 +3180,37 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/core/agent-tool-mode: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/core/scope: devDependencies: '@deepseek-ai/dsh-invariants': @@ -4033,6 +4205,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4682,6 +4857,80 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/preset/agent-presets: + dependencies: + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../settings/settings-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/preset/persona: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': @@ -5669,6 +5918,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -7135,6 +7387,9 @@ importers: python/sdk-runtime: dependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../vendor/group '@cordisjs/plugin-include': specifier: workspace:^ version: link:../../vendor/include diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 1cf370661c..4b9e3b48c8 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,6 +5,7 @@ "private": true, "type": "module", "dependencies": { + "@cordisjs/plugin-group": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-timer": "workspace:^", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 2626cec2da..42d1d4c90b 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,11 +1,11 @@ { - "AGENTS.md": 1782, + "AGENTS.md": 1900, "docs/AGENTS.md": 1320, - "docs/architecture.md": 2174, + "docs/architecture.md": 2400, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 936 + "packages/README.md": 980 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 662ad1df13..745f44ed0a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -45,6 +45,7 @@ export { REGION_BEGIN, REGION_END } export const SERVICE_PAGE: Record = { agentLoop: 'core.md', agentDefaultModel: 'core.md', + agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', bash: 'bash.md', @@ -346,6 +347,7 @@ export const LINK_MAP: Readonly> = { SkillProvider: 'skills.md', SkillProviderObservation: 'skills.md', SkillRegistration: 'skills.md', + SkillViewOptions: 'skills.md', SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', SpillRef: 'spill.md', @@ -388,6 +390,7 @@ export const LINK_MAP: Readonly> = { ToolExecutionResult: 'tools.md', ToolExecutionToken: 'tools.md', ToolGuard: 'tools.md', + ToolPresentationMode: 'tools.md', ToolRegistry: 'tools.md', ToolRestriction: 'tools.md', ToolSchema: 'tools.md', @@ -462,6 +465,9 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', + AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md', + AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', + PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md', BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 06b115ecee..59a8e0200f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -267,6 +267,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.', }, + { + key: 'agentPresets', + pkg: 'agent-presets', + title: 'Per-session agent composition', + mode: 'core', + note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.', + }, { key: 'commands', pkg: 'commands', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index fccabef852..48d0c72db7 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1060,6 +1060,11 @@ "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/subsystems/skills.md", + "symbol": "SkillViewOptions", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/subsystems/skills.md", "symbol": "SkillProviderObservation", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 4c4d83ead6..b0aef8ddc9 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -78,6 +78,7 @@ for (const file of files) { errors.push(...validateExampleResolution()) errors.push(...validateAppResolution()) errors.push(...validateSourcePlaneResolution()) +errors.push(...validatePresetPlaneSeparation()) if (errors.length > 0) { console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') @@ -87,6 +88,76 @@ if (errors.length > 0) { console.log(`verify-cordis-config: ${files.length} config files passed.`) } +/** + * No shipped agent preset may repeat a row the host composition still runs. + * + * A preset contributes what ONE session adds to the host's registries. A row + * active on both planes is therefore mounted twice — once per process and once + * per session — and what that costs depends on what the row does: a provider + * behind an `isolate` realm shadows the host's for its own consumers, so a host + * contributor to that service reaches nobody; a row that registers into a host + * singleton registers once per live session, so the second one collides. + * + * Both have happened. `bash-env` in a preset realm left `DSH_WEB_URL` reaching + * no shell, and `tool-subagent-report` handed every child `report` once per live + * session until the second registration threw. Neither changes a tool catalog, + * so no catalog assertion can see them — and the shipped presets are near-copies + * of each other, so a fix applied to three of four is the normal failure. + * @returns one diagnostic per preset row that is also active on the host plane. + */ +function validatePresetPlaneSeparation(): string[] { + const problems: string[] = [] + // The shipped Web surface is two bundle patch layers over an empty root. + const hostFile = 'packages/bundle/base/cordis.patch.yml' + const overlayFile = 'packages/bundle/web-app/cordis.patch.yml' + const hostRows = rowIds(hostFile) + const overlay = loadEntries(overlayFile) + const disabled = new Set() + for (const entry of overlay) { + if (!isRecord(entry)) continue + if (entry.disabled === true && typeof entry.id === 'string') disabled.add(entry.id) + } + // The overlay's own inserts are host-plane too; its disables take them back out. + const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id))) + for (const file of globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root })) { + for (const id of rowIds(file)) { + if (!active.has(id)) continue + problems.push( + `${file}: row "${id}" is also active in the host composition; ` + + 'a row belongs to exactly one plane', + ) + } + } + return problems +} + +/** Every entry of one config file, or an empty list when it is not an entry array. */ +function loadEntries(file: string): unknown[] { + const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + return isUnknownArray(document) ? document : [] +} + +/** + * Row ids declared anywhere in one config file, including inside group `config` + * lists — a preset nests most of its rows in `isolate` groups. + * @param file - repository-relative config path. + * @returns the declared ids. + */ +function rowIds(file: string): Set { + const ids = new Set() + const walk = (value: unknown): void => { + if (isUnknownArray(value)) { + for (const item of value) walk(item) + return + } + if (!isRecord(value)) return + if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id) + for (const child of Object.values(value)) walk(child) + } + walk(loadEntries(file)) + return ids +} + function validateEntry(value: unknown, file: string, path: string): void { if (!isRecord(value)) { errors.push(`${file}${path}: entry must be an object`) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 20e6c4bc14..ec71f75469 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,8 +47,11 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/core/agent-tool-mode': { kind: 'indirect', reason: 'The row only selects between the two projections dsh-tools owns; it registers no prompt, schema, or result of its own.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, + 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/e2b/e2b': { kind: 'none', reason: 'The shared remote-runtime owner registers no model context; provider adapters and consumers own rendered effects.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index e404e3fc8b..d07f1d4ef9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -100,6 +100,7 @@ "./packages/feedback/*/src/invariant.ts", "./packages/guard/*/src/invariant.ts", "./packages/plan/*/src/invariant.ts", + "./packages/preset/*/src/invariant.ts", "./packages/subagent/*/src/invariant.ts", "./packages/tasks/*/src/invariant.ts", "./packages/workflow/*/src/invariant.ts", @@ -171,6 +172,7 @@ "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"], + "@deepseek-ai/dsh-client-ui-agent-preset": ["./packages/client/ui-agent-preset/src"], "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], @@ -208,6 +210,7 @@ "./packages/feedback/*/src", "./packages/guard/*/src", "./packages/plan/*/src", + "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/tasks/*/src", "./packages/workflow/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 9ce72753c1..632f6a84a7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -68,6 +68,7 @@ { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, + { "path": "./packages/client/ui-agent-preset" }, { "path": "./packages/client/ui-permission" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-question" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index f890487f3b..5a54d4ed0c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,8 @@ "apps/web/tests/skill-user-invoke.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", + "apps/web/tests/agent-preset-selection.e2e.ts", + "apps/web/tests/agent-preset-authoring.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", @@ -152,6 +154,7 @@ { "path": "./packages/interaction/user-approval" }, { "path": "./packages/interaction/permission" }, { "path": "./packages/core/tools" }, + { "path": "./packages/core/agent-tool-mode" }, { "path": "./packages/skill/skill" }, { "path": "./packages/skill/skill-badge" }, { "path": "./packages/skill/skill-local" }, @@ -241,6 +244,8 @@ { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, + { "path": "./packages/preset/agent-presets" }, + { "path": "./packages/preset/persona" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/self-modification/tool-cordis" }, { "path": "./packages/self-modification/repository-plugin" },