index.ts 14 KB

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