index.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. /** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
  2. import { hostname } from 'node:os'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import z from '@deepseek-ai/schemastery'
  5. import { errorChain } from '@deepseek-ai/dsh-llm'
  6. import type {} from '@deepseek-ai/dsh-client-file-upload'
  7. import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
  8. import type { SessionId } from '@deepseek-ai/dsh-session'
  9. import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
  10. import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
  11. import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  12. import {
  13. ApiSessionAgentController,
  14. inspectApiSession,
  15. type ApiSessionAgentResult,
  16. } from './agent.ts'
  17. import { SessionCommandController } from './commands.ts'
  18. import { SessionControlController } from './control.ts'
  19. import { SessionHistoryController } from './history.ts'
  20. import { SessionFileReferences } from './file-references.ts'
  21. import { ApiSessionList } from './list.ts'
  22. import { buildModelCatalog } from './catalog.ts'
  23. import { installModelSelectionProjection } from './model-selection-projection.ts'
  24. import { SessionSkillCatalog } from './skill-catalog.ts'
  25. import { SessionMediaReferences } from './media-references.ts'
  26. import type {
  27. ModelCatalog,
  28. SessionAttachmentRequest,
  29. SessionAttachmentValue,
  30. SessionCancelRequest,
  31. SessionCancelValue,
  32. SessionControlFrame,
  33. SessionCreateRequest,
  34. SessionCreateValue,
  35. SessionFollowFrame,
  36. SessionFollowRequest,
  37. SessionForkRequest,
  38. SessionForkValue,
  39. SessionListRequest,
  40. SessionListValue,
  41. SessionOpenWorkspacePathRequest,
  42. SessionOpenWorkspacePathValue,
  43. SessionPage,
  44. SessionPageRequest,
  45. SessionPromptRequest,
  46. SessionPromptValue,
  47. SessionRenameRequest,
  48. SessionRenameValue,
  49. SessionSearchRequest,
  50. SessionSearchValue,
  51. SessionSelectModelRequest,
  52. SessionSelectModelValue,
  53. SessionUpdateQueueRequest,
  54. SessionUpdateQueueValue,
  55. } from './types.ts'
  56. export type * from './types.ts'
  57. export { ApiSessionNotFound } from './agent.ts'
  58. export { SessionFileReferences } from './file-references.ts'
  59. export { SessionSkillCatalog } from './skill-catalog.ts'
  60. declare module '@deepseek-ai/cordis' {
  61. interface Context {
  62. /** Host Session business API and Remote namespace owner. */
  63. sessionController: SessionController
  64. }
  65. }
  66. /** Session Controller deployment policy. */
  67. export interface Config {
  68. /** Override platform desktop-opener detection. */
  69. readonly nativeOpen?: boolean
  70. }
  71. /** Host integrations replaceable by direct unit tests. */
  72. export interface SessionControllerInternals {
  73. /** Native default-application handoff. */
  74. readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
  75. /** Native file-manager handoff. */
  76. readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
  77. /** Native handoff availability probe. */
  78. readonly canOpenPath?: () => boolean
  79. }
  80. /** Host service backing the generated `ctx.remote.session` namespace. */
  81. export class SessionController extends TypertRemoteService {
  82. static inject = [
  83. 'agentDefaultModel',
  84. 'agents',
  85. 'attachments',
  86. 'fileUploads',
  87. 'llm',
  88. 'sessions',
  89. 'sessionProjections',
  90. 'sessionQuery',
  91. 'typert',
  92. 'workspaceRegistry',
  93. ]
  94. static Config: z<Config> = z.object({
  95. nativeOpen: z.boolean(),
  96. })
  97. private readonly agents: ApiSessionAgentController
  98. private readonly commands: SessionCommandController
  99. private readonly controlState: SessionControlController
  100. private readonly history: SessionHistoryController
  101. private readonly listState: ApiSessionList
  102. private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
  103. private readonly revealPath: (path: string, signal: AbortSignal) => Promise<void>
  104. private readonly canOpenPath: () => boolean
  105. private readonly promotions = new Set<Promise<void>>()
  106. /**
  107. * @param ctx - Host context containing the Session capability assembly.
  108. * @param config - native-opener deployment policy.
  109. * @param internals - host integrations replaceable by direct unit tests.
  110. */
  111. constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
  112. super(ctx, 'sessionController', { namespace: 'session' })
  113. installModelSelectionProjection(ctx)
  114. this.agents = new ApiSessionAgentController(ctx)
  115. this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
  116. ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
  117. const result = await this.agents.resolveAgent(sessionId)
  118. if ('error' in result) throw result.error
  119. return result.agent
  120. }), 'session-controller: file-upload Agent resolver')
  121. this.controlState = new SessionControlController(ctx)
  122. // Registered before history so reverse-order teardown closes every
  123. // follower before waiting for already-admitted promotions.
  124. ctx.effect(() => async () => {
  125. await Promise.allSettled([...this.promotions])
  126. }, 'session-controller.promotions')
  127. this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
  128. this.listState = new ApiSessionList(ctx)
  129. this.openPath = internals.openPath ?? openNativePath
  130. this.revealPath = internals.revealPath ?? revealNativePath
  131. this.canOpenPath = internals.canOpenPath
  132. ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
  133. ctx.plugin(SessionFileReferences)
  134. ctx.plugin(SessionMediaReferences)
  135. ctx.plugin(SessionSkillCatalog)
  136. ctx.on('session/created', (session) => {
  137. ctx.emit('api-session/added', this.listState.summaryFor(session))
  138. })
  139. ctx.on('session/disposed', (session) => {
  140. ctx.emit('api-session/removed', session.id)
  141. })
  142. ctx.on('agent/status', ({ agent, status }) => {
  143. ctx.emit('api-session/status', agent.id, status === 'running')
  144. })
  145. ctx.on('agent/error', ({ agent, error }) => {
  146. ctx.emit('api-session/error', agent.id, errorChain(error))
  147. })
  148. ctx.on('session/event', (session, event) => {
  149. if (event.type === 'request/header') {
  150. const agent = ctx.agents.get(session.id)
  151. if (agent?.session === session) this.agents.consumeSelection(
  152. agent,
  153. event.data.header.config.provider,
  154. event.data.header.config.model,
  155. event.data.header.config.reasoningEffort,
  156. )
  157. }
  158. if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
  159. ctx.emit('api-session/activity', session.id, event.time)
  160. })
  161. }
  162. private promote(observation: SessionObservation): void {
  163. const sessionId = observation.header.id
  164. const task = (async () => {
  165. using ownedObservation = observation
  166. const result = await this.agents.resolveObservedAgent(ownedObservation)
  167. if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message)
  168. })().catch((error: unknown) => {
  169. this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`)
  170. })
  171. this.promotions.add(task)
  172. void task.finally(() => { this.promotions.delete(task) })
  173. }
  174. /**
  175. * Resolve or resume one ordinary Session for another Host API domain.
  176. * @param sessionId - Session identity whose Agent owns the operation.
  177. * @returns the live Agent or the stable Session-domain failure.
  178. */
  179. resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
  180. return this.agents.resolveAgent(sessionId)
  181. }
  182. /**
  183. * Inspect one attached or persisted Session without activating its Agent.
  184. * @param sessionId - durable Session identity.
  185. * @param signal - optional caller cancellation for persistence reads.
  186. * @returns the current attached state or persisted header and event prefix.
  187. */
  188. inspect(
  189. sessionId: SessionId,
  190. signal?: AbortSignal,
  191. ): Promise<SessionInspection> {
  192. const attached = this.ctx.sessions.get(sessionId)
  193. if (attached !== undefined) {
  194. return Promise.resolve({
  195. meta: attached.header,
  196. inheritedEventCount: attached.inheritedEventCount,
  197. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  198. events: attached.snapshotEvents(),
  199. })
  200. }
  201. return inspectApiSession(this.ctx, sessionId, signal)
  202. }
  203. /**
  204. * Read all visible Session rows without resuming an Agent.
  205. * @param _request - reserved empty list request.
  206. * @param signal - cancellation for persistence reads.
  207. * @returns visible Session summaries ordered by activity.
  208. */
  209. @Remote('list')
  210. async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue> {
  211. return { items: await this.listState.list(signal) }
  212. }
  213. /**
  214. * Search visible Session content without resuming an Agent.
  215. * @param request - literal message-content query.
  216. * @param signal - cancellation for list and search reads.
  217. * @returns authorized bounded Session search results.
  218. */
  219. @Remote('search')
  220. search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
  221. return this.listState.search(request.query, signal)
  222. }
  223. /**
  224. * Create or idempotently adopt one ordinary Session.
  225. * @param request - requested identity, location, and Agent preset.
  226. * @returns the Session identity and resolved preset when configured.
  227. */
  228. @Remote('create')
  229. create(request: SessionCreateRequest): Promise<SessionCreateValue> {
  230. return this.commands.create(request)
  231. }
  232. /**
  233. * Select one Session-local model after explicitly resuming the Session.
  234. * @param request - Session identity and requested model selection.
  235. * @returns the normalized selection installed for the Session.
  236. */
  237. @Remote('selectModel')
  238. selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
  239. return this.commands.selectModel(request)
  240. }
  241. /**
  242. * Describe every currently routable model for Host-generation selectors.
  243. * @returns provider-grouped models, the deployment default, and isolated provider failures.
  244. */
  245. @Remote('modelCatalog')
  246. modelCatalog(): Promise<ModelCatalog> {
  247. return buildModelCatalog(this.ctx)
  248. }
  249. /**
  250. * Report whether this deployment can hand a Session workspace path to a native desktop.
  251. * @returns true when the matching open operation is available.
  252. */
  253. @Remote
  254. canOpenWorkspacePath(): boolean {
  255. return this.canOpenPath()
  256. }
  257. /**
  258. * Describe the serving desktop for authenticated file-action routes.
  259. * @returns Host name, configured availability, and platform-specific file-manager behavior.
  260. */
  261. workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null } {
  262. const fileManager = nativeFileManager()
  263. return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager }
  264. }
  265. /**
  266. * Open one path prepared by a Session-aware caller on the Host desktop.
  267. * @param request - path after best-effort Session workspace resolution.
  268. * @param signal - caller lifetime; abort terminates the native command.
  269. * @returns confirmation after the native opener accepts the path.
  270. * @throws RemoteError when the request is invalid, cancelled, or the opener fails.
  271. */
  272. @Remote('openWorkspacePath')
  273. async openWorkspacePath(
  274. request: SessionOpenWorkspacePathRequest,
  275. signal: AbortSignal,
  276. ): Promise<SessionOpenWorkspacePathValue> {
  277. if (request.path.length === 0) {
  278. throw new RemoteError(
  279. 'gateway/bad-request',
  280. 'session.openWorkspacePath requires a non-empty path',
  281. {},
  282. )
  283. }
  284. signal.throwIfAborted()
  285. try {
  286. if (request.action === 'reveal') await this.revealPath(request.path, signal)
  287. else await this.openPath(request.path, signal)
  288. return { opened: true }
  289. } catch (error: unknown) {
  290. if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
  291. throw new RemoteError(
  292. 'gateway/internal',
  293. `path open failed: ${error instanceof Error ? error.message : String(error)}`,
  294. {},
  295. )
  296. }
  297. }
  298. /**
  299. * Rename one Session after explicitly resuming it.
  300. * @param request - Session identity and proposed title.
  301. * @returns the accepted title and durable event sequence.
  302. */
  303. @Remote('rename')
  304. rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
  305. return this.commands.rename(request)
  306. }
  307. /**
  308. * Fork one cold-readable completed-turn prefix into a new Session.
  309. * @param request - source Session and optional event anchor.
  310. * @returns the new Session identity.
  311. */
  312. @Remote('fork')
  313. fork(request: SessionForkRequest): Promise<SessionForkValue> {
  314. return this.commands.fork(request)
  315. }
  316. /**
  317. * Admit one prompt after explicitly resuming its Session.
  318. * @param request - Session identity, prompt content, source metadata, and delivery mode.
  319. * @param signal - caller cancellation before prompt admission begins.
  320. * @returns acknowledgement that the Agent accepted the prompt.
  321. */
  322. @Remote('prompt')
  323. prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
  324. signal.throwIfAborted()
  325. return this.commands.prompt(request)
  326. }
  327. /**
  328. * Read one image proven reachable from the addressed Session log.
  329. * @param request - Session and attachment identities used for authorization.
  330. * @returns the durable attachment reference and base64-encoded bytes.
  331. */
  332. @Remote('attachment')
  333. attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
  334. return this.commands.attachment(request)
  335. }
  336. /**
  337. * Mutate one still-pending queue occurrence on a live Agent.
  338. * @param request - Session, queue item, and requested mutation.
  339. * @returns acknowledgement that the queue mutation was applied.
  340. */
  341. @Remote('updateQueue')
  342. updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
  343. return this.commands.updateQueue(request)
  344. }
  345. /**
  346. * Cancel one active Agent turn without dropping its pending inbox.
  347. * @param request - Session whose active Agent turn is cancelled.
  348. * @returns acknowledgement that cancellation was requested.
  349. */
  350. @Remote('cancel')
  351. cancel(request: SessionCancelRequest): SessionCancelValue {
  352. return this.commands.cancel(request)
  353. }
  354. /**
  355. * Read one cold-safe, message-aligned Session history page.
  356. * @param request - durable address, backward cursor, and page budget.
  357. * @param signal - cancellation for persistence reads.
  358. * @returns one chronological page.
  359. */
  360. @Remote('page')
  361. page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
  362. return this.history.page(request, signal)
  363. }
  364. /**
  365. * Follow one Session log from its opening or resume cursor.
  366. * @param request - durable address and last committed sequence already held by the caller.
  367. * @param signal - cancellation owned by the Remote stream carrier.
  368. * @returns a complete opening snapshot followed by gap-free durable event
  369. * frames and optional cursorless assistant-stream frames.
  370. */
  371. @Remote({ mode: 'stream' })
  372. follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
  373. return this.history.follow(request, signal)
  374. }
  375. /**
  376. * Stream a complete live-control baseline followed by replacement frames.
  377. * @param signal - cancellation owned by the Remote stream carrier.
  378. * @returns one complete baseline followed by live replacement frames.
  379. */
  380. @Remote({ mode: 'stream' })
  381. control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
  382. return this.controlState.control(signal)
  383. }
  384. }
  385. export { buildModelCatalog }
  386. export default SessionController