fix(web): treat duplicate rectification tool calls as idempotent
Aborting the turn on a second identical public tool-call failed staging after evidence and compare had already succeeded. Skip the duplicate receipt instead and let maxSteps bound real loops. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+19
-3
@@ -5531,6 +5531,22 @@
|
||||
- 复发自:无
|
||||
- 修复版本:未修复
|
||||
|
||||
## BUG-372 | 生时纠正同一轮重复工具调用把已成功写入打成失败
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-25
|
||||
- 最近更新:2026-08-25
|
||||
- 影响面:`POST /api/rectification/agent`、`runV9AgentTurn` 观察器、公开 NDJSON
|
||||
- 用户现象:同一轮已读盘、写入证据、跑完诊断和候选比较后,流以 `run.failed` / `repeated_tool_call` 结束,文案「本轮没有完成,状态已记录。」,没有助手叙述。公开事件里每个工具只出现一次 started/completed。
|
||||
- 触发条件:`action=message` 的自由文本经历回合;模型在 `rectification-compare-candidates`(或同类只含 caseId 的公开工具)完成后,又发出一次相同工具名与相同参数的 `tool-call`。
|
||||
- 根因:BUG-368 P0-3 把「相同工具参数」做成观察器硬上限 1,第二次相同 `toolName + args` 在发布 `tool.activity` started 之前抛错。该码不在自动重试集合,`recoverable=false`。证据与比较已经落库,只是本轮被标失败且跳过 dossier 叙述器。`rectification-set-focus` 已从 Agent 工具列表移除,这条防线不再对应真实循环。
|
||||
- 修复:相同公开工具调用视为幂等,跳过重复的 started/phase 收据,不 abort、不 `attempt.reset`。真循环仍由 `maxSteps` 与超时约束。无模型正文时走既有服务器叙述。不改已哈希 Skill `10.0.11`。
|
||||
- 验证:`frontend/tests/rectification-v9-agent.test.ts` 锁定重复 read-case 完成叙述且无 `run.failed`;`frontend/tests/rectification-v9-stream.test.ts` 锁定重复 set-focus 继续出回答、诊断后重复 compare 走叙述且无 `attempt.reset`。
|
||||
- 防复发:禁止对第二次相同公开 `tool-call` 抛 `repeated_tool_call` 或失败整轮。禁止把幂等工具调用放进自动重试。禁止用观察器 abort 代替 `maxSteps` / timeout。
|
||||
- 相关记录:BUG-368、BUG-367
|
||||
- 复发自:BUG-368(去掉模型驱动 set-focus 后,仍用 abort-on-second-identical-call 防循环,误杀正常比较重发)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-371 | staging publish 的 next build 找不到 createServerSupabaseClient
|
||||
|
||||
- 状态:resolved
|
||||
@@ -5588,10 +5604,10 @@
|
||||
- 用户现象:用户说「2020年4月开始实习、6月转正、10月离职」后,界面先刷出残缺英文(`Let me`、`_probe`、`_gain`)。`rectification-set-focus` 连续失败后整轮重跑,已成功的证据再提交一次,只落地实习、另外两条被引文拒。随后聊天里出现一条用户从未输入的「2002 年发生什么了」。
|
||||
- 触发条件:自由文本经历回合;Agent 在调用工具前输出规划文本;`set-focus` 因重复探针或零信息增益被拒;失败运行的推荐问题被写进历史。
|
||||
- 根因:三组独立回归。(1) 禁止 `thinking.delta` 后,把每个 step 的 `text-delta` 都公开成 `answer.delta`,工具前规划变成用户正文;再对碎片做英文过滤,句子被剪成残片。(2) `compare-candidates` 已算出下一问,仍让模型自己调 `set-focus`;确定性校验失败后 `attempt.reset` 重放已成功的 Evidence 写入。(3) 未完成运行的 suggestion / 角色映射把「2002 年发生什么了」写成 user 消息。2002 不是引擎从 2020 算出来的。
|
||||
- 修复:按 step 缓冲,只发布无工具且 `stop`/`length` 的终端文本;`reasoning-delta` 不下发。Agent 工具列表去掉 `set-focus`,由 compare/read-case 在服务端持久化 `open_question`。`duplicate_focus` 等域错误不整轮重试,相同工具参数最多一次。用户消息记录 `origin`/`clientActionId`/`content_hash`。Evidence quote 用源消息 offset,按条返回 created/already_exists/quote_mismatch。推荐问题只在 `run.completed` 后解析,纠正 UI 仍无 suggestion chip。不改已哈希 Skill `10.0.11`。
|
||||
- 修复:按 step 缓冲,只发布无工具且 `stop`/`length` 的终端文本;`reasoning-delta` 不下发。Agent 工具列表去掉 `set-focus`,由 compare/read-case 在服务端持久化 `open_question`。`duplicate_focus` 等域错误不整轮重试。同一流里相同公开工具参数视为幂等,不得 abort 整轮;循环由 `maxSteps` / timeout 约束。用户消息记录 `origin`/`clientActionId`/`content_hash`。Evidence quote 用源消息 offset,按条返回 created/already_exists/quote_mismatch。推荐问题只在 `run.completed` 后解析,纠正 UI 仍无 suggestion chip。不改已哈希 Skill `10.0.11`。
|
||||
- 验证:`frontend/tests/rectification-step-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-server-focus.test.ts`、`frontend/tests/rectification-evidence-quote.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/rectification-v10-tool-contract.test.ts`。
|
||||
- 防复发:禁止把含工具调用的 step 的 `text-delta` 发给浏览器。禁止把 `thinking.delta` 改名为 `answer.delta`。禁止模型驱动 `set-focus`。禁止对 `duplicate_focus` / `quote_mismatch` / `zero_information_gain` 做 `attempt.reset`。禁止未完成运行解析或自动提交 suggestion。禁止模型改写 Evidence quote。
|
||||
- 相关记录:BUG-345、BUG-354、BUG-357、BUG-367
|
||||
- 防复发:禁止把含工具调用的 step 的 `text-delta` 发给浏览器。禁止把 `thinking.delta` 改名为 `answer.delta`。禁止模型驱动 `set-focus`。禁止对 `duplicate_focus` / `quote_mismatch` / `zero_information_gain` 做 `attempt.reset`。禁止对第二次相同公开工具调用 abort 整轮(见 BUG-372)。禁止未完成运行解析或自动提交 suggestion。禁止模型改写 Evidence quote。
|
||||
- 相关记录:BUG-345、BUG-354、BUG-357、BUG-367、BUG-372
|
||||
- 复发自:BUG-367(关掉公开 `thinking.delta` 后,中间 step 的 `text-delta` 被整段当成回答)
|
||||
- 修复版本:fe87a9ecdb71eae4eb79664664cc20c534177c19
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ type AttemptOutcome = Readonly<{
|
||||
attemptId: string;
|
||||
}>;
|
||||
|
||||
const REPEATED_TOOL_CALL_LIMIT = 1;
|
||||
const MAX_ATTEMPTS = 2;
|
||||
const RETRYABLE_ERROR_CODES = new Set([
|
||||
"stream_aborted",
|
||||
@@ -137,6 +136,22 @@ function first(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity for a public tool-call chunk. Mastra may put the model input on
|
||||
* `args`, `input`, or omit it; missing input collapses to `{}` so a second
|
||||
* call of the same tool name still looks identical.
|
||||
*/
|
||||
function publicToolCallKey(toolName: string, payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return `${toolName}:{}`;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const args = record.args ?? record.input ?? record.toolArgs ?? {};
|
||||
try {
|
||||
return `${toolName}:${JSON.stringify(args)}`;
|
||||
} catch {
|
||||
return `${toolName}:{}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function rpcOf(
|
||||
accounting: RectificationRpcClient,
|
||||
fn: string,
|
||||
@@ -622,6 +637,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|
||||
for await (const chunk of result.fullStream) {
|
||||
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
|
||||
// Identical public tool-call + args are idempotent. Throwing
|
||||
// `repeated_tool_call` (BUG-368 P0-3) aborted the turn after
|
||||
// evidence / diagnostics / compare had already committed, because
|
||||
// the model often re-issued compare with the same caseId. Bound
|
||||
// loops with maxSteps / timeout instead; do not attempt.reset.
|
||||
let skipDuplicateToolCallReceipt = false;
|
||||
if (chunk.type === "tool-call") {
|
||||
if (rawToolName === "rectification-read-case" && !skillBound) {
|
||||
throw new Error("skill_not_bound");
|
||||
@@ -630,10 +651,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
throw new Error("case_not_loaded");
|
||||
}
|
||||
if (isPublicRectificationToolName(rawToolName)) {
|
||||
const key = `${rawToolName}:${JSON.stringify(chunk.payload?.args ?? {})}`;
|
||||
const key = publicToolCallKey(rawToolName, chunk.payload);
|
||||
const count = (repeatedCalls.get(key) ?? 0) + 1;
|
||||
repeatedCalls.set(key, count);
|
||||
if (count > REPEATED_TOOL_CALL_LIMIT) throw new Error("repeated_tool_call");
|
||||
skipDuplicateToolCallReceipt = count > 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,7 +665,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
);
|
||||
if (stepEffect.kind === "publish") await publishSpokenStep(stepEffect.pieces);
|
||||
|
||||
const activityEvent = mapStreamChunkToActivity(chunk as never);
|
||||
const activityEvent = skipDuplicateToolCallReceipt
|
||||
? null
|
||||
: mapStreamChunkToActivity(chunk as never);
|
||||
if (activityEvent) {
|
||||
await publish(activityEvent);
|
||||
if (activityEvent.status === "started") {
|
||||
@@ -658,7 +681,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
|
||||
}
|
||||
}
|
||||
const phaseEvent = mapStreamChunkToPhase(chunk as never);
|
||||
const phaseEvent = skipDuplicateToolCallReceipt
|
||||
? null
|
||||
: mapStreamChunkToPhase(chunk as never);
|
||||
if (phaseEvent) {
|
||||
if (phaseEvent.type === "skill.bound" && !skillBound) {
|
||||
skillBound = true;
|
||||
|
||||
@@ -409,20 +409,32 @@ test("distinct evidence calls in one natural turn are not mistaken for a repeate
|
||||
]);
|
||||
});
|
||||
|
||||
test("a repeated identical tool call is detected and aborts the turn", async () => {
|
||||
const { options, billing } = runOptions({
|
||||
test("a repeated identical tool call is treated as idempotent and does not abort", async () => {
|
||||
const { options, emitted, billing } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||
chunk("tool-result", { toolName: "skill" }),
|
||||
...Array.from({ length: 2 }, () => chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } })),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "repeated_tool_call");
|
||||
assert.equal(billing.released, 1);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.match(result.answerText, /已经记下|请继续说下一件/);
|
||||
assert.equal(billing.completed, 1);
|
||||
assert.equal(billing.released, 0);
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.equal(
|
||||
emitted.filter((event) => event.type === "tool.activity"
|
||||
&& (event as { tool?: string; status?: string }).tool === "rectification-read-case"
|
||||
&& (event as { tool?: string; status?: string }).status === "started").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("a failed opening does not let the next turn skip the server Skill load gate", async () => {
|
||||
|
||||
@@ -1050,7 +1050,11 @@ test("does not retry set-focus with identical arguments", async () => {
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
|
||||
finalize_agentic_rectification_turn: (_fn, args) => ({
|
||||
turn_id: TURN_ID,
|
||||
status: args.p_status,
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const { options, emitted, billing } = runOptions({
|
||||
accounting: accounting.client,
|
||||
@@ -1071,10 +1075,66 @@ test("does not retry set-focus with identical arguments", async () => {
|
||||
|
||||
const result = await runV9AgentTurn(options);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "repeated_tool_call");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.equal(result.answerText, "主问题:请确认这段经历发生在哪个月?");
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
});
|
||||
|
||||
test("duplicate compare after diagnostics still completes with server narration", async () => {
|
||||
const executedMethods = [
|
||||
"ashtakavarga",
|
||||
"d1-rashi",
|
||||
"d10-dashamsa",
|
||||
"shadbala",
|
||||
"functional-benefic-malefic",
|
||||
"arudha-pada",
|
||||
];
|
||||
const { options, emitted, billing } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||
chunk("tool-result", { toolName: "skill" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
|
||||
chunk("tool-call", { toolName: "rectification-propose-evidence", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-propose-evidence" }),
|
||||
chunk("tool-call", { toolName: "rectification-confirm-evidence", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-confirm-evidence" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-diagnostics", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", {
|
||||
toolName: "rectification-read-diagnostics",
|
||||
result: { executed_methods: executedMethods },
|
||||
}),
|
||||
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", {
|
||||
toolName: "rectification-compare-candidates",
|
||||
result: { executed_methods: executedMethods },
|
||||
}),
|
||||
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
|
||||
const result = await runV9AgentTurn(options);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.match(result.answerText, /已经记下|请继续说下一件/);
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.equal(
|
||||
emitted.filter((event) => event.type === "tool.activity"
|
||||
&& (event as { tool?: string; status?: string }).tool === "rectification-compare-candidates"
|
||||
&& (event as { tool?: string; status?: string }).status === "started").length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
});
|
||||
|
||||
test("an unclaimed V10 attempt never starts the model", async () => {
|
||||
|
||||
Reference in New Issue
Block a user