Skip to content

Commit 0afe13e

Browse files
committed
feat: Implement duplicate detection for tool calls in ReactAgent and ToolExecutor
Signed-off-by: Megha Goyal <[email protected]>
1 parent 346d246 commit 0afe13e

File tree

5 files changed

+330
-13
lines changed

5 files changed

+330
-13
lines changed

packages/osd-agents/src/agents/langgraph/react_agent.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export interface ReactAgentState {
4040
threadId?: string; // Store thread identifier
4141
runId?: string; // Store run identifier
4242
modelId?: string; // Store model identifier from forwardedProps for dynamic model selection
43+
// Duplicate detection
44+
duplicateCallCount?: number; // Counter for consecutive duplicate tool calls
45+
recentToolSignatures?: string[]; // Signatures of recent tool calls for duplicate detection
4346
}
4447

4548
/**
@@ -197,6 +200,9 @@ export class ReactAgent implements BaseAgent {
197200
threadId: additionalInputs?.threadId,
198201
runId: additionalInputs?.runId,
199202
modelId: additionalInputs?.modelId,
203+
// Initialize duplicate detection
204+
duplicateCallCount: 0,
205+
recentToolSignatures: [],
200206
};
201207

202208
// Run the graph - unique config per request for stateless operation

packages/osd-agents/src/agents/langgraph/react_graph_builder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ const ReactStateAnnotation = Annotation.Root({
7272
reducer: (x, y) => y || x,
7373
default: () => undefined,
7474
}),
75+
duplicateCallCount: Annotation<number>({
76+
reducer: (x, y) => y,
77+
default: () => 0,
78+
}),
79+
recentToolSignatures: Annotation<string[]>({
80+
reducer: (x, y) => y || x,
81+
default: () => [],
82+
}),
7583
});
7684

7785
// Type for the state inferred from annotation

packages/osd-agents/src/agents/langgraph/react_graph_nodes.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ export class ReactGraphNodes {
319319
clientContext,
320320
threadId,
321321
runId,
322+
recentToolSignatures,
322323
} = state;
323324

324325
// Safety check: reject tool execution at max iterations
@@ -365,7 +366,8 @@ export class ReactGraphNodes {
365366
context: clientContext,
366367
threadId,
367368
runId,
368-
}
369+
},
370+
recentToolSignatures || []
369371
);
370372

371373
if (result.isClientTools) {
@@ -377,6 +379,7 @@ export class ReactGraphNodes {
377379
currentStep: 'executeTools',
378380
shouldContinue: false, // Stop here for client execution
379381
iterations: state.iterations, // Don't increment for client tools
382+
recentToolSignatures: result.updatedSignatures || recentToolSignatures || [],
380383
};
381384
}
382385

@@ -385,6 +388,7 @@ export class ReactGraphNodes {
385388
toolCalls: [],
386389
currentStep: 'executeTools',
387390
shouldContinue: false,
391+
recentToolSignatures: result.updatedSignatures || recentToolSignatures || [],
388392
};
389393
}
390394

@@ -397,6 +401,17 @@ export class ReactGraphNodes {
397401
agent_type: 'react',
398402
});
399403

404+
// Emit metric if duplicate was detected
405+
if (result.duplicateDetected) {
406+
metricsEmitter.emitCounter('react_agent_duplicate_tool_calls_detected_total', 1, {
407+
agent_type: 'react',
408+
});
409+
this.logger.warn('Duplicate tool call detected and handled', {
410+
toolCalls: toolCalls.map((tc) => tc.toolName),
411+
iterations: newIterations,
412+
});
413+
}
414+
400415
// Note: The assistant message with toolUse blocks is already in messages from callModelNode
401416
// We only need to add the toolResult message
402417
return {
@@ -407,6 +422,7 @@ export class ReactGraphNodes {
407422
iterations: newIterations, // Set the new iterations count
408423
shouldContinue: true, // Keep this true to allow the graph to decide
409424
lastToolExecution: Date.now(), // Track when tools were last executed
425+
recentToolSignatures: result.updatedSignatures || recentToolSignatures || [],
410426
};
411427
}
412428

packages/osd-agents/src/agents/langgraph/tool_executor.ts

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,53 @@ export class ToolExecutor {
8484
};
8585
}
8686

87+
/**
88+
* Generate a stable signature for a tool call based on its name and parameters
89+
* Used for semantic duplicate detection
90+
*/
91+
private generateToolCallSignature(toolName: string, input: any): string {
92+
try {
93+
// Create a stable string representation by sorting keys
94+
const sortedInput = JSON.stringify(input, Object.keys(input || {}).sort());
95+
return `${toolName}::${sortedInput}`;
96+
} catch (error) {
97+
this.logger.error('Failed to generate tool call signature', {
98+
error: error instanceof Error ? error.message : String(error),
99+
toolName,
100+
});
101+
// Fallback to basic signature
102+
return `${toolName}::${JSON.stringify(input)}`;
103+
}
104+
}
105+
106+
/**
107+
* Check if a tool call is a duplicate of recent calls
108+
* Returns the count of consecutive duplicates (0 if not a duplicate)
109+
*/
110+
private checkForDuplicate(
111+
toolCall: any,
112+
recentSignatures: string[]
113+
): { isDuplicate: boolean; consecutiveCount: number } {
114+
const newSignature = this.generateToolCallSignature(toolCall.toolName, toolCall.input);
115+
116+
// Check if the most recent signature matches (consecutive duplicate)
117+
if (recentSignatures.length > 0 && recentSignatures[0] === newSignature) {
118+
// Count how many times this same signature appears consecutively
119+
let consecutiveCount = 0;
120+
for (const sig of recentSignatures) {
121+
if (sig === newSignature) {
122+
consecutiveCount++;
123+
} else {
124+
break; // Stop at first non-matching signature
125+
}
126+
}
127+
128+
return { isDuplicate: true, consecutiveCount };
129+
}
130+
131+
return { isDuplicate: false, consecutiveCount: 0 };
132+
}
133+
87134
/**
88135
* Parse tool calls from XML format (fallback for when Bedrock doesn't recognize tools)
89136
*/
@@ -153,21 +200,120 @@ export class ToolExecutor {
153200
context?: any[];
154201
threadId?: string;
155202
runId?: string;
156-
}
203+
},
204+
recentToolSignatures?: string[]
157205
): Promise<{
158206
toolResults: Record<string, any>;
159207
toolResultMessage?: any;
160208
shouldContinue: boolean;
161209
isClientTools: boolean;
210+
updatedSignatures?: string[];
211+
duplicateDetected?: boolean;
162212
}> {
163213
const toolResults: Record<string, any> = {};
214+
const signatures = recentToolSignatures || [];
164215

165216
this.logger.info('Executing tools', {
166217
toolCallsCount: toolCalls.length,
167218
toolNames: toolCalls.map((tc) => tc.toolName),
168219
toolIds: toolCalls.map((tc) => tc.toolUseId),
220+
recentSignaturesCount: signatures.length,
169221
});
170222

223+
// Check for duplicates in the first tool call
224+
// Only check the first one since that's what matters for the loop detection
225+
if (toolCalls.length > 0 && signatures.length > 0) {
226+
const firstToolCall = toolCalls[0];
227+
const duplicateCheck = this.checkForDuplicate(firstToolCall, signatures);
228+
229+
if (duplicateCheck.isDuplicate) {
230+
const duplicateCount = duplicateCheck.consecutiveCount + 1; // +1 for this call
231+
this.logger.warn('Duplicate tool call detected', {
232+
toolName: firstToolCall.toolName,
233+
consecutiveCount: duplicateCount,
234+
toolUseId: firstToolCall.toolUseId,
235+
});
236+
237+
// If we've hit the threshold (3 consecutive duplicates), warn the LLM
238+
if (duplicateCount >= 3) {
239+
this.logger.warn('Duplicate threshold reached - injecting warning to LLM', {
240+
duplicateCount,
241+
toolName: firstToolCall.toolName,
242+
});
243+
244+
// Emit Prometheus metric
245+
const metricsEmitter = getPrometheusMetricsEmitter();
246+
metricsEmitter.emitCounter('react_agent_duplicate_tool_calls_total', 1, {
247+
agent_type: 'react',
248+
tool_name: firstToolCall.toolName,
249+
});
250+
251+
// Create a warning message for the LLM
252+
const warningResult = {
253+
duplicate_detected: true,
254+
consecutive_count: duplicateCount,
255+
message: `⚠️ You have called the tool "${firstToolCall.toolName}" with identical parameters ${duplicateCount} times in a row. The results are not changing.`,
256+
suggestion:
257+
'Please synthesize the information from your previous tool calls to provide a final answer, or try using different parameters if you need additional information.',
258+
note:
259+
'Repeatedly calling the same tool with the same parameters will not yield different results.',
260+
};
261+
262+
toolResults[firstToolCall.toolUseId] = warningResult;
263+
264+
// Still create the tool result message for proper conversation flow
265+
const toolResultMessage = {
266+
role: 'user' as const,
267+
content: [
268+
{
269+
toolResult: {
270+
toolUseId: firstToolCall.toolUseId,
271+
content: [{ text: JSON.stringify(warningResult, null, 2) }],
272+
},
273+
},
274+
],
275+
};
276+
277+
// Update signatures with this duplicate
278+
const newSignature = this.generateToolCallSignature(
279+
firstToolCall.toolName,
280+
firstToolCall.input
281+
);
282+
const updatedSignatures = [newSignature, ...signatures].slice(0, 10); // Keep last 10
283+
284+
// Emergency stop if we've hit 10 consecutive duplicates
285+
if (duplicateCount >= 10) {
286+
this.logger.error('Emergency stop: 10+ consecutive duplicate tool calls', {
287+
toolName: firstToolCall.toolName,
288+
});
289+
290+
streamingCallbacks?.onError?.(
291+
'The agent appears stuck in a loop. Ending conversation for safety.'
292+
);
293+
294+
return {
295+
toolResults,
296+
toolResultMessage,
297+
shouldContinue: false, // Force stop
298+
isClientTools: false,
299+
updatedSignatures,
300+
duplicateDetected: true,
301+
};
302+
}
303+
304+
// Return with warning but keep conversation alive
305+
return {
306+
toolResults,
307+
toolResultMessage,
308+
shouldContinue: true, // Let LLM handle it
309+
isClientTools: false,
310+
updatedSignatures,
311+
duplicateDetected: true,
312+
};
313+
}
314+
}
315+
}
316+
171317
// Check if we've already executed these exact tool calls to prevent duplicates
172318
const toolCallSignatures = toolCalls.map((tc) => tc.toolUseId);
173319

@@ -198,6 +344,8 @@ export class ToolExecutor {
198344
toolResults: {},
199345
shouldContinue: false,
200346
isClientTools: false,
347+
updatedSignatures: signatures,
348+
duplicateDetected: false,
201349
};
202350
}
203351

@@ -223,10 +371,19 @@ export class ToolExecutor {
223371

224372
// For client tools, we need to return an empty tool result message
225373
// This signals the completion of this request, client will send results in next request
374+
375+
// Update signatures even for client tools
376+
const newSignatures = clientToolCalls.map((tc) =>
377+
this.generateToolCallSignature(tc.toolName, tc.input)
378+
);
379+
const updatedSignatures = [...newSignatures, ...signatures].slice(0, 10);
380+
226381
return {
227382
toolResults: {},
228383
shouldContinue: false, // Stop here for client execution
229384
isClientTools: true,
385+
updatedSignatures,
386+
duplicateDetected: false,
230387
};
231388
}
232389

@@ -324,6 +481,8 @@ export class ToolExecutor {
324481
toolResults: {},
325482
shouldContinue: false,
326483
isClientTools: false,
484+
updatedSignatures: signatures,
485+
duplicateDetected: false,
327486
};
328487
}
329488

@@ -332,11 +491,19 @@ export class ToolExecutor {
332491
content: toolResultContent,
333492
};
334493

494+
// Update signatures with executed tool calls
495+
const newSignatures = mcpToolCalls.map((tc) =>
496+
this.generateToolCallSignature(tc.toolName, tc.input)
497+
);
498+
const updatedSignatures = [...newSignatures, ...signatures].slice(0, 10); // Keep last 10
499+
335500
return {
336501
toolResults,
337502
toolResultMessage,
338503
shouldContinue: true,
339504
isClientTools: false,
505+
updatedSignatures,
506+
duplicateDetected: false,
340507
};
341508
}
342509

0 commit comments

Comments
 (0)