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..0f7bd16552 --- /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: 408e5a52b15efde162fa1bc6ae1e9ede8a7f0d98 +2026-08-03-per-session-agent-presets.zh.md: a06aea8d7e89c77d374c06908c10a9b0e5029548 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..ee6303e5f5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -0,0 +1,46 @@ +# 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/core-web.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, delegation backends, 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. + +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. + +## Consequences + +**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 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. + +**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. + +**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. + +## 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..5a2e1c3d8d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -0,0 +1,46 @@ +# 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/core-web.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` 解析到,因为后者位于宿主平面。 + +挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 + +## 后果 + +**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 + +**preset 不得把服务发布进根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞——而这次相撞表现为 `setup` 永远观察不到的未处理 rejection,留下一个看起来健康、实则组装到一半的 agent。挂载改为直接拒绝它;本包的运行时不变量还会在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +**失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。 + +**fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。 + +**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。 + +## 考虑过的替代方案 + +**在 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/docs/capability-seams.md b/docs/capability-seams.md index cb24cee7d5..9099207264 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -80,6 +80,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"] @@ -168,6 +170,7 @@ flowchart LR pkg_acp --> svc_approval pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop + pkg_agent_presets --> svc_agentPresets pkg_approval --> svc_approval pkg_bash --> svc_bash pkg_bash_env --> svc_bashEnv @@ -370,6 +373,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/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/ui/user-interaction) | - | [`tool-ask-user`](../packages/ui/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 profile directories over trusted and user-authored roots and mounts one profile 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/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/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-projection/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/config-catalog.md b/docs/config-catalog.md index 0963f4d490..775387ca6f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -110,6 +110,37 @@ Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core 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 profile is the default, and where profiles live. */ +export interface Config { + /** Profile 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 profile subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per profile; a leading `~` expands. */ + path: string + /** Trust recorded on every profile discovered under this root. */ + trust: PresetTrust +} + +/** + * Where a profile's composition came from. A `system` profile ships with the + * deployment; a `user` profile 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:29`](../packages/preset/agent-presets/src/types.ts) + ## `@deepseek-ai/dsh-agent-spine-demo` ```ts config-catalog diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 44a44a139e..3030e2c348 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -46,6 +46,43 @@ Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-s 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 profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read. + +```ts cordis-catalog +/** + * Every profile the configured roots currently supply. + * @returns the profiles, first-root-wins per id. + */ +async list(): Promise + +/** + * Resolve one profile by id. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the resolved profile. + * @throws when no configured root supplies that id. + */ +async resolve(id?: string): Promise + +/** + * Compose one agent from a profile, installing it under that agent alone. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken profile never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the profile that was mounted, for the caller to record. + * @throws when the profile is unknown or its composition is unusable. + */ +async mount(agentCtx: Context, id?: string): Promise +``` + +Source: [`packages/preset/agent-presets/src/index.ts:36`](../../packages/preset/agent-presets/src/index.ts) + ## `ctx.agents` — `AgentRegistry` Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23794b5c9d..a805b4530c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,6 +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/ui/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/ui/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-title/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/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | +| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets) | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index e721814e79..7980b809d6 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: dec4d71ca2d323fe05f918dd3bf4709cfa01878e -README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009 +README.md: 365659617c97c44dd0f30fbcd3347b6438024eb3 +README.zh.md: 9edabd67ea728e77e2863a32c250675a5b9359f8 diff --git a/packages/README.md b/packages/README.md index dec4d71ca2..b736aa5dc9 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,6 +31,7 @@ Packages live at `packages///`; groups are containers, while names r | [`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 | | [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 9596dfe8bf..53081b5e8c 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -31,6 +31,7 @@ | [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | +| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定表面 | | [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 | | [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8a75107eea..55a50977a7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -80,6 +80,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'agentPresets', + summary: 'Registry over the deployment\'s agent presets.', + methods: [ + { + signature: 'async list(): Promise', + jsDoc: '/**\n * Every profile the configured roots currently supply.\n * @returns the profiles, first-root-wins per id.\n */', + }, + { + signature: 'async resolve(id?: string): Promise', + jsDoc: '/**\n * Resolve one profile by id.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the resolved profile.\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 profile, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken profile never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the profile that was mounted, for the caller to record.\n * @throws when the profile 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.', @@ -1601,6 +1619,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}', + }, { name: 'AgentSetup', declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', @@ -2193,6 +2215,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;', diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml new file mode 100644 index 0000000000..b554512392 --- /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: e7940642166f81e370e3a328f3097d15fd367151 +README.zh.md: 0767ca5074071e9ef2fa38769d27d8ef2344188e diff --git a/packages/preset/README.md b/packages/preset/README.md new file mode 100644 index 0000000000..7baac391c2 --- /dev/null +++ b/packages/preset/README.md @@ -0,0 +1,13 @@ +# 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` | + +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..4d8c350b28 --- /dev/null +++ b/packages/preset/README.zh.md @@ -0,0 +1,13 @@ +# preset/:按会话组装 agent + +[English](README.md) | 中文 + +**agent preset** 是一个目录,其中放置一份 `agent.cordis.yml`。把它挂载到某个 agent(智能体)的 scope 上下文之下,该会话就获得自己的工具与提示词段落,而其他在运行的会话各自保持不变,因此一个进程可以同时运行多个组装方式不同的 agent。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | + +本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;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..9106494073 --- /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: 6068a68d3c81081074165077a8afa6b42af48d1f +README.zh.md: 9f951f566a51a7b7acb666c7d9ea80061aa45d73 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md new file mode 100644 index 0000000000..5d66c23f24 --- /dev/null +++ b/packages/preset/agent-presets/README.md @@ -0,0 +1,62 @@ +# dsh-agent-presets + +English | [中文](README.zh.md) + +Per-session agent composition. A **preset** is a directory holding one `agent.cordis.yml`; mounting it under an agent's scope context gives that one session its own tools, prompt sections, and other model-facing contributions, while every other live session keeps its own. + +The mechanism is entirely Cordis: 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. Mounting a composition under `agent.ctx` therefore makes it that agent's alone, and unwinds it with the agent, without any new layering in those registries. + +## 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. + +- `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. +- `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. +- `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset and return the preset that was mounted, for the caller to record. + +`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file). + +### Where to call `mount()` + +The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the composition installed while the agent is still unpublished, so a rejected mount rolls the whole creation back rather than leaving a half-composed session. The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and the caller receives no disposer. + +## 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. + +## 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 rather than per-session, so the second session mounting the same preset collides with the first. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local for one session's private instance, or a shared label when several sessions should share one — 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. + +## 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 mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent. + +#### 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 on a live agent** — the mount happens once during creation, so switching a running session's composition would mean unwinding its subtree mid-turn, dropping tools the model may already have called. Changing the default affects only sessions created afterwards. +- **Display names are the directory id** — a preset carries no manifest, so pickers and settings surfaces show the id until a consumer needs richer metadata. +- **`isolate` realms cannot be expressed across rows without `cordis:group`** — an entry-local realm works on a single row, but grouping a provider with its consumers under one shared realm needs the group builtin, which `dsh-app-boot` does not register. +- **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..9cdcd8b11a --- /dev/null +++ b/packages/preset/agent-presets/README.zh.md @@ -0,0 +1,62 @@ +# dsh-agent-presets + +[English](README.md) | 中文 + +按会话组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;把它挂载到某个 agent 的 scope 上下文之下,该会话就拥有自己的工具、提示词段落以及其他面向模型的贡献,而其他在运行的会话各自保持不变。 + +其机制完全来自 Cordis:entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册。因此把一份组装挂到 `agent.ctx` 之下,它就只属于该 agent,并随 agent 一起卸载,无需在这些注册表中新增任何分层。 + +## 服务:`AgentPresets`(ctx 键:`agentPresets`) + +发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。 + +- `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。 +- `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。 +- `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。 +- `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent,并返回所挂载的 preset 供调用方记录。 + +`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。 + +### 应在何处调用 `mount()` + +agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `default` | 必填 | 调用方未指定时挂载的 preset id | +| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | + +根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 + +## 挂载会拒绝什么 + +直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。 + +**目标上下文没有 scope。** 挂载到不带 agent scope 的上下文,会把该 preset 的工具注册成全局的,作用于进程内每一个 agent。 + +**某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。 + +**某一行把服务发布进了根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——用 entry 本地 realm 得到该会话私有的实例,或用共享 label 让多个会话共用一个——否则该服务应改放进宿主组装。 + +最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +## 信任 + +preset 就是组装,因此一个 preset 的权限恰好等于它所引用的插件。`user` preset——无论由人还是由 agent 写出——与 shell 访问权限同级;`trust` 字段的存在是为了让消费方呈现这一差异,而不是用来强制隔离。 + +## Model Experience + +Indirectly, through the plugins a mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent. + +#### KV Cache effect + +在一个 agent 的整个生命周期内保持前缀稳定:组装只装入一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间不再重新读取。为新会话选择不同的 preset,只会为该会话建立不同的前缀,无法让任何已在运行的会话失去缓存复用。 + +## Known Limitations and Deferred Work + +- **无法在存活的 agent 上更换 preset** —— 挂载只在创建时发生一次,因此切换运行中会话的组装意味着要在轮次进行途中卸载其子树,抽走模型可能已经调用的工具。更改默认值只影响此后创建的会话。 +- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。 +- **跨多行的 `isolate` realm 需要 `cordis:group` 才能表达** —— 单行可用 entry 本地 realm,但要把一个提供方与它的消费方归入同一个共享 realm,需要 group 内建插件,而 `dsh-app-boot` 并未注册它。 +- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json new file mode 100644 index 0000000000..9dae4e584a --- /dev/null +++ b/packages/preset/agent-presets/package.json @@ -0,0 +1,54 @@ +{ + "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", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "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-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-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts new file mode 100644 index 0000000000..2b3af0aa11 --- /dev/null +++ b/packages/preset/agent-presets/src/discovery.ts @@ -0,0 +1,75 @@ +/** + * Filesystem discovery of agent presets. A preset is a directory holding + * {@link COMPOSITION_FILE}; 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. + * @module @deepseek-ai/dsh-agent-presets/discovery + */ + +import { readdir, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { expandHomePath } from '@deepseek-ai/dsh-paths' +import type { AgentPreset, PresetRoot } from './types.ts' + +/** The composition file that makes a directory a preset. */ +export const COMPOSITION_FILE = 'agent.cordis.yml' + +/** + * 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. + * @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()) continue + const path = join(dir, child.name, COMPOSITION_FILE) + if (!await isFile(path)) continue + found.push({ id: child.name, trust: root.trust, path }) + } + return found.sort((left, right) => left.id.localeCompare(right.id)) +} + +/** + * 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..778b0d7275 --- /dev/null +++ b/packages/preset/agent-presets/src/index.ts @@ -0,0 +1,100 @@ +/** + * Agent presets: each session composes its model-facing plugin set from one + * preset `cordis.yml` mounted under that agent's scope context. + * + * This package owns the preset vocabulary, filesystem discovery, and the + * guarded 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 composition installed while the agent is still + * unpublished, so a rejected mount rolls the whole creation back. + * @module @deepseek-ai/dsh-agent-presets + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { discoverPresets } from './discovery.ts' +import { mountPreset } from './mount.ts' +import type { AgentPreset, Config } from './types.ts' + +export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.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 + + constructor(ctx: Context, public config: Config) { + super(ctx, 'agentPresets') + } + + /** The preset id mounted when a caller names none. */ + get defaultId(): string { + return 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. + * @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.config.default + const presets = await this.list() + const found = presets.find(preset => preset.id === wanted) + if (found === undefined) { + const known = presets.map(preset => preset.id).join(', ') + throw new Error(`agent-presets: preset "${wanted}" not found (available: ${known || 'none'})`) + } + return found + } + + /** + * Compose one agent from a preset, installing it under that agent alone. + * + * 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 mounted, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ + async mount(agentCtx: Context, id?: string): Promise { + const preset = await this.resolve(id) + await mountPreset(agentCtx, preset) + return preset + } +} + +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/mount.ts b/packages/preset/agent-presets/src/mount.ts new file mode 100644 index 0000000000..ba09869cb4 --- /dev/null +++ b/packages/preset/agent-presets/src/mount.ts @@ -0,0 +1,205 @@ +/** + * 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 } from '@deepseek-ai/dsh-scope' +import 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() + +/** Include subclass whose only addition is publishing its tree and fiber for the audit. */ +class PresetTree extends Include { + constructor(ctx: Context, config: Include.Config) { + super(ctx, config) + mounted.set(config, { tree: this, fiber: ctx.fiber }) + } +} + +/** 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 +} + +const mounts = new Set() + +/** + * Every preset composition still installed, pruning fibers disposed since the + * last read. Records are dropped lazily 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. + * @returns the live mounts. + */ +export function livePresetMounts(): PresetMount[] { + for (const mount of mounts) { + if (mount.fiber.uid === null) mounts.delete(mount) + } + 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)) +} + +/** + * 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 +} + +/** + * 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 { + if (scopeOf(agentCtx) === 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 } + 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 }) + } 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. + } + /* v8 ignore next -- every path into this 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. */ + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`agent-presets: preset "${preset.id}" (${preset.path}) failed to mount: ${detail}`, { cause: error }) + } +} diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts new file mode 100644 index 0000000000..64fa2cdf07 --- /dev/null +++ b/packages/preset/agent-presets/src/types.ts @@ -0,0 +1,34 @@ +/** 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' + +/** 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 +} + +/** 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[] +} 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..79b7ffb793 --- /dev/null +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -0,0 +1,77 @@ +import { 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('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('skips a directory that holds no composition file', async () => { + const found = await scanRoot(USER) + + expect(found.map(preset => preset.id)).not.toContain('not-a-preset') + }) + + 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([]) + }) +}) 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/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/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts new file mode 100644 index 0000000000..cf3dc80ea0 --- /dev/null +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -0,0 +1,75 @@ +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('tracks a mounted composition and forgets it once the agent is gone', 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') + + await handle.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/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts new file mode 100644 index 0000000000..36810c61d1 --- /dev/null +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -0,0 +1,204 @@ +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, { leakedServices, livePresetMounts } 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 }, + { path: join(FIXTURES, 'user'), trust: 'user' as const }, +] + +/** A composition carrying the registries a preset contributes to, plus the preset roster. */ +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 }) + 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 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('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() + + expect(listed.map(preset => preset.id).sort()) + .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard']) + expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system') + }) + + it('exposes the configured default id', () => { + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) + +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('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([]) + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json new file mode 100644 index 0000000000..d8494fdfd5 --- /dev/null +++ b/packages/preset/agent-presets/tsconfig.json @@ -0,0 +1,31 @@ +{ + "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": "../../util/paths" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaaed3c423..f18d43bbc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4160,6 +4160,49 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/preset/agent-presets: + dependencies: + 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-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-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/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a6ad066add..1ca6dac5e1 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 920 + "packages/README.md": 980 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bcf90d1e82..cc7b76fc2c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -251,6 +251,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { 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', 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 dd00068fe4..b7faa4bc6c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -259,6 +259,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/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 316a4233de..5ef1edc4f9 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + '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/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index cba3a9972d..3c7c2ba1d2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -86,6 +86,7 @@ "./packages/goal/*/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", @@ -185,6 +186,7 @@ "./packages/goal/*/src", "./packages/guard/*/src", "./packages/plan/*/src", + "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/tasks/*/src", "./packages/workflow/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 38e909fe63..021ff2e23b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -217,6 +217,7 @@ { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, + { "path": "./packages/preset/agent-presets" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/cordis/repository-plugin" },