Skip to content
Open
2 changes: 2 additions & 0 deletions changelogs/fragments/10743.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Pass top 5 rows as default context and improve system prompt for ppl accuracy ([#10743](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10743))
6 changes: 6 additions & 0 deletions packages/osd-agents/src/agents/langgraph/react_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export interface ReactAgentState {
threadId?: string; // Store thread identifier
runId?: string; // Store run identifier
modelId?: string; // Store model identifier from forwardedProps for dynamic model selection
// Duplicate detection
duplicateCallCount?: number; // Counter for consecutive duplicate tool calls
recentToolSignatures?: string[]; // Signatures of recent tool calls for duplicate detection
}

/**
Expand Down Expand Up @@ -197,6 +200,9 @@ export class ReactAgent implements BaseAgent {
threadId: additionalInputs?.threadId,
runId: additionalInputs?.runId,
modelId: additionalInputs?.modelId,
// Initialize duplicate detection
duplicateCallCount: 0,
recentToolSignatures: [],
};

// Run the graph - unique config per request for stateless operation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ const ReactStateAnnotation = Annotation.Root({
reducer: (x, y) => y || x,
default: () => undefined,
}),
duplicateCallCount: Annotation<number>({
reducer: (x, y) => y,
default: () => 0,
}),
recentToolSignatures: Annotation<string[]>({
reducer: (x, y) => y || x,
default: () => [],
}),
});

// Type for the state inferred from annotation
Expand Down
18 changes: 17 additions & 1 deletion packages/osd-agents/src/agents/langgraph/react_graph_nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export class ReactGraphNodes {
clientContext,
threadId,
runId,
recentToolSignatures,
} = state;

// Safety check: reject tool execution at max iterations
Expand Down Expand Up @@ -365,7 +366,8 @@ export class ReactGraphNodes {
context: clientContext,
threadId,
runId,
}
},
recentToolSignatures || []
);

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

Expand All @@ -385,6 +388,7 @@ export class ReactGraphNodes {
toolCalls: [],
currentStep: 'executeTools',
shouldContinue: false,
recentToolSignatures: result.updatedSignatures || recentToolSignatures || [],
};
}

Expand All @@ -397,6 +401,17 @@ export class ReactGraphNodes {
agent_type: 'react',
});

// Emit metric if duplicate was detected
if (result.duplicateDetected) {
metricsEmitter.emitCounter('react_agent_duplicate_tool_calls_detected_total', 1, {
agent_type: 'react',
});
this.logger.warn('Duplicate tool call detected and handled', {
toolCalls: toolCalls.map((tc) => tc.toolName),
iterations: newIterations,
});
}

// Note: The assistant message with toolUse blocks is already in messages from callModelNode
// We only need to add the toolResult message
return {
Expand All @@ -407,6 +422,7 @@ export class ReactGraphNodes {
iterations: newIterations, // Set the new iterations count
shouldContinue: true, // Keep this true to allow the graph to decide
lastToolExecution: Date.now(), // Track when tools were last executed
recentToolSignatures: result.updatedSignatures || recentToolSignatures || [],
};
}

Expand Down
169 changes: 168 additions & 1 deletion packages/osd-agents/src/agents/langgraph/tool_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,53 @@ export class ToolExecutor {
};
}

/**
* Generate a stable signature for a tool call based on its name and parameters
* Used for semantic duplicate detection
*/
private generateToolCallSignature(toolName: string, input: any): string {
try {
// Create a stable string representation by sorting keys
const sortedInput = JSON.stringify(input, Object.keys(input || {}).sort());
return `${toolName}::${sortedInput}`;
} catch (error) {
this.logger.error('Failed to generate tool call signature', {
error: error instanceof Error ? error.message : String(error),
toolName,
});
// Fallback to basic signature
return `${toolName}::${JSON.stringify(input)}`;
}
}

/**
* Check if a tool call is a duplicate of recent calls
* Returns the count of consecutive duplicates (0 if not a duplicate)
*/
private checkForDuplicate(
toolCall: any,
recentSignatures: string[]
): { isDuplicate: boolean; consecutiveCount: number } {
const newSignature = this.generateToolCallSignature(toolCall.toolName, toolCall.input);

// Check if the most recent signature matches (consecutive duplicate)
if (recentSignatures.length > 0 && recentSignatures[0] === newSignature) {
// Count how many times this same signature appears consecutively
let consecutiveCount = 0;
for (const sig of recentSignatures) {
if (sig === newSignature) {
consecutiveCount++;
} else {
break; // Stop at first non-matching signature
}
}

return { isDuplicate: true, consecutiveCount };
}

return { isDuplicate: false, consecutiveCount: 0 };
}

/**
* Parse tool calls from XML format (fallback for when Bedrock doesn't recognize tools)
*/
Expand Down Expand Up @@ -153,21 +200,120 @@ export class ToolExecutor {
context?: any[];
threadId?: string;
runId?: string;
}
},
recentToolSignatures?: string[]
): Promise<{
toolResults: Record<string, any>;
toolResultMessage?: any;
shouldContinue: boolean;
isClientTools: boolean;
updatedSignatures?: string[];
duplicateDetected?: boolean;
}> {
const toolResults: Record<string, any> = {};
const signatures = recentToolSignatures || [];

this.logger.info('Executing tools', {
toolCallsCount: toolCalls.length,
toolNames: toolCalls.map((tc) => tc.toolName),
toolIds: toolCalls.map((tc) => tc.toolUseId),
recentSignaturesCount: signatures.length,
});

// Check for duplicates in the first tool call
// Only check the first one since that's what matters for the loop detection
if (toolCalls.length > 0 && signatures.length > 0) {
const firstToolCall = toolCalls[0];
const duplicateCheck = this.checkForDuplicate(firstToolCall, signatures);

if (duplicateCheck.isDuplicate) {
const duplicateCount = duplicateCheck.consecutiveCount + 1; // +1 for this call
this.logger.warn('Duplicate tool call detected', {
toolName: firstToolCall.toolName,
consecutiveCount: duplicateCount,
toolUseId: firstToolCall.toolUseId,
});

// If we've hit the threshold (3 consecutive duplicates), warn the LLM
if (duplicateCount >= 3) {
this.logger.warn('Duplicate threshold reached - injecting warning to LLM', {
duplicateCount,
toolName: firstToolCall.toolName,
});

// Emit Prometheus metric
const metricsEmitter = getPrometheusMetricsEmitter();
metricsEmitter.emitCounter('react_agent_duplicate_tool_calls_total', 1, {
agent_type: 'react',
tool_name: firstToolCall.toolName,
});

// Create a warning message for the LLM
const warningResult = {
duplicate_detected: true,
consecutive_count: duplicateCount,
message: `⚠️ You have called the tool "${firstToolCall.toolName}" with identical parameters ${duplicateCount} times in a row. The results are not changing.`,
suggestion:
'Please synthesize the information from your previous tool calls to provide a final answer, or try using different parameters if you need additional information.',
note:
'Repeatedly calling the same tool with the same parameters will not yield different results.',
};

toolResults[firstToolCall.toolUseId] = warningResult;

// Still create the tool result message for proper conversation flow
const toolResultMessage = {
role: 'user' as const,
content: [
{
toolResult: {
toolUseId: firstToolCall.toolUseId,
content: [{ text: JSON.stringify(warningResult, null, 2) }],
},
},
],
};

// Update signatures with this duplicate
const newSignature = this.generateToolCallSignature(
firstToolCall.toolName,
firstToolCall.input
);
const updatedSignatures = [newSignature, ...signatures].slice(0, 10); // Keep last 10

// Emergency stop if we've hit 10 consecutive duplicates
if (duplicateCount >= 10) {
this.logger.error('Emergency stop: 10+ consecutive duplicate tool calls', {
toolName: firstToolCall.toolName,
});

streamingCallbacks?.onError?.(
'The agent appears stuck in a loop. Ending conversation for safety.'
);

return {
toolResults,
toolResultMessage,
shouldContinue: false, // Force stop
isClientTools: false,
updatedSignatures,
duplicateDetected: true,
};
}

// Return with warning but keep conversation alive
return {
toolResults,
toolResultMessage,
shouldContinue: true, // Let LLM handle it
isClientTools: false,
updatedSignatures,
duplicateDetected: true,
};
}
}
}

// Check if we've already executed these exact tool calls to prevent duplicates
const toolCallSignatures = toolCalls.map((tc) => tc.toolUseId);

Expand Down Expand Up @@ -198,6 +344,8 @@ export class ToolExecutor {
toolResults: {},
shouldContinue: false,
isClientTools: false,
updatedSignatures: signatures,
duplicateDetected: false,
};
}

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

// For client tools, we need to return an empty tool result message
// This signals the completion of this request, client will send results in next request

// Update signatures even for client tools
const newSignatures = clientToolCalls.map((tc) =>
this.generateToolCallSignature(tc.toolName, tc.input)
);
const updatedSignatures = [...newSignatures, ...signatures].slice(0, 10);

return {
toolResults: {},
shouldContinue: false, // Stop here for client execution
isClientTools: true,
updatedSignatures,
duplicateDetected: false,
};
}

Expand Down Expand Up @@ -324,6 +481,8 @@ export class ToolExecutor {
toolResults: {},
shouldContinue: false,
isClientTools: false,
updatedSignatures: signatures,
duplicateDetected: false,
};
}

Expand All @@ -332,11 +491,19 @@ export class ToolExecutor {
content: toolResultContent,
};

// Update signatures with executed tool calls
const newSignatures = mcpToolCalls.map((tc) =>
this.generateToolCallSignature(tc.toolName, tc.input)
);
const updatedSignatures = [...newSignatures, ...signatures].slice(0, 10); // Keep last 10

return {
toolResults,
toolResultMessage,
shouldContinue: true,
isClientTools: false,
updatedSignatures,
duplicateDetected: false,
};
}

Expand Down
Loading
Loading