逐日AI
第 1 周 · D3约 4 小时

工具调用可视化与人在回路:中断不是本地拦截

把工具调用的三段事件拼成一张有状态的卡片,让用户看见 Agent 正在做什么;再实现人在回路的审批,并搞清楚一件最容易做错的事:审批是一次运行的中断与恢复,不是前端自己弹个框拦一下。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能把工具调用的开始、参数、结束、结果四个事件归并成一张有状态的卡片
  2. 能说清为什么工具参数是流式拼出来的字符串,以及参数不完整时界面该显示什么
  3. 能用中断与恢复的模型实现审批,并说出本地拦截式审批会在哪里出问题

前两天解决的都是「把模型说的话显示好」。今天开始处理「模型要动手做事」——这是信任问题的核心。读完回到页面顶部把三条目标勾掉。

小白版讲解

译员说「请稍等,我查一下这个术语」

同传现场偶尔会出现这样一幕:讲者提到一个生僻的专业词,译员卡了一下,然后对着话筒说「请稍等,我确认一下这个术语」,翻了几秒资料,接着说「刚才那个词是……」。

注意听众在这几秒里的感受。他们没有觉得译员不专业,反而更信任他了——因为译员把「我现在在做什么」明确说了出来。如果译员只是沉默了五秒然后继续,听众会以为设备坏了。

Agent 调用工具时是完全一样的处境。它要去查数据库、读文件、发请求,这些都要时间。如果界面在这段时间里只是转个圈,用户不知道它在干什么,也不知道还要等多久。而如果界面明确显示「正在调用 search_documents,参数是这些」,用户不但不会焦虑,还会因为看得见过程而更信任结果。

把中间过程暴露给用户,而不是做成黑盒,这是 Agent 界面和普通聊天界面最大的分野之一。

四个事件一张卡片

AG-UI 把一次工具调用拆成四个事件,它们分散到达,中间还可能穿插别的消息:

事件携带界面该做什么
TOOL_CALL_STARTtoolCallId toolCallName建一张卡片,显示工具名
TOOL_CALL_ARGStoolCallId delta累加参数片段
TOOL_CALL_ENDtoolCallId参数收齐了
TOOL_CALL_RESULTtoolCallId content显示结果,卡片收尾

关键是toolCallId 归组,不要依赖到达顺序。Agent 完全可能并发发起三个工具调用,三组事件交错着来。用数组按顺序 push 是行不通的,要用一个以 toolCallId 为键的表。

这和昨天处理消息用 messageId 是同一个套路:流式协议里的一切归并都靠标识,不靠顺序

工具参数是流式拼出来的字符串

这一节是今天最容易写出 bug 的地方。

TOOL_CALL_ARGSdelta一段 JSON 字符串的片段,不是对象。模型生成参数的过程也是逐 token 的,所以你收到的序列可能是:

{"path":
"reports/20
26-Q3/","re
cursive":true}

于是一个非常自然的写法就是错的:

TypeScriptTypeScript
// 错误:中途任何一次 parse 都会抛异常
function appendArgs(call, delta) {
  const rawArgs = call.rawArgs + delta
  return { ...call, rawArgs, args: JSON.parse(rawArgs) }
}

正确做法是边收边存原文,等 TOOL_CALL_END 到了再一次性 parse

TypeScriptTypeScript
// 收:只累加,不解析
const appendArgs = (call, delta) => ({ ...call, rawArgs: call.rawArgs + delta })
 
// END 到了才解析,而且要容错
function finalizeArgs(call) {
  try {
    return { ...call, args: JSON.parse(call.rawArgs || '{}'), status: 'running' }
  } catch {
    // 模型偶尔真的会吐出不合法的 JSON。不要崩,把原文留给用户看。
    return { ...call, status: 'error', result: `参数不是合法的 JSON:${call.rawArgs}` }
  }
}

那么参数还没收齐的那一两秒里,界面显示什么?绝对不要显示半截 JSON 原文——{"path":"repo 出现在界面上,用户会以为程序崩了。显示「正在准备参数…」加一个已收到的字符数,既诚实又不吓人。

卡片的状态机

把上面几件事串起来,一张工具卡片会经过这几个状态:

preparing  ──END──▶  running  ──RESULT──▶  success / error
   │                    │
   │                    └──需要审批──▶  awaiting-approval ──▶ success / rejected
   └──参数不是合法 JSON──▶ error

有两个设计判断值得说:

rejected 要独立于 error 用户拒绝一个操作,不是程序出错。把它显示成红色报错会让用户以为自己做错了什么,而实际上他只是行使了拒绝权。

preparingrunning 要分开。 前者是「参数还在传」,后者是「工具正在跑」。它们对用户的含义完全不同:前者通常几百毫秒,后者可能几十秒。合并成一个「加载中」会丢掉这个信息。

并发调用与过长结果:两个排版问题

有两件事在真实产品里天天遇到,但很少被写进教程。

第一,Agent 可能同时发起多个工具调用。 三组事件交错到达,如果你按到达顺序往数组里推,卡片内容会串台。解法在上一节说过了——按 toolCallId 归组。但排版上还有一个决定要做:三张卡片是并排还是竖排

建议竖排,因为它们完成的时间不同,并排会让先完成的那张孤零零地变化,视线要来回跳。竖排加上各自的状态标签,阅读顺序是稳定的。

第二,工具结果可能很长。 一次数据库查询返回两百行 JSON,全部铺在对话里会把后面的内容挤到几屏之外。

处理原则是默认折叠,给出摘要:显示「返回 200 条记录」加一个展开按钮,而不是直接把 JSON 倒出来。摘要怎么生成取决于工具——行数、字节数、或者前几个字段,都比一堆原始数据有用。

这两件事有个共同的判断依据:界面要按用户关心的粒度呈现,而不是按数据到达的粒度呈现。 数据是怎么来的是实现细节,不该直接决定它怎么显示。

审批为什么不能只在前端拦

现在到今天最重要的一节。

假设用户的 Agent 要执行 delete_files。多数人的第一直觉是:前端检测到危险工具,弹个确认框,用户点「取消」就不发这个请求。

这个模型是错的,而且错得很根本:运行已经在服务端跑起来了。

模型决定调用 delete_files 这件事发生在服务端。当这个决定的事件流到达前端时,服务端那边可能已经准备执行了。前端弹的框拦不住服务端——它只能拦住前端自己后续的动作。

更严重的是安全问题:前端的判断可以被绕过。危险工具清单写在前端代码里,用户改改请求、或者直接用 curl 调你的接口,那个确认框就完全不存在了。把前端当安全边界是个典型错误。

中断与恢复:正确的审批模型

AG-UI 给了标准答案,而且它的形状可能和你的直觉不一样:审批是一次运行的中断与恢复。

流程是这样的:

  1. 服务端执行到危险工具前,主动结束这次运行RUN_FINISHED 带一个 outcome,其 typeinterrupt,里面有 interruptId、原因、以及待确认的工具信息。
  2. 前端收到这个结局,把对应卡片改成「等待确认」,显示后果说明和两个按钮。这次的流已经关闭了,前端不是在等一个挂起的连接。
  3. 用户做出决定后,前端发起一次新的请求,在请求体的 resume 数组里带上这个决定:{ interruptId, status: 'resolved' | 'cancelled', payload }
  4. 服务端读到 resume,决定要不要真的执行,然后继续往下跑。
TypeScriptTypeScript
// 前端:决定不在本地生效,而是带进下一次请求
function decide(approve: boolean) {
  void run([
    {
      interruptId: pending.interruptId,
      status: approve ? 'resolved' : 'cancelled',
      payload: pending.payload,
    },
  ])
}

这个模型有几个好处是本地拦截给不了的:服务端是唯一的执行方,所以它是可靠的门中断状态可以持久化,用户关了浏览器明天再来批也行;审批记录天然可审计,因为每个决定都是一次带 interruptId 的真实请求。

拒绝之后呢:别让整个运行失败

最后一个容易做错的地方:用户点了「拒绝」,然后呢?

一个常见的实现是直接让这次运行报错结束。这很糟糕——用户只是不想删文件,不是想让对话崩掉。他多半希望 Agent 换个方案继续。

正确做法是把拒绝也当成一个工具结果回给模型,内容是「被用户拒绝」加上原因:

TypeScriptTypeScript
{
  type: 'TOOL_CALL_RESULT',
  toolCallId,
  content: JSON.stringify({ error: 'rejected_by_user', reason: '用户拒绝了删除操作' }),
}

模型拿到这个结果,就能理解发生了什么,并给出替代方案——比如「好的,不删了。需要我改成先归档这批报告吗?」。整个运行仍然是成功结束的,不是 RUN_ERROR

一个可操作的判断标准:只有系统真的出故障时才用错误态。用户的选择,无论是什么,都不是故障。

源码导读

动手实验

🧪 D3 实验:带审批门的工具调用卡片

代码位置:labs/frontend-agent-ux-7days/day-03-tool-approval

验收标准:

  1. 只读工具剧本下,卡片依次经过「准备参数 → 执行中 → 完成」三种状态
  2. 参数流式到达期间,界面不显示半截 JSON 原文
  3. 危险工具剧本下运行中断,卡片进入「等待你确认」并出现两个按钮
  4. 点「同意执行」后工具真的执行并返回结果
  5. 点「拒绝」后对话继续,而不是整个运行失败
  6. pnpm typecheck && pnpm selftest 退出码为 0

做完之后一定要打开网络面板确认一件事:点「同意」之后发出的是一个新请求,请求体里带着 resume。看见这一点,你就真正理解今天的核心了。

  1. 按 toolCallId 归并四类工具事件,渲染成卡片
  2. 实现参数的渐进显示,容忍拼到一半的 JSON
  3. 用危险工具剧本触发一次中断,渲染审批界面
  4. 把用户的同意或拒绝放进下一次请求的恢复条目里
  5. 验证拒绝后 Agent 能拿到理由并继续,而不是整个运行失败

面试题

今天 4 道题在下方题库区,侧重工具事件的归并与状态机、人在回路的协议设计、不可逆操作的前端职责。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。

检查清单与明日预告

  • 能把工具调用的开始、参数、结束、结果四个事件归并成一张有状态的卡片
  • 能说清为什么工具参数是流式拼出来的字符串,以及参数不完整时界面该显示什么
  • 能用中断与恢复的模型实现审批,并说出本地拦截式审批会在哪里出问题
  • 能说清为什么用户拒绝不该走错误态
  • 实验的 6 条验收标准全部通过
  • 4 道面试题不看要点也能答出至少 3 道

明天(D4)我们处理两样让 Agent 界面真正区别于聊天界面的东西:模型的推理过程,以及一份前后端共享的状态。今天做的是「看见它要做什么」,明天做的是「看见它在想什么、以及它手里那份数据长什么样」——有了共享状态,界面才能显示聊天记录之外的进展,比如一个正在被填写的表单、一份逐步成形的报告。

面试题库

  • 为什么人在回路的审批要做成运行中断加恢复,而不是前端弹窗拦住请求?Why implement human-in-the-loop approval as run interruption and resumption rather than a frontend modal that blocks the request?
    国内高频海外高频深入#human-in-the-loop#security#protocol-design

    分析过程 · 先想清楚再作答

    1. 这题的区分度极高,因为绝大多数人的第一直觉就是「前端弹个框」。答这个不算完全错,但接不住第一个追问:那用户绕过前端直接调你的接口呢。
    2. 先说清楚本地拦截错在哪,而且是两个独立的问题。第一个是时序:模型决定调用危险工具这件事发生在服务端,等事件流到前端时,服务端那边随时可能已经执行了,前端的框拦不住它。
    3. 第二个是安全,而且更致命:危险工具清单写在前端代码里,用户改改请求、或者直接用命令行调接口,那个确认框就完全不存在了。**把前端当安全边界是典型错误**,前端能做的只是提示,不是管控。
    4. 正确模型是把审批变成协议的一部分:服务端执行到危险工具前主动结束这次运行,结局标成中断并带上一个中断标识;前端据此渲染审批界面;用户决定后前端发起**新的一次请求**,在恢复条目里带上这个决定;服务端读到之后才决定要不要执行。
    5. 这个模型顺带解决了三件事:服务端是唯一执行方所以门是可靠的;中断状态可以持久化,用户关了浏览器明天再批也行;每个决定都是一次带标识的真实请求,审计记录天然就有了。
    6. 可预期的追问是「那前端的审批界面还有什么用」——它负责两件服务端做不了的事:把后果解释清楚,以及采集决定。还可以顺带提一句界面细节:审批按钮不该有默认选中项,默认值等于替用户做了决定。

    How to reason about it · think before answering

    1. This separates candidates sharply, because almost everyone's first instinct is a frontend modal. That answer is not entirely wrong, but it does not survive the first follow-up: what if the user calls your API directly?
    2. Name the two independent problems with local interception. First, timing: the decision to call a dangerous tool happens on the server, and by the time the event stream reaches the browser the server may already be executing. A modal cannot stop it.
    3. Second, and more serious, security: the dangerous-tool list lives in frontend code, so editing the request or calling the endpoint from a terminal makes the confirmation vanish. **Treating the frontend as a security boundary is a classic mistake**; the frontend can advise, not enforce.
    4. The correct model makes approval part of the protocol: before a dangerous tool, the server deliberately ends the run with an interrupt outcome carrying an interrupt id. The frontend renders an approval UI. Once the user decides, the frontend issues a **new request** carrying that decision in a resume entry, and only then does the server decide whether to execute.
    5. This buys three things: the server is the sole executor so the gate is trustworthy; interrupt state can be persisted so the user can approve tomorrow; and each decision is a real request carrying an id, so the audit trail exists by construction.
    6. Expect the follow-up on what the frontend approval UI is still for: explaining consequences and collecting the decision, both of which the server cannot do. Worth adding that approval buttons should have no default selection, since a default decides for the user.

    答题要点

    • 本地拦截有两个独立问题:运行已经在服务端跑起来了,以及前端判断可以被绕过。
    • 正确模型是服务端主动以中断结局结束运行,前端渲染审批界面,决定随下一次请求的恢复条目发回。
    • 服务端是唯一执行方,所以它才是可靠的门;前端只负责解释后果与采集决定。
    • 中断状态可持久化,用户可以晚些再批;每个决定是一次带标识的请求,审计记录天然具备。
    • 界面细节:审批按钮不设默认选中项,默认值等于替用户做了决定。

    Key points

    • Local interception has two separate problems: the run is already executing server-side, and frontend checks can be bypassed.
    • The right model ends the run with an interrupt outcome; the frontend renders approval and sends the decision back as a resume entry on the next request.
    • The server is the sole executor and therefore the only trustworthy gate; the frontend explains consequences and collects the decision.
    • Interrupt state can persist so approval can happen later, and each decision is an identified request, giving audit for free.
    • UI detail: no default selection on approval buttons, since a default decides for the user.
  • 工具调用的参数是一段段流式拼出来的,界面在参数还没拼完时应该显示什么?Tool call arguments arrive as streamed fragments. What should the UI display before they are complete?
    国内高频海外高频进阶#tool-calling#streaming#ui-state

    分析过程 · 先想清楚再作答

    1. 这题看着简单,考的其实是你有没有真处理过流式工具调用。没做过的人会答「显示参数」,而这恰恰会在界面上露出半截 JSON。
    2. 先说清楚事实:参数事件的增量是**一段 JSON 字符串的片段**,不是对象。你可能收到 `{"path":"reports/20` 这种东西,它 parse 一定抛异常。
    3. 所以第一条规则是**边收边存原文,不要边收边 parse**,等结束事件到了再一次性解析。而且解析要容错——模型偶尔真的会吐出不合法的 JSON,这时候不要让界面崩,把状态标成失败并把原文留给用户看,比白屏有用得多。
    4. 回到题目问的显示:**绝对不要把半截 JSON 原文显示出来**。`{"path":"repo` 出现在界面上,用户会以为程序崩了。合理的做法是显示「正在准备参数」,可以带一个已收到的字符数,既诚实又不制造恐慌。
    5. 顺带一个状态设计:「参数还在传」和「工具正在执行」要分成两个状态,不要合并成一个加载中。前者通常几百毫秒,后者可能几十秒,对用户的含义完全不同。
    6. 可预期的追问是「那怎么测这件事」——测试要盯**用户看得见的输出**,而不是内部变量。我自己写这段的测试时第一版断言的是「参数没收齐时内部字段为 null」,结果它恒真:提前 parse 半截 JSON 本来就失败返回 null。改成断言界面文案之后才真正有效。

    How to reason about it · think before answering

    1. It looks simple but tests whether you have handled streaming tool calls for real. People who have not say 'show the arguments', which is exactly how half-formed JSON ends up on screen.
    2. State the fact first: the delta on an arguments event is a **fragment of a JSON string**, not an object. You may hold `{"path":"reports/20`, which will throw if parsed.
    3. So rule one is accumulate the raw text and parse only once the end event arrives, with error tolerance. Models do occasionally emit invalid JSON; do not let the UI crash. Mark the call failed and show the raw text, which beats a blank screen.
    4. As for what to display: **never show the half-formed JSON**. Seeing `{"path":"repo` makes users think the app broke. Show something like 'preparing arguments', optionally with a character count — honest without being alarming.
    5. A related state design point: keep 'arguments still arriving' and 'tool executing' as separate states rather than one spinner. The first is usually hundreds of milliseconds, the second can be tens of seconds, and they mean different things to the user.
    6. Expect a follow-up on testing this: assert on **what the user can see**, not internal fields. My first version asserted that the parsed field stayed null while incomplete, which turned out to be vacuously true since parsing partial JSON fails and returns null anyway. Asserting the displayed text made it meaningful.

    答题要点

    • 参数增量是 JSON 字符串的片段而不是对象,中途 parse 必然抛异常。
    • 边收边存原文,结束事件到了再一次性解析,且解析失败要容错不要崩。
    • 界面绝不显示半截 JSON 原文,改显示「正在准备参数」加已收字符数。
    • 「参数在传」和「工具在跑」要分成两个状态,两者的时长量级和含义都不同。
    • 测试要断言用户可见的输出,断言内部中间状态容易写出恒真的假绿。

    Key points

    • Argument deltas are fragments of a JSON string, not objects, so mid-stream parsing always throws.
    • Accumulate raw text and parse once at the end event, tolerating failure rather than crashing.
    • Never render partial JSON; show 'preparing arguments' with a character count instead.
    • Keep 'arguments arriving' and 'tool executing' as distinct states; their durations and meanings differ.
    • Assert on user-visible output, since asserting internal intermediate state easily produces vacuously true tests.
  • 用户拒绝了一个工具调用,你的系统接下来应该怎么处理?The user rejects a tool call. What should your system do next?
    国内高频海外高频基础#ux#error-handling#human-in-the-loop

    分析过程 · 先想清楚再作答

    1. 这题考产品判断,不是技术难点。很多实现直接让这次运行报错结束,而这是个体验事故。
    2. 判断依据一句话就能说清:**用户只是不想执行这个操作,不是想让对话崩掉**。他多半希望 Agent 换个方案继续。把拒绝做成失败,等于惩罚用户行使了你给他的权利。
    3. 正确做法是把拒绝也当成一个**工具结果**回给模型,内容写明被用户拒绝以及原因。模型拿到这个结果就能理解发生了什么,并给出替代方案,比如从「删除这批报告」改成「先归档这批报告」。
    4. 整个运行因此仍然是成功结束的,不是错误结束。这一点在事件上是有区别的:应该是带成功结局的运行结束,而不是运行错误。
    5. 界面上也要区分:拒绝状态不要渲染成红色报错。用户会以为自己做错了什么,而他只是做了个选择。给它一个独立的中性状态。
    6. 一条可迁移的判断标准,值得主动说出来:**只有系统真的出故障时才用错误态。用户的选择,无论是什么,都不是故障。** 这条在表单校验、权限拒绝、支付取消等场景同样适用。

    How to reason about it · think before answering

    1. This tests product judgment rather than technical difficulty. Many implementations fail the run outright, which is a UX failure.
    2. The deciding principle fits in a sentence: **the user declined an action, they did not ask for the conversation to break**. They most likely want the agent to propose something else. Turning refusal into failure punishes the user for exercising the control you gave them.
    3. The right move is to return the refusal as a **tool result**, stating that the user rejected it and why. The model can then understand what happened and offer an alternative, such as archiving the reports instead of deleting them.
    4. The run therefore still ends successfully rather than in error, and that distinction is visible in the events: a run finished with a success outcome, not a run error.
    5. Reflect it in the UI too: do not render rejection as a red error. Users read that as having done something wrong, when they merely made a choice. Give it a distinct neutral state.
    6. State the transferable rule out loud: **use the error state only when the system actually failed. A user's choice, whatever it is, is not a failure.** The same applies to form validation, permission denials, and cancelled payments.

    答题要点

    • 拒绝不是失败:用户只是不想执行这个操作,不是想让对话崩掉。
    • 把拒绝作为工具结果回给模型,写明被拒绝与原因,让它给出替代方案。
    • 整个运行仍以成功结局结束,而不是发出运行错误事件。
    • 界面上给拒绝一个独立的中性状态,不要渲染成红色报错。
    • 通用判据:只有系统真出故障才用错误态,用户的选择不是故障。

    Key points

    • Rejection is not failure: the user declined an action, not the conversation.
    • Return the refusal as a tool result with the reason so the model can propose an alternative.
    • The run still ends with a success outcome rather than emitting a run error.
    • Give rejection its own neutral state in the UI instead of a red error.
    • General rule: reserve the error state for actual system failures; a user's choice is not one.
  • Agent 并发发起了三个工具调用,事件交错着到达前端,你怎么保证它们各自归位?An agent fires three tool calls concurrently and their events interleave on arrival. How do you keep them straight?
    国内高频海外高频进阶#streaming#state-management#tool-calling

    分析过程 · 先想清楚再作答

    1. 这题在考你有没有意识到「流式协议里不能依赖到达顺序」。按数组顺序 push 的实现在单个工具调用时完全正常,一并发就全乱,而且是那种线上才复现的乱。
    2. 答案本身很直接:**按工具调用标识归组**,用一个以该标识为键的表,而不是数组。每个事件都带这个标识,所以任何一片参数、任何一个结果都能找到自己的卡片。
    3. 值得多说一句的是这条规则的普适性:昨天处理消息增量用消息标识归并,今天处理工具用工具调用标识归并,明天处理子 Agent 用子运行标识归并。**流式协议里的一切归并都靠标识,不靠顺序**——这是一条能迁移的判断。
    4. 顺带提一个协议设计细节:工具事件上还带父消息标识,这让界面能知道这次调用挂在哪条消息下面,从而在时间线上正确排版,而不是把所有工具卡片堆在末尾。
    5. 实现上还要注意时间线本身:消息和工具卡片是混排的,所以除了两张表之外通常还需要一个记录出现顺序的列表,否则你有数据但不知道该按什么顺序渲染。
    6. 可预期的追问是「同一个工具被调用两次怎么办」——每次调用有各自独立的标识,所以天然是两张卡片,不需要特殊处理。真正需要小心的是你自己生成键的时候不要用工具名当键。

    How to reason about it · think before answering

    1. This checks whether you know not to rely on arrival order in a streaming protocol. Pushing to an array works perfectly with one tool call and breaks entirely under concurrency, in a way that only shows up in production.
    2. The answer is direct: **group by tool call id**, using a map keyed on that id rather than an array. Every event carries the id, so any argument fragment or result can find its card.
    3. Worth generalizing: yesterday message deltas merged by message id, today tool events merge by tool call id, tomorrow subagent events merge by subagent run id. **Everything in a streaming protocol merges by identifier, never by order** — that is the transferable lesson.
    4. A protocol detail worth mentioning: tool events also carry a parent message id, which lets the UI place the call under the right message in the timeline instead of piling every tool card at the end.
    5. Implementation-wise, mind the timeline itself: messages and tool cards interleave, so alongside the two maps you usually need an ordered list of what appeared when, or you have the data but no rendering order.
    6. Expect the follow-up about the same tool being called twice: each call has its own id, so you get two cards naturally. The real risk is keying on the tool name yourself.

    答题要点

    • 按工具调用标识归组,用以标识为键的表而不是数组,绝不依赖到达顺序。
    • 这是通用规则:流式协议里的归并一律靠标识,消息靠消息标识,子 Agent 靠子运行标识。
    • 工具事件带的父消息标识用来把卡片排到正确的消息下面,而不是堆在末尾。
    • 消息与工具卡片混排,所以还需要一个记录出现顺序的时间线列表。
    • 同一工具调用两次天然是两张卡片,因为每次调用有独立标识;不要用工具名当键。

    Key points

    • Group by tool call id using a map, never an array, and never rely on arrival order.
    • It generalizes: streaming protocols merge by identifier — message id for messages, subagent run id for subagents.
    • The parent message id on tool events places the card under the right message instead of at the end.
    • Messages and tool cards interleave, so keep an ordered timeline list alongside the maps.
    • Two calls to the same tool naturally produce two cards since each has its own id; never key on the tool name.

评论