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:
Jesse_Chen
2026-08-25 11:46:20 +08:00
co-authored by Cursor
parent 994b34a339
commit 01ac4b9b56
4 changed files with 131 additions and 18 deletions
@@ -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;
+18 -6
View File
@@ -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 () => {
+64 -4
View File
@@ -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 () => {