fix: guard human interaction by runtime ownership
This commit is contained in:
@@ -1174,7 +1174,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>',
|
||||
jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * Human-interaction requests are only valid from a top-level agent: a\n * delegated subagent has no human answerer in its own context, so asking\n * there would block forever. This mirrors the goal tools\' top-level-only\n * authority (`create_goal` rejects non-top-level agents).\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling\n * agent is a delegated subagent (`session.header.delegationDepth > 0`).\n */',
|
||||
jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * When a caller supplies an agent, human interaction is valid only for the\n * exact live runtime root. Runtime ownership, not durable session lineage,\n * decides this boundary: an owned child has no human answerer and would\n * block forever, while a lineage-bearing session resumed as a new runtime\n * root may ask normally.\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n * @throws {UserInteractionError} code `CALLER_NOT_LIVE` when a supplied\n * agent is not the registry\'s exact live instance, or `DELEGATED_CALLER`\n * when that live agent is owned by another agent.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import UserInteractionService, {
|
||||
UserInteractionError, type AskUserQuestionRequest,
|
||||
@@ -25,7 +25,11 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
|
||||
* and the following `step/start` session event used by the loop.
|
||||
*/
|
||||
|
||||
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
|
||||
async function agentWithSession(
|
||||
ctx: Context,
|
||||
id = 'agent-1',
|
||||
{ active, owner }: { active?: boolean; owner?: Agent } = {},
|
||||
): Promise<Agent & { session: Session }> {
|
||||
// A live store session when a store is mounted (the command executor logs
|
||||
// lifecycle events through it); bare otherwise (fold/tool-only benches).
|
||||
const session = Session.create(SessionId(id))
|
||||
@@ -44,8 +48,15 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { acti
|
||||
;(agent as { ctx?: Context }).ctx = scoped
|
||||
// Seeded plan state lands before the creation announcement, matching resume.
|
||||
if (active !== undefined) session.append('plan/mode', { active })
|
||||
// The loop announces creation after publication.
|
||||
ctx.emit('agent/created', { agent })
|
||||
// The loop publishes through the live registry when it is composed; narrow
|
||||
// fold-only benches retain the direct lifecycle event used before it exists.
|
||||
const agents = ctx.get('agents')
|
||||
if (agents === undefined) {
|
||||
ctx.emit('agent/created', { agent })
|
||||
} else {
|
||||
agents.enter(agent, owner)
|
||||
agents.announce(agent)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -653,6 +664,7 @@ describe('/plan', () => {
|
||||
describe('exit_plan_mode', () => {
|
||||
async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const asked: AskUserQuestionRequest[] = []
|
||||
if (answer !== undefined) {
|
||||
@@ -730,6 +742,26 @@ describe('exit_plan_mode', () => {
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }))
|
||||
ctx.userInteraction.registerProvider({ ask })
|
||||
const root = await agentWithSession(ctx, 'review-root')
|
||||
const child = await agentWithSession(ctx, 'review-child', { active: true, owner: root })
|
||||
|
||||
const result = await callExit(ctx, child)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text',
|
||||
text: "Error: 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",
|
||||
}])
|
||||
expect(ask).not.toHaveBeenCalled()
|
||||
expect(foldPlanMode(child.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
|
||||
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
|
||||
const result = await callExit(ctx, agent)
|
||||
@@ -765,6 +797,7 @@ describe('exit_plan_mode', () => {
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
await ctx.plugin(ExitRuntime)
|
||||
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const asked: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
@@ -939,6 +972,7 @@ describe('exit_plan_mode', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
|
||||
ctx.userInteraction.registerProvider({
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md
|
||||
README.md: cb1cc11bba4010c885f320fc5569509ff48fccc0
|
||||
README.zh.md: 7558816a2874509717c50e22b93a548697f86ef7
|
||||
README.md: 7af356263ea6582a081e7c6de22fd317ca8b96df
|
||||
README.zh.md: 3f7b814b83c8c6957a4b2574ee69e87d45f65ae6
|
||||
|
||||
@@ -54,5 +54,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only.
|
||||
- **Delegated subagents cannot ask the user** — `ask_user_question` rejects calls from a delegated subagent with `DELEGATED_CALLER`; a child that needs a decision must delegate the question to the top-level agent.
|
||||
- **Runtime-owned subagents cannot ask the user** — `ask_user_question` rejects a live child owned by another agent with `DELEGATED_CALLER`; the child must include the unresolved question or decision in its final result. Durable lineage does not decide this boundary, so a lineage-bearing session resumed as a runtime root may ask normally.
|
||||
- **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary.
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。
|
||||
- **委托的子代理不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝来自委托子代理的调用;需要决策的子代理必须把问题转交给顶层代理。
|
||||
- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝由另一个 agent 所有的存活子级;该子级必须在最终结果中包含尚未解决的问题或决策。持久化会话谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。
|
||||
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -28,6 +27,7 @@ interface OptionSchemaShape {
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
@@ -35,6 +35,14 @@ async function setup() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function stubAgent(id: string, delegationDepth = 0): Agent {
|
||||
const agentId = id as Agent['id']
|
||||
return {
|
||||
id: agentId,
|
||||
session: { id: agentId, header: { delegationDepth } },
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
describe('ask_user_question tool', () => {
|
||||
it('registers a model-facing tool schema', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -208,9 +216,8 @@ describe('ask_user_question tool', () => {
|
||||
expect(seen[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('passes optional header and agent through to the user-interaction request', async () => {
|
||||
it('passes optional header and a resumed runtime root through to the user-interaction request', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(SessionStore)
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
@@ -218,8 +225,8 @@ describe('ask_user_question tool', () => {
|
||||
return { answers: [{ id: 'continue', selected: ['ok'] }] }
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } })
|
||||
const agent = { session } as unknown as Agent
|
||||
const agent = stubAgent('resumed-root', 1)
|
||||
ctx.agents.enter(agent, undefined)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
@@ -249,9 +256,8 @@ describe('ask_user_question tool', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a delegated subagent with a structured DELEGATED_CALLER error', async () => {
|
||||
it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(SessionStore)
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
@@ -259,20 +265,26 @@ describe('ask_user_question tool', () => {
|
||||
return { answers: [{ id: 'continue', selected: ['ok'] }] }
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } })
|
||||
const agent = { session } as unknown as Agent
|
||||
const root = stubAgent('root', 0)
|
||||
const child = stubAgent('child', 0)
|
||||
ctx.agents.enter(root, undefined)
|
||||
ctx.agents.enter(child, root)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ask-delegated'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
agent,
|
||||
agent: child,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'UserInteractionError', code: 'DELEGATED_CALLER' } },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: "Error: 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",
|
||||
}],
|
||||
})
|
||||
expect(seen).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md
|
||||
README.md: d5b2353485b47b3077af74699a57728063f6ce22
|
||||
README.zh.md: 460feed0e8b0634372d5e7712e4bfca19e0f5523
|
||||
README.md: cba015e782623b3a5adf018303823577a0b96774
|
||||
README.zh.md: a5f944850c5ac4e05da59e8478167eef91796610
|
||||
|
||||
@@ -13,15 +13,17 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
|
||||
|
||||
### Key Types
|
||||
|
||||
- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
|
||||
- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. When present, `agent` must be the registry's exact live runtime root.
|
||||
- `AskUserQuestionOption` — `{ label, description? }`.
|
||||
- `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below.
|
||||
- `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`.
|
||||
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, and `DELEGATED_CALLER`.
|
||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`.
|
||||
|
||||
For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
|
||||
|
||||
When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. Agentless programmatic requests retain the existing provider path.
|
||||
|
||||
### Presentation intent
|
||||
|
||||
`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
|
||||
@@ -32,7 +34,7 @@ This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`, `Error: no user-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
|
||||
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: 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`, `Error: no user-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -13,15 +13,17 @@
|
||||
|
||||
### 关键类型
|
||||
|
||||
- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
|
||||
- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。如提供 `agent`,它必须与注册表中的存活运行时根 agent(智能体)是同一对象。
|
||||
- `AskUserQuestionOption`:`{ label, description? }`。
|
||||
- `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
|
||||
- `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。
|
||||
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
|
||||
- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED` 和 `DELEGATED_CALLER` 等代码。
|
||||
- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。
|
||||
|
||||
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
|
||||
|
||||
请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久化会话谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;由另一个 agent 所有的存活子级即使持久化深度为零也会被拒绝。不含 agent 的程序化请求继续沿用现有提供方路径。
|
||||
|
||||
### 呈现意图
|
||||
|
||||
`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`、`Error: no user-interaction provider is registered` 或 `Error: <message>`。等待人类回答不会增加 token。
|
||||
间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: human interaction requires the exact live calling agent when an agent is supplied`、`Error: 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`、`Error: no user-interaction provider is registered` 或 `Error: <message>`。等待人类回答不会增加 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export type {
|
||||
export interface AskUserQuestionRequest {
|
||||
/** Questions to display. */
|
||||
questions: AskUserQuestionItem[]
|
||||
/** Calling agent, when the request came from an agent tool call. */
|
||||
/** Exact live calling agent, when the request came from an agent tool call. */
|
||||
agent?: Agent
|
||||
/** Abort signal for the owning tool/step. */
|
||||
signal?: AbortSignal
|
||||
@@ -77,15 +77,17 @@ export class UserInteractionService extends Service {
|
||||
/**
|
||||
* Ask the active UI provider and wait for the user's answer.
|
||||
*
|
||||
* Human-interaction requests are only valid from a top-level agent: a
|
||||
* delegated subagent has no human answerer in its own context, so asking
|
||||
* there would block forever. This mirrors the goal tools' top-level-only
|
||||
* authority (`create_goal` rejects non-top-level agents).
|
||||
* 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 `DELEGATED_CALLER` when the calling
|
||||
* agent is a delegated subagent (`session.header.delegationDepth > 0`).
|
||||
* @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) {
|
||||
@@ -94,10 +96,20 @@ export class UserInteractionService extends Service {
|
||||
if (request.questions.length === 0) {
|
||||
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
|
||||
}
|
||||
if ((request.agent?.session.header.delegationDepth ?? 0) > 0) {
|
||||
throw new UserInteractionError(
|
||||
'ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent',
|
||||
'DELEGATED_CALLER')
|
||||
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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService, {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionRequest,
|
||||
@@ -19,6 +18,14 @@ function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUse
|
||||
}
|
||||
}
|
||||
|
||||
function stubAgent(id: string, delegationDepth = 0): Agent {
|
||||
const agentId = id as Agent['id']
|
||||
return {
|
||||
id: agentId,
|
||||
session: { id: agentId, header: { delegationDepth } },
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
describe('UserInteractionService', () => {
|
||||
it('delegates ask requests to the registered provider', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -86,30 +93,36 @@ describe('UserInteractionService', () => {
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a delegated subagent before reaching the provider', async () => {
|
||||
it('rejects a live runtime-owned agent before reaching the provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answers: [] })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } })
|
||||
const agent = { session } as unknown as Agent
|
||||
const root = stubAgent('root', 0)
|
||||
const child = stubAgent('child', 0)
|
||||
ctx.agents.enter(root, undefined)
|
||||
ctx.agents.enter(child, root)
|
||||
|
||||
await expect(ctx.userInteraction.ask({
|
||||
questions: [{ id: 'confirm', question: 'Proceed?' }],
|
||||
agent,
|
||||
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'DELEGATED_CALLER' })
|
||||
agent: child,
|
||||
})).rejects.toMatchObject({
|
||||
name: 'UserInteractionError',
|
||||
code: 'DELEGATED_CALLER',
|
||||
message: "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",
|
||||
})
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reaches the provider for a top-level agent (delegationDepth 0)', async () => {
|
||||
it('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = provider('yes')
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } })
|
||||
const agent = { session } as unknown as Agent
|
||||
const agent = stubAgent('resumed-root', 1)
|
||||
ctx.agents.enter(agent, undefined)
|
||||
|
||||
const result = await ctx.userInteraction.ask({
|
||||
questions: [{ id: 'confirm', question: 'Proceed?' }],
|
||||
@@ -119,6 +132,35 @@ describe('UserInteractionService', () => {
|
||||
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
|
||||
})
|
||||
|
||||
it('rejects a supplied agent when no live registry can attest it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answers: [] })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
|
||||
await expect(ctx.userInteraction.ask({
|
||||
questions: [{ id: 'confirm', question: 'Proceed?' }],
|
||||
agent: stubAgent('unattested'),
|
||||
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a stale agent object that reuses a live id', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answers: [] })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
const live = stubAgent('same-id')
|
||||
ctx.agents.enter(live, undefined)
|
||||
|
||||
await expect(ctx.userInteraction.ask({
|
||||
questions: [{ id: 'confirm', question: 'Proceed?' }],
|
||||
agent: stubAgent('same-id'),
|
||||
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an intent whose approve label names none of its own options', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
|
||||
Reference in New Issue
Block a user