index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. /**
  2. * Plugin-owned human-command registry shared by interactive UI adapters.
  3. * @module @deepseek-ai/dsh-commands
  4. */
  5. import { Context } from '@deepseek-ai/cordis'
  6. import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
  9. import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
  10. import type { ImageBlock } from '@deepseek-ai/dsh-llm'
  11. import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
  12. import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
  13. import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
  14. import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
  15. import { CommandId } from './brand.ts'
  16. import type {
  17. CommandDescriptor,
  18. CommandExecution,
  19. CommandInputDescriptor,
  20. CommandResult,
  21. } from './types.ts'
  22. export { CommandId } from './brand.ts'
  23. export type * from './types.ts'
  24. export const name = 'commands'
  25. const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
  26. /** Shared frozen attachments value for image-free invocations. */
  27. const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([])
  28. /** Invocation passed to one registered command handler. */
  29. export interface CommandInvocation {
  30. /** Pairing id already written to this invocation's `command/run` event. */
  31. readonly commandId: CommandId
  32. /** Exact agent whose UI received the command. */
  33. readonly agent: Agent
  34. /** Exact text following the registered command name, including separator whitespace. */
  35. readonly rawInput: string
  36. /**
  37. * Durably admitted image blocks accompanying this invocation, in submission
  38. * order; empty unless the definition declares `input.images`. The handler
  39. * owns their model-visible use — the registry never schedules them itself —
  40. * and a handler whose grammar cannot use them in this invocation returns an
  41. * error so the dispatching composer retains the originals.
  42. */
  43. readonly attachments: readonly ImageBlock[]
  44. /** Cancellation signal owned by the dispatching UI request. */
  45. readonly signal: AbortSignal
  46. }
  47. /** Plugin-owned command registration. */
  48. export interface CommandDefinition {
  49. /** Lowercase command name without the leading slash. */
  50. readonly name: string
  51. /** Human-readable summary used in discovery UI. */
  52. readonly description: string
  53. /** Optional free-form input hint advertised to capable clients. */
  54. readonly input?: CommandInputDescriptor
  55. /**
  56. * Whether `command/run` records `rawInput`. Defaults to true. A command
  57. * whose domain event owns the payload sets this false to avoid duplicating
  58. * that payload in the session log.
  59. */
  60. readonly recordInput?: boolean
  61. /** Execute against the receiving agent without sending the command to the model. */
  62. readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
  63. }
  64. /** Syntactically valid slash command before registry resolution. */
  65. export interface ParsedCommand {
  66. /** Lowercase command name without the leading slash. */
  67. readonly name: string
  68. /** Exact text following the command name. */
  69. readonly rawInput: string
  70. }
  71. interface RegisteredCommand {
  72. readonly definition: CommandDefinition
  73. readonly descriptor: CommandDescriptor
  74. }
  75. /** All command registrations owned by one global or scoped layer. */
  76. class CommandLayer implements ScopeLayer {
  77. readonly commands: NamedEntries<RegisteredCommand>
  78. /**
  79. * Create one command layer with diagnostics specific to its ownership scope.
  80. * @param scope - the scoped owner, or `undefined` for global registrations.
  81. */
  82. constructor(scope: ScopeKey | undefined) {
  83. this.commands = new NamedEntries(name => new Error(scope === undefined
  84. ? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
  85. : `command "${name}" is already registered in this scope`))
  86. }
  87. /** @returns whether this layer owns no command registrations. */
  88. isEmpty(): boolean {
  89. return this.commands.isEmpty()
  90. }
  91. }
  92. declare module '@deepseek-ai/cordis' {
  93. interface Context {
  94. commands: CommandRuntime
  95. }
  96. }
  97. /**
  98. * Parse an exact slash command without normalizing its trailing input.
  99. *
  100. * @param line - Complete candidate command line.
  101. * @returns The parsed command, or `undefined` when the line is not a command.
  102. */
  103. export function parseCommand(line: string): ParsedCommand | undefined {
  104. const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line)
  105. if (match === null) return undefined
  106. const name = match[1]
  107. /* v8 ignore next -- the first capture is required whenever the regular expression matches */
  108. if (name === undefined) return undefined
  109. return Object.freeze({ name, rawInput: line.slice(match[0].length) })
  110. }
  111. /** Convert arbitrary abort reasons to one stable rejected Error. */
  112. function abortError(signal: AbortSignal): Error {
  113. if (signal.reason instanceof Error) return signal.reason
  114. return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted')
  115. }
  116. /** The signal's normalized abort error when it is already aborted. */
  117. function cancellationOf(signal: AbortSignal): Error | undefined {
  118. return signal.aborted ? abortError(signal) : undefined
  119. }
  120. /** Render arbitrary thrown values without trusting their string coercion. */
  121. function renderThrown(value: unknown): string {
  122. try {
  123. return String(value)
  124. } catch {
  125. return '<unrenderable thrown value>'
  126. }
  127. }
  128. /** Stop awaiting an uncooperative handler once its owning UI request aborts. */
  129. function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
  130. if (signal.aborted) return Promise.reject(abortError(signal))
  131. return new Promise<T>((resolve, reject) => {
  132. const onAbort = (): void => {
  133. signal.removeEventListener('abort', onAbort)
  134. reject(abortError(signal))
  135. }
  136. signal.addEventListener('abort', onAbort, { once: true })
  137. promise.then(
  138. (value) => {
  139. signal.removeEventListener('abort', onAbort)
  140. resolve(value)
  141. },
  142. (error: unknown) => {
  143. signal.removeEventListener('abort', onAbort)
  144. reject(error instanceof Error
  145. ? error
  146. : new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error }))
  147. },
  148. )
  149. })
  150. }
  151. /** Reject invalid command metadata before it can reach a UI protocol. */
  152. function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
  153. if (!COMMAND_NAME.test(definition.name)) {
  154. throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`)
  155. }
  156. if (typeof definition.description !== 'string') {
  157. throw new TypeError(`command "${definition.name}" description must be a string`)
  158. }
  159. if (definition.description.trim().length === 0) {
  160. throw new TypeError(`command "${definition.name}" description must not be empty`)
  161. }
  162. if (typeof definition.handler !== 'function') {
  163. throw new TypeError(`command "${definition.name}" handler must be a function`)
  164. }
  165. const rawInput: unknown = definition.input
  166. let input: CommandInputDescriptor | undefined
  167. if (rawInput !== undefined) {
  168. if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput)
  169. || typeof rawInput.hint !== 'string') {
  170. throw new TypeError(`command "${definition.name}" input hint must be a string`)
  171. }
  172. if (rawInput.hint.trim().length === 0) {
  173. throw new TypeError(`command "${definition.name}" input hint must not be empty`)
  174. }
  175. if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') {
  176. throw new TypeError(`command "${definition.name}" input images flag must be a boolean`)
  177. }
  178. input = Object.freeze({
  179. hint: rawInput.hint,
  180. ...('images' in rawInput && rawInput.images === true) ? { images: true } : {},
  181. })
  182. }
  183. const normalized = Object.freeze({
  184. name: definition.name,
  185. description: definition.description,
  186. ...input === undefined ? {} : { input },
  187. ...definition.recordInput === undefined ? {} : { recordInput: definition.recordInput },
  188. handler: definition.handler,
  189. })
  190. const descriptor = Object.freeze({
  191. name: normalized.name,
  192. description: normalized.description,
  193. ...normalized.input === undefined ? {} : { input: normalized.input },
  194. })
  195. return { definition: normalized, descriptor }
  196. }
  197. /** Validate and detach an untrusted handler result at the registry boundary. */
  198. function normalizeResult(command: string, value: unknown): CommandResult {
  199. if (typeof value !== 'object' || value === null || !('kind' in value)) {
  200. throw new TypeError(`command "${command}" handler must return a CommandResult`)
  201. }
  202. const result = value as { kind?: unknown; text?: unknown; sourceEventSeq?: unknown }
  203. if (result.kind === 'success') {
  204. if (result.text !== undefined && typeof result.text !== 'string') {
  205. throw new TypeError(`command "${command}" success text must be a string when supplied`)
  206. }
  207. if (result.sourceEventSeq !== undefined
  208. && (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) {
  209. throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`)
  210. }
  211. return Object.freeze({
  212. kind: 'success',
  213. ...result.text === undefined ? {} : { text: result.text },
  214. ...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number },
  215. })
  216. }
  217. if (result.kind === 'error') {
  218. if (typeof result.text !== 'string' || result.text.trim().length === 0) {
  219. throw new TypeError(`command "${command}" error text must be a non-empty string`)
  220. }
  221. return Object.freeze({ kind: 'error', text: result.text })
  222. }
  223. throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`)
  224. }
  225. /**
  226. * Human-command registry. Plain-context definitions are global; definitions
  227. * registered through a command-injected child of an agent context shadow
  228. * globals for that agent.
  229. */
  230. export class CommandRuntime extends TypertRemoteService {
  231. private readonly layers = new ScopedLayers(
  232. scope => new CommandLayer(scope),
  233. () => { this.notifyChange() },
  234. )
  235. /** Monotonic per-instance counter behind {@link mintCommandId}. */
  236. private commandSeq = 0
  237. /** Instance token keeping minted ids unique across process restarts over one resumed log. */
  238. private readonly instanceToken = randomUUID().slice(0, 8)
  239. constructor(ctx: Context) {
  240. super(ctx, 'commands')
  241. }
  242. /**
  243. * Register a global or calling-agent-scoped command.
  244. * @param definition - discovery metadata and direct UI handler.
  245. * @returns the exact effect disposer that unregisters this definition.
  246. */
  247. register(definition: CommandDefinition): () => void {
  248. const registered = normalizeDefinition(definition)
  249. return this.layers.effect(
  250. this.ctx,
  251. layer => layer.commands.insert(registered.definition.name, registered),
  252. { label: 'commands.register()' },
  253. )
  254. }
  255. /**
  256. * List the effective immutable command descriptors for one agent.
  257. * @param agent - exact receiving agent and scoped-layer key.
  258. * @returns name-sorted descriptors after scoped shadowing.
  259. */
  260. @Remote
  261. list(agent: Agent): readonly CommandDescriptor[] {
  262. return Object.freeze([...this.view(agent).values()]
  263. .map(command => command.descriptor)
  264. // Names are unique in the effective view, so equality is impossible.
  265. .sort((left, right) => left.name < right.name ? -1 : 1))
  266. }
  267. /**
  268. * Resolve one effective command definition.
  269. * @param agent - exact receiving agent and scoped-layer key.
  270. * @param name - command name without a slash.
  271. * @returns the scoped shadow or global definition.
  272. */
  273. find(agent: Agent, name: string): CommandDefinition | undefined {
  274. return this.view(agent).get(name)?.definition
  275. }
  276. /**
  277. * Parse and execute a known command without sending it to the model.
  278. *
  279. * A resolved command's lifecycle is logged: `command/run` is appended
  280. * before the handler is invoked and `command/done` after settlement (a
  281. * thrown or aborted handler settles as `kind: 'error'`). Both are direct
  282. * log-only appends — no turn wraps them, and persistence drains them at
  283. * ordinary checkpoints. Admission misses (syntax or unknown name) log
  284. * nothing — they never entered a handler. A `command/run` append failure
  285. * fails the execution loud; a `command/done` append failure on the
  286. * handler-failure path is contained so the handler's own error stays the
  287. * reported failure.
  288. *
  289. * Image admission is enforced here, not in the composer: images sent to a
  290. * command that does not declare `input.images`, an absent attachment store,
  291. * and an exceeded attachment limit each settle as an error result before
  292. * the handler runs, and a rejected batch publishes no durable object.
  293. *
  294. * @param agent - exact receiving agent.
  295. * @param line - complete slash-command line.
  296. * @param images - base64-encoded composer images accompanying the line, in
  297. * submission order; empty for a plain invocation.
  298. * @param signal - cancellation signal owned by the UI request.
  299. * @returns the settled execution (result + lifecycle pairing id), or
  300. * `undefined` when syntax or name does not resolve.
  301. */
  302. @Remote
  303. async execute(
  304. agent: Agent,
  305. line: string,
  306. images: readonly EncodedImageAttachment[],
  307. signal: AbortSignal,
  308. ): Promise<CommandExecution | undefined> {
  309. const parsed = parseCommand(line)
  310. if (parsed === undefined) return undefined
  311. const command = this.view(agent).get(parsed.name)
  312. if (command === undefined) return undefined
  313. if (signal.aborted) throw abortError(signal)
  314. const commandId = this.mintCommandId()
  315. this.appendLifecycle(agent.session, 'command/run', {
  316. commandId,
  317. name: parsed.name,
  318. ...command.definition.recordInput === false ? {} : { args: parsed.rawInput },
  319. source: { kind: 'user' },
  320. })
  321. const settle = (result: CommandResult): CommandExecution => {
  322. this.appendLifecycle(agent.session, 'command/done', {
  323. commandId, kind: result.kind,
  324. ...result.text === undefined ? {} : { text: result.text },
  325. ...result.kind === 'success' && result.sourceEventSeq !== undefined
  326. ? { sourceEventSeq: result.sourceEventSeq }
  327. : {},
  328. })
  329. return Object.freeze({ commandId, result: Object.freeze(result) })
  330. }
  331. let attachments: readonly ImageBlock[] = NO_ATTACHMENTS
  332. if (images.length > 0) {
  333. if (command.definition.input?.images !== true) {
  334. return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` })
  335. }
  336. const store = this.ctx.get('attachments')
  337. if (store === undefined) {
  338. return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` })
  339. }
  340. try {
  341. const refs = await admitEncodedImages(store, images)
  342. attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref })))
  343. } catch (error: unknown) {
  344. if (error instanceof AttachmentError) {
  345. return settle({ kind: 'error', text: error.message })
  346. }
  347. this.settleThrown(agent.session, parsed.name, commandId, error)
  348. throw error
  349. }
  350. // Cancellation must be honored BEFORE the handler runs: admission may
  351. // await slow storage, and a handler entered after the caller cancelled
  352. // would mutate state the retrying caller then duplicates. (The committed
  353. // image objects stay unreferenced and are deferred-GC territory.)
  354. const cancelledDuringAdmission = cancellationOf(signal)
  355. if (cancelledDuringAdmission !== undefined) {
  356. this.settleThrown(agent.session, parsed.name, commandId, cancelledDuringAdmission)
  357. throw cancelledDuringAdmission
  358. }
  359. }
  360. const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, attachments, signal })
  361. let result: CommandResult
  362. try {
  363. const output = command.definition.handler(invocation)
  364. result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
  365. } catch (error: unknown) {
  366. this.settleThrown(agent.session, parsed.name, commandId, error)
  367. throw error
  368. }
  369. return settle(result)
  370. }
  371. /** Contained `command/done` error append for a thrown handler or admission failure. */
  372. private settleThrown(session: Session, command: string, commandId: CommandId, error: unknown): void {
  373. try {
  374. this.appendLifecycle(session, 'command/done', {
  375. commandId, kind: 'error',
  376. text: error instanceof Error ? error.message : renderThrown(error),
  377. })
  378. } catch (appendError: unknown) {
  379. this.ctx.logger.warn(`command "${command}": command/done append failed: ${renderThrown(appendError)}`)
  380. }
  381. }
  382. /** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
  383. private mintCommandId(): CommandId {
  384. this.commandSeq += 1
  385. return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`)
  386. }
  387. /**
  388. * Append one log-only lifecycle event directly: no turn is opened for it and
  389. * no flush is forced — persistence observes the eager `session/event` path
  390. * and drains at ordinary checkpoints and teardown, like every other
  391. * standalone plugin event.
  392. */
  393. private appendLifecycle<T extends 'command/run' | 'command/done'>(
  394. session: Session,
  395. type: T,
  396. data: SessionEventMap[T],
  397. ): SessionEvent<T> {
  398. // Both admitted types are log-only (non-surface), but TypeScript does not
  399. // reduce Session.append's conditional rest parameter through a generic
  400. // type parameter. Preserve the proven two-argument call shape.
  401. const appendLogOnly = session.append.bind(session) as (eventType: T, eventData: SessionEventMap[T]) => SessionEvent<T>
  402. return appendLogOnly(type, data)
  403. }
  404. /** Resolve global definitions followed by exact scoped shadows. */
  405. private view(agent: Agent): Map<string, RegisteredCommand> {
  406. return this.layers.merge(agent, layer => layer.commands)
  407. }
  408. /** Notify every registry observer without making UI refresh load-bearing. */
  409. private notifyChange(): void {
  410. // Cordis emit uses Array.map: one synchronous throw starves later listeners,
  411. // and returned promises are discarded. Registry notifications are
  412. // non-vetoing, so contain each callback independently.
  413. for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) {
  414. try {
  415. const returned: unknown = callback()
  416. void Promise.resolve(returned).catch((error: unknown) => {
  417. this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`)
  418. })
  419. } catch (error: unknown) {
  420. this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`)
  421. }
  422. }
  423. }
  424. }
  425. export default CommandRuntime