feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser affordance. The roster gains `read`/`write`/`remove` beside `select`, and the browser gains a settings section over them: the presets as rows, one composition open in a YAML editor at a time, and per-row default, duplicate, and delete. All four authoring methods are loopback-pinned. A composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `agentPreset.list` deliberately stays ordinary and now reports `authorable`, so a surface knows whether creating is possible at all rather than offering a button whose save always fails. Authoring starts by duplicating: a shipped preset opens read-only because the deployment's copy is what a broken local one is compared against. Ids are contained before they become directory names, and the text is parsed with the loader's own schema, so a save cannot leave a file no session could load. Fixes a defect the real-composition test found: a preset written under the user's home could never mount, because the loader resolves a row against the composition's own directory and Node's `node_modules` walk from there never reaches the installed harness. The mount now records the host base and sends bare specifiers there, leaving relative paths resolving from the preset. Also closes the coverage the earlier surfaces in this stack shipped without — the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
@@ -25,7 +25,8 @@ import {
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import {
|
||||
PresetMountError, resolveSessionPreset, UnknownPresetError,
|
||||
InvalidCompositionError, InvalidPresetIdError, PresetMountError,
|
||||
PresetNotWritableError, resolveSessionPreset, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
@@ -658,6 +659,33 @@ class SubagentSessionOwnership extends Error {
|
||||
* replay tool calls the rebuilt agent cannot make. Naming a different preset
|
||||
* is therefore a caller error rather than a switch.
|
||||
*/
|
||||
/** The roster is absent: this deployment composes no agent presets at all. */
|
||||
function noRoster(agentPreset: string): RpcError {
|
||||
return {
|
||||
code: 'agent-preset-not-found',
|
||||
message: 'this deployment composes no agent presets',
|
||||
details: { agentPreset, available: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one authoring/roster failure onto its wire code. */
|
||||
function presetError(agentPreset: string, error: unknown): RpcError {
|
||||
if (error instanceof UnknownPresetError) {
|
||||
return {
|
||||
code: 'agent-preset-not-found',
|
||||
message: error.message,
|
||||
details: { agentPreset: error.presetId, available: [...error.available] },
|
||||
}
|
||||
}
|
||||
if (error instanceof PresetNotWritableError) {
|
||||
return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
|
||||
}
|
||||
if (error instanceof InvalidPresetIdError || error instanceof InvalidCompositionError) {
|
||||
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
|
||||
}
|
||||
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
|
||||
}
|
||||
|
||||
class AgentPresetConflict extends Error {
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
@@ -2501,7 +2529,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// simply offers no choice.
|
||||
async list(request) {
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return ok(request, { presets: [] })
|
||||
if (presets === undefined) return ok(request, { presets: [], authorable: false })
|
||||
const defaultId = presets.defaultId
|
||||
return ok(request, {
|
||||
presets: (await presets.list()).map(preset => ({
|
||||
@@ -2509,6 +2537,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
trust: preset.trust,
|
||||
isDefault: preset.id === defaultId,
|
||||
})),
|
||||
authorable: presets.authorable,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -2563,6 +2592,50 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection):
|
||||
// a composition names the plugins a session runs, so reading one is
|
||||
// reconnaissance and writing one is arbitrary capability.
|
||||
async read(request) {
|
||||
const { agentPreset } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
const preset = await presets.resolve(agentPreset)
|
||||
return ok(request, {
|
||||
agentPreset: preset.id,
|
||||
trust: preset.trust,
|
||||
content: await presets.read(preset.id),
|
||||
writable: preset.trust === 'user' && presets.authorable,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
}
|
||||
},
|
||||
|
||||
async write(request) {
|
||||
const { agentPreset, content } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
await presets.write(agentPreset, content)
|
||||
return ok(request, { agentPreset })
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
}
|
||||
},
|
||||
|
||||
async remove(request) {
|
||||
const { agentPreset } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
await presets.remove(agentPreset)
|
||||
return ok(request, {})
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const agentPresetListRequestSchema = z.object({
|
||||
/** agentPreset.list response value. */
|
||||
export const agentPresetListValueSchema = z.object({
|
||||
presets: z.array(agentPresetEntrySchema),
|
||||
authorable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.select request payload. */
|
||||
@@ -35,3 +36,36 @@ export const agentPresetSelectRequestSchema = z.object({
|
||||
export const agentPresetSelectValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.read request payload. */
|
||||
export const agentPresetReadRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.read response value. */
|
||||
export const agentPresetReadValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
content: z.string(),
|
||||
writable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.write request payload. */
|
||||
export const agentPresetWriteRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
content: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.write'>>>
|
||||
|
||||
/** agentPreset.write response value. */
|
||||
export const agentPresetWriteValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.write'>>>
|
||||
|
||||
/** agentPreset.remove request payload. */
|
||||
export const agentPresetRemoveRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.remove'>>>
|
||||
|
||||
/** agentPreset.remove response value. */
|
||||
export const agentPresetRemoveValueSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.remove'>>>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* agent-presets domain contract: the roster a browser offers when starting a
|
||||
* session. Read-only — a preset is a composition on disk, and authoring one is
|
||||
* a filesystem act rather than an RPC.
|
||||
* session, plus the authoring calls behind it.
|
||||
*
|
||||
* `list` is ordinary: it carries ids and trust, and every preset picker needs
|
||||
* it. Everything else is privileged and loopback-pinned — a composition names
|
||||
* the plugins a session runs, so reading one is reconnaissance, writing one is
|
||||
* arbitrary capability, and selecting one can move a session onto a preset
|
||||
* that edits the live runtime.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -26,9 +31,12 @@ export interface AgentPresetsApi {
|
||||
/**
|
||||
* Lists every preset the deployment currently supplies, ordered by id.
|
||||
* An empty roster means the deployment composes no presets at all, and
|
||||
* every session shares the host composition.
|
||||
* every session shares the host composition. `authorable` reports whether
|
||||
* the deployment configures a root new presets can be written to, which is
|
||||
* a deployment fact rather than a per-preset one.
|
||||
*/
|
||||
list(request: RpcRequest<{}>): Promise<RpcResponse<{ presets: readonly AgentPresetEntry[] }>>
|
||||
list(request: RpcRequest<{}>):
|
||||
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean }>>
|
||||
|
||||
/**
|
||||
* Recompose one session's agent from a different preset.
|
||||
@@ -40,4 +48,24 @@ export interface AgentPresetsApi {
|
||||
*/
|
||||
select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Read one preset's composition text, for an editor.
|
||||
*
|
||||
* Privileged: a composition names the plugins a session runs, so reading one
|
||||
* is reconnaissance and writing one is arbitrary capability.
|
||||
*/
|
||||
read(request: RpcRequest<{ agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string; trust: 'system' | 'user'; content: string; writable: boolean }>>
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset. Shipped presets are refused;
|
||||
* the text is shape-checked before it lands, so a save cannot leave a file no
|
||||
* session could load.
|
||||
*/
|
||||
write(request: RpcRequest<{ agentPreset: string; content: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/** Delete a locally authored preset. Shipped presets are refused. */
|
||||
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface RpcMethodMap {
|
||||
'skill.list': SkillsApi['list']
|
||||
'agentPreset.list': AgentPresetsApi['list']
|
||||
'agentPreset.select': AgentPresetsApi['select']
|
||||
'agentPreset.read': AgentPresetsApi['read']
|
||||
'agentPreset.write': AgentPresetsApi['write']
|
||||
'agentPreset.remove': AgentPresetsApi['remove']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -46,6 +46,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-exists': { path: string }
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-preset-read-only': { agentPreset: string; reason: string }
|
||||
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
|
||||
'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string }
|
||||
'agent-preset-not-found': { agentPreset: string; available: string[] }
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import { agentPresetListValueSchema, agentPresetSelectValueSchema } from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
agentPresetListValueSchema, agentPresetReadValueSchema, agentPresetRemoveValueSchema,
|
||||
agentPresetSelectValueSchema, agentPresetWriteValueSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
@@ -123,6 +126,9 @@ export interface IApiClient {
|
||||
readonly agentPresets: {
|
||||
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
|
||||
select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>>
|
||||
read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>>
|
||||
write(payload: RequestPayload<'agentPreset.write'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.write'>>>
|
||||
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -192,6 +198,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'skill.list': skillListValueSchema,
|
||||
'agentPreset.list': agentPresetListValueSchema,
|
||||
'agentPreset.select': agentPresetSelectValueSchema,
|
||||
'agentPreset.read': agentPresetReadValueSchema,
|
||||
'agentPreset.write': agentPresetWriteValueSchema,
|
||||
'agentPreset.remove': agentPresetRemoveValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -455,6 +464,12 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
this.callUnary('agentPreset.list', payload, signal),
|
||||
select: (payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.select', payload, signal),
|
||||
read: (payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.read', payload, signal),
|
||||
write: (payload: RequestPayload<'agentPreset.write'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.write', payload, signal),
|
||||
remove: (payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.remove', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import { agentPresetListRequestSchema, agentPresetSelectRequestSchema } from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
agentPresetListRequestSchema, agentPresetReadRequestSchema, agentPresetRemoveRequestSchema,
|
||||
agentPresetSelectRequestSchema, agentPresetWriteRequestSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
@@ -112,6 +115,9 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
|
||||
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
|
||||
'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) },
|
||||
'agentPreset.write': { schema: agentPresetWriteRequestSchema, invoke: (api, r) => api.agentPresets.write(r) },
|
||||
'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
|
||||
Reference in New Issue
Block a user