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