Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
144 lines
5.6 KiB
TypeScript
144 lines
5.6 KiB
TypeScript
/**
|
|
* 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()` 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.
|
|
*
|
|
* @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
|