Add ask_user_question interaction tool

This commit is contained in:
Yichen Jiang
2026-06-25 22:51:38 +08:00
parent 329371a529
commit 08fc8467bc
31 changed files with 1083 additions and 16 deletions
+22
View File
@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-user-interaction
Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision.
## Service: `UserInteractionService` (ctx key: `userInteraction`)
### Public API
- `ctx.userInteraction.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it.
- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` Ask the active provider and wait for the answer.
### Key Types
- `AskUserQuestionRequest``{ question, header?, options?, allowCustom?, agent?, signal? }`.
- `AskUserQuestionOption``{ label, value?, description?, recommended? }`.
- `AskUserQuestionAnswer``{ answer, option? }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI implementations such as `@deepseek-ai/dsh-ui-stdio` provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.
@@ -0,0 +1,32 @@
{
"name": "@deepseek-ai/dsh-user-interaction",
"description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* User-interaction 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 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
declare module 'cordis' {
interface Context {
userInteraction: UserInteractionService
}
}
/** One selectable answer offered to the user. */
export interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Value returned to the model when selected. Defaults to `label`. */
value?: string
/** Optional extra context rendered by capable UIs. */
description?: string
/** Marks the recommended/default option. */
recommended?: boolean
}
/** Request for a human answer. */
export interface AskUserQuestionRequest {
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether free-form answers are accepted. Defaults to `true`. */
allowCustom?: boolean
/** Calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
signal?: AbortSignal
}
/** The human's answer. */
export interface AskUserQuestionAnswer {
/** Model-facing answer text. */
answer: string
/** The selected option, when the answer came from `options`. */
option?: AskUserQuestionOption
}
/** 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 Error {
readonly code: string
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, options)
this.code = code
this.name = 'UserInteractionError'
}
}
/** `ctx.userInteraction`: one active UI provider plus an `ask()` surface. */
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. */
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. */
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 (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}
return this.provider.ask(request)
}
}
export default UserInteractionService
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import UserInteractionService, {
UserInteractionError,
type AskUserQuestionRequest,
type UserInteractionProvider,
} from '@deepseek-ai/dsh-user-interaction'
function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
const seen: AskUserQuestionRequest[] = []
return {
seen,
async ask(request) {
seen.push(request)
return { answer }
},
}
}
describe('UserInteractionService', () => {
it('delegates ask requests to the registered provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
const result = await ctx.userInteraction.ask({ question: 'Proceed?' })
expect(result).toEqual({ answer: 'yes' })
expect(p.seen).toEqual([{ question: 'Proceed?' }])
})
it('rejects ask requests when no provider is registered', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await expect(ctx.userInteraction.ask({ question: 'Proceed?' }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
})
it('registers providers with HMR-safe disposal', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider()
const dispose = ctx.userInteraction.registerProvider(p)
dispose()
dispose()
await expect(ctx.userInteraction.ask({ question: 'Proceed?' }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('rejects duplicate providers instead of replacing the active UI', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
ctx.userInteraction.registerProvider(provider('first'))
expect(() => ctx.userInteraction.registerProvider(provider('second')))
.toThrow(UserInteractionError)
})
it('fails before reaching the provider when the signal is already aborted', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answer: 'too late' })) }
ctx.userInteraction.registerProvider(p)
const controller = new AbortController()
controller.abort()
await expect(ctx.userInteraction.ask({ question: 'Proceed?', signal: controller.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(p.ask).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../agent"
}
]
}