`core-web` said neither of the things that matter about it. The `-web` suffix is a leftover from the whole-process `core-web.cordis.yml` overlay, and presets are per-session and not web-specific. `core` reads as "the foundational one" when it is in fact the one with the fewest capabilities. `minimal` says what it is and orders the shipped set legibly by capability: minimal, standard, cordis. Breaking: a session created under `core-web` records that id in its header and will fail to resolve it on resume. Nothing outside this repository has shipped, so no migration path is offered. The identically named `config/core-web.cordis.yml` — the legacy whole-process overlay behind the web snapshot test — is a different thing and keeps its name.
13 KiB
Agent Note: A session's agent is composed from a preset cordis.yml
Status: implemented
English | 中文
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 and dsh-system-prompt already file registrations into the calling context's scope layer, and the agent is a registration scope. 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 deployment ships three presets — standard (the full coding agent), minimal (a two-tool benchmark surface), and cordis (the standard agent plus the self-referential toolset and a composition-authoring skill).
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. All four are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. list deliberately stays ordinary — ids and trust only, and a LAN client's picker needs it. 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.
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 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.