api-proxy.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. /**
  2. * Host-side ApiProxy implementation (minimal-first —
  3. * describe/list/create/history/prompt/cancel and both streams are real,
  4. * respond is a stub). Signature discipline: unary takes the narrow
  5. * RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
  6. */
  7. import { randomUUID } from 'node:crypto'
  8. import { stat } from 'node:fs/promises'
  9. import type { Context } from 'cordis'
  10. import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
  11. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  12. import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  13. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  14. import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
  15. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  16. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  17. /** Page size when history is called without maxMessages. */
  18. const DEFAULT_MAX_MESSAGES = 50
  19. /** Surface message event types (the pagination counting unit). */
  20. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
  21. /**
  22. * Message-boundary pagination: count maxMessages surface messages backwards from
  23. * the window tail; the cut is the starting seq of the oldest message group
  24. * (chunks group via sourceEventSeqs — never cut mid-message). The tail page
  25. * naturally includes the in-progress partial.
  26. */
  27. function paginate(
  28. events: readonly SessionEvent[],
  29. beforeSeq: number | undefined,
  30. maxMessages: number,
  31. ): { events: SessionEvent[]; hasMore: boolean } {
  32. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  33. let count = 0
  34. let cut = 0
  35. for (let i = window.length - 1; i >= 0; i--) {
  36. const event = window[i] as SessionEvent
  37. if (!MESSAGE_TYPES.has(event.type)) continue
  38. count++
  39. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  40. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  41. if (count >= maxMessages) {
  42. cut = groupStart
  43. break
  44. }
  45. }
  46. const page = window.filter(event => event.seq >= cut)
  47. return { events: page, hasMore: cut > 0 }
  48. }
  49. /** Wrap an ok result echoing the request's rpcId. */
  50. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  51. return { rpcId: request.rpcId, result: { ok: true, value } }
  52. }
  53. /** Wrap an error result echoing the request's rpcId. */
  54. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  55. return { rpcId: request.rpcId, result: { ok: false, error } }
  56. }
  57. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  58. class FrameQueue<F> {
  59. private buffer: F[] = []
  60. private waiter: (() => void) | undefined
  61. private done = false
  62. push(item: F): void {
  63. if (this.done) return
  64. this.buffer.push(item)
  65. this.waiter?.()
  66. }
  67. end(): void {
  68. this.done = true
  69. this.waiter?.()
  70. }
  71. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  72. const onAbort = (): void => { this.end() }
  73. signal.addEventListener('abort', onAbort, { once: true })
  74. try {
  75. while (true) {
  76. while (this.buffer.length > 0) yield this.buffer.shift() as F
  77. if (this.done || signal.aborted) return
  78. await new Promise<void>((resolve) => { this.waiter = resolve })
  79. this.waiter = undefined
  80. }
  81. } finally {
  82. signal.removeEventListener('abort', onAbort)
  83. cleanup()
  84. }
  85. }
  86. }
  87. /**
  88. * Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
  89. * for answerable frames belong to the approval/question registry, absent in
  90. * this minimal version).
  91. */
  92. function frame<F>(payload: F): RpcRequest<F> {
  93. return { rpcId: RpcId(randomUUID()), payload }
  94. }
  95. /** SessionSummary projection for attached (in-memory) sessions. */
  96. function summarize(session: Session, running: boolean): SessionSummary {
  97. return {
  98. sessionId: session.id,
  99. updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
  100. running,
  101. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  102. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  103. }
  104. }
  105. /**
  106. * SessionSummary projection for cold (persisted, unattached) sessions.
  107. * updatedAt is the log file's mtime; backends without a per-session file
  108. * (locate() undefined) fall back to the header's createdAt.
  109. */
  110. async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
  111. let updatedAt = meta.createdAt
  112. const location = persistence.locate(meta)
  113. if (location !== undefined) {
  114. try {
  115. updatedAt = (await stat(location.path)).mtimeMs
  116. } catch {
  117. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  118. }
  119. }
  120. return {
  121. sessionId: meta.id,
  122. updatedAt,
  123. running: false,
  124. ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
  125. /* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
  126. filters those out (legacy logs are not served); the conditional mirrors
  127. summarize() shape. */
  128. ...meta.cwd === undefined ? {} : { cwd: meta.cwd },
  129. }
  130. }
  131. /** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
  132. export interface ApiProxyDefaults {
  133. provider: string
  134. model: string
  135. /** Default project directory for new sessions whose create request carries no cwd. */
  136. cwd: string
  137. }
  138. /** The tool/call payload fields the presenter path reads. */
  139. interface ToolCallData { callId: string; name: string; arguments: string }
  140. /** The tool/result payload fields the presenter path reads. */
  141. interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: unknown }
  142. /**
  143. * Compute the render intent for a tool/call or tool/result event through the
  144. * presenters registered at this moment; every other event type gets none. A
  145. * result's presenter needs its call's parsed args — `argsFor` supplies them
  146. * (live: the per-session call table; history: an in-page backscan), returning
  147. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  148. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  149. * the client's documented default (generic JSON card) covers every miss.
  150. */
  151. function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
  152. try {
  153. if (event.type === 'tool/call') {
  154. const { name, arguments: raw } = event.data as ToolCallData
  155. const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
  156. return view === undefined ? undefined : { for: 'call', view }
  157. }
  158. if (event.type === 'tool/result') {
  159. const { callId, content, isError, meta } = event.data as ToolResultData
  160. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  161. if (call === undefined) return undefined
  162. const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
  163. return view === undefined ? undefined : { for: 'result', view }
  164. }
  165. } catch (error: unknown) {
  166. // A throwing presenter (or unparseable arguments) must not break delivery;
  167. // the event still ships, just without a view.
  168. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  169. }
  170. return undefined
  171. }
  172. /**
  173. * Resolve a tool/result's call pairing by scanning a window of events backwards
  174. * for the matching tool/call. Used by the history path (the page is the
  175. * window — a cross-page pairing soft-falls to no view) and by live-path table
  176. * misses after a reconnect-eviction.
  177. */
  178. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  179. for (let i = events.length - 1; i >= 0; i--) {
  180. const event = events[i] as SessionEvent
  181. if (event.type !== 'tool/call') continue
  182. const data = event.data as ToolCallData
  183. if (data.callId !== callId) continue
  184. try {
  185. return { name: data.name, args: JSON.parse(data.arguments) }
  186. } catch {
  187. // Unparseable stored arguments: same soft-fall as a live parse failure.
  188. return undefined
  189. }
  190. }
  191. return undefined
  192. }
  193. /**
  194. * Thrown by the cold-resume path when the id names no servable session
  195. * (absent from the store, or a pre-project legacy log without a cwd).
  196. */
  197. class SessionNotFound extends Error {}
  198. /**
  199. * Implement ApiProxy over the ctx composed by bootHost.
  200. * @param ctx - the root context returned by bootHost (sessions/agents services mounted).
  201. * @param defaults - host-level default provider/model: injected as
  202. * agentOptions on create/resume, reported by describe from the same source.
  203. * @returns the ApiProxy implementation (minimal-first; stubs noted per method).
  204. */
  205. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  206. const agentOptions = { provider: defaults.provider, model: defaults.model }
  207. /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
  208. const resumes = new Map<SessionId, Promise<Agent>>()
  209. /**
  210. * Gate the cold path on the store: an id absent from it, or naming a legacy
  211. * log without a cwd (pre-release stance: not served, no compatibility), is
  212. * not-found before any resume is attempted. With the gate passed, a later
  213. * resume failure is genuinely internal. No persistence configured skips the
  214. * gate — resume itself then fails loud with its own diagnostic.
  215. */
  216. async function assertServable(sessionId: SessionId): Promise<void> {
  217. const persistence = ctx.get('sessionPersistence')
  218. if (persistence === undefined) return
  219. const meta = (await persistence.list()).find(m => m.id === sessionId)
  220. if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
  221. }
  222. async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
  223. const live = ctx.agents.get(sessionId)
  224. if (live !== undefined) return { agent: live }
  225. let resume = resumes.get(sessionId)
  226. if (resume === undefined) {
  227. resume = (async () => {
  228. try {
  229. await assertServable(sessionId)
  230. const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
  231. return handle.agent
  232. } finally {
  233. resumes.delete(sessionId)
  234. }
  235. })()
  236. resumes.set(sessionId, resume)
  237. }
  238. try {
  239. return { agent: await resume }
  240. } catch (error: unknown) {
  241. if (error instanceof SessionNotFound) {
  242. return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
  243. }
  244. // The internal details slot is contractually {}; the reason rides the message.
  245. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
  246. }
  247. }
  248. return {
  249. sessions: {
  250. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  251. // sessions merge in from the persistence store so history survives restarts.
  252. // Legacy logs without a cwd (pre-project stance) are not served — every
  253. // session now records its project at create time.
  254. async list(request) {
  255. const items = ctx.sessions.list().map((session) => {
  256. const agent = ctx.agents.get(session.id)
  257. return summarize(session, agent?.status === 'running')
  258. })
  259. const attached = new Set(items.map(item => item.sessionId))
  260. const persistence = ctx.get('sessionPersistence')
  261. if (persistence !== undefined) {
  262. const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  263. items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
  264. }
  265. items.sort((a, b) => b.updatedAt - a.updatedAt)
  266. return ok(request, { items })
  267. },
  268. async create(request) {
  269. const sessionId = `session-${randomUUID()}` as SessionId
  270. // A session's cwd is its project path. When the creator does not choose
  271. // one, the default project is the host-level default (the host process
  272. // working directory unless boot overrides it).
  273. const cwd = request.payload.cwd ?? defaults.cwd
  274. const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
  275. return ok(request, { sessionId: handle.agent.id })
  276. },
  277. async history(request) {
  278. const { sessionId, beforeSeq, maxMessages } = request.payload
  279. const found = await agentFor(sessionId)
  280. if ('error' in found) return err(request, found.error)
  281. const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  282. // Views are computed against the registry at pagination time; result
  283. // pairing scans within the page only (message-boundary pagination keeps
  284. // a call and its result on one page — a cross-page miss soft-falls).
  285. const entries: HistoryEntry[] = page.events.map((event) => {
  286. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
  287. return { event, ...view === undefined ? {} : { view } }
  288. })
  289. return ok(request, { events: entries, hasMore: page.hasMore })
  290. },
  291. async prompt(request) {
  292. const { sessionId, mode, content } = request.payload
  293. const found = await agentFor(sessionId)
  294. if ('error' in found) return err(request, found.error)
  295. const agent = found.agent
  296. // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
  297. const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
  298. try {
  299. if (mode === 'steer') agent.steer(content, { source })
  300. else agent.send(content, { source })
  301. } catch (error: unknown) {
  302. // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
  303. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
  304. }
  305. return ok(request, { accepted: true as const })
  306. },
  307. cancel(request) {
  308. const { sessionId } = request.payload
  309. const agent = ctx.agents.get(sessionId)
  310. if (agent === undefined) {
  311. return Promise.resolve(err(request, {
  312. code: 'session-not-found',
  313. message: `session "${sessionId}" not found (not attached)`,
  314. details: { sessionId },
  315. }))
  316. }
  317. agent.cancel()
  318. return Promise.resolve(ok(request, { accepted: true as const }))
  319. },
  320. },
  321. host: {
  322. describe(request) {
  323. // TODO(step2): version should read apps/cli's package.json; placeholder for now.
  324. return Promise.resolve(ok(request, {
  325. version: '0.0.1',
  326. cwd: process.cwd(),
  327. provider: defaults.provider,
  328. model: defaults.model,
  329. attachedSessions: ctx.agents.list().length,
  330. }))
  331. },
  332. },
  333. events: {
  334. mux(_request, signal) {
  335. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  336. for (const session of ctx.sessions.list()) {
  337. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  338. }
  339. // Per-session open-call table for result-view pairing. Bounded by the
  340. // per-turn call count: entries clear on turn/end; a table miss (stream
  341. // opened mid-turn) backscans the session's in-memory events instead.
  342. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  343. const disposers = [
  344. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  345. if (event.type === 'tool/call') {
  346. const data = event.data as ToolCallData
  347. try {
  348. let table = openCalls.get(session.id)
  349. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  350. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  351. } catch {
  352. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  353. }
  354. } else if (event.type === 'turn/end') {
  355. openCalls.delete(session.id)
  356. }
  357. const view = viewFor(ctx, event, callId =>
  358. openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
  359. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  360. }),
  361. ctx.on('session/created', (session: Session) => {
  362. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  363. }),
  364. ctx.on('session/disposed', (session: Session) => {
  365. openCalls.delete(session.id)
  366. }),
  367. ]
  368. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  369. },
  370. host(_request, signal) {
  371. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  372. const disposers = [
  373. ctx.on('session/created', (session: Session) => {
  374. queue.push(frame({
  375. type: 'host/session-added',
  376. sessionId: session.id,
  377. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  378. }))
  379. }),
  380. ctx.on('session/disposed', (session: Session) => {
  381. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  382. }),
  383. ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
  384. if (status === 'disposed') return
  385. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  386. }),
  387. ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
  388. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
  389. }),
  390. ]
  391. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  392. },
  393. },
  394. // TODO(step2): approval/question pending registry (wire answerer + proxy provider).
  395. respond(_message: ClientResponse): Promise<RpcReceipt> {
  396. return Promise.resolve({ accepted: false, reason: 'not-pending' })
  397. },
  398. }
  399. }