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] 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)