Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,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.
Expand Down
26 changes: 26 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -7957,6 +7957,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 |

The `US-UI-*` visual scenarios (§UI shell visual scenarios) trace to the
Codex parity decisions in [decisions-log §D](../08-meta/decisions-log.md)
Expand Down Expand Up @@ -14085,6 +14087,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
Expand Down
20 changes: 20 additions & 0 deletions docs/spec/08-meta/decisions-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -6470,3 +6470,23 @@ that was sitting at the bottom — including after the turn had finished.
- Renderer CSS and the theme surface regression change only. There is no scroll
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.
5 changes: 5 additions & 0 deletions docs/zh-CN/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,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` 回退。超限历史的水合会直接复制文件,不会先把内容
Expand Down
15 changes: 15 additions & 0 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -5269,6 +5269,8 @@ IPC 请求无法关闭。
| 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 |

`US-UI-*` 视觉场景(§UI shell 视觉场景)追踪到
[决策日志 §D](/zh-CN/spec/08-meta/decisions-log) 中的法典平价决策
Expand Down Expand Up @@ -8316,6 +8318,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
Expand Down
11 changes: 11 additions & 0 deletions docs/zh-CN/spec/08-meta/decisions-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -4599,3 +4599,14 @@ that amendment are retired by ADR 0268; the upstream work-panel lifecycle stays.
- 仅改动渲染器 CSS 与主题表面回归测试;不改变滚动状态、协议、持久化、主题 schema
或权限。见 `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。
- 守卫刻意放在请求边界而不是历史重建处:这样也能覆盖**会话运行期间**产生的重复,而重建期的过滤看不到它。
86 changes: 86 additions & 0 deletions packages/agent-runtime/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
81 changes: 80 additions & 1 deletion packages/agent-runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProposalKind, string> = {
plan: "EnterPlanMode",
Expand Down Expand Up @@ -1900,9 +1903,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) =>
Expand Down Expand Up @@ -2694,6 +2700,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<string>();
const claimedResults = new Set<string>();
const droppedIds = new Set<string>();
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[] {
Expand Down