Merge pull request #1145 from deepseek-harness/fix/f4-ask-user-child-guard
fix(user-interaction): guard human interaction by runtime ownership
This commit is contained in:
@@ -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: 0350a7383f7ace6ecd742c98db18f8b977720cf9
|
||||
2026-08-01-ask-user-delegated-caller-guard.zh.md: 9c19722feb4586e07845fd20279574301267542d
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Reject human interaction from runtime-owned subagents
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-01-ask-user-delegated-caller-guard.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
**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 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.
|
||||
|
||||
**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
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 拒绝运行时中归属于其他 agent 的 subagent 向人类发起交互
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-01-ask-user-delegated-caller-guard.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一次性 subagent 调用 `ask_user_question` 时可能无限阻塞。该调用会等待人类回答,但子级没有由自身独立拥有的人类交互通道,因此子级无法完成,等待其完成的父级也会随之停滞。
|
||||
|
||||
持久化会话谱系无法判断应答者是否存在。子会话之后可能恢复为新的顶层运行时根,而运行时中归属于其他 agent(智能体)的存活子级,其持久化委托深度却可能为零或缺失。共享 seam 上的错误指引还必须适用于每个消费方:`exit_plan_mode` 会使用 `ctx.userInteraction.ask()`,但不会调用 `ask_user_question`。
|
||||
|
||||
## 决策
|
||||
|
||||
如果存在 `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 界面的排序方式;它不会削弱此运行时守卫。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**使用 `session.header.delegationDepth > 0`。** 不予采用:持久化谱系会在恢复后继续存在,却不能证明当前进程内所有者。该方案会拒绝有效的已恢复根,也可能放行持久化 header 不完整的存活子级。
|
||||
|
||||
**仅在 `dsh-tool-ask-user` 内拒绝。** 不予采用:`exit_plan_mode` 与直接调用方共用 `ctx.userInteraction.ask()`。服务是所有人机交互消费方共同经过的最窄操作边界。
|
||||
|
||||
**让子级向上委托或等待转发。** 不予采用:一次性委托没有公开从子级向父级请求的通道,也没有回答转发协议。唯一有保证的返回路径是子级的最终结果。
|
||||
|
||||
**依赖浏览器的 composer 修复。** 不予采用:呈现方式无法凭空产生由所有者负责的人类通道,非浏览器部署仍然需要该调用能够终止。
|
||||
|
||||
## 影响
|
||||
|
||||
运行时中归属于其他 agent 的子级调用会以稳定的结构化错误快速失败,而不是挂起。注册表中的确切存活根和不带 agent 的程序化调用仍有资格提问,包括带有历史子级谱系的已恢复会话。`ask_user_question` 与 `exit_plan_mode` 会收到相同的中性纠正指引,而其模型可见 schema 和系统提示词前缀保持不变;只有追加的错误结果发生变化,因此现有 KV Cache 前缀仍可复用。
|
||||
|
||||
## Testing
|
||||
|
||||
服务测试覆盖持久化深度为零的存活子级、深度为一的已恢复运行时根、缺失注册表、仅 id 相同的陈旧对象,以及每次拒绝都不调用提供方。工具与 plan-mode 测试证明两个消费方都会呈现中性的 `DELEGATED_CALLER` 结果,且绝不触达提供方。无密钥组装快照委托一个尝试调用 `ask_user_question` 的子级,固定其错误工具结果和最终交接,并证明父级可以完成,而不是一直等待回答。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 中通过编译,却在运行时失败。之所以在运行时声明中重复确切元组,正是为了让系统能以机械方式拒绝这种漂移。并发问题与审批仍采用单界面策略;本提案保留其当前顺序,不解决多交互排队问题。
|
||||
Reference in New Issue
Block a user