8d06b2d576
`config.default` becomes the composition base of an `agent-presets` settings namespace, so the user document layers over the deployment's engineering default and a person can change which preset new sessions get without a restart. The value is read per resolution rather than snapshotted: a hot-reloaded document takes effect on the next session created, and every running session stays on the preset it was composed from — which is the same rule the session-header guard enforces from the other side. `resolve()` read `config.default` directly, which would have made the whole setting inert; it now goes through `defaultId` like every other caller. The write-protection test is rewritten against a temp profile root. It was passing vacuously: the un-overridden Loader REWRITES the composition it read — stamping `disabled: true` onto the self-disposing row — so the committed fixture had been mutated by the very run that proved the bug, and every later run compared against the damaged file and passed. Building the preset in a temp directory makes the assertion immune to its own failure mode, and it now fails with a visible `+ disabled: true` when the override is removed. Review follow-ups on this layer. The exported schema is `AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings` interface it resolves and self-describing at an import site. The `session.create` JSDoc promised "the deployment's default preset" for an omitted `agentPreset`, which this layer makes false — it now names the effective default. The constructor records why it does not use `installSettingsSection`: that helper re-judges what a consumer DERIVED across attach and detach, and nothing here is derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back instead of reaching into `ctx.reflect.store`, and the write-protection wait says why slack is the right shape for an absence assertion. The real composition covers the layering too. `apps/cli` boots the shipped `cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session composes from it — the package suite proves the layering against a hand-built context, this proves the roster and the settings provider are wired to each other. That test also pins the settings row at a temp file: it defaulted to `$DSH_HOME/settings.yaml`, so a developer's own stored default decided the outcome of a file whose whole point is that only the shipped root does. The Agent Note records the per-resolution read and its correspondence with the session header, and the vacuous-test finding above.
79 lines
6.5 KiB
Markdown
79 lines
6.5 KiB
Markdown
# 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<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id.
|
|
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it.
|
|
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 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.
|
|
|
|
### The default preset is a user setting
|
|
|
|
When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default:
|
|
|
|
```yaml
|
|
agent-presets:
|
|
default: core-web
|
|
```
|
|
|
|
The value is read per resolution rather than snapshotted, so a hot-reloaded document takes effect on the next session created and every running session stays on the preset it was composed from. Clearing the user field re-inherits the composition default. A default naming a preset no root supplies is stored without complaint and fails at the next `resolve()` — the roster is a live directory, so a name absent now may exist by the time a session asks for it.
|
|
|
|
## What a mount rejects
|
|
|
|
A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things.
|
|
|
|
**An unscoped target.** Mounting into a context that carries no agent scope would register the preset's tools globally, for every agent in the process.
|
|
|
|
**A row that never became usable.** The loader already rejects a row whose module failed to import or whose plugin threw; what remains is a row still waiting for a service the composition never supplies, which the audit names.
|
|
|
|
**A row that published a service into the root realm.** Such a service is process-global 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.
|
|
|
|
## A preset file is an input, never a persistence target
|
|
|
|
The Loader writes a tree back to its source file whenever it decides the config changed, and a row disposing its own fiber is enough to decide that: the entry is marked `disabled` and the tree is written. Inherited, that would burn one session's runtime state into a file every session shares — comments stripped by the YAML round trip, and a `writeFile` rejection inside a `setTimeout` for a read-only shipped preset.
|
|
|
|
The mounted subtree therefore overrides `write()` as a no-op. Nothing in this package writes a composition; authoring one is a separate, explicit operation.
|
|
|
|
## Trust
|
|
|
|
Presets are compositions, so a preset is exactly as privileged as the plugins it names. A `user` preset — authored by a person or by an agent — carries the same trust as shell access; the `trust` field exists so consumers can present that difference, not to enforce it.
|
|
|
|
## Model Experience
|
|
|
|
Indirectly, through the plugins a 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.
|
|
- **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()`.
|