index.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. /**
  2. * Service Definition for the user-interaction capability seam (`ctx.userInteraction`): 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 provide
  5. * the single active provider.
  6. *
  7. * @module @deepseek-ai/dsh-user-interaction
  8. */
  9. import { Context, Service } from '@deepseek-ai/cordis'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import { HarnessError } from '@deepseek-ai/dsh-llm'
  12. declare module '@deepseek-ai/cordis' {
  13. interface Context {
  14. userInteraction: UserInteractionService
  15. }
  16. }
  17. import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
  18. export type {
  19. AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
  20. AskUserQuestionOption,
  21. } from './types.ts'
  22. /** Request for a human answer. */
  23. export interface AskUserQuestionRequest {
  24. /** Questions to display. */
  25. questions: AskUserQuestionItem[]
  26. /** Exact live calling agent, when the request came from an agent tool call. */
  27. agent?: Agent
  28. /** Abort signal for the owning tool/step. */
  29. signal?: AbortSignal
  30. }
  31. /** UI-side provider for user questions. */
  32. export interface UserInteractionProvider {
  33. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
  34. }
  35. /** Stable error taxonomy for user-interaction failures. */
  36. export class UserInteractionError extends HarnessError {
  37. constructor(message: string, code: string, options?: ErrorOptions) {
  38. super(message, code, options)
  39. this.name = 'UserInteractionError'
  40. }
  41. }
  42. /** `ctx.userInteraction`: one active UI provider plus an `ask()` API. */
  43. export class UserInteractionService extends Service {
  44. private provider: UserInteractionProvider | undefined
  45. constructor(ctx: Context) {
  46. super(ctx, 'userInteraction')
  47. }
  48. /**
  49. * Register the UI provider. Only one provider may be active in a context.
  50. *
  51. * @param provider UI-side implementation that collects answers.
  52. * @returns Disposer that unregisters this provider.
  53. */
  54. registerProvider(provider: UserInteractionProvider): () => void {
  55. const dispose = this.ctx.effect(function* (this: UserInteractionService) {
  56. if (this.provider !== undefined) {
  57. throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER')
  58. }
  59. this.provider = provider
  60. yield () => {
  61. this.provider = undefined
  62. }
  63. }.bind(this), 'userInteraction.registerProvider()')
  64. return () => void dispose()
  65. }
  66. /**
  67. * Ask the active UI provider and wait for the user's answer.
  68. *
  69. * When a caller supplies an agent, human interaction is valid only for the
  70. * exact live runtime root. Runtime ownership, not durable session lineage,
  71. * decides this boundary: an owned child has no human answerer and would
  72. * block forever, while a lineage-bearing session resumed as a new runtime
  73. * root may ask normally.
  74. *
  75. * @param request Questions, owner agent, and abort signal.
  76. * @returns The answer chosen or typed by the human.
  77. * @throws {UserInteractionError} code `CALLER_NOT_LIVE` when a supplied
  78. * agent is not the registry's exact live instance, or `DELEGATED_CALLER`
  79. * when that live agent is owned by another agent.
  80. */
  81. async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  82. if (request.signal?.aborted) {
  83. throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
  84. }
  85. if (request.questions.length === 0) {
  86. throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
  87. }
  88. const agent = request.agent
  89. if (agent !== undefined) {
  90. const agents = this.ctx.get('agents')
  91. if (agents === undefined || agents.get(agent.id) !== agent) {
  92. throw new UserInteractionError(
  93. 'human interaction requires the exact live calling agent when an agent is supplied',
  94. 'CALLER_NOT_LIVE')
  95. }
  96. if (!agents.roots().includes(agent)) {
  97. throw new UserInteractionError(
  98. 'human interaction is unavailable while the calling agent is owned by another live agent; '
  99. + "include the unresolved question or decision in the child agent's final result",
  100. 'DELEGATED_CALLER')
  101. }
  102. }
  103. // A presentation intent asserts two things the types cannot: that the
  104. // named approve label is one of this question's own options, and that a
  105. // plan-review carries the plan it is a review of. A UI honouring the
  106. // intent answers with that label, and shows that detail as the plan, so
  107. // either gap would put a choice the asker never offered — or an approval of
  108. // something invisible — in front of the user. Caught at the asker, where
  109. // the mistake is, rather than in each UI.
  110. for (const question of request.questions) {
  111. const intent = question.intent
  112. if (intent === undefined) continue
  113. if (!(question.options ?? []).some(option => option.label === intent.approve)) {
  114. throw new UserInteractionError(
  115. `question ${question.id} declares intent ${intent.kind} whose approve label `
  116. + `${JSON.stringify(intent.approve)} names none of its options`,
  117. 'BAD_INTENT')
  118. }
  119. if (question.detail === undefined) {
  120. throw new UserInteractionError(
  121. `question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
  122. 'BAD_INTENT')
  123. }
  124. }
  125. if (this.provider === undefined) {
  126. throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
  127. }
  128. return this.provider.ask(request)
  129. }
  130. }
  131. export default UserInteractionService