feat(web): expose the agent-preset roster over the API

`agentPreset.list` gives a browser the deployment's roster so it can offer a
choice when starting a session. Each row carries the id, its `trust`, and
whether it is the current default.

`trust` is on the wire deliberately: a `user` preset is exactly as privileged
as the plugins it names, so a surface that offers one alongside a shipped
preset can say which is which rather than presenting both as vetted.

The domain is read-only. A preset is a composition on disk, so authoring one
is a filesystem act rather than an RPC; and a deployment composing no presets
answers with an empty roster rather than an error, because sharing the host
composition is a valid deployment.

The RPC map made every registration site a type error, so the route, the
response-schema table, the service delegate, and the browser fixture are all
wired rather than only the ones I remembered.
This commit is contained in:
Yichen Jiang
2026-08-04 00:15:37 +08:00
parent 8d06b2d576
commit e6fe32b3c3
16 changed files with 159 additions and 2 deletions

View File

@@ -2484,6 +2484,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
agentPresets: {
// A deployment with no roster answers with an empty list rather than an
// error: composing no presets is a valid deployment, and the browser
// simply offers no choice.
async list(request) {
const presets = ctx.get('agentPresets')
if (presets === undefined) return ok(request, { presets: [] })
const defaultId = presets.defaultId
return ok(request, {
presets: (await presets.list()).map(preset => ({
id: preset.id,
trust: preset.trust,
isDefault: preset.id === defaultId,
})),
})
},
},
skills: {
// Skill lookup never touches the Agent registry: the session address
// resolves to a canonical cwd from the host-resident session header, so

View File

@@ -0,0 +1,25 @@
/**
* agent-presets domain zod schemas (names derived from map keys:
* agentPresetListRequestSchema / agentPresetListValueSchema).
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { AgentPresetEntry } from './agent-presets.ts'
/** AgentPresetEntry row of agentPreset.list. */
export const agentPresetEntrySchema = z.object({
id: z.string().min(1),
trust: z.union([z.literal('system'), z.literal('user')]),
isDefault: z.boolean(),
}) satisfies z.ZodType<Wire<AgentPresetEntry>>
/** agentPreset.list request payload. */
export const agentPresetListRequestSchema = z.object({
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.list'>>>
/** agentPreset.list response value. */
export const agentPresetListValueSchema = z.object({
presets: z.array(agentPresetEntrySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>

View File

@@ -0,0 +1,31 @@
/**
* 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.
*/
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
}
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
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.
*/
list(request: RpcRequest<{}>): Promise<RpcResponse<{ presets: readonly AgentPresetEntry[] }>>
}

View File

@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts'
import type { SubagentsApi } from './subagents.ts'
import type { EventsApi } from './events.ts'
@@ -25,6 +26,7 @@ export interface ApiProxy {
workspace: WorkspaceApi
commands: CommandsApi
skills: SkillsApi
agentPresets: AgentPresetsApi
events: EventsApi
goals: GoalsApi
settings: SettingsApi
@@ -47,6 +49,7 @@ export type {
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'

View File

@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts'
import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
@@ -50,6 +51,7 @@ export interface RpcMethodMap {
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
'skill.list': SkillsApi['list']
'agentPreset.list': AgentPresetsApi['list']
'goal.create': GoalsApi['create']
'goal.edit': GoalsApi['edit']
'goal.pause': GoalsApi['pause']

View File

@@ -40,6 +40,7 @@ import {
} from '../api/workspace.schema.ts'
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
import { agentPresetListValueSchema } from '../api/agent-presets.schema.ts'
import {
goalCreateValueSchema,
goalEditValueSchema,
@@ -119,6 +120,9 @@ export interface IApiClient {
skills: {
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
}
readonly agentPresets: {
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
@@ -185,6 +189,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
'skill.list': skillListValueSchema,
'agentPreset.list': agentPresetListValueSchema,
'goal.create': goalCreateValueSchema,
'goal.edit': goalEditValueSchema,
'goal.pause': goalPauseValueSchema,
@@ -443,6 +448,11 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
}
readonly agentPresets = {
list: (payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal) =>
this.callUnary('agentPreset.list', payload, signal),
}
readonly goals: IApiClient['goals'] = {
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),

View File

@@ -42,6 +42,7 @@ import {
} from '../api/workspace.schema.ts'
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
import { agentPresetListRequestSchema } from '../api/agent-presets.schema.ts'
import {
goalCreateRequestSchema,
goalEditRequestSchema,
@@ -109,6 +110,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(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) },

View File

@@ -63,6 +63,7 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly commands: ApiProxy['commands']
readonly goals: ApiProxy['goals']
readonly skills: ApiProxy['skills']
readonly agentPresets: ApiProxy['agentPresets']
readonly settings: ApiProxy['settings']
readonly credentials: ApiProxy['credentials']
readonly llm: ApiProxy['llm']
@@ -85,6 +86,7 @@ export class ApiProxyService extends Service implements ApiProxy {
this.commands = api.commands
this.goals = api.goals
this.skills = api.skills
this.agentPresets = api.agentPresets
this.settings = api.settings
this.credentials = api.credentials
this.llm = api.llm