Bladeren bron

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.
Yichen Jiang 1 maand geleden
bovenliggende
commit
e6fe32b3c3

+ 12 - 0
packages/client/connection/src/client/fixture.ts

@@ -2312,6 +2312,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         return ok(request, { matched: true as const, commandId })
       },
     },
+    agentPresets: {
+      // Two rows so a picker has something to choose between, and so the
+      // trust distinction a surface must present is visible in the fixture.
+      list: request => ok(request, {
+        presets: [
+          { id: 'standard', trust: 'system' as const, isDefault: true },
+          { id: 'core-web', trust: 'system' as const, isDefault: false },
+        ],
+      }),
+    },
+
     skills: {
       list: (request) => {
         const missing = requireSession(request)
@@ -2609,6 +2620,7 @@ export class FixtureApiClient extends AbstractApiClient {
       case 'command.list': return this.api.commands.list(request)
       case 'command.execute': return this.api.commands.execute(request, signal)
       case 'skill.list': return this.api.skills.list(request)
+      case 'agentPreset.list': return this.api.agentPresets.list(request)
       case 'goal.create': return this.api.goals.create(request)
       case 'goal.edit': return this.api.goals.edit(request)
       case 'goal.pause': return this.api.goals.pause(request)

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
-README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
+README.md: 9484fadcc798652979f998c81f84444c1ebdbf52
+README.zh.md: 44e1d4b563e52c2491e07854469bbb283e30b28b

File diff suppressed because it is too large
+ 2 - 0
packages/host/apiproxy/README.md


File diff suppressed because it is too large
+ 2 - 0
packages/host/apiproxy/README.zh.md


+ 18 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -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

+ 25 - 0
packages/host/apiproxy/src/api/agent-presets.schema.ts

@@ -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'>>>

+ 31 - 0
packages/host/apiproxy/src/api/agent-presets.ts

@@ -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[] }>>
+}

+ 3 - 0
packages/host/apiproxy/src/api/index.ts

@@ -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'

+ 2 - 0
packages/host/apiproxy/src/api/rpc-map.ts

@@ -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']

+ 10 - 0
packages/host/apiproxy/src/fetch/client.ts

@@ -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),

+ 2 - 0
packages/host/apiproxy/src/fetch/handler.ts

@@ -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) },

+ 2 - 0
packages/host/apiproxy/src/index.ts

@@ -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

+ 27 - 0
packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts

@@ -235,3 +235,30 @@ describe('a capability the session\'s preset mounts', () => {
     expect(failure.error.message).toContain('neither this session')
   })
 })
+
+describe('agentPreset.list', () => {
+  it('marks the default and carries each preset\'s trust', async () => {
+    const { api } = await harness(['standard', 'core-web'])
+
+    const response = await api.agentPresets.list(request({}))
+
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.presets).toEqual([
+      { id: 'standard', trust: 'system', isDefault: true },
+      { id: 'core-web', trust: 'system', isDefault: false },
+    ])
+  })
+
+  it('answers with an empty roster when the deployment composes no presets', async () => {
+    const { api } = await harness()
+
+    const response = await api.agentPresets.list(request({}))
+
+    // Composing no presets is a valid deployment, not an error: every session
+    // then shares the host composition and the browser offers no choice.
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.presets).toEqual([])
+  })
+})

+ 2 - 0
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -23,6 +23,7 @@ function scriptedApi(overrides: {
   host?: Partial<ApiProxy['host']>
   commands?: Partial<ApiProxy['commands']>
   skills?: Partial<ApiProxy['skills']>
+  agentPresets?: Partial<ApiProxy['agentPresets']>
   events?: Partial<ApiProxy['events']>
   goals?: Partial<ApiProxy['goals']>
   settings?: Partial<ApiProxy['settings']>
@@ -86,6 +87,7 @@ function scriptedApi(overrides: {
       ...overrides.commands,
     },
     skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
+    agentPresets: { list: r => ok(r, { presets: [] }), ...overrides.agentPresets },
     goals: {
       create: err,
       edit: err,

+ 5 - 0
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -193,6 +193,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
         return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
       },
     },
+    agentPresets: {
+      list(request: RpcRequest<{}>) {
+        return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { presets: [] } } })
+      },
+    },
     skills: {
       async list(request) {
         return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }

+ 14 - 0
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -32,6 +32,7 @@ import {
   commandListRequestSchema, commandListValueSchema,
 } from '../src/api/commands.schema.ts'
 import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
+import { agentPresetEntrySchema, agentPresetListValueSchema } from '../src/api/agent-presets.schema.ts'
 import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
 import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
 import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
@@ -502,3 +503,16 @@ describe('respond payload schemas', () => {
     expect(payload.sessionId).toBe('s')
   })
 })
+
+describe('agent-preset schemas', () => {
+  it('accepts a roster row and rejects an unknown trust', () => {
+    expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true }))
+      .toEqual({ id: 'standard', trust: 'system', isDefault: true })
+    expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow()
+    expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow()
+  })
+
+  it('accepts an empty roster', () => {
+    expect(agentPresetListValueSchema.parse({ presets: [] })).toEqual({ presets: [] })
+  })
+})

Some files were not shown because too many files changed in this diff