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.
117 lines
5.2 KiB
TypeScript
117 lines
5.2 KiB
TypeScript
/**
|
|
* agent-presets domain contract: the roster a browser offers when starting a
|
|
* session, plus the authoring calls behind it.
|
|
*
|
|
* `list` is ordinary: it carries ids and trust, and every preset picker needs
|
|
* it. The authoring calls are privileged and loopback-pinned — a composition
|
|
* names the plugins a session runs, so reading one is reconnaissance, and
|
|
* although authoring is copy-only (no caller supplies composition text or a
|
|
* path), copying and deleting still rearrange what the deployment offers.
|
|
*/
|
|
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
|
|
|
/** One preset the deployment can compose a session's agent from. */
|
|
export interface AgentPresetEntry {
|
|
/** Stable identifier, also the display name until presets carry metadata. */
|
|
readonly id: string
|
|
/**
|
|
* Whether the preset ships with the deployment or was authored locally.
|
|
* A `user` preset is exactly as privileged as the plugins it names, so a
|
|
* surface offering one should say so rather than present it as vetted.
|
|
*/
|
|
readonly trust: 'system' | 'user'
|
|
/** Whether a session that names no preset gets this one. */
|
|
readonly isDefault: boolean
|
|
/**
|
|
* Display name the preset published, absent when it published none. A
|
|
* surface falls back to {@link id}; it is never a second identity, and it
|
|
* never decides trust — a locally authored preset cannot name itself into
|
|
* the shipped set.
|
|
*/
|
|
readonly name?: string
|
|
/** One sentence on what the preset is for, when it published one. */
|
|
readonly description?: string
|
|
/**
|
|
* Why this preset cannot compose a session, absent when it can. A broken
|
|
* preset stays listed — its directory still occupies the id, so a surface
|
|
* must be able to show and delete it — but offering it for selection would
|
|
* only defer this reason to a failed session start.
|
|
*/
|
|
readonly broken?: string
|
|
}
|
|
|
|
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
|
export interface AgentPresetsApi {
|
|
/**
|
|
* Lists every preset the deployment currently supplies, in root-precedence
|
|
* order — the roots as configured, each root's own presets sorted by id,
|
|
* and the first root to supply an id wins. The order is not globally
|
|
* sorted: a user root's preset sits in that root's block, not among the
|
|
* shipped ids.
|
|
* An empty roster means the deployment composes no presets at all, and
|
|
* every session shares the host composition. `authorable` reports whether
|
|
* the deployment configures a root new presets can be written to, and
|
|
* `hasDocument` whether `openDocument` can hand a preset directory to a
|
|
* native opener — both deployment facts rather than per-preset ones, and
|
|
* neither exposes a Host path.
|
|
*/
|
|
list(request: RpcRequest<{}>):
|
|
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>>
|
|
|
|
/**
|
|
* Recompose one session's agent from a different preset.
|
|
*
|
|
* Allowed only while the session is blank — no turn has run. Once a
|
|
* conversation starts, its history was produced under that preset's tools,
|
|
* and swapping them would leave logged tool calls the new composition cannot
|
|
* make; the attempt answers `agent-preset-locked`.
|
|
*/
|
|
select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>):
|
|
Promise<RpcResponse<{ agentPreset: string }>>
|
|
|
|
/**
|
|
* Read one preset's composition text, for the read-only viewer.
|
|
*
|
|
* Privileged: a composition names the plugins a session runs, so reading
|
|
* one is reconnaissance.
|
|
*/
|
|
read(request: RpcRequest<{ agentPreset: string }>):
|
|
Promise<RpcResponse<{
|
|
agentPreset: string
|
|
trust: 'system' | 'user'
|
|
content: string
|
|
name?: string
|
|
description?: string
|
|
}>>
|
|
|
|
/**
|
|
* Create a locally authored preset by copying an existing one whole.
|
|
*
|
|
* The only authoring write. No composition text and no path crosses the
|
|
* wire: `from` and `agentPreset` are ids the Host resolves against its own
|
|
* roots, so a copy is exactly as loadable as its source and grants nothing
|
|
* the roster did not already carry. The copy keeps the source's description
|
|
* (the file is the author's to edit afterwards) but not its name — `name`
|
|
* here or the id fallback is what distinguishes the rows.
|
|
*/
|
|
copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>):
|
|
Promise<RpcResponse<{ agentPreset: string }>>
|
|
|
|
/**
|
|
* Hand one locally authored preset's DIRECTORY to the platform opener, for
|
|
* editing the files that are now the only composition editor. The request
|
|
* carries an id, never a path — the Host resolves it — so no browser
|
|
* payload can select an arbitrary filesystem target. Where the deployment
|
|
* has no native opener (`hasDocument: false` on `list`), the reply carries
|
|
* the resolved directory for the surface to show as text instead. Shipped
|
|
* presets are refused: their install is not the user's to manage.
|
|
*/
|
|
openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
|
|
Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
|
|
|
|
/** Delete a locally authored preset. Shipped presets are refused. */
|
|
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
|
|
}
|