From 306419cc8494cf4e5c99a09dae877b7e3e33bc2f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:54:54 +0800 Subject: [PATCH] refactor(agent-presets): expose browser operations through Remote --- packages/host/apiproxy/src/api-proxy.ts | 179 +------------- .../apiproxy/src/api/agent-presets.schema.ts | 71 +----- .../host/apiproxy/src/api/agent-presets.ts | 107 +-------- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 5 - packages/host/apiproxy/src/fetch/client.ts | 18 +- packages/host/apiproxy/src/fetch/handler.ts | 8 +- packages/preset/agent-presets/package.json | 16 +- packages/preset/agent-presets/src/index.ts | 219 +++++++++++++++++- packages/preset/agent-presets/src/preset.ts | 18 ++ packages/preset/agent-presets/src/types.ts | 71 +++++- packages/preset/agent-presets/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 13 files changed, 338 insertions(+), 382 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56b90e64f2..857172bcc2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -8,11 +8,11 @@ import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import { - InvalidPresetIdError, PresetExistsError, PresetMountError, + InvalidPresetIdError, PresetExistsError, PresetNotWritableError, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' import type { @@ -80,42 +80,6 @@ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } -/** - * Map an agent-preset selection failure onto its stable RPC refusal, or leave - * unrelated failures to the caller. - * @param request - the request being answered. - * @param error - the thrown value. - * @returns the refusal, or undefined when the caller should keep handling. - */ -function presetFailure(request: RpcRequest, error: unknown): RpcResponse | undefined { - if (error instanceof UnknownPresetError) { - return err(request, { - code: 'agent-preset-not-found', - message: error.message, - details: { agentPreset: error.presetId, available: [...error.available] }, - }) - } - if (error instanceof PresetMountError) { - return err(request, { - code: 'agent-preset-invalid', - message: error.message, - details: { agentPreset: error.presetId, reason: error.reason }, - }) - } - return undefined -} - -/** - * Whether the session's conversation has started: no turn has run yet (a - * turn is one model-loop execution). Standalone plugin events — command - * lifecycle records, plan/mode, titles, goals — never open a turn, so - * running `/plan` or `/goal` on a fresh session keeps it blank - * (list-hidden, reusable). - */ -function sessionBlank(session: Session): boolean { - return !session.events.some(event => event.type === 'turn/start') -} - /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ function directoryError(error: unknown): RpcError { if (error instanceof DirectoryPickerError) { @@ -195,14 +159,6 @@ function projectionsUnavailableError(): RpcError { } } -/** - * The requested preset differs from the one this session already runs. - * - * A session's composition is fixed at creation: its history was produced under - * that preset's tools, so adopting the identity under a different one would - * 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 { @@ -239,17 +195,6 @@ function presetError(agentPreset: string, error: unknown): RpcError { export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL - /** - * Serializes `agentPreset.select` per session. Two concurrent selects both - * pass the blank check, and the second `unmountPresetFor` then finds nothing - * to unmount because the first already removed the record — leaving two - * compositions registered into one agent layer. The client's `busy` flag is - * not enforcement: the wire is reachable directly. - */ - const presetSwitches = new Map>() - const agentFor = (sessionId: SessionId) => - ctx.sessionController.resolveAgent(sessionId) - /** Resolve a Session's live or standing preset scope without resuming it. */ async function sessionScopeFor( sessionId: SessionId, @@ -580,112 +525,8 @@ 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: [], authorable: false, hasDocument: false }) - const defaultId = presets.defaultId - return ok(request, { - presets: (await presets.list()).map(preset => ({ - id: preset.id, - trust: preset.trust, - isDefault: preset.id === defaultId, - ...preset.name === undefined ? {} : { name: preset.name }, - ...preset.description === undefined ? {} : { description: preset.description }, - ...preset.broken === undefined ? {} : { broken: preset.broken }, - })), - authorable: presets.authorable, - hasDocument: canOpenPaths(), - }) - }, - - // Recomposing is limited to a blank session because a started - // conversation's history was produced under its preset's tools; the - // agent and the session survive, only the composition is swapped. - async select(request) { - const { sessionId, agentPreset } = request.payload - const presets = ctx.get('agentPresets') - if (presets === undefined) { - return err(request, { - code: 'agent-preset-not-found', - message: 'this deployment composes no agent presets', - details: { agentPreset, available: [] }, - }) - } - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const { agent } = found - const swap = async (): Promise> => { - // Re-read inside the queue: an earlier switch may have run, and a - // conversation may have started, since this request arrived. - if (!sessionBlank(agent.session)) { - return err(request, { - code: 'agent-preset-locked', - message: `session "${sessionId}" has already started; its agent preset is fixed`, - details: { sessionId, agentPreset }, - }) - } - try { - const preset = await presets.recompose(agent.ctx, agentPreset) - // Recorded only after the swap committed: the log states what the - // agent runs, and a rejected mount leaves the previous composition. - agent.session.append('agent-preset/selected', { agentPreset: preset.id }) - return ok(request, { agentPreset: preset.id }) - } catch (error: unknown) { - const refused = presetFailure(request, error) - if (refused !== undefined) return refused - return err(request, { - code: 'internal', - message: `failed to select agent preset "${agentPreset}": ${String(error)}`, - details: {}, - }) - } - } - const queued = presetSwitches.get(sessionId) ?? Promise.resolve() - const turn = queued.then(swap) - presetSwitches.set(sessionId, turn.catch(() => undefined)) - try { - return await turn - } finally { - if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId) - } - }, - - // A composition names the plugins a session runs, so reading one is - // reconnaissance, and copy/remove/openDocument manage the roster and - // drive the host desktop. Connection authenticates the complete API. - 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), - ...preset.name === undefined ? {} : { name: preset.name }, - ...preset.description === undefined ? {} : { description: preset.description }, - }) - } catch (error: unknown) { - return err(request, presetError(agentPreset, error)) - } - }, - - async copy(request) { - const { from, agentPreset, name } = request.payload - const presets = ctx.get('agentPresets') - if (presets === undefined) return err(request, noRoster(agentPreset)) - try { - await presets.copy(from, agentPreset, name) - return ok(request, { agentPreset }) - } catch (error: unknown) { - return err(request, presetError(agentPreset, error)) - } - }, - + // Only the desktop opener remains here: the roster, selection, and + // authoring calls are the AgentPresets service's own Remote namespace. async openDocument(request, signal) { const { agentPreset } = request.payload const presets = ctx.get('agentPresets') @@ -708,18 +549,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro 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: { diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts index 36a4a5b9c8..ad15512ae0 100644 --- a/packages/host/apiproxy/src/api/agent-presets.schema.ts +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -1,71 +1,11 @@ /** * agent-presets domain zod schemas (names derived from map keys: - * agentPresetListRequestSchema / agentPresetListValueSchema). + * agentPresetOpenDocumentRequestSchema / agentPresetOpenDocumentValueSchema). */ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './ids.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(), - name: z.string().optional(), - description: z.string().optional(), - broken: z.string().min(1).optional(), -}) satisfies z.ZodType> - -/** agentPreset.list request payload. */ -export const agentPresetListRequestSchema = z.object({ -}) satisfies z.ZodType>> - -/** agentPreset.list response value. */ -export const agentPresetListValueSchema = z.object({ - presets: z.array(agentPresetEntrySchema), - authorable: z.boolean(), - hasDocument: z.boolean(), -}) satisfies z.ZodType>> - -/** agentPreset.select request payload. */ -export const agentPresetSelectRequestSchema = z.object({ - sessionId: sessionIdSchema, - agentPreset: z.string().min(1), -}) satisfies z.ZodType>> - -/** agentPreset.select response value. */ -export const agentPresetSelectValueSchema = z.object({ - agentPreset: z.string(), -}) satisfies z.ZodType>> - -/** agentPreset.read request payload. */ -export const agentPresetReadRequestSchema = z.object({ - agentPreset: z.string().min(1), -}) satisfies z.ZodType>> - -/** agentPreset.read response value. */ -export const agentPresetReadValueSchema = z.object({ - agentPreset: z.string(), - trust: z.union([z.literal('system'), z.literal('user')]), - content: z.string(), - name: z.string().optional(), - description: z.string().optional(), -}) satisfies z.ZodType>> - -/** agentPreset.copy request payload. */ -export const agentPresetCopyRequestSchema = z.object({ - from: z.string().min(1), - agentPreset: z.string().min(1), - name: z.string().optional(), -}) satisfies z.ZodType>> - -/** agentPreset.copy response value. */ -export const agentPresetCopyValueSchema = z.object({ - agentPreset: z.string(), -}) satisfies z.ZodType>> /** agentPreset.openDocument request payload. */ export const agentPresetOpenDocumentRequestSchema = z.object({ @@ -77,12 +17,3 @@ export const agentPresetOpenDocumentValueSchema = z.union([ z.object({ opened: z.literal(true) }), z.object({ opened: z.literal(false), path: z.string() }), ]) satisfies z.ZodType>> - -/** agentPreset.remove request payload. */ -export const agentPresetRemoveRequestSchema = z.object({ - agentPreset: z.string().min(1), -}) satisfies z.ZodType>> - -/** agentPreset.remove response value. */ -export const agentPresetRemoveValueSchema = z.object({ -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts index 3918de5894..a7749ab6aa 100644 --- a/packages/host/apiproxy/src/api/agent-presets.ts +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -1,116 +1,25 @@ /** - * agent-presets domain contract: the roster a browser offers when starting a - * session, plus the authoring calls behind it. + * agent-presets domain contract: handing one preset's directory to the + * platform opener, which is the only agent-preset call still carried here. * - * A composition names the plugins a session runs, so reading one is - * reconnaissance; although authoring is copy-only (no caller supplies - * composition text or a path), copying and deleting still rearrange what the - * deployment offers. Connection authenticates these calls with the complete - * Host API rather than assigning a separate method tier. + * The roster and its authoring calls are the AgentPresets service's own Remote + * namespace. This one stays because the opener is a Host desktop integration + * rather than a preset operation. */ -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> - - /** - * 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> - - /** - * 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> - - /** - * 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> - /** * Hand one locally authored preset's DIRECTORY to the platform opener, for * editing the files, which are 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. + * has no native opener (`canOpenPath: false` on `host.describe`), 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> - - /** Delete a locally authored preset. Shipped presets are refused. */ - remove(request: RpcRequest<{ agentPreset: string }>): Promise> } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 5ab6e52043..be660ec53b 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -37,7 +37,7 @@ export type { SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' export type { SkillsApi, SkillEntry } from './skills.ts' -export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' +export type { AgentPresetsApi } from './agent-presets.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index edeb2d10a8..ad37ade1b8 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -27,12 +27,7 @@ export interface RpcMethodMap { 'host.createDirectory': HostApi['createDirectory'] 'host.openPath': HostApi['openPath'] 'skill.list': SkillsApi['list'] - 'agentPreset.list': AgentPresetsApi['list'] - 'agentPreset.select': AgentPresetsApi['select'] - 'agentPreset.read': AgentPresetsApi['read'] - 'agentPreset.copy': AgentPresetsApi['copy'] 'agentPreset.openDocument': AgentPresetsApi['openDocument'] - 'agentPreset.remove': AgentPresetsApi['remove'] 'settings.describe': SettingsApi['describe'] 'settings.openDocument': SettingsApi['openDocument'] 'settings.update': SettingsApi['update'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index c68ca65410..21a89483dc 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -18,8 +18,7 @@ import { } from '../api/host.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { - agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, - agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema, + agentPresetOpenDocumentValueSchema, } from '../api/agent-presets.schema.ts' import { settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema, @@ -64,12 +63,7 @@ export interface IApiClient { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } agentPresets: { - list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> - select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise>> - read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise>> - copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise>> openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise>> - remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise>> } settings: { describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise>> @@ -104,12 +98,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('agentPreset.list', payload, signal), - select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal), - read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal), - copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal), openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal), - remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal), } readonly settings: IApiClient['settings'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 5ba7197cbb..1451741fc2 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -21,8 +21,7 @@ import { } from '../api/host.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { - agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, - agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema, + agentPresetOpenDocumentRequestSchema, } from '../api/agent-presets.schema.ts' import { settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema, @@ -64,12 +63,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, '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.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) }, 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, - 'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) }, 'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) }, 'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) }, 'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 15db5b3afd..ba432078bb 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -26,6 +26,14 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -34,7 +42,11 @@ "lib/invariant.js", "presets", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "peerDependencies": { @@ -50,6 +62,7 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -73,6 +86,7 @@ "@deepseek-ai/dsh-settings-file": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 83516d5c4d..6be67f11b4 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -22,27 +22,96 @@ */ import { stat } from 'node:fs/promises' -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { AgentPresetDocument, AgentPresetErrorDetailsMap, AgentPresetRoster } from './types.ts' import type {} from '@deepseek-ai/dsh-session-projection' // Type-only: resolves the registry notification emitted after scope reparenting. import type {} from '@deepseek-ai/dsh-tools' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' -import { copyComposition, deleteComposition, readComposition } from './authoring.ts' +import { + copyComposition, deleteComposition, readComposition, + InvalidPresetIdError, PresetExistsError, PresetNotWritableError, +} from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' -import { PresetExistsError } from './authoring.ts' -import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts' +import { + PresetLockedError, PresetMountError, UnknownPresetError, + type AgentPreset, type Config, type PresetRoot, +} from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' export type * from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' +/** Construct one typed preset failure for the Remote carrier. */ +function remotePresetFailure( + code: Code, + message: string, + details: AgentPresetErrorDetailsMap[Code], +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +/** Map one preset rejection to its stable Remote code and details. */ +function presetFailure(error: unknown, agentPreset: string): TypertRemoteFailure | undefined { + if (error instanceof UnknownPresetError) { + return remotePresetFailure( + 'agent-preset-not-found', + error.message, + { agentPreset: error.presetId, available: [...error.available] }, + ) + } + if (error instanceof PresetMountError) { + return remotePresetFailure( + 'agent-preset-invalid', + error.message, + { agentPreset: error.presetId, reason: error.reason }, + ) + } + if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { + return remotePresetFailure( + 'agent-preset-invalid', + error.message, + { agentPreset: error.presetId, reason: error.message }, + ) + } + if (error instanceof PresetNotWritableError) { + return remotePresetFailure( + 'agent-preset-read-only', + error.message, + { agentPreset, reason: error.message }, + ) + } + if (error instanceof PresetLockedError) { + return remotePresetFailure( + 'agent-preset-locked', + `session "${error.sessionId}" has already started; its agent preset is fixed`, + { sessionId: error.sessionId, agentPreset: error.presetId }, + ) + } + return undefined +} + +/** Refuse an empty preset id before invoking a domain operation. */ +function validatePresetId(value: string, field: 'agentPreset' | 'from'): void { + if (value.length === 0) { + throw remotePresetFailure('bad-request', `${field} must be a non-empty string`, {}) + } +} + +/** Throw the stable preset failure or the caller's operation-specific fallback. */ +function rejectPreset(error: unknown, agentPreset: string, fallbackMessage: string): never { + throw presetFailure(error, agentPreset) ?? remotePresetFailure('internal', fallbackMessage, {}) +} + /** The user-writable slice of this plugin's config. */ export interface AgentPresetSettings { /** Preset mounted when a session names none. */ @@ -67,7 +136,7 @@ export { PresetNotWritableError, readComposition, writableRoot, } from './authoring.ts' export { agentPresetProjectionDefinition } from './session.ts' -export { PresetMountError, UnknownPresetError } from './preset.ts' +export { PresetLockedError, PresetMountError, UnknownPresetError } from './preset.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts' declare module '@deepseek-ai/cordis' { @@ -83,7 +152,7 @@ declare module '@deepseek-ai/cordis' { * call so a preset authored while the process runs is visible immediately, * and a preset deleted underneath a picker disappears from the next read. */ -export class AgentPresets extends Service { +export class AgentPresets extends TypertRemoteService { static inject = ['loader'] /** Runtime schema for the preset roster. */ @@ -213,6 +282,30 @@ export class AgentPresets extends Service { return await discoverPresets(this.resolvedRoots) } + /** + * The roster off the Host: {@link list} projected to path-free rows, with + * the default marked and this deployment's authoring capability beside it. + * + * Whether a client can open a preset's directory is the Host's own opener + * capability, not a roster property — a caller needing both joins them. + * @returns the rows and the authoring capability. + */ + @Remote('list') + async remoteExportList(): Promise { + const defaultId = this.defaultId + return { + presets: (await this.list()).map(preset => ({ + id: preset.id, + trust: preset.trust, + isDefault: preset.id === defaultId, + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + ...preset.broken === undefined ? {} : { broken: preset.broken }, + })), + authorable: this.authorable, + } + } + /** * Resolve one preset by id. * @@ -376,6 +469,30 @@ export class AgentPresets extends Service { return await readComposition(await this.resolve(id)) } + /** + * One preset's composition text with the roster row it belongs to. + * @param agentPreset - the preset id. + * @returns the composition beside its trust and published metadata. + * @throws {TypertRemoteFailure} `bad-request` for an empty id, or + * `agent-preset-not-found` when no configured root supplies it. + */ + @Remote('read') + async readDocument(agentPreset: string): Promise { + validatePresetId(agentPreset, 'agentPreset') + try { + const preset = await this.resolve(agentPreset) + return { + agentPreset: preset.id, + trust: preset.trust, + content: await this.read(preset.id), + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + } + } catch (error: unknown) { + rejectPreset(error, agentPreset, `agent preset "${agentPreset}": ${String(error)}`) + } + } + /** * Create a locally authored preset by copying an existing one whole. * @@ -406,8 +523,29 @@ export class AgentPresets extends Service { this.standing.delete(id) } + /** + * Copy one preset through the Remote API. + * @param from - the source preset id. + * @param id - the new preset id. + * @param name - the copy's optional display name. + * @returns once the copy is stored. + * @throws {TypertRemoteFailure} with the corresponding stable preset code + * and details when the copy is refused. + */ + @Remote('copy') + async remoteExportCopy(from: string, id: string, name?: string): Promise { + validatePresetId(from, 'from') + validatePresetId(id, 'agentPreset') + try { + await this.copy(from, id, name) + } catch (error: unknown) { + rejectPreset(error, id, `agent preset "${id}": ${String(error)}`) + } + } + /** * Delete a locally authored preset. + * * @param id - the preset id. * @throws when the preset is unknown or ships with the deployment. */ @@ -429,6 +567,23 @@ export class AgentPresets extends Service { ) } + /** + * Delete one preset through the Remote API. + * @param id - the preset id. + * @returns once the preset is deleted. + * @throws {TypertRemoteFailure} with the corresponding stable preset code + * and details when deletion is refused. + */ + @Remote('deletePreset') + async remoteExportDelete(id: string): Promise { + validatePresetId(id, 'agentPreset') + try { + await this.remove(id) + } catch (error: unknown) { + rejectPreset(error, id, `agent preset "${id}": ${String(error)}`) + } + } + /** * One agent's instance of a service its preset mounted. * @@ -495,6 +650,58 @@ export class AgentPresets extends Service { return preset } + /** + * Serializes {@link select} per session. Two concurrent selects would both + * pass the blank check, and the second re-link would then find the record + * the first already replaced — leaving two compositions registered into one + * agent layer. A client's `busy` flag is not enforcement: the wire is + * reachable directly. + * + * Entries hold a failure-swallowing guard rather than the turn itself, so a + * refused switch does not reject the next caller's chain. + */ + private readonly switches = new Map>() + + /** + * Compose a blank session's agent from a different preset and record it. + * @param agent - the session's live agent, resolved from the wire identity. + * @param agentPreset - the preset to compose the agent from instead. + * @returns the preset id that was recorded. + * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, + * `agent-preset-not-found`, or `agent-preset-invalid` when refused. + */ + @Remote('select') + async select(agent: Agent, agentPreset: string): Promise { + validatePresetId(agentPreset, 'agentPreset') + const queued = this.switches.get(agent.id) ?? Promise.resolve() + const turn = queued.then(() => this.swap(agent, agentPreset)) + const guard = turn.catch(() => undefined) + this.switches.set(agent.id, guard) + try { + return await turn + } catch (error: unknown) { + return rejectPreset(error, agentPreset, `failed to select agent preset "${agentPreset}": ${String(error)}`) + } finally { + if (this.switches.get(agent.id) === guard) this.switches.delete(agent.id) + } + } + + /** One queued switch: re-check, recompose, then record what the agent runs. */ + private async swap(agent: Agent, agentPreset: string): Promise { + // Re-read inside the queue: an earlier switch may have run, and a + // conversation may have started, since this call was queued. A turn is one + // model-loop execution; standalone plugin events never open one, so a + // session that has only run commands is still blank. + if (agent.session.events.some(event => event.type === 'turn/start')) { + throw new PresetLockedError(agent.id, agentPreset) + } + const preset = await this.recompose(agent.ctx, agentPreset) + // Recorded only after the swap committed: the log states what the agent + // runs, and a rejected mount leaves the previous composition. + agent.session.append('agent-preset/selected', { agentPreset: preset.id }) + return preset.id + } + /** * The standing scope key of one preset, for a host reader with no agent. * diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index bbb02c5623..bb0dd18496 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -1,5 +1,7 @@ /** Agent-preset vocabulary shared by discovery, mounting, and consumers. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' + /** * Where a preset's composition came from. A `system` preset ships with the * deployment; a `user` preset was authored locally, by a person or by an @@ -87,6 +89,22 @@ export class UnknownPresetError extends Error { } } +/** + * The session's composition is fixed: its conversation has started, so its + * history was produced under the preset it runs and swapping the composition + * would leave logged tool calls the new one cannot make. + */ +export class PresetLockedError extends Error { + constructor( + /** The session whose composition is already fixed. */ + readonly sessionId: SessionId, + /** The preset that was refused. */ + readonly presetId: string, + ) { + super(`agent-presets: session "${sessionId}" has already started; its agent preset is fixed`) + } +} + /** A preset exists but its composition cannot be installed. */ export class PresetMountError extends Error { constructor( diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index a1e04c3d2f..fc567f9ad5 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -1,5 +1,74 @@ -/** Client-safe event declarations owned by the agent-preset domain. */ +/** Client-safe payloads and event declarations owned by the agent-preset domain. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PresetTrust } from './preset.ts' + +export type { PresetTrust } from './preset.ts' + +/** + * One roster row as a client reads it. Path-free: a preset is addressed by id + * everywhere off the Host, and the composition's location is the Host's own. + */ +export interface AgentPresetRow { + /** Stable identifier; also the label's fallback. */ + readonly id: string + /** Trust of the root this preset was discovered under. */ + readonly trust: PresetTrust + /** Whether a session naming no preset composes this one. */ + readonly isDefault: boolean + /** Display name the preset published. */ + readonly name?: string + /** One sentence on what this preset is for. */ + readonly description?: string + /** Why this preset cannot compose a session; absent when it can. */ + readonly broken?: string +} + +/** The roster one deployment currently supplies, with its authoring capability. */ +export interface AgentPresetRoster { + /** Every preset the configured roots supply, first-root-wins per id. */ + readonly presets: readonly AgentPresetRow[] + /** Whether this deployment has a root locally authored presets go to. */ + readonly authorable: boolean +} + +/** Stable details for agent-preset failures returned by the Remote namespace. */ +export interface AgentPresetErrorDetailsMap { + /** A required preset id is empty. */ + 'bad-request': Record + /** No configured root supplies the requested id. */ + 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] } + /** The id is unusable, already taken, or its composition cannot be installed. */ + 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string } + /** The preset ships with the deployment and is not the user's to change. */ + 'agent-preset-read-only': { readonly agentPreset: string; readonly reason: string } + /** The session's conversation has started, so its composition is fixed. */ + 'agent-preset-locked': { readonly sessionId: SessionId; readonly agentPreset: string } + /** The preset operation failed without a caller-actionable classification. */ + internal: Record +} + +/** One agent-preset refusal as a client reads it. */ +export type AgentPresetError = { + [Code in keyof AgentPresetErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: AgentPresetErrorDetailsMap[Code] + } +}[keyof AgentPresetErrorDetailsMap] + +/** One preset's composition text beside the row it belongs to. */ +export interface AgentPresetDocument { + /** The preset the composition belongs to. */ + readonly agentPreset: string + /** Trust of the root this preset was discovered under. */ + readonly trust: PresetTrust + /** The composition exactly as stored. */ + readonly content: string + /** Display name the preset published. */ + readonly name?: string + /** One sentence on what this preset is for. */ + readonly description?: string +} declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index f42a158941..926ed9ce51 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -36,6 +36,9 @@ { "path": "../../core/tools" }, + { + "path": "../../typert/protocol" + }, { "path": "../../settings/settings" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bea0355bd..d112863181 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6649,6 +6649,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/preset/persona: dependencies: