refactor(agent-presets): expose browser operations through Remote

This commit is contained in:
imccyu
2026-08-26 11:18:28 +08:00
parent 5e52e7eaef
commit 306419cc84
13 changed files with 338 additions and 382 deletions
+4 -175
View File
@@ -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<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
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<unknown>, error: unknown): RpcResponse<never> | 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<SessionId, Promise<unknown>>()
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<RpcResponse<{ agentPreset: string }>> => {
// 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: {
@@ -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<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),
authorable: z.boolean(),
hasDocument: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
/** agentPreset.select request payload. */
export const agentPresetSelectRequestSchema = z.object({
sessionId: sessionIdSchema,
agentPreset: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.select'>>>
/** agentPreset.select response value. */
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(),
name: z.string().optional(),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
/** 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<Wire<RequestPayload<'agentPreset.copy'>>>
/** agentPreset.copy response value. */
export const agentPresetCopyValueSchema = z.object({
agentPreset: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>>
/** 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<Wire<ResponseValue<'agentPreset.openDocument'>>>
/** 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,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<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, 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<RpcResponse<{ opened: true } | { opened: false; path: string }>>
/** Delete a locally authored preset. Shipped presets are refused. */
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
}
+1 -1
View File
@@ -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'
@@ -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']
+1 -17
View File
@@ -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<RpcResponse<ResponseValue<'skill.list'>>>
}
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'>>>
copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>>
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
}
settings: {
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
@@ -104,12 +98,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'host.createDirectory': hostCreateDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'skill.list': skillListValueSchema,
'agentPreset.list': agentPresetListValueSchema,
'agentPreset.select': agentPresetSelectValueSchema,
'agentPreset.read': agentPresetReadValueSchema,
'agentPreset.copy': agentPresetCopyValueSchema,
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
'agentPreset.remove': agentPresetRemoveValueSchema,
'settings.describe': settingsDescribeValueSchema,
'settings.openDocument': settingsOpenDocumentValueSchema,
'settings.update': settingsUpdateValueSchema,
@@ -279,12 +268,7 @@ export abstract class AbstractApiClient implements IApiClient {
// the whole gateway, and with it the host `Context` merges, into every
// Client program that imports this carrier.
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload, signal) => 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'] = {
+1 -7
View File
@@ -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) },
+15 -1
View File
@@ -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:^"
}
}
+213 -6
View File
@@ -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 extends keyof AgentPresetErrorDetailsMap>(
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<AgentPresetRoster> {
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<AgentPresetDocument> {
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<void> {
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<void> {
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<string, Promise<unknown>>()
/**
* 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<string> {
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<string> {
// 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.
*
@@ -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(
+70 -1
View File
@@ -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<never, never>
/** 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<never, never>
}
/** 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 {
@@ -36,6 +36,9 @@
{
"path": "../../core/tools"
},
{
"path": "../../typert/protocol"
},
{
"path": "../../settings/settings"
},
+3
View File
@@ -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: