8.3 KiB
Agent Note: 计划审阅是一次决定,不是一道题
Status: implemented
English | 中文
问题
exit_plan_mode 通过 ctx.userInteraction.ask() 把写好的计划交给用户审阅,而这正是 ask_user_question 使用的同一个 seam。在 Web GUI 上,这导致计划审阅渲染为ask-question Web 呈现里的通用问题流程:一个 1 / 1 分页器、计划作为问题的补充说明、两个裁决作为带描述的编号单选行、一行"其他,请填写自定义答案",以及底部的 跳过本题 / 提交。
这些可交互元素对这个界面而言无一正确。审阅一份计划是对一份文档做一次决定,而做题式的界面告诉用户他正在被考试,而不是被请求批准一份工作 —— 实际反馈是"让人很困惑以为在做题"。分页控件在给只有一项的集合分页。跳过并不是该工具接受的结果(它会折叠成继续规划)。最糟的是,这个界面完全没有暗示这就是计划关口,而旁边的等待审批接管早就具备了一次决定该有的形状:一条带色条带说明正在决定什么、主体是决定的对象、右对齐的操作行。
决定
一个问题可以声明呈现意图(presentation intent),Web 输入区把已声明的意图渲染为它自己的界面。AskUserQuestionItem 新增 intent?: AskUserQuestionIntent,一个带标签的形状,目前唯一成员是 { kind: 'plan-review', approve: string };plan-mode 在审阅问题上设置它,并指明 Approve 是表示批准的标签。
意图只塑造呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,exit_plan_mode 读到的都是同一种回答形状;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。
approve 指名肯定选项,而不依赖选项顺序,因此没有任何 UI 会从位置推断裁决。意图作出的两项断言超出类型的表达能力,UserInteractionService.ask() 都以 BAD_INTENT 在提问方一侧拒绝:approve 未命中该问题自身的任一选项 —— 早于任何 UI 回答一个从未被提供过的选择;以及意图落在没有 detail 的问题上,而 detail 正是它自称在审阅的东西,那会让用户去批准一件看不见的事。在协议格式(wire format)上意图是可辨识联合,因此无法识别的标签是被拒绝的帧,而不是静默退回通用渲染。
ui-question 把该意图渲染为 PlanReviewPanel,沿用等待审批卡片的语言:琥珀色条带写着 Plan review,计划是可滚动的 markdown 主体,决定行放三个操作 —— Chat about it、Refuse、Approve。问题文本成为卡片的无障碍名称而非标题,因为按钮已经说明了这次决定是什么。Approve 与 Refuse 用提问方自己的选项标签回答,并把提问方的描述保留为 tooltip;Chat about it 取消该请求,从而让输入区归位,用户直接说他想说的话即可。所有文案在既有 question 命名空间下双语。
路由住在单一输入区条目内部(由 QuestionComposer 选择形状),而不是第二个链式注册;planReviewOf 仅在卡片能够发出该请求允许的每一个答案时才接管:只有一个问题且声明了意图、以 detail 承载计划、提供了被指名的批准标签,且是二元单选 —— 除批准外最多一个选项,且非多选。出现第三个选项或多选批次时,其答案是两个按钮无法表达的,通用流程保留它,也保留其他任何卡片渲染不了的请求。因此"只塑造呈现"是字面意义上的:意图绝不让用户失去一个可达的答案,而位于协议边界下游的客户端让每个请求都保持可回答。
放弃审阅成为面向模型的独立结果。ASK_CANCELLED 以前传到模型的是"the user cancelled ask_user_question",指名了一个它从未调用的工具;现在 exit_plan_mode 报告用户放弃审阅是为了改用说话,并要求留在 plan mode 中等待。其余每一种 ask 失败 —— 轮次取消或提供方拆卸导致的中止,那里并没有用户会来 —— 保留它们自己的消息。
备选方案
让计划审阅成为自己的待处理种类(plan-review/requested)。 否决:对一个呈现问题来说尺寸不对。它换来的是诚实的响应形状(approve / decline / discuss 而非一批回答),代价是第三个 PendingKind、新的 requested/resolved 帧与 schema、一个 api-proxy 注册表与响应分支、客户端会话与基线重放处理,以及为一个问题协议已能表达的决定新增一个三包能力 seam。只有当计划审阅长出回答形状承载不了的结果时才值得重新考虑。
按问题的 id 或 header(plan-review / Plan review)路由卡片。 否决:这是跨协议边界嗅探另一个包的文案字符串,任何措辞改动都会静默破坏它。意图才是让路由可读的那个声明。
约定选项顺序,让卡片把第 0 个位置读作批准。 否决:这是包边界上的位置约定,在类型和协议帧里都看不见,也无法强制 —— 生产方一旦重排选项,就会颠倒用户的裁决。指名标签只花一个字符串。
为计划卡片注册第二个输入区链条目。 否决:两个条目会对同一个待回答问题载体做选择,使界面取决于链优先级、以及计划包的客户端半边是否被组合。一个自己挑形状的条目不会和自己抢,而通用流程正是内建的回退。
把面板放在 ui-plan 里、紧挨计划状态标签。 否决:面板的全部行为就是问题载体的回答编码(PendingQuestion),那是 ui-question 拥有的;意图是问题协议的字段,不是 plan-mode 的私有通道。渲染已声明的意图属于拥有问题渲染的那个包,正如工具渲染意图属于工具渲染方。
与 ui-conversation 的 ApprovalPanel 抽出共享的接管卡片。 未做:两个接管在 token 和几何上一致,但内容不一致 —— 这边的主体是可滚动 markdown,那边是一行标题加一行命令 —— 共享外壳只会剩两个元素宽。它们靠 token 保持一致,而不是靠组件。
给 Chat about it 自己的协议结果。 否决:放弃一个请求是通用流程已有的动词(取消整批的 ×)。把它提升为带标签的按钮属于呈现;为它发明第四种协议结果不属于。
结果
问题协议从此带有一个呈现轴。新增第二个意图 = 联合上的一个标签、一个设置它的生产方、一个 schema 成员、一个面板 —— 不需要新的帧、服务或回答形状。代价是问题 seam 从此知道"呈现"这件事存在,且 ui-question 知道"plan"这个词;两者都是由单一条目拥有全部问题界面所要付的价钱。
计划关口读起来就像计划关口:计划是卡片的内容,裁决是两个带标签的按钮,把轮次拿回来是第三个。通用流程对其他每个问题都未受影响,其已提交的 golden 也没有变动。
客户端半边早于本次改动的部署仍然显示做题式布局 —— 正确、可回答、只是没有专门样式 —— 因为意图是增量的,而回退就是通用流程。
测试
ui-question 测试钉住收窄(单问题批、意图存在、计划作为 detail、被指名的批准标签确实被提供、二元单选、只提供批准时 decline 缺席)与面板(条带、markdown 计划、无障碍名称、无分页/单选/跳过/自定义、批准与拒绝用提问方的标签回答、放弃触发取消、一次性闭锁在回执被拒时重新武装并给出消息、tooltip 有与无、两种语言)。user-interaction 测试钉住两种 BAD_INTENT 拒绝与意图透传;plan-mode 测试钉住已声明的意图与其自身选项列表的一致、以及两条失败消息;apiproxy schema 测试钉住协议接受与未知标签的拒绝。
plan-review Web e2e 通道录制了 /plan 真实进入 plan mode、模型调用 exit_plan_mode、决定卡片接管输入区(并断言通用流程没有接管该请求)、以及卡片自身的 Approve 完成该轮 —— 两份无密钥 golden:等待中的卡片与批准后的会话记录。