Skip to content

kernel: pair orphaned tool halves by order and multiplicity, not id-set membership (#3046) - #3047

Closed
OMSociety wants to merge 4 commits into
ranxianglei:masterfrom
OMSociety:2026-10-13_kernel-duplicate-tool-call-ids
Closed

OMSociety wants to merge 4 commits into
ranxianglei:masterfrom
OMSociety:2026-10-13_kernel-duplicate-tool-call-ids

Conversation

@OMSociety

@OMSociety OMSociety commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

(ai: deepseek/deepseek-v4.1-flash)

kernel-change: the unpaired half is produced by the kernel's own fold/render pipeline — a fold covers one instance's partner and the guard then re-pairs that half against a different message carrying the same id — and host-minted tool-call ids must stay byte-exact on the wire (#1039), so the kernel cannot rewrite, re-mint or de-duplicate ids to make a set lookup work. The only thing it can do is refuse to build the invalid shape, which is exactly what these guards exist for ("drop the dangling run rather than ship a 400-triggering half-pair").

Summary

kernel/src/prune.ts paired tool calls with tool results by id-set membership. Host ids are not guaranteed unique — a host that numbers calls name:index re-issues the same id on later turns — so a half survived whenever any message carried its id, including a duplicate on the other side of the fold or a declaration that comes later in the request. The rebuilt body then went to the wire with a role:"tool" message before any assistant declared its tool_call_id, and the upstream refused the whole conversation:

{"message":"Invalid request: tool_call_id  is not found","type":"invalid_request_error"}

The mirror shape (an assistant tool_calls entry no later tool message answers) is the same defect with the other half surviving. Both were hit in production on this machine, and both sessions became unusable: every later turn rebuilt the same request and got HTTP 400.

Fixes #3046.

What changed

  • kernel/src/prune.ts — stripOrphanedToolResults / stripOrphanedToolCalls now pair by order and multiplicity: a counter of open (unanswered) calls and a counter of open results, each half claiming the nearest partner, never a distant same-id twin. The result side walks forward (a tool result may only be claimed by a call that precedes it); the call side walks backward (a call may only claim a result that follows it). Id-less messages keep their previous behavior, and compress calls still stay regardless of pairing.
  • kernel/tests/issue3046-duplicate-tool-call-ids.test.ts (new) — a minimal fixture for each half, plus a no-op case proving complete duplicate-id pairs are neither dropped nor reordered.
  • kernel/package.json 0.0.123 → 0.0.124 (master had already reached 0.0.123 via fix(#2886): make recent-zone protection pair-aware (prevent orphaned function_call) #2892), with root and kernel lockfiles synced.
  • tests/wire-contract-fakes.ts / tests/wire-contract.test.ts — WC-018 (openai-chat) records the constraint learned from production, with a probe body that trips it. The clause is order + multiplicity aware and runs before the tools early-return, so it binds on any messages array. (Renumbered from WC-017 at rebase: [bug] Codex CLI 经 bili 0.1.196 转发时返回空 turn(exit 0 + 0 字节输出;直连正常) #3063 took WC-017 for the codex-lane stream rule; all references in this PR track the rename.)

Root cause

const knownCallIds = new Set<string>();
for (const m of messages) {
  if (m.contentType === "tool-call" && m.toolCallId) knownCallIds.add(m.toolCallId);
}
return messages.filter(
  (m) => m.contentType !== "tool-result" || !m.toolCallId || knownCallIds.has(m.toolCallId),
);

A set cannot distinguish two instances that share an id, and membership says nothing about order. The halves themselves are separated by the compression path (the fold covers one instance's partner; the integrity-withdrawal carve can leave the partner visible), but this guard is the last decision point before the wire — so the invalid shape reached the provider.

Test

On origin/master both pairing tests fail at the assertion that names the defect:

AssertionError: result of the covered call is stripped (id-set pairing kept it)
AssertionError: result with no visible call is stripped (id-set pairing kept it)

With the patch they pass, along with the no-op case.

Verification

  • node --import tsx --test kernel/tests/issue3046-duplicate-tool-call-ids.test.ts kernel/tests/orphan-fixes.test.ts kernel/tests/tool-pairs.test.ts kernel/tests/tool-pair-integrity.test.ts kernel/tests/state-prune.test.ts kernel/tests/reasoning-pairs.test.ts → 63 pass / 0 fail (8 suites)
  • node --import tsx --test tests/wire-contract.test.ts → 18 pass / 0 fail
  • npm run typecheck → exit 0
  • npx prettier --check kernel/src/prune.ts kernel/tests/issue3046-duplicate-tool-call-ids.test.ts → clean
  • Suites that import the wire-contract fakes (copilot-schema-contract, wire-contract-go-session, prefix-affinity-orphan, e2e-responses-offhead-system, the orphan/strict-echo loop suites) unchanged behavior.

Behavior change

Disclosed: a rebuilt request can now lose a half it previously kept. Only messages that cannot be paired in render order are removed, i.e. exactly the ones that would have tripped the upstream contract; complete pairs, id-less messages and compress calls are untouched (pinned by the no-op test).

Config surface

None — no new config field, env var, CLI flag, or persisted field. No id is minted, rewritten, or de-duplicated.

Related

Out of scope

Other id-keyed helpers in the same pipeline share the uniqueness assumption — pairSafeAnchorIndex / adjustBoundariesForToolPairs in kernel/src/prune.ts and computeIntegrityWithdrawals in the compressor. They are how the stray halves stayed visible, but they do not decide what goes on the wire; happy to file that separately if you want it tracked.

Reported by an AI agent on behalf of a user; every artifact above was produced by running the code in this branch and on origin/master. Kernel change — flagging for owner review per the Kernel Boundary rule.

Rebase note (2026-10-14)

Rebased onto master @ bd2aa6e after #3055/#3063 landed. Conflicts were metadata-only: kernel version line (→ 0.0.124), both lockfiles, and the WIRE_RULES id allocation (→ WC-018, all references updated). No logic change versus the originally reviewed diff.

@OMSociety
OMSociety force-pushed the 2026-10-13_kernel-duplicate-tool-call-ids branch 2 times, most recently from b6d612d to e807efd Compare October 13, 2026 13:50
@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 PR #3047 审核结论(对应根因 issue #3046)

审核对象:最终 head e807efdb5。审核期间作者推了三次(内容未变,均为 rebase/同步 master);已核对 PR 的 7 个文件在三个 head 间逐字节一致,代码结论对最终 head 成立。

八问:

  1. 是什么问题:当宿主复用同一个工具 id(name:index 风格、每轮重新签发)时,bili 压缩剪枝按"id 是否出现过"来配对孤儿工具调用/结果,配不上对的半边会漏进重建后的请求 → 上游直接 400(role:tool 找不到声明它的 assistant,或 tool_calls 无人应答),会话从此永久不可用。
  2. 什么原因导致的:配对判据用的是 id 集合成员关系,而不是顺序+计数。同一 id 出现两次时,"集合里有这个 id"恒为真,多重性和先后顺序被完全忽略——这是机制层的错配,不是某个边界条件的疏漏。
  3. 影响范围与严重程度:仅当会话同时满足「重复 id」+「折叠后产生孤儿半边」两个条件才触发;一旦触发该会话彻底不可用(每次请求都 400)。id 唯一的正常会话行为不变。
  4. 怎么修复的:把剪枝的两个配对函数改为顺序+计数配对——结果向前认领最近一个未被认领的同 id 调用,调用向后认领最近的结果(compress 调用照旧豁免);顺序非法的输入(结果先于调用)两边一起丢弃——这种形状本就不可能出现在合法线上流量里,丢掉的纯粹是致 400 的形状。同时在 wire-contract 账本加了一条永久校验规则(WC-017,含探针测试),把这条线不变量钉在假上游层,防止未来回归。
  5. 是否有回归问题:逐项审过——良构输入(id 唯一、顺序正确)输出字节级不变,由新增 no-op 测试钉住;compress 豁免语义不变;无 id 消息不受影响;两个函数是模块私有的,全仓唯一调用点(kernel/src/prune.ts:122-123),不存在旧语义的其他消费者。PR 披露的行为变更正是修复目标本身:非法形状下原本被保留(并导致 400)的半边现在会被丢弃。回归测试有牙已验证:把 prune.ts 回退到 master 版本,新测试 3 个里 2 个失败;恢复 PR 版本全部通过。修复位于 kernel 剪枝层,plugin/proxy 两种压缩模式共用此路径,两种模式下分析均成立。
  6. 是否破坏缓存:不破坏。良构请求重建字节不变,prefix cache 命中率不受影响;坏会话从「永久 400」变为可恢复。
  7. 是否改了设计方向:没有。仍是同一条剪枝管线,只是把配对判据修在了正确的决策点(上线前的 guard,不是下游过滤)。作者声明了范围外项:fold 边界选择里的几个辅助函数(pairSafeAnchorIndex / adjustBoundariesForToolPairs / computeIntegrityWithdrawals)共享同一唯一性假设——它们决定孤儿半边为何存活到折叠窗口,不决定什么上线。按「同一症状 = 同一 issue」原则,建议作为 [Bug] prune strip guards pair tool calls by id-set, so duplicate host ids ship an unpaired half (upstream 400, session bricked) #3046 内的 checklist 项跟踪、不单独立项,请 owner 定夺。
  8. 是否引入了新的配置:无。

验证细节:

结论:审核通过,未发现阻塞问题。 kernel 变更按 Kernel Boundary 约定需人工合并——@OMSociety 请自行合并:#3047

ranxianglei pushed a commit that referenced this pull request Oct 14, 2026
…tall warning

Tier-1 beautification for #3047 (reference: tintinweb pi-subagents fork 0.19.0-fork.6):

- New delegate-render module: renderShell:'self' + renderCall/renderResult on
  acp_delegate / acp_delegate_wait / acp_delegate_cancel. The call line shows
  agent + first task line (resume calls marked); the result line shows a status
  icon, exit label, duration and up to 3 preview lines of run output (Ctrl+O
  expands to 40). Renderers fall back to plain text on bad input, never throw.
- Previews live only in bounded tool-result details (<=4KB, cut on a newline
  boundary) and the rendered screen. Model-facing content text is unchanged:
  task title + result file path.
- Fleet widget rows append a stall warning when a running child has been
  silent for 2+ minutes (before the idle watchdog kills it).
- promptGuidelines gain two routing entries (route by task kind; read the
  child's result file instead of re-running its exploration).
- exitLabel moved to delegate-render.ts and re-exported from delegate-tool.js
  (public surface preserved).
- Hosts < pi-coding-agent 0.62 ignore the renderer fields (< 0.67.3 ignore
  renderShell) and keep default tool output; no version floor added.

pi-subagents 0.1.5 -> 0.2.0 (CI-enforced for src changes).
ranxianglei pushed a commit that referenced this pull request Oct 14, 2026
…tall warning

Tier-1 beautification for #3047 (reference: tintinweb pi-subagents fork 0.19.0-fork.6):

- New delegate-render module: renderShell:'self' + renderCall/renderResult on
  acp_delegate / acp_delegate_wait / acp_delegate_cancel. The call line shows
  agent + first task line (resume calls marked); the result line shows a status
  icon, exit label, duration and up to 3 preview lines of run output (Ctrl+O
  expands to 40). Renderers fall back to plain text on bad input, never throw.
- Previews live only in bounded tool-result details (<=4KB, cut on a newline
  boundary) and the rendered screen. Model-facing content text is unchanged:
  task title + result file path.
- Fleet widget rows append a stall warning when a running child has been
  silent for 2+ minutes (before the idle watchdog kills it).
- promptGuidelines gain two routing entries (route by task kind; read the
  child's result file instead of re-running its exploration).
- exitLabel moved to delegate-render.ts and re-exported from delegate-tool.js
  (public surface preserved).
- Hosts < pi-coding-agent 0.62 ignore the renderer fields (< 0.67.3 ignore
  renderShell) and keep default tool output; no version floor added.

pi-subagents 0.1.5 -> 0.2.0 (CI-enforced for src changes).
@ranxianglei

Copy link
Copy Markdown
Owner

冲突 ,ework重新基于最新master review一次 走最新的规则

@ranxianglei ranxianglei added the bug Something isn't working label Oct 14, 2026
…et membership (#3046)

stripOrphanedToolResults / stripOrphanedToolCalls collected ids into a Set, so a half survived whenever ANY message carried its id - including a duplicate id from another turn or a declaration on the other side of the fold. Count open calls/results in render order instead: results claim the nearest earlier unanswered call, calls claim the nearest later unanswered result.

Verified: new kernel/tests/issue3046-duplicate-tool-call-ids.test.ts fails on master at both pairing assertions and passes here; orphan-fixes, tool-pairs, tool-pair-integrity, state-prune, reasoning-pairs stay green (63/63).
…3046)

Records the constraint learned from production: a role:tool message must be preceded by the assistant declaring its tool_call_id, and every declared id must be answered exactly once later. Validator clause is order+multiplicity aware and runs before the tools early-return.
ranxianglei added a commit that referenced this pull request Oct 14, 2026
@ranxianglei
ranxianglei force-pushed the 2026-10-13_kernel-duplicate-tool-call-ids branch from e807efd to 1c221a7 Compare October 14, 2026 13:44
@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

(ai: vllm-qwen/qwen3.8-27b)

冲突已解决 + 基于最新 master 的独立复审

冲突处理:PR 分支已 rebase 到最新 master(bd2aa6e15,落后 159 个提交),冲突全部解决后推回 OMSociety fork 的 PR 分支(PR head 现为 1c221a7eb)。merge state 已从 dirty 转干净(当前 blocked = 等新 head 的 CI)。两处冲突及解法:

  1. ledger 规则编号撞车:master 在 [bug] Codex CLI 经 bili 0.1.196 转发时返回空 turn(exit 0 + 0 字节输出;直连正常) #3063 里已经占用了 WC-017(codex WS create 帧必须带 stream:true),本 PR 的规则顺延为 WC-018(openai-chat tool-call/result 顺序配对),validator 报错串、测试探针注释同步改号;master 的 WC-017 原样保留。
  2. kernel 版本:master 期间已由 0.0.121 涨到 0.0.123([Bug] neverPreserveRecentTools splits tool-call/result pairs at protected boundary, causing upstream 400 invalid_request_error #2886/compress.preserveRecentMessages default 5 → 3 (kernel default + web display + docs) #3058/[Bug] MCP compress 历史调用未被清理,长会话累积大量旧摘要与回执 #2970),本 PR 的 kernel 改动在其上再 +1 = 0.0.124,root package-lock 与 kernel/package.json 同步。顺手修了一处 master 既有漂移:kernel/package-lock.json 还停在 0.0.121(之前两次 bump 没同步 lockfile),一并对齐到 0.0.124。

结构化八问(基于最新 master 重新独立审过,不复用旧结论)

  1. 是什么问题:当宿主复用同一个 tool-call id(DSH Agent Teams 用 名称:序号 风格,send_message:0/:1 跨轮重复出现)时,kernel 清理孤儿工具消息的两个守卫按「这个 id 在不在场」配对而不是按顺序和数量配对,折进摘要后会把半个工具对留在请求里,严格上游(Kimi,OpenAI-chat 线)直接 400 整个 turn。
  2. 什么原因导致的:stripOrphanedToolResults / stripOrphanedToolCalls(kernel/src/prune.ts)各自先把所有同侧消息的 id 收进一个 Set(位置、数量全丢),再过滤——tool-result 只要任意位置存在同 id 的 call 就存活(哪怕那个 call 在它后面、或是另一轮的同号调用),call 侧镜像同理。折叠只需盖住一对中的任一个实例,另一个实例的同号「影子」就能让孤儿半边逃过清理,发出去的请求违反上游配对契约。
  3. 影响范围与严重程度:特定条件下有问题——宿主复用 tool-call id 且压缩恰好折掉重复对中的一员时,该 turn 整体 400。已有生产证据(bili 0.1.195/0.1.196,Kimi via OpenAI-chat,两个镜像 400 信封见 WC-018 provenance)。不是全面不可用,id 唯一的主机不受影响。
  4. 怎么修复的:改成顺序+多重性配对。result 侧单遍正向扫描,每个 id 维护未认领计数——只有前面存在尚未认领的同号 call 才存活并认领之;call 侧反向镜像扫描,只有后面存在未认领的同号 result 才存活(LIFO 配对);compress 调用自带回执永远保留;无 id 消息行为不变。坏掉的半边诚实丢弃,不再发出线上非法体。
  5. 是否有回归问题(逐项清点既有行为):完整工具对在输出中字节与顺序不变(测试 3 用 deepEqual 钉死);我逐一追踪了交错场景(多 call 同轮、跨轮重发、超额重复回答),新逻辑只比旧逻辑更严地丢弃,不存在它丢而旧逻辑合法放行的配置——即不会误伤任何线上合法的请求体;唯一调用点在 kernel/src/prune.ts:121-134 的管线组合处,未变;master 新增的 [Bug] neverPreserveRecentTools splits tool-call/result pairs at protected boundary, causing upstream 400 invalid_request_error #2886 recent-zone 配对保护落在 recommend.ts,与本改动无代码交叠、方向互补(一个防拆对、一个清残骸)。无阈值/时序/输出格式漂移,无 fail-fast 变吞错。
  6. 是否破坏缓存:不涉及。pruning 作用于已重建的消息视图,不动缓存键/增量游标/去重键;丢掉非法半边本身是让请求恢复可被接受(400 的请求什么都缓存不了)。
  7. 是否改了设计方向:不涉及。同一道防御层内收紧配对语义,模块边界、管线节点顺序均未动。
  8. 是否引入了新的配置:不涉及。

行为变更披露(旧值 → 新值)

  • 孤儿判定语义:tool-result 从「id 出现在任意可见 call 即存活」改为「须有先于它且未被认领的同号 call」;tool-call 从「id 出现在任意可见 result 即存活」改为「须有晚于它且未被认领的同号 result」。对宽松上游而言,以前能带病通过的畸形体(结果先于声明、超额重复回答)现在会被剔除——这正是修复目的,但属于对外可见行为变化,照规披露。
  • ledger 规则号:PR 原稿 WC-017 → WC-018(撞号,见上)。
  • kernel 版本:0.0.122(PR 原稿)→ 0.0.124(master 已先行两版)。

验证证据

  • 本地定向(不跑全量,全量归 CI):kernel 修剪域套件(issue3046 回归 + orphan-fixes + tool-pairs + tool-pair-integrity + state-prune + reasoning-pairs + issue2886)+ tests/wire-contract.test.ts = 87/87 通过;typecheck 干净(kernel + pi-subagents 构建通过)。
  • 回退检验(revert-check):把 master 旧版 prune.ts 换回去跑新回归测试,恰好 2 条失败、第 3 条(防过度剥离)两边都过——测试有牙,钉得住原 bug。
  • 全量回归以新 head 1c221a7eb 的远程 CI 为准(回复时已排队/运行中)。

遗留项(不另开 issue,留在 #3046 清单内)

作者声明的超范围部分我核过属实:pairSafeAnchorIndex / adjustBoundariesForToolPairs(kernel/src/prune.ts)与 computeIntegrityWithdrawals 仍假设 id 唯一——与本修复同一机制家族(工具对完整性里的唯一性假设),按「同症状同 issue」纪律作为 #3046 的子项跟踪,不碎片化立项。

结论

冲突解决完毕,独立复审通过。本 PR 含 kernel/ 改动,按 Kernel Boundary 属人工合并档:@OMSociety CI 绿后请自行合并 #3047 不代合)。

@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

(ai: vllm-qwen/qwen3.8-27b)

冲突处理完成,rebase 后已按最新规则重新审核

一、冲突点与解法(4 处,全部是元数据/编号类,无逻辑冲突)

冲突 原因 解法
kernel 版本号 PR 分支上 0.0.121→0.0.122,master 已被 #2892 推到 0.0.123 定为 0.0.124(Kernel Boundary:kernel 变更必须伴随版本递增)
package-lock.json(root) acp-kernel file-link 版本号 同步到 0.0.124
kernel/package-lock.json 同上 两行版本都到 0.0.124(顺带修掉 master 既有不一致:#2892 升 0.0.123 时漏了更新这个文件,一直停在 0.0.121)
WIRE_RULES 编号 master 的 #3063 已占用 WC-017(codex responses stream 字段) 本 PR 新增的 openai-chat 配对规则改名为 WC-018,全部引用同步更新(账本条目、校验器注释、两条错误文案、测试探针注释)

rebase 到最新 master(bd2aa6e),拆成 4 个提交并保留原作者署名:主修复 / root lockfile 同步 / kernel lockfile 同步 / wire-contract 账本。force-with-lease 推到作者 fork 的 PR 分支(PR 开了 maintainer_can_modify),PR 描述同步更正(版本号、WC-018、rebase note)。推送后 PR 恢复可合并状态。

二、重新审核结论(基于 rebase 后 head 1c221a7 独立重审,非复读旧结论)

  1. 是什么问题:宿主发出重复的 tool-call id(DSH Agent Teams 的 send_message:0 这类风格,同一 id 跨轮复用)时,kernel 折叠后的孤儿工具半段清理只按「id 是否存在」判断,不按出现顺序,孤儿半段漏网,重建请求 400(tool_call_id is not found),会话永久死亡。
  2. 什么原因导致的:配对逻辑用集合成员判断(id ∈ seen)而非顺序+次数匹配——重复 id 把多次出现坍缩成一次,孤儿检测失效。
  3. 影响范围与严重程度:仅影响发重复 id 的宿主(DSH Agent Teams 场景);一旦触发是整会话死亡。其他宿主不受影响。
  4. 怎么修复的:双向清理改为顺序匹配——结果向前认领「最近的同 id 未答调用」,调用向后认领「最近的同 id 未配结果」,compress 调用豁免、无 id 消息不动。我手工验证了两个 pass 组合的收敛性:任何经过两遍清理的输出都满足「每个结果之前有未答的同 id 调用、每个非 compress 调用恰被一个后续结果应答」——与新账本规则 WC-018 在假上游上的校验完全同构,形成闭环。
  5. 是否有回归问题:逐项审过——PR 不碰任何 src/(请求管线/attach/端口路径零接触);golden pin 文件未动;WIRE_RULES 只增不减(master 的 WC-017 codex 规则及其交叉引用完好);与 master 新合入的 fix(#2886): make recent-zone protection pair-aware (prevent orphaned function_call) #2892(recent-zone pair-awareness)是分层互补(fix(#2886): make recent-zone protection pair-aware (prevent orphaned function_call) #2892 决定哪些工具对能活过折叠边界,本 PR 保证最终发出的没有不成对的);全部既有 kernel 测试(含 [Bug] neverPreserveRecentTools splits tool-call/result pairs at protected boundary, causing upstream 400 invalid_request_error #2886/fix(#2886): make recent-zone protection pair-aware (prevent orphaned function_call) #2892 新增的两个套件)在 rebase 后树上通过。未发现回归。
  6. 是否破坏缓存:不涉及——prefix cache、重试、增量游标逻辑均未触碰。
  7. 是否改了设计方向:不涉及——只改 prune.ts 内部实现,架构/边界/数据流不变。
  8. 是否引入了新的配置:不涉及。

行为变更披露(PR 描述已有):重复 id 输入下,重建请求现在可能丢弃以前会保留的某些工具半段——正是触发 400 的那些;良构输入输出字节级不变(no-op 测试钉住)。

测试质量:3 个新测试都有牙(test2 把修复回退成旧的 Set 逻辑即失败;test3 钉字节级恒等与顺序),assertPairsOrdered 是镜像 WC-018 的独立不变量检查器。本地定向验证:typecheck + build + wire-contract 19/19 + kernel 目标套件 55/55 全绿。

三、CI 状态:红 lane 与本 PR 无关(已取证)

本 PR 相关的所有守卫 lane(kernel-guard、lock-sync、static-gates、config-surface-guard、各 e2e 专项)全绿。6 个红的 test 分片 lane 经排查是仓库级抽风集群:

说明:我的 PAT 无权调 rerun-failed(API 返回 404)。要拿干净绿,请在 Actions 页面点 Re-run failed jobs,或等 #3188 修好后自然转绿。

四、判定

代码层面本 PR 可合;因触碰 kernel/,按 Kernel Boundary 必须人工审核后手动合并,我不代合。建议先重跑抽风 lane(或先修 #3188)拿到干净绿,再走人工 review 合并。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] prune strip guards pair tool calls by id-set, so duplicate host ids ship an unpaired half (upstream 400, session bricked)

2 participants