Tool and prompt-section visibility is inherited along dsh-scope's parent chain, and an agent's scope key is minted with no parent. Per-session agent presets moved every model-facing row onto the agent plane and made AgentPresets.mount() the one thing that binds that link, from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone — which is empty wherever a preset roster is composed. One-shot children reached the model with no tools, continuable ones with only the host-plane `report`, and neither carried its parent's persona, workspace context, or skill catalog. AgentPresets.composeFrom() joins one agent to the standing composition another already runs on. It is a bind, not a mount: the child gets its parent's exact generation, so a composition edited since the parent started cannot fork it onto another one, and it is synchronous, which is what lets a child creation window use it. applyChildComposition() now takes the parent and performs the join first, making a child composed without it unrepresentable at the call sites. childSessionMeta() records the joined id so a cold read rebuilds the composition the child actually ran under. The audit that followed found two api-proxy readers on the wrong authority: presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both read header.agentPreset, which goes stale the moment a blank session switches preset. A switched session's cold transcript resolved presenters in the older composition's layer and silently degraded to generic cards, and the gateway refused to adopt a live session under the preset it actually runs while accepting the one it left. Both now resolve through resolveSessionPreset(), matching the resume branch fifteen lines above. The owning architecture Agent Note carried the stale claim that the header records what a session runs; it is corrected to name the header/log pair and its three readers. Fixes #2165
16 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 presets the deployment ships are the directories under apps/cli/config/agent-presets/; the roster is that listing, not a list restated here.
Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own isolate vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance.
Which preset an unnamed session gets is a user setting (agent-presets.default) layered over the composition's own default, which becomes the base. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a cordis.yml they may not own.
Consequences
The effective default is read per resolution, never snapshotted. A cached value would need a watch subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an agent-preset/selected event records any later blank-session switch, so a reader resolves the pair (resolveSessionPreset) and never the header alone: a resume rebuilds the composition its history was produced under rather than today's default, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
A directly-plugged subtree is invisible to the boot audit. It never links itself to an Entry, so it is absent from ctx.loader.entries() and assertEntriesActivated cannot see it. The mount audits its own rows instead, reading the tree through an Include subclass that publishes it.
A preset can only name a group because the app registers one. Sharing a realm across rows is a cordis:group row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve @cordisjs/plugin-group by name: Node's upward node_modules walk never reaches the harness from there. boot() therefore registers cordis:group beside cordis:include as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the isolate vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers.
A preset may not publish into the root service realm. Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first — and the collision surfaces as an unhandled rejection that setup never observes, leaving a half-composed agent that looks healthy. The mount rejects it instead, and the package invariant re-checks on every service notification because a row publishing from a timer or an asynchronous continuation would escape a one-shot audit.
Failure rolls the agent back. setup runs before publication, so a rejected mount fails ctx.agents.create() and leaves nothing behind. This is why setup is the one supported call site.
A test that the preset file is never rewritten has to be able to fail. The first version asserted the file was unchanged after an ordinary mount, and could not have caught anything: the Loader only reaches its write path when it decides the config changed, and nothing in that composition ever self-disposed. The regression plants a row that disposes itself — the shape a real preset hits every time an agent is torn down — and keeps the composition in a temp root rather than under fixtures/, because without the override the Loader rewrites the file it read: a committed fixture would be damaged by the very run that proves the bug, and every run after it would compare against the damaged file and pass.
Fiber membership is object identity, not uid. A uid is a per-registry counter, so fibers in two different roots collide on it; comparing by uid made one runtime's subtree answer for a service published in another. ctx.plugin() returns a thenable Object.create(fiber) wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction.
A preset file is an input, never a persistence target. EntryTree.write() persists a tree whenever the Loader decides the config changed, and a plugin self-disposing is enough — tearing an agent down disposes its whole subtree. Inherited, that rewrites the composition it read, in practice truncating a shipped preset to [] the first time a session ends. The subtree overrides write() to do nothing.
A plugin that looks itself up in the global registry breaks inside a preset. ctx.tools.register() files into the CALLING context's scope, so a plugin mounted in a preset registers for one agent and an unscoped ctx.tools.get(name) correctly finds nothing. dsh-tool-skill did exactly that and threw on every preset mount; it now compares against the definition it registered. Any plugin meant to be preset-mountable must hold its own registration rather than re-read it by name.
An entry-local isolate realm is invisible to the agent's own scope, not only to the host. Only rows inside that group resolve the service. That is what makes a preset's skills registry belong to one agent rather than being shared — and it means a consumer left outside its provider's group silently resolves the host registry and contributes nothing.
Switching is allowed only while a session is blank. Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so agentPreset.select answers agent-preset-locked. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the AgentHandle it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails.
Authoring a preset is an RPC, and a privileged one. A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained read/write/remove beside select. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. list and select deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but session.create already takes an agentPreset, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries bash and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id ([a-z0-9][a-z0-9-]*), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought.
A service with a consumer outside the agent plane cannot move into a preset. The aggressive split moved the subagents registry and its spawn/fork backends into the delegation group's entry-local realm, and dsh web then failed to boot: dsh-host-apiproxy is a HOST row that injects subagents to answer the browser's cross-session queries (listChildren, followup), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. workflows stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones.
A real-composition test that disables a host row cannot audit that row. The web composition test disabled api-gateway — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off.
A preset's package names must resolve from the harness, not from the preset. EntryTree.import() resolves a row against its own tree's baseUrl, which Include sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward node_modules walk never reaches the installed harness, so every @deepseek-ai/dsh-* row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it.
The preset id is model-visible and must be logged. It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside cwd, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default.
A durable header field is not durable until every backend writes it. agentPreset landed on SessionHeader with the right rationale and neither persistence backend carried it: the JSONL header line, the SQLite sessions row, and the derived query index each map the header column by column, so a resumed session came back with no preset and the surfaces that name it fell silent. summarizeCold had the same shape — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it.
The choice belongs to the screen where it still works. The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is staged: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding sessions.create would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright.
A preset multiplies a cost the host was already paying: nothing disposes an agent. Measured against the shipped compositions with --expose-gc, one live agent holds ~0.17 MB on minimal and ~1.31 MB on standard/cordis, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 standard agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. dsh-host-apiproxy discards the AgentHandle it creates, archiveSession only edits the workspace registry, AgentRegistry has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber uid has cleared, and an agent that never dies never clears one.
- Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam.
Alternatives considered
Add a preset tier to the scoped registries. ScopedLayers.merge() combines the global layer with exactly one exact-scope layer. A middle tier would let many sessions share one mounted composition, but it changes dsh-scope and every scope-aware registry to save a cost measured in milliseconds, and it gives a preset's registrations a lifetime no agent owns.
Make the agent's scope key the preset. Sessions on one preset would share a layer for free, but per-agent registrations — installAgentLlmTarget, per-agent tool restrictions — would then collide across sessions.
Run each preset as a child process. subagent-dsh-sdk 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.