fix(preset): correct the composition-authoring skill and give it a real check
The `cordis` preset's `editing-cordis-compositions` skill is the only guidance an agent has when it authors a preset, and four of its statements were false. `tool-bash` was named as the worked example of a row that hides a service; it provides nothing and injects `bashEnv` from the host's own `bash-env` row, so following that advice strands the row behind its realm and the preset fails to mount. The `isolate` example composed `tasks-local` with `tool-tasks`, which the shipped compositions' own comments say breaks `run_in_background`. A string realm label was described as pooling one instance; labels join realms and `provide()` still throws on the second registration. Rows were to be checked against a package README, which no harness package publishes. Verification is now the agent's own: `standingKeyFor(id)` runs the same mount a session start performs and rejects an unresolvable package, an invalid config, a service in the root realm, and a row that never activated. The skill states that `list()`'s `broken` field is a shape check that every one of those passes, ships the `cordis_mount` plugin that reaches the roster service, and names `copy()` as the authoring write. The prohibition on touching the shipped install is promoted to its own section and extended to the host composition. Fixes #2266
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
---
|
||||
name: editing-cordis-compositions
|
||||
description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing.
|
||||
description: Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing.
|
||||
---
|
||||
|
||||
# Editing Cordis compositions
|
||||
|
||||
Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.
|
||||
|
||||
## Off-limits
|
||||
|
||||
**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.
|
||||
|
||||
To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.
|
||||
|
||||
## Decide the plane first
|
||||
|
||||
Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared.
|
||||
@@ -17,16 +23,103 @@ Two planes, and the choice is not about how "agent-related" something feels —
|
||||
|
||||
**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.
|
||||
|
||||
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<name>/`.
|
||||
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`.
|
||||
|
||||
## The roster service
|
||||
|
||||
`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.
|
||||
|
||||
Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on:
|
||||
|
||||
- `list()` — every preset with its `id`, `trust`, and absolute `path`. This is how you locate the shipped compositions without knowing the install layout.
|
||||
- `read(id)` — one preset's composition text.
|
||||
- `copy(from, id, name?)` — the only authoring write (see below).
|
||||
- `standingKeyFor(id)` — mount-validate one preset (see below).
|
||||
|
||||
```js
|
||||
return {
|
||||
name: 'preset-tools',
|
||||
inject: ['agentPresets', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'preset_check',
|
||||
description: 'Mount-validate one preset by id.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },
|
||||
async execute(args) {
|
||||
try {
|
||||
await ctx.agentPresets.standingKeyFor(args.id)
|
||||
return 'mounted OK'
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.
|
||||
|
||||
## Authoring a preset
|
||||
|
||||
1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.
|
||||
2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands.
|
||||
3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster.
|
||||
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above.
|
||||
1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id (lowercase letters, digits, and hyphens, because it becomes the directory name), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, and the copy is exactly as loadable as its source. `standard` is the full coding agent and the usual source.
|
||||
2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.
|
||||
3. **Give the copy its own `name` and `description`** in `preset.yml`.
|
||||
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.
|
||||
5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.
|
||||
|
||||
### Native product subagents
|
||||
A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.
|
||||
|
||||
## The rule that catches people
|
||||
|
||||
**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.
|
||||
|
||||
Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:"services"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.
|
||||
|
||||
When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:
|
||||
|
||||
```yaml
|
||||
- id: delegation
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
workflows: true
|
||||
config:
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
```
|
||||
|
||||
`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.
|
||||
|
||||
A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.
|
||||
|
||||
Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-tasks`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:
|
||||
|
||||
- a row whose package does not resolve (`Cannot find package …`);
|
||||
- a row whose config is invalid (`invalid config: $.<field> missing required value`);
|
||||
- a service published into the root realm (`service "<name>" has been registered at <Owner>`);
|
||||
- a row that never activated (`N row(s) did not activate: <id>: waiting for <service>`).
|
||||
|
||||
It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.
|
||||
|
||||
**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.
|
||||
|
||||
`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.
|
||||
|
||||
After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.
|
||||
|
||||
`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.
|
||||
|
||||
## Native product subagents
|
||||
|
||||
Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field.
|
||||
|
||||
@@ -54,43 +147,6 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o
|
||||
|
||||
The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product.
|
||||
|
||||
The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete.
|
||||
|
||||
## The rule that catches people
|
||||
|
||||
**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.
|
||||
|
||||
Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service.
|
||||
|
||||
When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm:
|
||||
|
||||
```yaml
|
||||
- id: tasks
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
tasks: true
|
||||
config:
|
||||
- id: tasks-local
|
||||
name: '@deepseek-ai/dsh-tasks-local'
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
```
|
||||
|
||||
`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate.
|
||||
|
||||
A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing.
|
||||
|
||||
Host capabilities exposed through registries need no realm: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it.
|
||||
|
||||
To check a preset you authored, re-read the files and validate these fields: the top level is a YAML list, every row is a map with a `name`, every group carries its own list, and service-publishing rows sit behind an `isolate` realm. The settings page's preset roster validates the same fields and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself.
|
||||
|
||||
`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.
|
||||
|
||||
## What not to move into a preset
|
||||
|
||||
`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.
|
||||
|
||||
Reference in New Issue
Block a user