api-proxy.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. /**
  2. * Host-side ApiProxy implementation. Signature discipline: unary takes the
  3. * narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
  4. */
  5. import { randomUUID } from 'node:crypto'
  6. import { stat } from 'node:fs/promises'
  7. import type { Context } from 'cordis'
  8. import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
  9. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  10. import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  11. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  12. import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
  13. import type {
  14. ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
  15. } from '@deepseek-ai/dsh-host-apiproxy/api'
  16. import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
  17. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  18. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  19. import type {
  20. AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
  21. } from '@deepseek-ai/dsh-user-interaction'
  22. import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
  23. /** Page size when history is called without maxMessages. */
  24. const DEFAULT_MAX_MESSAGES = 50
  25. /** Surface message event types (the pagination counting unit). */
  26. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
  27. /**
  28. * Message-boundary pagination: count maxMessages surface messages backwards from
  29. * the window tail; the cut is the starting seq of the oldest message group
  30. * (chunks group via sourceEventSeqs — never cut mid-message). The tail page
  31. * naturally includes the in-progress partial.
  32. */
  33. function paginate(
  34. events: readonly SessionEvent[],
  35. beforeSeq: number | undefined,
  36. maxMessages: number,
  37. ): { events: SessionEvent[]; hasMore: boolean } {
  38. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  39. let count = 0
  40. let cut = 0
  41. for (let i = window.length - 1; i >= 0; i--) {
  42. const event = window[i] as SessionEvent
  43. if (!MESSAGE_TYPES.has(event.type)) continue
  44. count++
  45. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  46. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  47. if (count >= maxMessages) {
  48. cut = groupStart
  49. break
  50. }
  51. }
  52. const page = window.filter(event => event.seq >= cut)
  53. return { events: page, hasMore: cut > 0 }
  54. }
  55. /** Wrap an ok result echoing the request's rpcId. */
  56. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  57. return { rpcId: request.rpcId, result: { ok: true, value } }
  58. }
  59. /** Wrap an error result echoing the request's rpcId. */
  60. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  61. return { rpcId: request.rpcId, result: { ok: false, error } }
  62. }
  63. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  64. class FrameQueue<F> {
  65. private buffer: F[] = []
  66. private waiter: (() => void) | undefined
  67. private done = false
  68. push(item: F): void {
  69. if (this.done) return
  70. this.buffer.push(item)
  71. this.waiter?.()
  72. }
  73. end(): void {
  74. this.done = true
  75. this.waiter?.()
  76. }
  77. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  78. const onAbort = (): void => { this.end() }
  79. signal.addEventListener('abort', onAbort, { once: true })
  80. try {
  81. while (true) {
  82. while (this.buffer.length > 0) yield this.buffer.shift() as F
  83. if (this.done || signal.aborted) return
  84. await new Promise<void>((resolve) => { this.waiter = resolve })
  85. this.waiter = undefined
  86. }
  87. } finally {
  88. signal.removeEventListener('abort', onAbort)
  89. cleanup()
  90. }
  91. }
  92. }
  93. /**
  94. * Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
  95. * for answerable frames belong to the approval/question registry, absent in
  96. * this minimal version).
  97. */
  98. function frame<F>(payload: F): RpcRequest<F> {
  99. return { rpcId: RpcId(randomUUID()), payload }
  100. }
  101. type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
  102. /** Project the latest durable title without exposing title-generation policy. */
  103. function titleFrame(session: Session): SessionTitleFrame | undefined {
  104. const title = foldSessionTitle(session.events)
  105. if (title === undefined) return undefined
  106. return {
  107. type: 'session/title',
  108. sessionId: session.id,
  109. title: title.title,
  110. eventSeq: title.eventSeq,
  111. updatedAt: title.updatedAt,
  112. }
  113. }
  114. /** Queue the subscription baseline followed by its optional title snapshot. */
  115. function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
  116. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  117. const title = titleFrame(session)
  118. if (title !== undefined) queue.push(frame(title))
  119. }
  120. /** SessionSummary projection for attached (in-memory) sessions. */
  121. function summarize(session: Session, running: boolean): SessionSummary {
  122. return {
  123. sessionId: session.id,
  124. updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
  125. running,
  126. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  127. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  128. }
  129. }
  130. /**
  131. * SessionSummary projection for cold (persisted, unattached) sessions.
  132. * updatedAt is the log file's mtime; backends without a per-session file
  133. * (locate() undefined) fall back to the header's createdAt.
  134. */
  135. async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
  136. let updatedAt = meta.createdAt
  137. const location = persistence.locate(meta)
  138. if (location !== undefined) {
  139. try {
  140. updatedAt = (await stat(location.path)).mtimeMs
  141. } catch {
  142. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  143. }
  144. }
  145. return {
  146. sessionId: meta.id,
  147. updatedAt,
  148. running: false,
  149. ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
  150. /* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
  151. filters those out (legacy logs are not served); the conditional mirrors
  152. summarize() shape. */
  153. ...meta.cwd === undefined ? {} : { cwd: meta.cwd },
  154. }
  155. }
  156. /** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
  157. export interface ApiProxyDefaults {
  158. provider: string
  159. model: string
  160. /** Default project directory for new sessions whose create request carries no cwd. */
  161. cwd: string
  162. }
  163. /** The tool/call payload fields the presenter path reads. */
  164. interface ToolCallData { callId: string; name: string; arguments: string }
  165. /** The tool/result payload fields the presenter path reads. */
  166. interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
  167. /** One host-owned question wait, addressed by the stable server-request id. */
  168. interface PendingQuestion {
  169. rpcId: RpcId
  170. sessionId: SessionId
  171. questions: AskUserQuestionItem[]
  172. resolve: (answer: AskUserQuestionAnswer) => void
  173. reject: (error: UserInteractionError) => void
  174. signal?: AbortSignal
  175. onAbort?: () => void
  176. }
  177. /** Validate one answer batch against the exact question request it resolves. */
  178. function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
  179. if (payload.sessionId !== pending.sessionId) return false
  180. const answers = payload.answer.answers
  181. if (answers.length !== pending.questions.length) return false
  182. return answers.every((answer, index) => {
  183. const question = pending.questions[index] as AskUserQuestionItem
  184. if (answer.id !== question.id) return false
  185. if (new Set(answer.selected).size !== answer.selected.length) return false
  186. const custom = answer.custom?.trim()
  187. if (custom !== undefined && custom === '') return false
  188. if (custom !== undefined && answer.selected.length > 0) return false
  189. if (question.multiSelect !== true && answer.selected.length > 1) return false
  190. const labels = new Set(question.options?.map(option => option.label) ?? [])
  191. return answer.selected.every(label => labels.has(label))
  192. })
  193. }
  194. /**
  195. * Compute the render intent for a tool/call or tool/result event through the
  196. * presenters registered at this moment; every other event type gets none. A
  197. * result's presenter needs its call's parsed args — `argsFor` supplies them
  198. * (live: the per-session call table; history: an in-page backscan), returning
  199. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  200. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  201. * the client's documented default (generic JSON card) covers every miss.
  202. */
  203. function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
  204. try {
  205. if (event.type === 'tool/call') {
  206. const { name, arguments: raw } = event.data as ToolCallData
  207. const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
  208. return view === undefined ? undefined : { for: 'call', view }
  209. }
  210. if (event.type === 'tool/result') {
  211. const { callId, content, isError, meta } = event.data as ToolResultData
  212. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  213. if (call === undefined) return undefined
  214. const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
  215. return view === undefined ? undefined : { for: 'result', view }
  216. }
  217. } catch (error: unknown) {
  218. // A throwing presenter (or unparseable arguments) must not break delivery;
  219. // the event still ships, just without a view.
  220. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  221. }
  222. return undefined
  223. }
  224. /**
  225. * Resolve a tool/result's call pairing by scanning a window of events backwards
  226. * for the matching tool/call. Used by the history path (the page is the
  227. * window — a cross-page pairing soft-falls to no view) and by live-path table
  228. * misses after a reconnect-eviction.
  229. */
  230. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  231. for (let i = events.length - 1; i >= 0; i--) {
  232. const event = events[i] as SessionEvent
  233. if (event.type !== 'tool/call') continue
  234. const data = event.data as ToolCallData
  235. if (data.callId !== callId) continue
  236. try {
  237. return { name: data.name, args: JSON.parse(data.arguments) }
  238. } catch {
  239. // Unparseable stored arguments: same soft-fall as a live parse failure.
  240. return undefined
  241. }
  242. }
  243. return undefined
  244. }
  245. /**
  246. * Thrown by the cold-resume path when the id names no servable session
  247. * (absent from the store, or a pre-project legacy log without a cwd).
  248. */
  249. class SessionNotFound extends Error {}
  250. /**
  251. * Implement ApiProxy over the ctx composed by bootHost.
  252. * @param ctx - the root context returned by bootHost (sessions/agents services mounted).
  253. * @param defaults - host-level default provider/model: injected as
  254. * agentOptions on create/resume, reported by describe from the same source.
  255. * @returns the ApiProxy implementation.
  256. */
  257. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  258. const agentOptions = { provider: defaults.provider, model: defaults.model }
  259. /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
  260. const resumes = new Map<SessionId, Promise<Agent>>()
  261. const pendingQuestions = new Map<RpcId, PendingQuestion>()
  262. const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
  263. /** Send one transient frame to every connected mux consumer. */
  264. function broadcast(payload: MuxFrame): void {
  265. const envelope = frame(payload)
  266. for (const queue of muxQueues) queue.push(envelope)
  267. }
  268. /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
  269. function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
  270. pendingQuestions.delete(pending.rpcId)
  271. if (pending.signal !== undefined && pending.onAbort !== undefined) {
  272. pending.signal.removeEventListener('abort', pending.onAbort)
  273. }
  274. broadcast({
  275. type: 'question/resolved', sessionId: pending.sessionId,
  276. questionRpcId: pending.rpcId, outcome,
  277. })
  278. }
  279. const disposeProvider = ctx.userInteraction.registerProvider({
  280. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  281. const sessionId = request.agent?.id
  282. if (sessionId === undefined) {
  283. return Promise.reject(new UserInteractionError(
  284. 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
  285. }
  286. return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
  287. const rpcId = RpcId(randomUUID())
  288. const pending: PendingQuestion = {
  289. rpcId, sessionId, questions: request.questions, resolve, reject,
  290. ...(request.signal === undefined ? {} : { signal: request.signal }),
  291. }
  292. const onAbort = (): void => {
  293. claimQuestion(pending, 'cancelled')
  294. reject(new UserInteractionError(
  295. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
  296. }
  297. pending.onAbort = onAbort
  298. pendingQuestions.set(rpcId, pending)
  299. request.signal?.addEventListener('abort', onAbort, { once: true })
  300. const envelope: RpcRequest<MuxFrame> = {
  301. rpcId,
  302. payload: { type: 'question/requested', sessionId, questions: request.questions },
  303. }
  304. for (const queue of muxQueues) queue.push(envelope)
  305. })
  306. },
  307. })
  308. ctx.effect(() => () => {
  309. disposeProvider()
  310. for (const pending of [...pendingQuestions.values()]) {
  311. claimQuestion(pending, 'cancelled')
  312. pending.reject(new UserInteractionError(
  313. 'web user-interaction provider was disposed', 'ASK_ABORTED'))
  314. }
  315. }, 'api-proxy: user-interaction provider')
  316. /**
  317. * Gate the cold path on the store: an id absent from it, or naming a legacy
  318. * log without a cwd (pre-release stance: not served, no compatibility), is
  319. * not-found before any resume is attempted. With the gate passed, a later
  320. * resume failure is genuinely internal. No persistence configured skips the
  321. * gate — resume itself then fails loud with its own diagnostic.
  322. */
  323. async function assertServable(sessionId: SessionId): Promise<void> {
  324. const persistence = ctx.get('sessionPersistence')
  325. if (persistence === undefined) return
  326. const meta = (await persistence.list()).find(m => m.id === sessionId)
  327. if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
  328. }
  329. async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
  330. const live = ctx.agents.get(sessionId)
  331. if (live !== undefined) return { agent: live }
  332. let resume = resumes.get(sessionId)
  333. if (resume === undefined) {
  334. resume = (async () => {
  335. try {
  336. await assertServable(sessionId)
  337. const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
  338. return handle.agent
  339. } finally {
  340. resumes.delete(sessionId)
  341. }
  342. })()
  343. resumes.set(sessionId, resume)
  344. }
  345. try {
  346. return { agent: await resume }
  347. } catch (error: unknown) {
  348. if (error instanceof SessionNotFound) {
  349. return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
  350. }
  351. // The internal details slot is contractually {}; the reason rides the message.
  352. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
  353. }
  354. }
  355. return {
  356. sessions: {
  357. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  358. // sessions merge in from the persistence store so history survives restarts.
  359. // Legacy logs without a cwd (pre-project stance) are not served — every
  360. // session now records its project at create time.
  361. async list(request) {
  362. const items = ctx.sessions.list().map((session) => {
  363. const agent = ctx.agents.get(session.id)
  364. return summarize(session, agent?.status === 'running')
  365. })
  366. const attached = new Set(items.map(item => item.sessionId))
  367. const persistence = ctx.get('sessionPersistence')
  368. if (persistence !== undefined) {
  369. const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  370. items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
  371. }
  372. items.sort((a, b) => b.updatedAt - a.updatedAt)
  373. return ok(request, { items })
  374. },
  375. async create(request) {
  376. const sessionId = `session-${randomUUID()}` as SessionId
  377. // A session's cwd is its project path. When the creator does not choose
  378. // one, the default project is the host-level default (the host process
  379. // working directory unless boot overrides it).
  380. const cwd = request.payload.cwd ?? defaults.cwd
  381. const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
  382. return ok(request, { sessionId: handle.agent.id })
  383. },
  384. async history(request) {
  385. const { sessionId, beforeSeq, maxMessages } = request.payload
  386. const found = await agentFor(sessionId)
  387. if ('error' in found) return err(request, found.error)
  388. const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  389. // Views are computed against the registry at pagination time; result
  390. // pairing scans within the page only (message-boundary pagination keeps
  391. // a call and its result on one page — a cross-page miss soft-falls).
  392. const entries: HistoryEntry[] = page.events.map((event) => {
  393. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
  394. return { event, ...view === undefined ? {} : { view } }
  395. })
  396. return ok(request, { events: entries, hasMore: page.hasMore })
  397. },
  398. async prompt(request) {
  399. const { sessionId, mode, content } = request.payload
  400. const found = await agentFor(sessionId)
  401. if ('error' in found) return err(request, found.error)
  402. const agent = found.agent
  403. // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
  404. const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
  405. try {
  406. if (mode === 'steer') agent.steer(content, { source })
  407. else agent.send(content, { source })
  408. } catch (error: unknown) {
  409. // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
  410. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
  411. }
  412. return ok(request, { accepted: true as const })
  413. },
  414. cancel(request) {
  415. const { sessionId } = request.payload
  416. const agent = ctx.agents.get(sessionId)
  417. if (agent === undefined) {
  418. return Promise.resolve(err(request, {
  419. code: 'session-not-found',
  420. message: `session "${sessionId}" not found (not attached)`,
  421. details: { sessionId },
  422. }))
  423. }
  424. agent.cancel()
  425. return Promise.resolve(ok(request, { accepted: true as const }))
  426. },
  427. },
  428. host: {
  429. describe(request) {
  430. // TODO(step2): version should read apps/cli's package.json; placeholder for now.
  431. return Promise.resolve(ok(request, {
  432. version: '0.0.1',
  433. cwd: process.cwd(),
  434. provider: defaults.provider,
  435. model: defaults.model,
  436. attachedSessions: ctx.agents.list().length,
  437. }))
  438. },
  439. },
  440. events: {
  441. mux(_request, signal) {
  442. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  443. muxQueues.add(queue)
  444. for (const session of ctx.sessions.list()) {
  445. subscribeSession(queue, session)
  446. }
  447. for (const pending of pendingQuestions.values()) {
  448. queue.push({
  449. rpcId: pending.rpcId,
  450. payload: {
  451. type: 'question/requested', sessionId: pending.sessionId,
  452. questions: pending.questions,
  453. },
  454. })
  455. }
  456. // Per-session open-call table for result-view pairing. Bounded by the
  457. // per-turn call count: entries clear on turn/end; a table miss (stream
  458. // opened mid-turn) backscans the session's in-memory events instead.
  459. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  460. const disposers = [
  461. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  462. if (event.type === 'tool/call') {
  463. const data = event.data as ToolCallData
  464. try {
  465. let table = openCalls.get(session.id)
  466. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  467. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  468. } catch {
  469. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  470. }
  471. } else if (event.type === 'turn/end') {
  472. openCalls.delete(session.id)
  473. }
  474. const view = viewFor(ctx, event, callId =>
  475. openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
  476. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  477. if (event.type === 'session/title') {
  478. // The accepted raw event is already in session.events, so the fold must find it.
  479. queue.push(frame(titleFrame(session) as SessionTitleFrame))
  480. }
  481. }),
  482. ctx.on('session/created', (session: Session) => {
  483. subscribeSession(queue, session)
  484. }),
  485. ctx.on('session/disposed', (session: Session) => {
  486. openCalls.delete(session.id)
  487. }),
  488. ]
  489. return queue.iterate(signal, () => {
  490. muxQueues.delete(queue)
  491. for (const dispose of disposers) dispose()
  492. })
  493. },
  494. host(_request, signal) {
  495. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  496. const disposers = [
  497. ctx.on('session/created', (session: Session) => {
  498. queue.push(frame({
  499. type: 'host/session-added',
  500. sessionId: session.id,
  501. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  502. }))
  503. }),
  504. ctx.on('session/disposed', (session: Session) => {
  505. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  506. }),
  507. ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
  508. if (status === 'disposed') return
  509. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  510. }),
  511. ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
  512. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
  513. }),
  514. ]
  515. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  516. },
  517. },
  518. respond(message: ClientResponse): Promise<RpcReceipt> {
  519. const pending = pendingQuestions.get(message.rpcId)
  520. if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
  521. if (!message.result.ok) {
  522. if (message.result.error.code !== 'cancelled') {
  523. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  524. }
  525. claimQuestion(pending, 'cancelled')
  526. pending.reject(new UserInteractionError(
  527. 'the user cancelled ask_user_question', 'ASK_CANCELLED'))
  528. return Promise.resolve({ accepted: true })
  529. }
  530. const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
  531. if (!parsed.success) {
  532. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  533. }
  534. const payload: QuestionResponsePayload = {
  535. sessionId: parsed.data.sessionId,
  536. answer: {
  537. answers: parsed.data.answer.answers.map(answer => ({
  538. id: answer.id,
  539. selected: answer.selected,
  540. ...(answer.custom === undefined ? {} : { custom: answer.custom }),
  541. })),
  542. },
  543. }
  544. if (!matchesQuestions(payload, pending)) {
  545. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  546. }
  547. claimQuestion(pending, 'answered')
  548. pending.resolve(payload.answer)
  549. return Promise.resolve({ accepted: true })
  550. },
  551. }
  552. }