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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user