index.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. /**
  2. * Service Definition for the user-questions capability seam (`ctx.userQuestions`): a UI-backed service for
  3. * pausing an agent tool call until the human answers a question. The model-
  4. * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages compose
  5. * answerers on the Agent-scoped Cordis waterfall.
  6. *
  7. * @module @deepseek-ai/dsh-user-questions
  8. */
  9. import { Context, Service } from '@deepseek-ai/cordis'
  10. import type {} from '@deepseek-ai/dsh-agent'
  11. import { HarnessError } from '@deepseek-ai/dsh-llm'
  12. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  13. declare module '@deepseek-ai/cordis' {
  14. interface Context {
  15. userQuestions: UserQuestionService
  16. }
  17. }
  18. import type {
  19. AskUserQuestionAnswer, AskUserQuestionRequestEvent,
  20. } from './types.ts'
  21. export type {
  22. AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
  23. AskUserQuestionOption,
  24. } from './types.ts'
  25. /** Request for a human answer. */
  26. export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {}
  27. /** Stable error taxonomy for user-questions failures. */
  28. export class UserQuestionError extends HarnessError {
  29. constructor(message: string, code: string, options?: ErrorOptions) {
  30. super(message, code, options)
  31. this.name = 'UserQuestionError'
  32. }
  33. }
  34. function abortedQuestion(cause?: unknown): UserQuestionError {
  35. return new UserQuestionError(
  36. 'ask_user_question was aborted before the user answered',
  37. 'ASK_ABORTED',
  38. cause === undefined ? undefined : { cause },
  39. )
  40. }
  41. function isRecord(value: unknown): value is Record<string, unknown> {
  42. return typeof value === 'object' && value !== null && !Array.isArray(value)
  43. }
  44. function restoreUserQuestionError(reason: unknown): unknown {
  45. if (reason instanceof UserQuestionError) return reason
  46. if (isRecord(reason)
  47. && reason.name === 'UserQuestionError'
  48. && typeof reason.message === 'string'
  49. && typeof reason.code === 'string') {
  50. return new UserQuestionError(reason.message, reason.code, { cause: reason })
  51. }
  52. return reason
  53. }
  54. /** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */
  55. export class UserQuestionService extends Service {
  56. constructor(ctx: Context) {
  57. super(ctx, 'userQuestions')
  58. }
  59. /**
  60. * Ask the scoped answerer waterfall and wait for the user's answer.
  61. *
  62. * When a caller supplies an agent, human interaction is valid only for the
  63. * exact live runtime root. Runtime ownership, not durable session lineage,
  64. * decides this boundary: an owned child has no human answerer and would
  65. * block forever, while a lineage-bearing session resumed as a new runtime
  66. * root may ask normally.
  67. *
  68. * @param request Questions, owner agent, and abort signal.
  69. * @returns The answer chosen or typed by the human.
  70. * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal
  71. * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent
  72. * is not the registry's exact live instance, or `DELEGATED_CALLER` when
  73. * that live agent is owned by another agent.
  74. */
  75. async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  76. if (request.signal?.aborted) {
  77. throw abortedQuestion()
  78. }
  79. if (request.questions.length === 0) {
  80. throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
  81. }
  82. const agent = request.agent
  83. if (agent !== undefined) {
  84. const agents = this.ctx.get('agents')
  85. if (agents === undefined || agents.get(agent.id) !== agent) {
  86. throw new UserQuestionError(
  87. 'human interaction requires the exact live calling agent when an agent is supplied',
  88. 'CALLER_NOT_LIVE')
  89. }
  90. if (!agents.roots().includes(agent)) {
  91. throw new UserQuestionError(
  92. 'human interaction is unavailable while the calling agent is owned by another live agent; '
  93. + "include the unresolved question or decision in the child agent's final result",
  94. 'DELEGATED_CALLER')
  95. }
  96. }
  97. // A presentation intent asserts two things the types cannot: that the
  98. // named approve label is one of this question's own options, and that a
  99. // plan-review carries the plan it is a review of. A UI honouring the
  100. // intent answers with that label, and shows that detail as the plan, so
  101. // either gap would put a choice the asker never offered — or an approval of
  102. // something invisible — in front of the user. Caught at the asker, where
  103. // the mistake is, rather than in each UI.
  104. for (const question of request.questions) {
  105. const intent = question.intent
  106. if (intent === undefined) continue
  107. if (!(question.options ?? []).some(option => option.label === intent.approve)) {
  108. throw new UserQuestionError(
  109. `question ${question.id} declares intent ${intent.kind} whose approve label `
  110. + `${JSON.stringify(intent.approve)} names none of its options`,
  111. 'BAD_INTENT')
  112. }
  113. if (question.detail === undefined) {
  114. throw new UserQuestionError(
  115. `question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
  116. 'BAD_INTENT')
  117. }
  118. }
  119. const noAnswerer = () => Promise.reject(new UserQuestionError(
  120. 'no user-questions answerer accepted the request',
  121. 'NO_PROVIDER',
  122. ))
  123. try {
  124. return await (agent === undefined
  125. ? this.ctx.waterfall('user-questions/request', request, noAnswerer)
  126. : this.ctx.waterfall(
  127. scopeTarget(agent, agent),
  128. 'user-questions/request',
  129. { ...request, agent },
  130. noAnswerer,
  131. ))
  132. } catch (error) {
  133. const restored = restoreUserQuestionError(error)
  134. if (restored instanceof UserQuestionError) throw restored
  135. if (request.signal?.aborted) {
  136. throw abortedQuestion(error)
  137. }
  138. throw restored
  139. }
  140. }
  141. }
  142. export default UserQuestionService