| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143 |
- /**
- * Service Definition for the user-interaction capability seam (`ctx.userInteraction`): a UI-backed service for
- * pausing an agent tool call until the human answers a question. The model-
- * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide
- * the single active provider.
- *
- * @module @deepseek-ai/dsh-user-interaction
- */
- import { Context, Service } from '@deepseek-ai/cordis'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import { HarnessError } from '@deepseek-ai/dsh-llm'
- declare module '@deepseek-ai/cordis' {
- interface Context {
- userInteraction: UserInteractionService
- }
- }
- import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
- export type {
- AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
- AskUserQuestionOption,
- } from './types.ts'
- /** Request for a human answer. */
- export interface AskUserQuestionRequest {
- /** Questions to display. */
- questions: AskUserQuestionItem[]
- /** Exact live calling agent, when the request came from an agent tool call. */
- agent?: Agent
- /** Abort signal for the owning tool/step. */
- signal?: AbortSignal
- }
- /** UI-side provider for user questions. */
- export interface UserInteractionProvider {
- ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
- }
- /** Stable error taxonomy for user-interaction failures. */
- export class UserInteractionError extends HarnessError {
- constructor(message: string, code: string, options?: ErrorOptions) {
- super(message, code, options)
- this.name = 'UserInteractionError'
- }
- }
- /** `ctx.userInteraction`: one active UI provider plus an `ask()` API. */
- export class UserInteractionService extends Service {
- private provider: UserInteractionProvider | undefined
- constructor(ctx: Context) {
- super(ctx, 'userInteraction')
- }
- /**
- * Register the UI provider. Only one provider may be active in a context.
- *
- * @param provider UI-side implementation that collects answers.
- * @returns Disposer that unregisters this provider.
- */
- registerProvider(provider: UserInteractionProvider): () => void {
- const dispose = this.ctx.effect(function* (this: UserInteractionService) {
- if (this.provider !== undefined) {
- throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER')
- }
- this.provider = provider
- yield () => {
- this.provider = undefined
- }
- }.bind(this), 'userInteraction.registerProvider()')
- return () => void dispose()
- }
- /**
- * Ask the active UI provider and wait for the user's answer.
- *
- * When a caller supplies an agent, human interaction is valid only for the
- * exact live runtime root. Runtime ownership, not durable session lineage,
- * decides this boundary: an owned child has no human answerer and would
- * block forever, while a lineage-bearing session resumed as a new runtime
- * root may ask normally.
- *
- * @param request Questions, owner agent, and abort signal.
- * @returns The answer chosen or typed by the human.
- * @throws {UserInteractionError} code `CALLER_NOT_LIVE` when a supplied
- * agent is not the registry's exact live instance, or `DELEGATED_CALLER`
- * when that live agent is owned by another agent.
- */
- async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
- if (request.signal?.aborted) {
- throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
- }
- if (request.questions.length === 0) {
- throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
- }
- const agent = request.agent
- if (agent !== undefined) {
- const agents = this.ctx.get('agents')
- if (agents === undefined || agents.get(agent.id) !== agent) {
- throw new UserInteractionError(
- 'human interaction requires the exact live calling agent when an agent is supplied',
- 'CALLER_NOT_LIVE')
- }
- if (!agents.roots().includes(agent)) {
- throw new UserInteractionError(
- 'human interaction is unavailable while the calling agent is owned by another live agent; '
- + "include the unresolved question or decision in the child agent's final result",
- 'DELEGATED_CALLER')
- }
- }
- // A presentation intent asserts two things the types cannot: that the
- // named approve label is one of this question's own options, and that a
- // plan-review carries the plan it is a review of. A UI honouring the
- // intent answers with that label, and shows that detail as the plan, so
- // either gap would put a choice the asker never offered — or an approval of
- // something invisible — in front of the user. Caught at the asker, where
- // the mistake is, rather than in each UI.
- for (const question of request.questions) {
- const intent = question.intent
- if (intent === undefined) continue
- if (!(question.options ?? []).some(option => option.label === intent.approve)) {
- throw new UserInteractionError(
- `question ${question.id} declares intent ${intent.kind} whose approve label `
- + `${JSON.stringify(intent.approve)} names none of its options`,
- 'BAD_INTENT')
- }
- if (question.detail === undefined) {
- throw new UserInteractionError(
- `question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
- 'BAD_INTENT')
- }
- }
- if (this.provider === undefined) {
- throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
- }
- return this.provider.ask(request)
- }
- }
- export default UserInteractionService
|