提问是一条消息,回答也是一条消息。中间不需要有谁一直等着。

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
人没答却发了条新消息撤回问题,结束等待,状态 supersededabort 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
  1. 定义就是工具调用的参数。 卡片不另外存一份 schema,componentsOfToolCall({ id, name, arguments }) 直接从 assistant 行里的调用读出组件。服务端、WebUI、iOS 和推送降级都用这同一个函数,渲染结果不会各说各的。这跟 visual_render 小部件是同一个模型,只是多了状态。
  2. 组件 id 由结构决定。 <tool_call_id>:<本地 id>。旧实现在重启后要靠「问题文本 + 选项 id」的 sha256 前 16 位去重,才能认出「这还是刚才那个问题」。现在 id 就是调用 id,天然唯一,也不会因为模型续跑时改了个措辞就对不上。
  3. 状态在一张表里,并镜像到 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。文档原话:「这就是『状态怎么同步』的全部」。
  4. 回答是人的一条新消息。 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……重启后原样在时间线上」
超时转 MainComponentService.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-handoff phase: 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 帧和恢复逻辑,都随之删掉了。