* feat(preset): declare Agent compositions in profile YAML * fix(preset): share scope binding cleanup and refresh module graph * fix(preset): use host syntax colors in the YAML editor * fix(preset): write new profile declarations as block YAML * fix(preset): align eager activation with browser and worker surfaces * fix(ui-agent-preset): retain the existing preset card layout * fix(preset): keep rows waiting for a Host service mounted and re-audit after settlement A preset row whose Host provider was still activating at declaration time was reported as waiting for a missing service, and the registry recorded the definition as broken for the rest of the process. Split the mount audit into failed and pending rows: failures and root-realm leaks still reject the mount, while pending rows stay mounted and every read and binding re-audits them after the Host Loader tree settles. Remove the accidental self-dependency of @deepseek-ai/dsh-web-app and its lockfile self-link. Archive the copy-only preset authoring note, which the declarative-preset note fully supersedes, and update the ralph note's restore recipe to the profile-patch mechanism. * chore(lint): re-key and prune the unknown-cast baseline after the master merge * refactor(web-app): ship each Agent preset as its own bundle patch file `dsh.bundle.patch` accepts an ordered file list, so the four shipped preset declarations move out of the 1000-line web patch into `presets/<id>.patch.yml`, one insert per file, applied after the host patch. The host patch keeps the preset registry row. Gates and test scaffolds that read the web layer resolve every patch file through `bundlePatchPaths` instead of one hardcoded path. * refactor(preset): drop the save notice injected into live Agents The notice duplicated the save result the Web editor already shows, and after the producer-owned message sources it would have needed its own persisted source kind. Saving now only persists and reports the activation diagnostic; the persistence catalog is unchanged from master. * docs: regenerate the module graph after dropping the registry's llm dependency * test(ui-agent-preset): cover the guide dialog's keyboard handling on the declarative section * fix(preset): address the remaining review comments Apply the lowercase id rule only when creating a declaration, so presets declared with other ids stay editable. Export `isBuiltInPreset` from the display fold and key the client card groups on it. Remove the dead `error` and `retry` locale keys, the JSDoc orphaned by the `trust` removal, the `trust` fields in the plugin-inventory test fixtures, and the stale `dsh-agent-presets` package name in comments and READMEs. * refactor(preset): make the Web preset page view-and-select only Remove the child-plugin YAML editor, the `agent_preset` tool and the registry's read/save Remote methods. Presets are declared and changed only by bundle patches; the settings page keeps the roster, the new-task default and the mode help, and its question icon explains that and offers to start a Creator-mode task. The `editing-cordis-compositions` skill and the Creator persona describe the bundle-based path, and the authored skill-load Session and Web snapshots are refreshed for it. * docs(skill): make the composition skill self-sufficient for preset authoring State where the shipped declarations live, list every declaration field, inline a complete minimal bundle and an override example, and name the verification calls, so Creator mode no longer reads DSH package sources to infer the format. Refresh the authored skill-load Session for the new text. * test(tools): drop the removed agent_preset tool from the catalog expectation
18 KiB
Agent Note: A session's agent is composed from a preset cordis.yml
Status: implemented Archived: 2026-09-18
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), the subagent providers those facilities resolve, 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 packages/preset/agent-presets/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.
The agent-presets user-settings namespace carries modeSelectionEnabled and default. modeSelectionEnabled defaults to true: the existing new-session picker remains present and an unnamed session resolves to the saved user default, or the composition's deployment default when none exists. The Web Settings toggle changes only that policy: disabling selection temporarily uses the deployment default, while re-enabling it restores the saved user default. This is a deliberate exception to the ordinary user-over-composition settings precedence established in #1539: hiding the chooser disables the user's mode-selection policy without deleting its saved value. The Host policy governs every later session whose caller omits a preset; explicitly named presets and existing sessions remain unchanged. The composition value also keeps the package working with no settings provider, while an enabled user override changes later sessions without editing a deployment-owned cordis.yml.
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. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant 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 the deployment default at resume time, 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 auditStartupEntries cannot see it. The mount reads its own rows through an Include subclass that publishes the tree and requires every enabled row to activate, independently of the application startup policy.
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. Connection authenticates authoring, list, select, and the complete Host API with one browser session: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability, while choosing a preset grants nothing session.create with agentPreset did not already grant. 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: SubagentRuntime is a Host row that exposes the browser's cross-session Remote queries (list, prompt), so it waited forever for a service only sessions 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 every shared backend, including the fixed Codex and Claude Code product providers, are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools 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 keeps API Gateway and the domain Remote services active while disabling the transport-only webserver, Connection, Session export, asset, and telemetry rows. With the browse directory picker substituted, its startup audit covers the host-plane service graph without binding a port.
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 the client surfaces show what a session actually runs rather than the deployment's current default.
A durable header field is not durable until the provider writes it. agentPreset landed on SessionHeader with the right rationale and the JSONL provider omitted it; the derived query index also maps header fields explicitly, so a resumed Session came back with no preset and the surfaces that name it fell silent. summarizeCold had the same form — 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 control lives on the new-session screen beside the workspace picker, is present under the default-on Host policy, and disappears only after modeSelectionEnabled is disabled. Its 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; hiding the picker discards a stage that has not reached a session and returns the current blank session to the deployment default through the same selection path. What a running or historical session runs remains 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. ApiSessionAgentController discards the AgentHandle returned by the registry, 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.
Give product subagents global enable settings and a separate settings page. The process-wide value would compete with the preset as owner of model-visible tools and could not express two sessions using different compositions. Product providers stay host-side, while ordinary preset rows independently expose Codex and Claude Code tools.
Ship one preset for every Codex and Claude Code combination. Four identities duplicate the full preset composition to represent two independent rows. A copied preset can enable either row directly, so combination presets add roster and maintenance cost without adding a user result.