fix(agent-presets,web): broken presets are roster rows, not gaps

A hand-damaged preset was silent until the worst moment. An unparsable
composition listed as an ordinary selectable row and failed only at the
next session start — set as default, every new session failed. A
directory whose composition file was deleted vanished from the roster
while still occupying its id: copy answered "delete the existing preset
first" while remove answered "not found", a dead end.

Discovery now owns health: every id-shaped directory is a roster slot,
broken when its composition is missing or unloadable, checked with the
loader's own entryListSchema dialect (!!js included) so health never
rejects what the loader accepts. `broken` rides AgentPreset, the
agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor
refuse broken up front with the discovery-reported reason, while
resolve/read/remove still answer. The section renders marked red cards —
unselectable, uncopyable, deletable, location kept on custom rows — and
both pickers drop broken rows entirely.

The cordis preset's persona now forbids editing the shipped install
(corrupting cordis would disable the mode itself) and points authoring
at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the
copy-first workflow, the one-escalation sandbox reality, and honest
verification. Exercised live: asked to edit the shipped composition the
composed agent refuses citing both rules; asked for real presets (simple
and complex) it lands them under the user root with one approved
escalation each and self-checks with the loader dialect.
This commit is contained in:
Yichen Jiang
2026-08-09 02:17:56 +08:00
parent bf31797e95
commit c56f23ae1f
36 changed files with 720 additions and 87 deletions
+25 -3
View File
@@ -153,6 +153,10 @@ export class AgentPresets extends Service {
/**
* Resolve one preset by id.
*
* A broken preset resolves — deleting one, reading one, and reporting one
* all need the row — and the mounting paths refuse it AFTER resolution
* through {@link resolveMountable}.
* @param id - the preset id, or `undefined` for {@link defaultId}.
* @returns the resolved preset.
* @throws when no configured root supplies that id.
@@ -167,6 +171,24 @@ export class AgentPresets extends Service {
return found
}
/**
* Resolve one preset that is about to compose an agent, refusing a broken
* one with its discovery-reported reason. Failing here rather than inside
* the loader keeps the answer the same for every unloadable shape — ghost
* directory, unparsable YAML, rowless list — and spends no mount attempt
* on a composition discovery already read as unusable.
* @param id - the preset id, or `undefined` for {@link defaultId}.
* @returns the resolved, mountable preset.
* @throws when the preset is unknown or discovery reports it broken.
*/
private async resolveMountable(id?: string): Promise<AgentPreset> {
const preset = await this.resolve(id)
if (preset.broken !== undefined) {
throw new PresetMountError(preset.id, preset.broken)
}
return preset
}
/**
* Standing mounts by preset id, single-flight so two agents racing the
* first use of one preset share one composition. A settled failure is
@@ -198,7 +220,7 @@ export class AgentPresets extends Service {
if (agentKey === undefined) {
throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset')
}
const preset = await this.resolve(id)
const preset = await this.resolveMountable(id)
const standing = await this.ensureStanding(preset)
setScopeParent(agentKey, standing.key)
return preset
@@ -314,7 +336,7 @@ export class AgentPresets extends Service {
if (agentKey === undefined) {
throw new Error('agent-presets: refusing to recompose an unscoped context')
}
const preset = await this.resolve(id)
const preset = await this.resolveMountable(id)
const standing = await this.ensureStanding(preset)
setScopeParent(agentKey, standing.key)
return preset
@@ -332,7 +354,7 @@ export class AgentPresets extends Service {
* @throws when the preset is unknown or its composition is unusable.
*/
async standingKeyFor(id?: string): Promise<ScopeKey> {
const preset = await this.resolve(id)
const preset = await this.resolveMountable(id)
return (await this.ensureStanding(preset)).key
}