From 8d92a9bdaaebf79f45027a48ada60212416b20dc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:43:44 +0800 Subject: [PATCH 1/7] fix(user-interaction): reject ask_user_question from delegated subagents --- ...-ask-user-delegated-caller-guard.i18n.yaml | 6 ++++ ...6-08-01-ask-user-delegated-caller-guard.md | 29 +++++++++++++++ ...8-01-ask-user-delegated-caller-guard.zh.md | 29 +++++++++++++++ packages/ui/tool-ask-user/README.i18n.yaml | 4 +-- packages/ui/tool-ask-user/README.md | 1 + packages/ui/tool-ask-user/README.zh.md | 1 + .../tool-ask-user/tests/tool-ask-user.spec.ts | 33 ++++++++++++++++- packages/ui/user-interaction/README.i18n.yaml | 4 +-- packages/ui/user-interaction/README.md | 4 +-- packages/ui/user-interaction/README.zh.md | 4 +-- packages/ui/user-interaction/src/index.ts | 12 +++++++ .../tests/user-interaction.spec.ts | 35 +++++++++++++++++++ 12 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml new file mode 100644 index 0000000000..800a575f3b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md +2026-08-01-ask-user-delegated-caller-guard.md: 17c5e42a1d12c018507c6cf410129bb17099e967 +2026-08-01-ask-user-delegated-caller-guard.zh.md: 38f059ba87ac5208cca6cb94ba9ee5223a6987b0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md new file mode 100644 index 0000000000..17c5e42a1d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md @@ -0,0 +1,29 @@ +# Agent Note: Reject ask_user_question from delegated subagents + +Status: implemented + +English | [中文](2026-08-01-ask-user-delegated-caller-guard.zh.md) + +## Problem + +A delegated subagent that calls the `ask_user_question` tool blocks indefinitely. The tool pauses for a human answer, but a child context has no human answerer, so no answer ever arrives and the subagent run hangs until it is cancelled externally. + +## Decision + +`UserInteractionService.ask()` rejects any request whose calling agent is a delegated subagent — `request.agent.session.header.delegationDepth > 0` — with a new `UserInteractionError` code `DELEGATED_CALLER` and the message `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`. The check runs at the top of `ask()`, after the aborted/empty guards and before intent validation, so no provider interaction happens for a rejected child. This mirrors the goal tools' top-level-only authority (`create_goal` rejects non-top-level agents with a direct-human-turn requirement). + +## Alternatives considered + +**Leave the child blocked until the parent forwards an answer.** Rejected: no answerer exists in the child context and no forwarding seam exists; the observed behavior is a permanent hang. + +**Reject inside the tool (`dsh-tool-ask-user`) instead of the service.** Rejected: that consumer seam is bypassed by direct callers of `ctx.userInteraction.ask()`; the operation boundary that owns the decision is the service itself. + +**Warn children off via the model-facing description.** Rejected: the rejection is already a loud, self-explanatory error, and a description edit would not stop the hang for a model that calls anyway. + +## Consequences + +Delegated subagent calls fail fast with a stable error instead of hanging; a child that needs a decision must delegate the question to the top-level agent. Programmatic askers without an agent and top-level agents (`delegationDepth` absent or 0) are unaffected and still reach the provider. The `DELEGATED_CALLER` code joins the documented `UserInteractionError` taxonomy in the package READMEs, and the model-facing description is unchanged. + +## Testing + +Two new unit tests exercise the guard: `user-interaction.spec.ts` asserts that `ask()` rejects with `DELEGATED_CALLER` and never calls the provider for a session created with `{ meta: { delegationDepth: 1 } }`, plus a positive control at `delegationDepth: 0`; `tool-ask-user.spec.ts` asserts that a tool call from a delegated agent surfaces the structured error and never reaches the provider. Both packages pass, as does the parent `packages/ui` scope, and the two touched `src` files hold 100% per-file coverage. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md new file mode 100644 index 0000000000..38f059ba87 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 拒绝委托子代理调用 ask_user_question + +Status: implemented + +[English](2026-08-01-ask-user-delegated-caller-guard.md) | 中文 + +## 问题 + +委托子代理调用 `ask_user_question` 工具时会无限阻塞。该工具会暂停等待人类回答,但子代理上下文中没有人类应答者,因此永远等不到回答,子代理运行只能被外部取消。 + +## 决策 + +`UserInteractionService.ask()` 拒绝任何调用方为委托子代理的请求 —— `request.agent.session.header.delegationDepth > 0` —— 抛出新的 `UserInteractionError`,代码为 `DELEGATED_CALLER`,消息为 `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`。该检查位于 `ask()` 开头,在已中止/空问题守卫之后、意图校验之前,因此被拒绝的子代理不会触发任何提供方交互。这与 goal 工具仅限顶层代理的权限保持一致(`create_goal` 以直接人工回合要求拒绝非顶层代理)。 + +## 备选方案 + +**让子代理一直阻塞,直到父代理转发回答。** 不予采用:子代理上下文中不存在应答者,也没有任何转发 seam;实际观察到的行为就是永久挂起。 + +**在工具(`dsh-tool-ask-user`)而非服务中拒绝。** 不予采用:直接调用 `ctx.userInteraction.ask()` 的调用方会绕过该消费方 seam;拥有此决策权的操作边界是服务本身。 + +**通过模型侧描述来警告子代理。** 不予采用:拒绝本身已是响亮且自解释的错误,而且修改描述并不能阻止仍然去调用的模型造成挂起。 + +## 影响 + +委托子代理的调用会以稳定错误快速失败,而不是挂起;需要决策的子代理必须把问题转交给顶层代理。不带 agent 的程序化调用方以及顶层代理(`delegationDepth` 缺省或为 0)不受影响,仍会到达提供方。`DELEGATED_CALLER` 代码已加入包 README 中记载的 `UserInteractionError` 分类,模型侧描述保持不变。 + +## Testing + +两个新的单元测试覆盖该守卫:`user-interaction.spec.ts` 断言以 `{ meta: { delegationDepth: 1 } }` 创建的会话调用 `ask()` 会以 `DELEGATED_CALLER` 拒绝且绝不调用提供方,并补充了 `delegationDepth: 0` 的正向对照;`tool-ask-user.spec.ts` 断言委托子代理发出的工具调用会呈现结构化错误且绝不触达提供方。两个包均通过,父级 `packages/ui` 作用域也通过,且两个被改动的 `src` 文件保持 100% 逐文件覆盖率。 diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 7b8e667b58..869a5988c1 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -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: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f +README.md: d7866ff018ebfed5afbf105b1a20714490bdb818 +README.zh.md: 18a1c8e9f958c174fc34f26a572d88b6c031d7f9 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..d7866ff018 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -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. +- **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. - **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. diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index acaffec076..18a1c8e9f9 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -54,4 +54,5 @@ ## 已知限制与暂缓事项 - **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。 +- **委托的子代理不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝来自委托子代理的调用;需要决策的子代理必须把问题转交给顶层代理。 - **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..4c9e572c40 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -2,6 +2,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 SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' @@ -201,6 +202,7 @@ describe('ask_user_question tool', () => { it('passes optional header and agent through to the user-interaction request', async () => { const ctx = await setup() + await ctx.plugin(SessionStore) const seen: AskUserQuestionRequest[] = [] ctx.userInteraction.registerProvider({ async ask(request) { @@ -208,7 +210,8 @@ describe('ask_user_question tool', () => { return { answers: [{ id: 'continue', selected: ['ok'] }] } }, }) - const agent = { id: 'main' } as unknown as Agent + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } }) + const agent = { session } as unknown as Agent const result = await ctx.tools.execute({ signal: testToolSignal, @@ -238,6 +241,34 @@ describe('ask_user_question tool', () => { }) }) + it('rejects a delegated subagent with a structured DELEGATED_CALLER error', async () => { + const ctx = await setup() + await ctx.plugin(SessionStore) + const seen: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + async ask(request) { + seen.push(request) + return { answers: [{ id: 'continue', selected: ['ok'] }] } + }, + }) + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } }) + const agent = { session } as unknown as Agent + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('ask-delegated'), + name: 'ask_user_question', + arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, + agent, + }) + + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'UserInteractionError', code: 'DELEGATED_CALLER' } }, + }) + expect(seen).toHaveLength(0) + }) + it('returns a structured error for empty question batches', async () => { const ctx = await setup() diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index a74e6ec71b..7d2ea23b14 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -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: d62e75d110b8be339c5f9449b0834320f695ac99 -README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816 +README.md: 3459f915f2cd94d4083975440731661d8aeb9108 +README.zh.md: 26d40e98dbcc15ef18a85cd98205defb765d4469 diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d62e75d110..3459f915f2 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -18,7 +18,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `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`, and `DELEGATED_CALLER`. When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. @@ -32,7 +32,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: `. 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: 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: `. Waiting for the human adds no tokens. #### KV Cache effect diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 55258e85e5..26d40e98db 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -18,7 +18,7 @@ - `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` 和 `DELEGATED_CALLER` 等代码。 当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 @@ -32,7 +32,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: `。等待人类回答不会增加 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: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`、`Error: no user-interaction provider is registered` 或 `Error: `。等待人类回答不会增加 token。 #### KV Cache 影响 diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts index 506b3c6bfe..b7e76c1d47 100644 --- a/packages/ui/user-interaction/src/index.ts +++ b/packages/ui/user-interaction/src/index.ts @@ -77,8 +77,15 @@ 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). + * * @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`). */ async ask(request: AskUserQuestionRequest): Promise { if (request.signal?.aborted) { @@ -87,6 +94,11 @@ 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') + } // 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 diff --git a/packages/ui/user-interaction/tests/user-interaction.spec.ts b/packages/ui/user-interaction/tests/user-interaction.spec.ts index df6b878cbd..30fdf49a7e 100644 --- a/packages/ui/user-interaction/tests/user-interaction.spec.ts +++ b/packages/ui/user-interaction/tests/user-interaction.spec.ts @@ -1,5 +1,7 @@ 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 UserInteractionService, { UserInteractionError, type AskUserQuestionRequest, @@ -84,6 +86,39 @@ describe('UserInteractionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('rejects a delegated subagent before reaching the provider', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + 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 + + await expect(ctx.userInteraction.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + agent, + })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'DELEGATED_CALLER' }) + expect(p.ask).not.toHaveBeenCalled() + }) + + it('still reaches the provider for a top-level agent (delegationDepth 0)', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + 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 result = await ctx.userInteraction.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + agent, + }) + + expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) + }) + it('rejects an intent whose approve label names none of its own options', async () => { const ctx = new Context() await ctx.plugin(UserInteractionService) From 46d8d97efec7aec57ac3a280a8b5956c0b13ccf9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:45:33 +0800 Subject: [PATCH 2/7] chore(cordis): regenerate service catalog for user-interaction JSDoc --- docs/cordis-catalog/services.md | 7 +++++++ packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bc6343ea..dee9c09f38 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2445,8 +2445,15 @@ registerProvider(provider: UserInteractionProvider): () => void /** * 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). + * * @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`). */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..7089ee0f5d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1114,7 +1114,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async ask(request: AskUserQuestionRequest): Promise', - jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n */', + 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 */', }, ], }, From 408de0f5883970d91f9daf9b76bc437e2447a4d5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:29:53 +0800 Subject: [PATCH 3/7] docs: propose semantic composer chain phases --- ...8-semantic-composer-chain-phases.i18n.yaml | 6 +++ ...26-08-08-semantic-composer-chain-phases.md | 46 +++++++++++++++++++ ...08-08-semantic-composer-chain-phases.zh.md | 46 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md create mode 100644 .agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.zh.md diff --git a/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.i18n.yaml new file mode 100644 index 0000000000..c70cbb870b --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md +2026-08-08-semantic-composer-chain-phases.md: 7d85518e9d5e5aa3e44e443cb1b90b325b0ffc6a +2026-08-08-semantic-composer-chain-phases.zh.md: a202b9bfeb9276cdccaa7b438036cd1236a33317 diff --git a/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md new file mode 100644 index 0000000000..7d85518e9d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md @@ -0,0 +1,46 @@ +# Agent Note: Semantic phases for composer-chain election + +Status: proposed + +English | [中文](2026-08-08-semantic-composer-chain-phases.zh.md) + +## Problem + +The browser's `conversation.composer` chain orders every candidate by one global numeric `priority`, then elects the first selector returning a match. Question uses the default priority `0`, approval uses `1`, and the one-shot or unavailable-parent read-only subagent composer uses `-10`. A selected one-shot history can therefore show the read-only explanation while an answerable question or approval is pending underneath it. + +The defect is not one incorrect number. The chain currently uses the same scalar for two different decisions: whether a candidate resolves an existing interaction or restricts starting new work, and the local preference between candidates of the same semantic kind. Any numeric repair preserves that hidden coupling and lets a later registrant recreate the bug. + +## Proposal + +A chain declaration may define an ordered tuple of domain-owned phases. `conversation.composer` declares `['interaction', 'restriction']`; every registration on that phased chain must name one phase, and its numeric `priority` orders entries only within that phase. `SlotCore` sorts by declared phase index, then local priority, then stable registration order. Registration fails immediately when a phased chain entry omits its phase or names one outside the declaration. Unphased chains retain their current numeric behavior. + +Question and approval register in `interaction`, retaining their current within-phase order of question before approval. `SubagentReadOnlyComposer` registers in `restriction` with an ordinary local priority. The domain rule is precise: an interaction resolves a live Host wait that already exists; a restriction prevents the user from initiating work through the ordinary composer. Resolving an existing wait is not a new follow-up to the one-shot child, so the interaction phase goes first. Once the wait resolves, the chain re-elects and the read-only restriction becomes visible again. + +The phase vocabulary belongs to the declaring slot, not to the slot framework globally. `SlotMap` carries the exact phase tuple for compile-time registration, and the runtime `SlotSpec` repeats that tuple as the sorting authority. Other chains acquire no composer terminology and need no migration unless they deliberately declare phases. + +This proposal extends the [Web subagent conversation](../../implemented/feature/2026-07-27-web-subagent-conversations.md), [Web permission and approval](../../implemented/feature/2026-07-23-web-permission-and-approval.md), and [plan-review presentation](../../implemented/feature/2026-07-30-plan-review-presentation-intent.md) contracts; it supersedes none of them. The [runtime-owned child guard](../../implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md) remains the authority that prevents new child-owned human waits. No active Agent Note should be archived when this proposal lands. + +## Alternatives considered + +**Move the read-only priority after question and approval.** This is the smallest tactical fix, but it leaves semantic dominance encoded as undocumented number spacing and makes the next composer kind guess at the same global scale. + +**Make the read-only selector decline whenever `interactions` is non-empty.** This fixes the current pair but makes a restriction plugin understand every actionable domain and duplicates election policy across selectors. A new interaction kind would require edits in unrelated restrictions. + +**Rely only on the runtime child guard.** The guard fixes new model calls but cannot define browser ordering for already-pending waits, rolling-version overlap, or other interaction kinds such as approval. Runtime authority and presentation election are separate invariants. + +**Render all matching takeovers as a stack.** The composer has one action seat. Stacking question, approval, and read-only surfaces makes keyboard focus and answer ownership ambiguous instead of selecting one current action. + +## Acceptance criteria + +- `SlotCore` tests prove phase order dominates arbitrary local priorities, local priority and stable registration order still work within a phase, unknown or omitted phases fail loud, and unphased chains are unchanged. +- Composer tests cover question plus read-only, approval plus read-only, question plus approval plus read-only, resolution back to read-only, and the all-declined InputBar fallback. Question remains ahead of approval within `interaction`. +- Disposal, HMR re-registration, and reconnect replay cannot leave a stale elected phase; election remains a pure function of the current owner props and current registrations. +- A keyless assembled Web snapshot pins a one-shot addressed conversation with a pending interaction, the interaction surface winning, its resolution, and the read-only surface returning afterward. +- Slot, conversation, question, permission, and subagent README/JSDoc contracts describe phase ownership and the interaction-before-restriction rule together. +- The change modifies no model-visible tool definition, system-prompt section, request routing, or session event. Browser election therefore has no token cost and no KV-cache invalidation; tests compare the model request header before and after the client-only transition. + +## Risks + +Phase names can become a vague substitute for design. Each phased slot therefore owns a short ordering rule and rejects entries that cannot state which side they belong to. A future hard safety surface that must preempt answering should not be mislabeled `restriction`; it needs an explicit earlier phase or a boundary outside this composer chain. + +The generic slot types and stored-entry shape gain one conditional field, so an incomplete migration could compile in one face yet fail at runtime. The exact tuple is repeated in the runtime declaration specifically to make that drift mechanically rejectable. Concurrent questions and approvals remain a single-surface policy; this proposal preserves their current order rather than solving multi-interaction queueing. diff --git a/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.zh.md b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.zh.md new file mode 100644 index 0000000000..a202b9bfeb --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.zh.md @@ -0,0 +1,46 @@ +# Agent Note: composer 链选举的语义阶段 + +Status: proposed + +[English](2026-08-08-semantic-composer-chain-phases.md) | 中文 + +## 问题 + +浏览器的 `conversation.composer` 链先按一个全局数值 `priority` 对所有候选项排序,再选出第一个返回匹配项的选择器。问题采用默认优先级 `0`,审批采用 `1`,一次性或父级不可用时使用的只读 subagent composer 采用 `-10`。因此,选中一次性 subagent 历史记录后,即使其下方有等待应答的问题或审批,界面仍可能显示只读说明。 + +该缺陷并非某个数值有误。当前链用同一个标量作出两项不同的决策:候选项究竟用于解决现有交互,还是用于限制发起新工作;以及如何确定同一语义类别内各候选项的局部优先顺序。任何数值修复都会保留这种隐式耦合,让后续注册方可以再次引入同一缺陷。 + +## 提案 + +链声明可以定义由所属领域拥有的有序阶段元组。`conversation.composer` 声明 `['interaction', 'restriction']`;该分阶段链上的每项注册都必须指名一个阶段,其数值 `priority` 只在该阶段内排序。`SlotCore` 依次按声明的阶段索引、局部优先级和稳定注册顺序排序。如果分阶段链的注册项省略阶段,或指名声明外的阶段,注册会立即失败。未分阶段的链继续沿用当前的数值排序行为。 + +问题与审批注册到 `interaction`,并保留问题先于审批的现有阶段内顺序。`SubagentReadOnlyComposer` 以普通局部优先级注册到 `restriction`。领域规则定义明确:交互用于使 Host 上已经存在且仍有效的等待完成;限制则阻止用户通过普通 composer 发起工作。完成现有等待并非向一次性子级发送新的后续消息,因此交互阶段排在前面。等待完成后,链会重新选举,只读限制会再次出现。 + +阶段词汇归声明该 slot 的领域所有,而不属于全局 slot 框架。`SlotMap` 携带确切的阶段元组,用于编译期注册;运行时 `SlotSpec` 重复该元组,作为排序依据。其他链不会获得任何 composer 术语,也无需迁移,除非它们主动声明阶段。 + +本提案扩展 [Web subagent 对话](../../implemented/feature/2026-07-27-web-subagent-conversations.md)、[Web 权限与审批](../../implemented/feature/2026-07-23-web-permission-and-approval.md)和[计划审阅呈现](../../implemented/feature/2026-07-30-plan-review-presentation-intent.md)契约,但不取代其中任何一项。[运行时所有权子级守卫](../../implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md)仍然是防止子级新建自己负责的人类等待的权威机制。本提案落地时,不应归档任何活跃 Agent Note。 + +## 备选方案 + +**把只读项的优先级移到问题和审批之后。** 这是最小的战术修复,但它仍以未记载的数值间距编码语义支配关系,并迫使下一种 composer 类型在同一个全局尺度上猜测自身位置。 + +**当 `interactions` 非空时,让只读选择器拒绝匹配。** 这可以修复当前这一对组件,但会迫使限制插件理解每个可操作领域,并在各选择器中重复选举策略。每新增一种交互类型,都需要修改与其无关的限制项。 + +**只依赖运行时子级守卫。** 该守卫可以修复新的模型调用,但无法定义浏览器对已有待处理等待、滚动升级中的版本重叠或审批等其他交互类型的排序。运行时权限与呈现选举是两项独立的不变量。 + +**把所有匹配的接管界面渲染成一个栈。** composer 只有一个操作席位。同时堆叠问题、审批和只读界面,会使键盘焦点与回答所有权含混不清,而不是选出一个当前操作。 + +## 验收标准 + +- `SlotCore` 测试证明阶段顺序优先于任意局部优先级;局部优先级和稳定注册顺序在阶段内仍然有效;未知或缺失的阶段会明确失败;未分阶段的链保持不变。 +- Composer 测试覆盖问题加只读项、审批加只读项、问题加审批加只读项、解决后回到只读项,以及所有选择器均拒绝匹配时回退到 InputBar。问题在 `interaction` 内仍排在审批之前。 +- dispose(资源释放)、HMR(热模块替换)重新注册和重新连接回放均不能留下陈旧的当选阶段;选举仍是当前所有者 props 与当前注册项的纯函数。 +- 一项无密钥组装 Web 快照固定已寻址的 one-shot 对话及其待处理交互:交互界面胜出,解决该交互后,只读界面再次出现。 +- slot、conversation、question、permission 和 subagent 的 README/JSDoc 契约共同描述阶段所有权以及交互先于限制的规则。 +- 该变更不修改任何模型可见的工具定义、系统提示词章节、请求路由或会话事件。因此,浏览器选举既不产生 token 开销,也不会使 KV Cache 失效;测试会比较仅在客户端发生状态转换前后的模型请求 header。 + +## 风险 + +阶段名称可能沦为含混的设计替代品。因此,每个分阶段 slot 都要拥有一条简短的排序规则,并拒绝无法说明自身归属哪一侧的注册项。未来如有必须优先于回答操作的硬性安全界面,不应将其误标为 `restriction`;它需要一个显式排在更前的阶段,或位于该 composer 链以外的边界。 + +通用 slot 类型与已存储条目形态会增加一个条件字段,因此迁移不完整时,代码可能在一个 face 中通过编译,却在运行时失败。之所以在运行时声明中重复确切元组,正是为了让系统能以机械方式拒绝这种漂移。并发问题与审批仍采用单界面策略;本提案保留其当前顺序,不解决多交互排队问题。 From dc64c5f1c2d5146c6905863fd39040e3689948e6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:30:08 +0800 Subject: [PATCH 4/7] fix: guard human interaction by runtime ownership --- ...-ask-user-delegated-caller-guard.i18n.yaml | 4 +- ...6-08-01-ask-user-delegated-caller-guard.md | 26 +- ...8-01-ask-user-delegated-caller-guard.zh.md | 26 +- docs/cordis-catalog/services.md | 14 +- .../user-interaction.i18n.yaml | 4 +- docs/core-data-structures/user-interaction.md | 4 +- .../user-interaction.zh.md | 4 +- .../child-question.cordis.snapshot.yml | 50 ++ examples/acp-agent/child-question.cordis.yml | 14 + examples/acp-agent/tests/acp.snapshot.ts | 14 + .../tests/fixtures/child-question-tripwire.ts | 17 + .../input.json | 7 + .../session.1.jsonl | 29 + .../session.jsonl | 28 + .../stdout.expected.jsonl | 4 + .../tool-schemas.expected.json | 565 ++++++++++++++++++ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 42 +- packages/ui/tool-ask-user/README.i18n.yaml | 4 +- packages/ui/tool-ask-user/README.md | 2 +- packages/ui/tool-ask-user/README.zh.md | 2 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 34 +- packages/ui/user-interaction/README.i18n.yaml | 4 +- packages/ui/user-interaction/README.md | 8 +- packages/ui/user-interaction/README.zh.md | 8 +- packages/ui/user-interaction/src/index.ts | 34 +- .../tests/user-interaction.spec.ts | 66 +- 27 files changed, 935 insertions(+), 81 deletions(-) create mode 100644 examples/acp-agent/child-question.cordis.snapshot.yml create mode 100644 examples/acp-agent/child-question.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/child-question-tripwire.ts create mode 100644 examples/acp-agent/tests/snapshots/subagent-child-question-rejection/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml index 800a575f3b..39bf79e628 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md -2026-08-01-ask-user-delegated-caller-guard.md: 17c5e42a1d12c018507c6cf410129bb17099e967 -2026-08-01-ask-user-delegated-caller-guard.zh.md: 38f059ba87ac5208cca6cb94ba9ee5223a6987b0 +2026-08-01-ask-user-delegated-caller-guard.md: 0350a7383f7ace6ecd742c98db18f8b977720cf9 +2026-08-01-ask-user-delegated-caller-guard.zh.md: 9c19722feb4586e07845fd20279574301267542d diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md index 17c5e42a1d..0350a7383f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md @@ -1,4 +1,4 @@ -# Agent Note: Reject ask_user_question from delegated subagents +# Agent Note: Reject human interaction from runtime-owned subagents Status: implemented @@ -6,24 +6,34 @@ English | [中文](2026-08-01-ask-user-delegated-caller-guard.zh.md) ## Problem -A delegated subagent that calls the `ask_user_question` tool blocks indefinitely. The tool pauses for a human answer, but a child context has no human answerer, so no answer ever arrives and the subagent run hangs until it is cancelled externally. +A one-shot subagent that calls `ask_user_question` can block indefinitely. The call waits for a human answer, but the child has no independently owned human channel, so the child's completion and the parent waiting on that completion both stall. + +Durable session lineage cannot decide whether an answerer exists. A child session may later be resumed as a new top-level runtime root, while a live runtime-owned child may carry a zero or absent durable delegation depth. Error guidance at the shared seam must also fit every consumer: `exit_plan_mode` uses `ctx.userInteraction.ask()` without calling `ask_user_question`. ## Decision -`UserInteractionService.ask()` rejects any request whose calling agent is a delegated subagent — `request.agent.session.header.delegationDepth > 0` — with a new `UserInteractionError` code `DELEGATED_CALLER` and the message `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`. The check runs at the top of `ask()`, after the aborted/empty guards and before intent validation, so no provider interaction happens for a rejected child. This mirrors the goal tools' top-level-only authority (`create_goal` rejects non-top-level agents with a direct-human-turn requirement). +When `AskUserQuestionRequest.agent` is present, `UserInteractionService.ask()` authenticates the exact live agent through `ctx.agents` and admits it only when `ctx.agents.roots()` contains that instance. A missing registry or stale same-id object fails with `CALLER_NOT_LIVE`; a live agent owned by another live agent fails with `DELEGATED_CALLER`. The check runs after the existing aborted and empty-batch guards and before intent validation or provider dispatch, so an owned child never creates a UI wait. + +Runtime ownership is the authority. A lineage-bearing session resumed without an owner is a runtime root and may ask; a live child remains ineligible even when its durable `delegationDepth` is zero. Agentless programmatic calls retain the existing provider path. + +The shared failure text is consumer-neutral and actionable: the child includes the unresolved question or decision in its final result. The parent already receives that result through the delegation contract and can decide whether to ask the human. Neither the service nor a child claims an upward messaging or answer-forwarding capability that does not exist. + +This safety boundary is independent of the browser's composer election. The proposed [semantic composer phases](../../proposed/architecture/2026-08-08-semantic-composer-chain-phases.md) address how an already-pending interaction and a read-only subagent surface should be ordered; they do not weaken this runtime guard. ## Alternatives considered -**Leave the child blocked until the parent forwards an answer.** Rejected: no answerer exists in the child context and no forwarding seam exists; the observed behavior is a permanent hang. +**Use `session.header.delegationDepth > 0`.** Rejected because durable lineage survives resume and does not attest the current process-local owner. It rejects valid resumed roots and can admit a live child whose durable header is incomplete. -**Reject inside the tool (`dsh-tool-ask-user`) instead of the service.** Rejected: that consumer seam is bypassed by direct callers of `ctx.userInteraction.ask()`; the operation boundary that owns the decision is the service itself. +**Reject only inside `dsh-tool-ask-user`.** Rejected because `exit_plan_mode` and direct callers share `ctx.userInteraction.ask()`. The service is the narrow operation boundary common to every human-interaction consumer. -**Warn children off via the model-facing description.** Rejected: the rejection is already a loud, self-explanatory error, and a description edit would not stop the hang for a model that calls anyway. +**Tell the child to delegate upward or wait for forwarding.** Rejected because one-shot delegation exposes no child-to-parent request channel and no answer-forwarding protocol. The only guaranteed return path is the child's final result. + +**Rely on the browser composer fix.** Rejected because presentation cannot make an ownerless human channel exist, and non-browser deployments still need the call to terminate. ## Consequences -Delegated subagent calls fail fast with a stable error instead of hanging; a child that needs a decision must delegate the question to the top-level agent. Programmatic askers without an agent and top-level agents (`delegationDepth` absent or 0) are unaffected and still reach the provider. The `DELEGATED_CALLER` code joins the documented `UserInteractionError` taxonomy in the package READMEs, and the model-facing description is unchanged. +Runtime-owned child calls fail fast with a stable structured error instead of hanging. Exact live roots and agentless programmatic calls remain eligible, including resumed sessions with historical child lineage. `ask_user_question` and `exit_plan_mode` receive the same neutral corrective guidance, while their model-visible schemas and system-prompt prefixes remain unchanged; only the appended error result differs, so existing KV-cache prefixes remain reusable. ## Testing -Two new unit tests exercise the guard: `user-interaction.spec.ts` asserts that `ask()` rejects with `DELEGATED_CALLER` and never calls the provider for a session created with `{ meta: { delegationDepth: 1 } }`, plus a positive control at `delegationDepth: 0`; `tool-ask-user.spec.ts` asserts that a tool call from a delegated agent surfaces the structured error and never reaches the provider. Both packages pass, as does the parent `packages/ui` scope, and the two touched `src` files hold 100% per-file coverage. +Service tests cover a zero-depth live child, a depth-one resumed runtime root, a missing registry, a stale same-id object, and provider non-invocation on every rejection. Tool and plan-mode tests prove both consumers surface the neutral `DELEGATED_CALLER` result and never reach the provider. The keyless assembled snapshot delegates to a child that attempts `ask_user_question`, pins the child's error tool result and final handoff, and proves the parent completes instead of waiting for an answer. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md index 38f059ba87..9c19722feb 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 拒绝委托子代理调用 ask_user_question +# Agent Note: 拒绝运行时中归属于其他 agent 的 subagent 向人类发起交互 Status: implemented @@ -6,24 +6,34 @@ Status: implemented ## 问题 -委托子代理调用 `ask_user_question` 工具时会无限阻塞。该工具会暂停等待人类回答,但子代理上下文中没有人类应答者,因此永远等不到回答,子代理运行只能被外部取消。 +一次性 subagent 调用 `ask_user_question` 时可能无限阻塞。该调用会等待人类回答,但子级没有由自身独立拥有的人类交互通道,因此子级无法完成,等待其完成的父级也会随之停滞。 + +持久化会话谱系无法判断应答者是否存在。子会话之后可能恢复为新的顶层运行时根,而运行时中归属于其他 agent(智能体)的存活子级,其持久化委托深度却可能为零或缺失。共享 seam 上的错误指引还必须适用于每个消费方:`exit_plan_mode` 会使用 `ctx.userInteraction.ask()`,但不会调用 `ask_user_question`。 ## 决策 -`UserInteractionService.ask()` 拒绝任何调用方为委托子代理的请求 —— `request.agent.session.header.delegationDepth > 0` —— 抛出新的 `UserInteractionError`,代码为 `DELEGATED_CALLER`,消息为 `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`。该检查位于 `ask()` 开头,在已中止/空问题守卫之后、意图校验之前,因此被拒绝的子代理不会触发任何提供方交互。这与 goal 工具仅限顶层代理的权限保持一致(`create_goal` 以直接人工回合要求拒绝非顶层代理)。 +如果存在 `AskUserQuestionRequest.agent`,`UserInteractionService.ask()` 会通过 `ctx.agents` 验证该 agent 就是注册表中的存活实例,并且只在 `ctx.agents.roots()` 包含该实例时才允许调用。缺失注册表或传入仅 id 相同的陈旧对象时,以 `CALLER_NOT_LIVE` 失败;存活 agent 归属于另一个存活 agent 时,以 `DELEGATED_CALLER` 失败。该检查位于现有的已中止和空批次守卫之后、意图校验或提供方分派之前,因此归属于其他 agent 的子级绝不会触发 UI 等待。 + +以运行时所有权为权限依据。携带谱系的会话在无所有者的情况下恢复时就是运行时根,可以提问;存活子级即使持久化 `delegationDepth` 为零,仍无资格提问。不带 agent 的程序化调用继续沿用现有提供方路径。 + +共享失败文本与具体消费方无关,并给出可执行指引:子级把尚未解决的问题或决策写入最终结果。委托契约本就会把该结果传给父级,父级可据此决定是否询问人类。服务和子级都不会宣称存在实际上并不存在的向上消息传递或回答转发能力。 + +该安全边界与浏览器的 composer 选举相互独立。提议的[语义 composer 阶段](../../proposed/architecture/2026-08-08-semantic-composer-chain-phases.md)解决已有待处理交互与只读 subagent 界面的排序方式;它不会削弱此运行时守卫。 ## 备选方案 -**让子代理一直阻塞,直到父代理转发回答。** 不予采用:子代理上下文中不存在应答者,也没有任何转发 seam;实际观察到的行为就是永久挂起。 +**使用 `session.header.delegationDepth > 0`。** 不予采用:持久化谱系会在恢复后继续存在,却不能证明当前进程内所有者。该方案会拒绝有效的已恢复根,也可能放行持久化 header 不完整的存活子级。 -**在工具(`dsh-tool-ask-user`)而非服务中拒绝。** 不予采用:直接调用 `ctx.userInteraction.ask()` 的调用方会绕过该消费方 seam;拥有此决策权的操作边界是服务本身。 +**仅在 `dsh-tool-ask-user` 内拒绝。** 不予采用:`exit_plan_mode` 与直接调用方共用 `ctx.userInteraction.ask()`。服务是所有人机交互消费方共同经过的最窄操作边界。 -**通过模型侧描述来警告子代理。** 不予采用:拒绝本身已是响亮且自解释的错误,而且修改描述并不能阻止仍然去调用的模型造成挂起。 +**让子级向上委托或等待转发。** 不予采用:一次性委托没有公开从子级向父级请求的通道,也没有回答转发协议。唯一有保证的返回路径是子级的最终结果。 + +**依赖浏览器的 composer 修复。** 不予采用:呈现方式无法凭空产生由所有者负责的人类通道,非浏览器部署仍然需要该调用能够终止。 ## 影响 -委托子代理的调用会以稳定错误快速失败,而不是挂起;需要决策的子代理必须把问题转交给顶层代理。不带 agent 的程序化调用方以及顶层代理(`delegationDepth` 缺省或为 0)不受影响,仍会到达提供方。`DELEGATED_CALLER` 代码已加入包 README 中记载的 `UserInteractionError` 分类,模型侧描述保持不变。 +运行时中归属于其他 agent 的子级调用会以稳定的结构化错误快速失败,而不是挂起。注册表中的确切存活根和不带 agent 的程序化调用仍有资格提问,包括带有历史子级谱系的已恢复会话。`ask_user_question` 与 `exit_plan_mode` 会收到相同的中性纠正指引,而其模型可见 schema 和系统提示词前缀保持不变;只有追加的错误结果发生变化,因此现有 KV Cache 前缀仍可复用。 ## Testing -两个新的单元测试覆盖该守卫:`user-interaction.spec.ts` 断言以 `{ meta: { delegationDepth: 1 } }` 创建的会话调用 `ask()` 会以 `DELEGATED_CALLER` 拒绝且绝不调用提供方,并补充了 `delegationDepth: 0` 的正向对照;`tool-ask-user.spec.ts` 断言委托子代理发出的工具调用会呈现结构化错误且绝不触达提供方。两个包均通过,父级 `packages/ui` 作用域也通过,且两个被改动的 `src` 文件保持 100% 逐文件覆盖率。 +服务测试覆盖持久化深度为零的存活子级、深度为一的已恢复运行时根、缺失注册表、仅 id 相同的陈旧对象,以及每次拒绝都不调用提供方。工具与 plan-mode 测试证明两个消费方都会呈现中性的 `DELEGATED_CALLER` 结果,且绝不触达提供方。无密钥组装快照委托一个尝试调用 `ask_user_question` 的子级,固定其错误工具结果和最终交接,并证明父级可以完成,而不是一直等待回答。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1b13e21948..c46b3e8521 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2627,15 +2627,17 @@ registerProvider(provider: UserInteractionProvider): () => void /** * 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 ``` diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 50087c9f56..5480052231 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -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 docs/core-data-structures/user-interaction.md -user-interaction.md: dd9dc4f98e6517e016d224e6bed25a511c2a4256 -user-interaction.zh.md: 669fbe04c76039e637a162e4de817dc5704fedeb +user-interaction.md: 73727cbb754f10633d3213f67f7c3c9fdb215500 +user-interaction.zh.md: b6e95af4026e58c63c713d87ee9a86b3712b311f diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index dd9dc4f98e..73727cbb75 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -70,14 +70,14 @@ interface AskUserQuestionItem { ## Ask request -`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. +`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. When present, `agent` is the exact live caller; the interaction seam admits it only while the live registry identifies that instance as a runtime root. ```ts type-equiv /** Request for a human answer. */ 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 diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 669fbe04c7..b6e95af402 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -70,14 +70,14 @@ interface AskUserQuestionItem { ## 提问请求 -`AskUserQuestionRequest` 是跨包(package)的请求。`questions` 是数组,这样 UI 可以在一个流程中呈现相关提示,同时保持每个回答有稳定的 id。 +`AskUserQuestionRequest` 是跨包(package)的请求。`questions` 是数组,这样 UI 可以在一个流程中呈现相关提示,同时保持每个回答有稳定的 id。如提供 `agent`,它必须与存活调用方是同一实例;只有当当前注册表将该实例识别为运行时根时,交互 seam 才会接纳该 agent。 ```ts type-equiv /** Request for a human answer. */ 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 diff --git a/examples/acp-agent/child-question.cordis.snapshot.yml b/examples/acp-agent/child-question.cordis.snapshot.yml new file mode 100644 index 0000000000..4eb0c5bfb4 --- /dev/null +++ b/examples/acp-agent/child-question.cordis.snapshot.yml @@ -0,0 +1,50 @@ +# Keyless counterpart to child-question.cordis.yml: keep the real interaction +# seam, model-facing tool, and tripwire provider while replacing DeepSeek with +# per-session replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: child-question-tripwire + name: './tests/fixtures/child-question-tripwire.ts' diff --git a/examples/acp-agent/child-question.cordis.yml b/examples/acp-agent/child-question.cordis.yml new file mode 100644 index 0000000000..803d21c35d --- /dev/null +++ b/examples/acp-agent/child-question.cordis.yml @@ -0,0 +1,14 @@ +# Snapshot-only human-interaction composition. The provider is a tripwire: the +# runtime-owned child must be rejected by the seam before any UI wait begins. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: child-question-tripwire + name: './tests/fixtures/child-question-tripwire.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 386926e51b..c09dd7d2ed 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -40,6 +40,7 @@ const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url)) const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) +const CHILD_QUESTION_CONFIG = fileURLToPath(new URL('../child-question.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) @@ -351,6 +352,19 @@ const SCENARIOS: Scenario[] = [ overridden: true, configPath: DEPTH_TWO_CONFIG, }, + // Authored keyless replay through the assembled app: a one-shot child calls + // the real ask_user_question tool, the runtime-ownership guard rejects before + // the tripwire provider, and the child carries the unresolved decision in its + // final result so the parent can complete instead of waiting forever. + { + name: 'subagent-child-question-rejection', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'child-question', + systemPromptSource: 'text-turn', + configPath: CHILD_QUESTION_CONFIG, + }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. diff --git a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts new file mode 100644 index 0000000000..7eb15ac551 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts @@ -0,0 +1,17 @@ +import type { Context } from 'cordis' +import '@deepseek-ai/dsh-user-interaction' + +/** Snapshot-only provider whose invocation means the child guard failed. */ +export const name = 'child-question-tripwire' + +/** User-interaction service required by the tripwire provider. */ +export const inject = ['userInteraction'] + +/** Register a provider that must remain unreachable for the delegated call. */ +export function apply(ctx: Context): void { + ctx.userInteraction.registerProvider({ + async ask() { + throw new Error('snapshot tripwire: delegated question reached the UI provider') + }, + }) +} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/input.json b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/input.json new file mode 100644 index 0000000000..d61b79c3ee --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl new file mode 100644 index 0000000000..68527f63ac --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":2001,"cwd":"{{cwd}}","parentSession":"44444444-4444-4444-8444-444444444444","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":1786173701247,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} +{"type":"turn/start","seq":1,"time":1786173701247,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786173701247,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1786173701270,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} +{"type":"step/start","seq":4,"time":1786173701272,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"fafefa37-7640-4c80-a00a-6a0c3ce46281"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786173701272,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} +{"type":"assistant/chunk","seq":12,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} +{"type":"tool/result","seq":17,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","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"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1786173701292,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1786173701309,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} +{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} +{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":24,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1786173701315,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl new file mode 100644 index 0000000000..ed155f158f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":2000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786173701174,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"851bea02-2961-471a-84ec-3b068c451db0"}]}} +{"type":"turn/start","seq":1,"time":1786173701175,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786173701175,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786173701216,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786173701216,"data":{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"851bea02-2961-471a-84ec-3b068c451db0"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8ef74c46-9e80-475c-9093-0e85ba92e346"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786173701217,"data":{"title":"Delegate one question check. Ask","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786173701218,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786173701219,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_question_child","name":"subagent","argumentsDelta":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1786173701233,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_question_child","name":"subagent","arguments":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1786173701233,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1786173701233,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786173701233,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_question_child","name":"subagent","arguments":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f8909de9-23ae-4dbe-a8c1-eaf1e8f2aba5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786173701234,"data":{"turn":1,"step":1,"callId":"call_question_child","name":"subagent","arguments":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}} +{"type":"tool/result","seq":16,"time":1786173701316,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_question_child"},"content":[{"type":"tool-result","toolCallId":"call_question_child","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"isError":false}],"role":"user","id":"fdc5b075-574b-48c6-bdda-1b6442edfbef"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786173701317,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1786173701334,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1786173701339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1786173701339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"PARENT_COMPLETED"}}} +{"type":"assistant/chunk","seq":21,"time":1786173701340,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_COMPLETED"}}}} +{"type":"assistant/chunk","seq":22,"time":1786173701340,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":23,"time":1786173701340,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1786173701340,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_COMPLETED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"700b9e56-965e-406a-bf5c-2db06b96c536"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1786173701340,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1786173701340,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl new file mode 100644 index 0000000000..ef130490e8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_COMPLETED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json new file mode 100644 index 0000000000..81f30ee5ec --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json @@ -0,0 +1,565 @@ +{ + "initial": [ + { + "name": "ask_user_question", + "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] + } + }, + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1acdfbbf08..c4970a6c8e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1174,7 +1174,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async ask(request: AskUserQuestionRequest): Promise', - 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 */', }, ], }, diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 63abed59ea..3a18e44088 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -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 { +async function agentWithSession( + ctx: Context, + id = 'agent-1', + { active, owner }: { active?: boolean; owner?: Agent } = {}, +): Promise { // 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({ diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 56a4d487a4..bbd0ba0561 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -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 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index cb1cc11bba..7af356263e 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -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. diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index 7558816a28..3f7b814b83 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -54,5 +54,5 @@ ## 已知限制与暂缓事项 - **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。 -- **委托的子代理不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝来自委托子代理的调用;需要决策的子代理必须把问题转交给顶层代理。 +- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝由另一个 agent 所有的存活子级;该子级必须在最终结果中包含尚未解决的问题或决策。持久化会话谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。 - **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 9459f7753c..14e71ae15b 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -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) }) diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index 4cf5d5b92f..1da849ed9b 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -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 diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d5b2353485..cba015e782 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -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: `. 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: `. Waiting for the human adds no tokens. #### KV Cache effect diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 460feed0e8..a5f944850c 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -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: `。等待人类回答不会增加 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: `。等待人类回答不会增加 token。 #### KV Cache 影响 diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts index b7e76c1d47..761db57c65 100644 --- a/packages/ui/user-interaction/src/index.ts +++ b/packages/ui/user-interaction/src/index.ts @@ -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 { 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 diff --git a/packages/ui/user-interaction/tests/user-interaction.spec.ts b/packages/ui/user-interaction/tests/user-interaction.spec.ts index 30fdf49a7e..8270b81bfd 100644 --- a/packages/ui/user-interaction/tests/user-interaction.spec.ts +++ b/packages/ui/user-interaction/tests/user-interaction.spec.ts @@ -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) From f6db2eb1aba6ee96839f528ea564bd14c6a19a91 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:40:01 +0800 Subject: [PATCH 5/7] chore: declare child-question snapshot fixture --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index bce84f9b1c..248066d8e4 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,7 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", + "acp-agent/tests/fixtures/child-question-tripwire.ts", "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", From f6449c838c17ef8fe4eca3a06ac312de2c3e7f36 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:43:06 +0800 Subject: [PATCH 6/7] docs: document delegated plan review failure --- packages/plan/plan-mode/README.i18n.yaml | 4 ++-- packages/plan/plan-mode/README.md | 1 + packages/plan/plan-mode/README.zh.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index b29223b8e1..b24d4b1954 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -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/plan/plan-mode/README.md -README.md: b22e218d1a0350d9dfb6e64fcaed426deaceb33e -README.zh.md: 57ac85577a690d5c560bec39bef851692be03efd +README.md: f2a78fe98b6a85ce91d727e95ddf62e6f77546b7 +README.zh.md: e1b5f111fd8f761fdd8f25558463c0cc327a14be diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index b22e218d1a..f2a78fe98b 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -94,4 +94,5 @@ Mode transitions do not change the tool catalog; plan arguments and review resul - Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls. - A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it. - Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option. +- A live child owned by another agent cannot open the `exit_plan_mode` review. The failed call tells the child to include the unresolved decision in its final result; durable fork lineage alone does not prevent a session resumed as a runtime root from opening the review. - Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow. diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 57ac85577a..e1b5f111fd 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -94,4 +94,5 @@ mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩 - Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 - 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 +- 由另一个 agent 所有的存活子级无法打开 `exit_plan_mode` 审阅。该调用失败时会提示子级在最终结果中包含尚未解决的决策;仅有持久化 fork 谱系并不会阻止恢复为运行时根的会话打开该审阅。 - 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。 From b837e30a80d939693fc2f1aa386554560d4f27ea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:48:22 +0800 Subject: [PATCH 7/7] test(apiproxy): register question callers --- .../host/apiproxy/tests/api-proxy-question.spec.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index ee5747039f..32835f5054 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -10,6 +10,7 @@ import { createApiProxy } from '../src/api-proxy.ts' async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) return { ctx, @@ -17,8 +18,11 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { } } -function agent(id: string): Agent { - return { id } as unknown as Agent +function agent(ctx: Context): Agent { + const session = ctx.sessions.create() + const value = { id: session.id, session, status: 'idle', ctx } as Agent + ctx.agents.register(value) + return value } function openMux(api: ApiProxy, abort: AbortController): { @@ -71,7 +75,7 @@ describe('question response validation', () => { const abort = new AbortController() const mux = openMux(api, abort) const asked = ctx.userInteraction.ask({ - agent: agent('session-multi'), + agent: agent(ctx), questions: [{ id: 'targets', question: 'Choose targets and add another', @@ -95,7 +99,7 @@ describe('question response validation', () => { const abort = new AbortController() const mux = openMux(api, abort) const asked = ctx.userInteraction.ask({ - agent: agent('session-single'), + agent: agent(ctx), questions: [{ id: 'target', question: 'Choose one target',