Merge pull request #1145 from deepseek-harness/fix/f4-ask-user-child-guard

fix(user-interaction): guard human interaction by runtime ownership
This commit is contained in:
Tianyi Cui
2026-08-08 16:47:24 +08:00
committed by GitHub
35 changed files with 1142 additions and 32 deletions

View File

@@ -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: 64da4d75d01a0df0ae51b1557ed1c796317b906f
README.zh.md: fdfa1ff2470258e2864f505fbadfcd2fc8be8101
README.md: 7af356263ea6582a081e7c6de22fd317ca8b96df
README.zh.md: 3f7b814b83c8c6957a4b2574ee69e87d45f65ae6

View File

@@ -54,4 +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.
- **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.

View File

@@ -54,4 +54,5 @@
## 已知限制与暂缓事项
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。
- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝由另一个 agent 所有的存活子级;该子级必须在最终结果中包含尚未解决的问题或决策。持久化会话谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。

View File

@@ -1,7 +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 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'
@@ -27,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)
@@ -34,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()
@@ -207,7 +216,7 @@ 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()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
@@ -216,7 +225,8 @@ describe('ask_user_question tool', () => {
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const agent = { id: 'main' } as unknown as Agent
const agent = stubAgent('resumed-root', 1)
ctx.agents.enter(agent, undefined)
const result = await ctx.tools.execute({
signal: testToolSignal,
@@ -246,6 +256,39 @@ describe('ask_user_question tool', () => {
})
})
it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
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: 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)
})
it('returns a structured error for empty question batches', async () => {
const ctx = await setup()

View File

@@ -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: 7d3c4e87c018e42794d319b540fd0a94abc9b43c
README.zh.md: 0e5d15a673c124abab4b13e869623df1a5c63acd
README.md: cba015e782623b3a5adf018303823577a0b96774
README.zh.md: a5f944850c5ac4e05da59e8478167eef91796610

View File

@@ -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`, and `ASK_ABORTED`.
- `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: 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

View File

@@ -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` 等代码。
- `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: 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 影响

View File

@@ -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,8 +77,17 @@ export class UserInteractionService extends Service {
/**
* 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) {
@@ -87,6 +96,21 @@ export class UserInteractionService extends Service {
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

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, {
UserInteractionError,
type AskUserQuestionRequest,
@@ -17,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()
@@ -84,6 +93,74 @@ describe('UserInteractionService', () => {
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a live runtime-owned agent before reaching the provider', 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 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: 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('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
const agent = stubAgent('resumed-root', 1)
ctx.agents.enter(agent, undefined)
const result = await ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent,
})
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)