提问是一条消息,不是一次等待:把 ask-wait 从 agent 回合里拆出去

提问是一条消息,回答也是一条消息。中间不需要有谁一直等着。
1. 原来的做法:工具调用一直挂着,等人回来⌗
agent 碰到只有人能回答的分叉,比如偏好、业务上的取舍、只有他手里才有的一串字符,就得问人。最直接的实现是让提问也成为一次普通的工具调用:调 ask_user_question,工具不返回,回合停在这里,等人回答了,答案就是这次调用的结果,模型接着往下推理。
MaxClaw 原来就是这么做的,实现在 src/dispatch/ask-wait.ts。从模型那边看,这个设计很省心:问完就拿到答案,上下文一点没断,跟调一次慢一点的 API 没区别。
麻烦都落在框架这边。一个挂着的工具调用,其实是一个活在进程内存里、等着外部事件的 promise。这个事实一路传下去,牵出了一整套东西:
| 情形 | 阻塞模型下要处理什么 | 当时的实现 |
|---|---|---|
| 人在看这条对话 | 卡片在对话里,限时 1 小时 | ASK_USER_TIMEOUT_MS |
| 人不在 | 每个问题建一条决策、推送、上灵动岛,由这次调用持有 | inline-asks 的进程内持有表 |
| 卡片计时和等待计时不一致 | 卡片比等待多活 60 秒,以等待为准 | CARD_GRACE_MS = 60_000 |
| 人没答却发了条新消息 | 撤回问题,结束等待,状态 superseded | abort reason steering |
| 人按了停止 | 撤回问题,状态 aborted | |
| 计划内重启 | 回合被停下,问题保留;续跑的回合再问一遍,用问题文本的哈希去重,找回原来那条决策 | ASK_DRAIN_ABORT_REASON、dedupeKey |
| 重启期间人已经答了 | 续跑时到最近 6 小时已落定的决策里找答案 | RECENT_ANSWER_MS = 6h |
| 进程崩溃 | promise 没了,卡片成了没人等的「僵尸」,只能标成 lost 并定格 24 小时 | recover-pending-interactions.ts 的 markLost |
| 超时转给 Main 代答 | 另建 ask_handoffs 表,加一组 WS 帧和一组端点 | src/ask-handoff/ |
| Main 自己要问人 | Main 还要接别人转来的问题,不能阻塞在自己的提问上,于是另做一个专用工具 | reach_max |
最后一行最能说明问题。Main 是负责统筹的那一层,它恰恰最常需要找人,却用不了 ask_user_question,因为阻塞会让它没法处理别人交过来的问题。为了绕开这一点,系统专门造了 reach_max,底下是一条决策,外加 decisions.bar 和 bar_snoozed_at 两列。一个工具在最需要它的地方用不了,通常说明抽象本身有问题。
这些补丁都是从「人回答之前,回合必须活着」这一个前提来的:重启、插话、停止、崩溃、超时,每种情形都要单独回答一遍「挂着的那个 promise 怎么办」。
2. 换个看法:卡片属于消息,不属于回合⌗
这次改动(10 个提交,131 个文件,+4121 / −6061)把前提整个换掉了。设计文档 docs/rich-message.md 第一段就把立场写明了:
agent 发出带组件的消息后照常结束回合,不再在回合里等 Max。
具体是四条:
assistant 行 tool 行(结果) message_components
───────────────────────── ─────────────────────────── ──────────────────────
toolCalls: [{ { posted: true, id = call_7:q1
id: call_7, components: [{ id: call_7:q1, status: open → answered
name: ask_user_question, kind: choice, answer, answered_by
arguments: { questions… } ◀── answer_due_at }] } due_at, decision_id …
}] display: { kind: 'components', ◀── 镜像(每次状态变化 patch)
▲ states: {…} }
│ 定义 = 参数 │
│ │ Max 作答
└─────────────── 时间线上的一张卡 ─────────────────────────────────┘
▼
user 行:「回答『…』(上面那条消息里的提问卡 call_7:q1):
选了:… 附言:…」
sourceMeta.componentAnswer → 指回 call_7:q1
- 定义就是工具调用的参数。 卡片不另外存一份 schema,
componentsOfToolCall({ id, name, arguments })直接从 assistant 行里的调用读出组件。服务端、WebUI、iOS 和推送降级都用这同一个函数,渲染结果不会各说各的。这跟visual_render小部件是同一个模型,只是多了状态。 - 组件 id 由结构决定。
<tool_call_id>:<本地 id>。旧实现在重启后要靠「问题文本 + 选项 id」的 sha256 前 16 位去重,才能认出「这还是刚才那个问题」。现在 id 就是调用 id,天然唯一,也不会因为模型续跑时改了个措辞就对不上。 - 状态在一张表里,并镜像到 tool 行的
display。 新表message_components(迁移 0636,文件头注释里叫 9002)保存会变的部分:status、answer、answered_by、due_at、decision_id等。每次状态变化,服务端 patch 发卡那次调用的结果行的display = { v: 1, kind: 'components', states },messages.rev触发器随之让所有连接收到record.upsert。文档原话:「这就是『状态怎么同步』的全部」。 - 回答是人的一条新消息。
ComponentService.answer校验、做 CAS、镜像,然后调postMaxMessage,把回答写成 Max 的一条 user 消息投进这个对话,开一个新回合(回合还在跑的话就作为插话进去)。正文由answerMessageText生成,写明回答的是哪张卡、选了什么、附了什么话。
ask_user_question 现在调用一次就立刻返回 posted: true,结果里附的 note 是这样写的:
回合不会等他:能做的事继续做,然后正常结束回合。他的回答会作为他的一条消息
回到这个对话(正文写明回答的是哪张卡、选了什么、附了什么话),开启新的一轮。
……不要为了等答案而空转或反复追问同一个问题。
3. 第一节那张表还剩什么⌗
拿新模型把第一节的表逐行过一遍:
| 情形 | 现在怎么办 |
|---|---|
| 人在看 / 不在看 | 只决定 due_at(1 小时,或 main_after_minutes,默认 30)和要不要建推送用的决策,不影响回合 |
| 卡片计时和等待计时 | 只剩一个 due_at,存在库里 |
| 人发了条新消息 | 就是一个普通回合。卡片继续开着,之后照样能答 |
| 停止 | 没东西可停,回合本来就结束了 |
| 计划内重启 | 没东西可恢复:卡片是时间线上的一行,状态在 PG 里 |
| 进程崩溃 | 同上。recover-pending-interactions.ts 删掉了 markLost 和 24 小时定格,注释只剩一句:「提问卡不再有等待中的 promise……重启后原样在时间线上」 |
| 超时转 Main | ComponentService.sweepDue 挂在 cron 的分钟 tick 上,从部分索引 message_components_due 里取到期的卡 |
| Main 自己要问 | 直接用 ask_user_question。componentToolsOffered(worker) 只看是不是后台任务,不再排除 Main;reach_max 删除 |
迁移里有三行很能说明问题:
DROP TABLE ask_handoffs;
DELETE FROM pending_interactions WHERE kind = 'ask_user_question';
ALTER TABLE pending_interactions ADD CONSTRAINT pending_interactions_kind_check
CHECK (kind IN ('queued_message', 'tool_confirmation'));
转交不再是一张独立的表,变成了组件的一个状态(with-main)。「挂起的交互」里也没有提问这一类了,因为已经没有谁在挂着等。
按文件算,删掉的有 ask-wait.ts 466 行、ask-user-question.ts 512 行、src/ask-handoff/ 六个文件共 545 行,WebUI 的 AskUserQuestionCard.tsx 420 行,还有 WS 帧、端点和输入框上方的提问卡堆。新增的 src/components/ 七个文件共 884 行。整体净减约 1900 行。行数少了是结果,真正少掉的是需要处理的情形:恢复路径从五种结局(answered / unanswered / superseded / aborted / restart)变成了零。
4. 并发:谁先答算谁的⌗
去掉阻塞以后,「答案交给谁」不再有天然的唯一接收者。以前答案就是那次调用的返回值,先到的 resolve 掉 promise,后到的没人接。现在一张卡可能同时从四个地方被回答:卡片本身、手机推送上的按钮、推送的回复框、决策面板(Main 的卡还多一个悬浮条),再加上 Main 代答。
这件事交给了数据库。message_components 上的每次状态迁移都是带前置状态的 UPDATE … WHERE status = ANY(...) RETURNING *:
// store.ts — answeredByMax
UPDATE message_components SET status = 'answered', answer = …, answered_by = 'max', …
WHERE id = ${id} AND status = ANY(${from}::text[]) RETURNING *
没有返回行就说明别处已经抢先答了,ComponentService 抛 ComponentError('settled'),不会再写第二条回答消息。Main 代答把落定决策和改组件状态放在同一个事务里,事务失败就报「Max 已经在别处回答了这个问题,没有代答」。测试 a second answer is refused and sends nothing 锁住的就是这一点:第二个答案被拒,对话里不会多出一条消息。
推送这一侧没有另造一套机制。问题的「载体」是一条 agent-question 决策(dedupe_key = ask:<componentId>),推送、灵动岛、决策面板读的都是决策,原有的选择卡推送原样复用:选项变成按钮(推荐项排前),加「其他…」和一个回复框。载体无论在哪里被结清,都走 settleFromCarrier 回到 answer(…, 'decision');卡片上先答了,就用同一个答案去结清载体。两边谁先到都只算一次。
5. 第二层:Main 代答,但能撤销,也有不能碰的⌗
人没在期限内回答,或者自己点了「让 Main 来决定」,问题转给 Main。状态机:
Max 答 ───────────────────────────────▶ answered
╱
open ──┤ answer_for_max(option|text, reason)
╲ 到期/点按 ┌────────▶ main-answered ──revoke──▶ revoked ──Max 答──▶ answered
▼ │
with-main ─────────────────────┤
(卡片锁定) └─hand_back─▶ handed-back ──Max 答──▶ answered
几个边界写在 mainMayTakeSpec 和 mainAnswer 里,都有对应的测试:
- 对象卡不转。 「应用这个 diff 吗」只能由人决定,Main 替他点「应用」没有意义。
- 含敏感答案的卡不转。 答案本来就只有他知道。
- Main 自己的对话、隔离对话、带类别的对话里的卡不转。
stakes: irreversible的卡会转,但 Main 只能交回。 让它知道有这么个问题,不让它替人拍板,错误信息写的是「这个问题猜错无法挽回,不能代答」。- 代答必须附一句
reason,卡片上不显示,点开才看得到。人撤销以后,revoke会通过决策队列的 reverter 把决策重新打开,并给提问的对话发一条ask-handoffphase: revoked事件,让它按新的回答调整。
with-main 期间卡片本身是锁着的(409 locked),但决策面板和推送仍然能答,answer 对 via: 'decision' 放行 with-main 这个前置状态。锁只防人和 Main 在卡片这一个界面上撞车,不防人从别处抢先作答,抢先作答本来就是合理的。
6. 敏感答案:只写进模型读的那一行⌗
阻塞模型下,敏感答案是工具结果,只有模型看得到,处理起来反而简单。改成「回答是一条消息」以后,这条消息会出现在时间线、分享快照、推送里,敏感答案必须在每个出口都被挡住。
做法是存储和投影分开:
| 位置 | 存的是什么 |
|---|---|
message_components.answer | { selected: [], hidden: true } |
decisions.answer_text | 占位 |
tool 行 display | 不含原文 |
| 推送 / 锁屏 / 灵动岛 | 回复框 placeholder「输入(不会回显)…」 |
回答气泡的 line | 「已提交(不回显)」 |
| 模型读到的 user 行正文 | 原文 |
测试 the answer reaches the thread as his message and nowhere else 用一个假密钥串逐一检查:组件行、决策行、推送、镜像的 display 都不含它,只有投进对话的那条消息正文含它。从推送回复框答和在卡片上直接输入,各有一条用例。
7. 代价⌗
不阻塞也有代价。
回答到来时,开的是一个新回合。 模型不再是在一次工具调用里拿到答案接着推理,而是在新一轮里读到一条用户消息,得自己把上下文接上。这依赖两件事:回答消息的正文足够自描述(卡片标题、组件 id、选项的 label,不只是选项 id),而且卡片就在时间线上一两行之前。对于「问一下,然后照答案继续做完」这种线性流程,这比原来多了一次回合边界,也多一次重新读历史的开销。
模型得学会问完就走。 阻塞模型下,模型不会在答案回来之前做别的。现在工具结果明说「能做的事继续做」,也明说「不要为了等答案而空转或反复追问同一个问题」。这是靠提示约束,不是框架强制。框架能保证的只有一点:问题不会因为回合结束而丢。
同一时间可能有好几张没答的卡。 以前一次只挂一个问题;现在人可以先不答,接着聊别的,卡片一直开着,等想好了再答。这对异步协作是好事,代价是模型收到回答时,对话可能已经往前走了一段,它得判断这个答案现在还适不适用。
我认为这些代价值得付。阻塞模型的代价落在框架上,表现为每种中断都要一条恢复路径,而中断只会越来越多。非阻塞模型的代价落在模型上,表现为多一次回合边界和一点判断。框架里的恢复路径是要人写、要测试覆盖、出事要排查的代码;回合边界本来就是 agent 每天都在处理的东西。
8. 为什么这样更贴合异步协作⌗
人和 agent 的协作本来就是异步的。人不会盯着屏幕等 agent 提问,agent 也不该为了一个问题把整个回合押上。把提问做成同步调用,等于在异步系统中间放了一个同步点,然后再花一千多行代码去对付这个同步点碰到重启、插话、停止和崩溃时的各种情况。
把提问降回「一条带交互组件的消息」,几件事就跟着简单了:
- 持久化是现成的。 消息本来就落库、本来就回放,卡片跟着消息走,翻历史也看得到当时问了什么、怎么答的。
- 同步也是现成的。 状态挂在 tool 行的
display上,复用已有的record.upsert,iOS 端不需要新帧,也不需要新订阅(docs/rich-message.md§8)。 - 唤醒也是现成的。 人的一条消息本来就会开回合,不需要另一条「答案到达」事件通道。
- Main 和普通对话用同一套。 悬浮条只是 Main 那张卡在别的页面上的另一个出口,
raiseNudge并进了组件服务。
整体上,这次改动不是给提问加了一套新机制,而是把它并进了已有的消息、工具调用和时间线里。原来为它单独搭的等待器、转交表、WS 帧和恢复逻辑,都随之删掉了。