From 1a7edd165090ff899946bf069c1704abbc6a9bf2 Mon Sep 17 00:00:00 2001 From: zhangqingkun976 <1047045074@QQ.COM> Date: Mon, 21 Sep 2026 14:09:11 +0800 Subject: [PATCH] fix(agent-runtime): keep tool-call ids unique in every request Anthropic-family endpoints, DeepSeek's included, reject the whole turn with `tool_use ids must be unique` when one call id appears twice, and the session cannot continue while that lasts (issue #718). The transcript is an append-only snapshot stream that tolerates a retried append, so the same call can reach the assembled context twice: under one row id (which the host's keep-last read already collapses) or under two (which it cannot). The last view before the wire now keeps the first occurrence of each `toolCall` id and drops a later call or a later result for that id, so the call/result pair the provider validates stays well-formed. A request with no duplicates is returned unchanged, and a drop is reported once on the `agent` log channel with the session and the ids. The guard sits at the request boundary (`convertToLlm`) rather than at the history rebuild, so it also covers a duplicate that appears while the session runs. Nothing is rewritten on disk; no compaction, retention or budget rule changes. C:\Users\10470\.pi-desktop\scratch\cb9e2d41-8a55-4a18-899d-92ca2791ab51\commit-718-r2.txt --- docs/spec/03-runtime/02-agent-runtime.md | 11 +++ docs/spec/06-delivery/04-e2e-test-plan.md | 26 ++++++ docs/spec/08-meta/decisions-log.md | 20 +++++ .../zh-CN/spec/03-runtime/02-agent-runtime.md | 5 ++ .../spec/06-delivery/04-e2e-test-plan.md | 15 ++++ docs/zh-CN/spec/08-meta/decisions-log.md | 11 +++ packages/agent-runtime/src/runtime.test.ts | 86 +++++++++++++++++++ packages/agent-runtime/src/runtime.ts | 81 ++++++++++++++++- 8 files changed, 254 insertions(+), 1 deletion(-) diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index eaa9d10f19..55d5c9924f 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -664,6 +664,17 @@ criterion-by-criterion report of what was met and the evidence observed. restored into durable UI messages or transcript records. - Failed assistant messages remain durable diagnostic transcript entries but are never restored into pi model context on a later turn. +- A tool-call id is unique in every request. The transcript is an append-only + snapshot stream that tolerates a retried append, so the same call can reach + the assembled context twice — under one row id, which the host's keep-last + read already collapses, or under two, which it cannot. The last view before + the wire therefore keeps the first occurrence of each `toolCall` id and drops + a later call or a later result for it, so the pair the provider validates + stays well-formed; a request with no duplicates is returned unchanged. A drop + is reported once on the `agent` log channel with the session and the ids + (D608). Anthropic-family endpoints, including DeepSeek's, reject the whole + turn with `tool_use ids must be unique` (issue #718), which leaves the session + unable to continue. - Restored checkpoints clear provider usage from retained assistant messages for budgeting. That usage measured the pre-compacted request and must not make the summary + tail appear as large as the discarded context. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 010f2ee41d..850e442f36 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -7991,6 +7991,8 @@ identify the platform validation still needed. | C — Conversation & stream (delegate context budget) | E2E-SUBAGENT-context-overflow-compacts-before-failing, E2E-SUBAGENT-context-overflow-reports-actionable-failure, E2E-SUBAGENT-resume-seeds-within-context-budget | | Quality (delegate context budget) | E2E-SUBAGENT-context-overflow-compacts-before-failing, E2E-SUBAGENT-context-overflow-reports-actionable-failure, E2E-SUBAGENT-resume-seeds-within-context-budget | | M6+ (delegate context budget) | E2E-SUBAGENT-context-overflow-compacts-before-failing, E2E-SUBAGENT-context-overflow-reports-actionable-failure, E2E-SUBAGENT-resume-seeds-within-context-budget | +| C — Conversation & stream (unique tool-call ids) | E2E-RUNTIME-unique-tool-call-ids-per-request | +| Quality (unique tool-call ids) | E2E-RUNTIME-unique-tool-call-ids-per-request | | G — Plugin host lifecycle (crash report) | E2E-PLUGIN-crash-report-names-the-exit-code | | Quality (crash report) | E2E-PLUGIN-crash-report-names-the-exit-code | @@ -14146,6 +14148,30 @@ the latest destination. These assertions measure work counts, not device FPS. success fixture uses a child-only extra CA; it does not reproduce a specific antivirus installation or claim native macOS/Linux verification. +### E2E-RUNTIME-unique-tool-call-ids-per-request + +- **Preconditions:** A session transcript that carries the same tool call twice + (a retried append leaves the call under a second row id), loaded into a + recreated runtime against a deterministic provider fixture. No real provider + credentials. +- **Steps:** Send one prompt so the runtime assembles and issues a request. Read + the outgoing request the fixture received, and the `agent` log channel. Repeat + with a transcript whose calls are already unique. +- **Expected:** The outgoing request carries exactly one `toolCall` for that id + and exactly one matching result, so no provider can answer + `tool_use ids must be unique`. The log channel gets one line naming the + session and the ids. The unique transcript's request is unchanged, byte for + byte, and logs nothing. +- **Specs:** `03-runtime/02-agent-runtime.md` §5, + `08-meta/decisions-log.md` D608. +- **Acceptance:** C (conversation and stream), Quality. +- **Milestone:** Post-MVP regression coverage. +- **Automation:** `packages/agent-runtime/src/runtime.test.ts` covers both halves + against a real runtime built from a duplicated history (drop + one log line) + and a unique one (identity, no log line). +- **Status:** Unit-covered; no end-to-end driver issues a real provider request + against a duplicated transcript. + ### E2E-MCP-HTTP-ACK — HTTP acknowledgement and authorization status - **Preconditions**: A local mock Streamable HTTP server returns JSON for diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 6886755f60..6c75083d55 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -6471,6 +6471,26 @@ that was sitting at the bottom — including after the turn had finished. state, protocol, persistence, theme schema, or permission change. See `04-ux/08-component-spec.md` and E2E-CHAT-opaque-floating-decision-and-retry-surfaces. +## 2026-09-21 — A tool-call id is unique in every request (D608, issue #718) + +- Anthropic-family endpoints, DeepSeek's included, reject a whole turn with + `tool_use ids must be unique` when the request carries a call id twice, and + the session cannot continue while that lasts. The transcript is an append-only + snapshot stream that tolerates a retried append, so the same call can reach + the assembled context twice: under one row id, which the host's keep-last read + already collapses, or under two, which it cannot. +- The last view before the wire therefore keeps the first occurrence of each + `toolCall` id and drops a later call or a later result for that id, so the + call/result pair the provider validates stays well-formed. A request with no + duplicates is returned unchanged (identity, not a copy). Nothing is rewritten + on disk and no compaction or retention rule changes. +- A drop is reported once on the `agent` log channel with the session and the + ids, so the next report of this names the writer instead of only the + provider's sentence. See `03-runtime/02-agent-runtime.md` §5. +- The guard is deliberately the request boundary rather than the history + rebuild: it also covers a duplicate that appears while the session runs, which + a rebuild-time filter cannot see. + ## 2026-09-21 — A plugin crash reports its exit code without copying raw output (D607, issue #747) - The host-process crash path reported only the plugin id, so a report read diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md index 1fd2d92e7f..a5e730765d 100644 --- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md +++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md @@ -524,6 +524,11 @@ Goal 批准所承诺的内容与 Plan 批准所承诺的内容完全相同:`mo 恢复为错误结果;辅助行丢失的工具行 获得合成的仅呼叫辅助运营商,以便 call/result 对保留 格式良好,适用于每个提供商 API。 +- 每个请求里的工具调用 id 必须唯一。转录是仅追加的快照流,容忍重试造成的重复追加,因此同一次调用可能两次进入组装后的 + 上下文——同一个行 id(宿主按 keep-last 读取时已折叠),或者两个不同的行 id(宿主无法折叠)。因此上线前的最后一个视图 + 对每个 `toolCall` id 只保留第一次出现,丢弃其后重复的调用或结果,使提供商校验的「一调用一结果」配对保持完整;没有重复的 + 请求原样返回。一旦发生丢弃,会在 `agent` 日志通道上报告一次,带上会话与 id(D608)。Anthropic 系端点(含 DeepSeek) + 会以 `tool_use ids must be unique` 拒绝整个回合(issue #718),使该会话无法继续。 - 视觉运行时只从会话绑定的附件、scratch 与项目根目录中水合持久化的图片引用。 处于 10 MB 内联安全上限之内的图片会成为临时的 pi-ai 图片块;超限或不可用的 图片则退化为安全的 `@path` 回退。超限历史的水合会直接复制文件,不会先把内容 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 767aaba189..de0c06de4c 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5285,6 +5285,8 @@ eleven-tool-round desktop paths are verified by | C — 对话和直播(委托上下文预算) | E2E-SUBAGENT-context-overflow-compacts-before-failing、E2E-SUBAGENT-context-overflow-reports-actionable-failure、E2E-SUBAGENT-resume-seeds-within-context-budget | | 品质(委托上下文预算) | E2E-SUBAGENT-context-overflow-compacts-before-failing、E2E-SUBAGENT-context-overflow-reports-actionable-failure、E2E-SUBAGENT-resume-seeds-within-context-budget | | M6+(委托上下文预算) | E2E-SUBAGENT-context-overflow-compacts-before-failing、E2E-SUBAGENT-context-overflow-reports-actionable-failure、E2E-SUBAGENT-resume-seeds-within-context-budget | +| C — 对话与流式(工具调用 id 唯一) | E2E-RUNTIME-unique-tool-call-ids-per-request | +| 品质(工具调用 id 唯一) | E2E-RUNTIME-unique-tool-call-ids-per-request | | G — 插件宿主生命周期(崩溃上报) | E2E-PLUGIN-crash-report-names-the-exit-code | | 品质(崩溃上报) | E2E-PLUGIN-crash-report-names-the-exit-code | @@ -8334,6 +8336,19 @@ the latest destination. These assertions measure work counts, not device FPS. - **规格:** 04-ux/06-settings-ia、04-ux/08-component-spec、 04-ux/09-interaction-patterns;ADR turn-process-and-thinking-display。 +### E2E-RUNTIME-unique-tool-call-ids-per-request + +- **先决条件:** 一份把同一次工具调用携带两次的会话转录(重试追加让该调用落在第二个行 id 上),在确定性提供商夹具下加载进 + 重新创建的运行时;不使用真实凭据。 +- **步骤:** 发一条提示,使运行时组装并发出请求。读取夹具收到的出站请求与 `agent` 日志通道。再用工具调用本就唯一的转录重复一次。 +- **预期:** 出站请求对那个 id 只携带一个 `toolCall` 与恰好一个对应结果,因此提供商不可能回 `tool_use ids must be unique`; + 日志通道出现一行,带会话与 id。唯一转录的请求逐字不变,且不产生任何日志。 +- **规格:** 03-runtime/02-agent-runtime §5、08-meta/decisions-log D608。**验收:** C(对话与流)、品质。 + **里程碑:** Post-MVP 回归覆盖。 +- **自动化:** `packages/agent-runtime/src/runtime.test.ts` 用真实的运行时覆盖两半:重复历史(丢弃 + 一行日志)与唯一历史 + (同一对象、无日志)。 +- **状态:** 单元测试覆盖;没有端到端驱动对重复转录发出真实提供商请求。 + ### E2E-MCP-HTTP-ACK — HTTP acknowledgement and authorization status - **Preconditions**: A local mock Streamable HTTP server returns JSON for diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 0244a9617e..02f5840084 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -4600,6 +4600,17 @@ that amendment are retired by ADR 0268; the upstream work-panel lifecycle stays. 或权限。见 `04-ux/08-component-spec.md` 与 E2E-CHAT-opaque-floating-decision-and-retry-surfaces。 +## 2026-09-21 —— 每个请求里的工具调用 id 必须唯一(D608,issue #718) + +- Anthropic 系端点(含 DeepSeek)在请求中同一个调用 id 出现两次时,会以 `tool_use ids must be unique` 拒绝整个回合, + 期间该会话无法继续。转录是仅追加的快照流、容忍重试造成的重复追加,因此同一次调用可能两次进入组装后的上下文:同一个行 id + (宿主按 keep-last 读取时已折叠)或两个不同行 id(宿主无法折叠)。 +- 因此上线前的最后一个视图对每个 `toolCall` id 只保留第一次出现,丢弃其后重复的调用或结果,使提供商校验的「一调用一结果」 + 配对保持完整;没有重复的请求原样返回(返回同一对象,而非副本)。磁盘上的内容不会被改写,压缩与保留规则也不变。 +- 一旦发生丢弃,会在 `agent` 日志通道上报告一次,带上会话与 id,使下一次同类报障能指向写入方而不只是提供商那句话。 + 见 `03-runtime/02-agent-runtime.md` §5。 +- 守卫刻意放在请求边界而不是历史重建处:这样也能覆盖**会话运行期间**产生的重复,而重建期的过滤看不到它。 + ## 2026-09-21 —— 插件崩溃上报带上退出码但不复制原始输出(D607,issue #747) - 宿主进程崩溃路径此前只报插件 id,于是报障里只有 `plugin host process exited: ` 一句话——issue #747 的报告者手里 diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index 4aaab91485..c751511ef8 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -3363,6 +3363,92 @@ describe("DesktopAgentRuntime tool history restore (D120)", () => { await runtime.dispose(); }); + it("keeps one tool call when the transcript carries the same call id twice", async () => { + // A retried append can leave the same call on two rows (the file is + // append-only), and a provider rejects the whole request with `tool_use ids + // must be unique` (issue #718). The first occurrence wins; the later row is + // dropped, because that call already has its result. + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const runtime = createRuntime({ + history: [ + { + id: "user-1", + role: "user", + content: "optimize the form", + createdAt: new Date().toISOString(), + status: "complete", + }, + { + id: "assistant-1", + role: "assistant", + content: "Let me find the render code.", + createdAt: new Date().toISOString(), + status: "complete", + }, + toolRow({ id: "tool-1", toolCallId: "call-1" }), + // The same call again, under the row id a re-persist would have left. + toolRow({ id: "tool-1-retry", toolCallId: "call-1" }), + { + id: "assistant-2", + role: "assistant", + content: "Found it.", + createdAt: new Date().toISOString(), + status: "complete", + }, + ], + }); + const context = (runtime as any).liveSessionContext().messages; + // The rebuilt context still carries both snapshots; the request view is + // where the provider's rule is enforced, because that is the last place + // both a resumed and a live-appended history pass through. + const outgoing = (runtime as any).dropDuplicateToolCalls(context); + + const toolCalls = outgoing + .filter((message: any) => message.role === "assistant") + .flatMap((message: any) => + (message.content as any[]).filter((block) => block.type === "toolCall"), + ); + expect(toolCalls.map((call: any) => call.id)).toEqual(["call-1"]); + expect( + outgoing.filter((message: any) => message.role === "toolResult"), + ).toHaveLength(1); + // The result the kept call belongs to is the one that survives. + expect( + outgoing.find((message: any) => message.role === "toolResult").toolCallId, + ).toBe("call-1"); + + const line = stderr.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(line).toContain("dropped 2 duplicate tool call entries"); + expect(line).toContain("session=session-1"); + expect(line).toContain("ids=call-1"); + stderr.mockRestore(); + + await runtime.dispose(); + }); + + it("leaves a request with unique tool calls untouched", async () => { + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const runtime = createRuntime({ + history: [ + { + id: "assistant-1", + role: "assistant", + content: "Two reads.", + createdAt: new Date().toISOString(), + status: "complete", + }, + toolRow({ id: "tool-a", toolCallId: "call-a" }), + toolRow({ id: "tool-b", toolCallId: "call-b" }), + ], + }); + const context = (runtime as any).liveSessionContext().messages; + // Identity, not a copy: the common path pays nothing and logs nothing. + expect((runtime as any).dropDuplicateToolCalls(context)).toBe(context); + expect(stderr).not.toHaveBeenCalled(); + stderr.mockRestore(); + await runtime.dispose(); + }); + it("keeps call-only assistant turns as carriers and drops truly empty ones", async () => { const runtime = createRuntime({ history: [ diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 60f6a8014f..269b4fc208 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -602,6 +602,9 @@ const AGENT_CORE_TOOL_NAMES = new Set([ const MAX_ON_DEMAND_TOOL_PROMPT_ENTRIES = 64; const MAX_TOOL_SEARCH_RESULT_NAMES = 24; +/** Ids named in the one log line a duplicate-collision rebuild writes. */ +const MAX_LOGGED_DUPLICATE_TOOL_CALLS = 8; + /** Tools that ask the host to switch this session into a contract mode (D198). */ const ENTER_TOOL_NAMES: Record = { plan: "EnterPlanMode", @@ -1897,9 +1900,12 @@ Delegation rules: // A vendor account has no long-lived key. Leaving it unset keeps pi-ai // from overriding the auth the provider just resolved for this request. getApiKey: async () => runtimeApiKey || undefined, + // The provider's rule that a tool-call id is unique is enforced here, on + // the last view before the wire: the request is the only place it can be + // guaranteed for both a rebuilt context and one that grew in this process. convertToLlm: (messages) => alignRetainedReasoningIdentity( - convertToLlm(messages), + convertToLlm(this.dropDuplicateToolCalls(messages)), this.reasoningReplayIdentity(), ), prepareNextTurnWithContext: (context, signal) => @@ -2691,6 +2697,79 @@ Delegation rules: ); } + /** + * A `tool_use` id has to be unique across the request: Anthropic-family + * endpoints (DeepSeek's included) reject the whole turn with "tool_use ids + * must be unique" (issue #718), and a session that hits that 400 cannot + * continue. Every message the next request carries passes through here, so + * this is the one place that can guarantee the provider's rule for both a + * rebuilt context and one that grew during this process. + * + * The transcript is append-only and tolerates a retried append, so the same + * call can reach the request twice: under the same row id (which the host's + * keep-last dedupe already collapses) or a new one (which it cannot). The + * first occurrence wins, and a later call *or* a later result for that id is + * dropped, so the pair the provider validates stays well-formed — one call, + * one result. What was dropped is logged with its ids, because the next + * report of this should name the writer instead of only the provider's + * sentence. + */ + private dropDuplicateToolCalls(messages: AgentMessage[]): AgentMessage[] { + const claimedCalls = new Set(); + const claimedResults = new Set(); + const droppedIds = new Set(); + let droppedCount = 0; + const note = (id: string) => { + droppedCount += 1; + if (droppedIds.size < MAX_LOGGED_DUPLICATE_TOOL_CALLS) droppedIds.add(id); + }; + const next: AgentMessage[] = []; + for (const message of messages) { + if (message.role === "assistant") { + const content = (message as AssistantMessage).content; + if (!Array.isArray(content)) { + next.push(message); + continue; + } + const kept = content.filter((block) => { + if (!isRecord(block) || block.type !== "toolCall") return true; + const id = typeof block.id === "string" ? block.id : undefined; + if (!id) return true; + if (claimedCalls.has(id)) { + note(id); + return false; + } + claimedCalls.add(id); + return true; + }); + next.push( + kept.length === content.length + ? message + : { ...(message as AssistantMessage), content: kept }, + ); + continue; + } + if (message.role === "toolResult") { + const id = (message as { toolCallId?: unknown }).toolCallId; + if (typeof id === "string" && id) { + if (claimedResults.has(id)) { + note(id); + continue; + } + claimedResults.add(id); + } + } + next.push(message); + } + if (droppedCount === 0) return messages; + process.stderr.write( + `[agent-runtime] dropped ${droppedCount} duplicate tool call ${ + droppedCount === 1 ? "entry" : "entries" + } before the request (session=${this.sessionId} ids=${[...droppedIds].join(",")})\n`, + ); + return next; + } + private entriesWithCompaction( checkpoint: ContextCompactionRecord | undefined = this.activeCompaction, ): Entry[] {