From 6fa073242edc937ef02b84960223ab62a6f6f77e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:00:38 +0000 Subject: [PATCH 1/4] Initial plan From 6ef60f85661426182a2104d50c95f3099b2d9bc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:08:25 +0000 Subject: [PATCH 2/4] Implement duplicate tool call prevention with reasoning support Co-authored-by: wmeddie <122480+wmeddie@users.noreply.github.com> --- agent_components.py | 98 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/agent_components.py b/agent_components.py index 947e273..9ab34d6 100644 --- a/agent_components.py +++ b/agent_components.py @@ -1008,6 +1008,45 @@ def _dispatch_llm_call(ctx, provider, model_name, conversation, temperature): # --- End Standalone LLM Call Functions --- +# --- Duplicate Tool Call Prevention Functions --- + +def normalize_tool_args(args_dict): + """Remove _nonce and _duplicate_reasoning from args for comparison""" + if args_dict is None: + return None + normalized = args_dict.copy() + normalized.pop('_nonce', None) + normalized.pop('_duplicate_reasoning', None) + return normalized + +def extract_nonce_and_reasoning(args_dict): + """Extract _nonce and _duplicate_reasoning from parsed args""" + if args_dict is None: + return None, None + return args_dict.get('_nonce'), args_dict.get('_duplicate_reasoning') + +def is_duplicate_tool_call(tool_name, args_dict, tool_history): + """Check if this tool call is a duplicate of a previous call""" + normalized_args = normalize_tool_args(args_dict) + + for prev_call in tool_history: + if (prev_call['tool_name'] == tool_name and + prev_call['normalized_args'] == normalized_args): + return True + return False + +def is_reasoning_unique(reasoning, tool_history): + """Check if the reasoning is unique within the current tool history""" + if reasoning is None or reasoning.strip() == "": + return False + + used_reasoning = [call.get('reasoning') for call in tool_history + if call.get('reasoning') is not None] + return reasoning not in used_reasoning + +# --- End Duplicate Tool Call Prevention Functions --- + + @xai_component class AgentRun(Component): """Run the agent with the given conversation. @@ -1032,8 +1071,15 @@ class AgentRun(Component): out_conversation: OutArg[list] last_response: OutArg[str] + + def __init__(self): + super().__init__() + # Track tool calls within this AgentRun instance to prevent duplicates + self.tool_call_history = [] def execute(self, ctx) -> None: + # Reset tool call history for this execution + self.tool_call_history = [] try: self.do_execute(ctx) except Exception as e: @@ -1145,6 +1191,58 @@ def handle_tool_use(self, ctx, agent, conversation, content, standard_toolbelt, pre_tool_text = content[:match.start()].strip() conversation[-1]['content'] = pre_tool_text + # Parse tool arguments to check for duplicates + parsed_args, _ = parse_tool_args(tool_args_str) + nonce, reasoning = extract_nonce_and_reasoning(parsed_args) + + # Check for duplicate tool calls + if is_duplicate_tool_call(tool_name, parsed_args, self.tool_call_history): + if reasoning is None or reasoning.strip() == "": + # Duplicate call without reasoning - block it + error_message = f"Duplicate tool call detected for '{tool_name}' with same arguments. To retry this tool, provide '_duplicate_reasoning' parameter with unique justification." + print(f"Blocked duplicate tool call: {tool_name} with args: {normalize_tool_args(parsed_args)}") + + if is_openai_model(model_name): + conversation.append({"role": "system", "content": f"ERROR: {error_message}"}) + else: + conversation.append({"role": "user", "content": f"SYSTEM:\nERROR: {error_message}"}) + + # Give on_thought a chance to see the error + self.out_conversation.value = conversation + if hasattr(self, 'on_thought') and self.on_thought: + SubGraphExecutor(self.on_thought).do(ctx) + + return min(stress_level + 0.1, 1.5) # Increase stress due to blocked duplicate + + elif not is_reasoning_unique(reasoning, self.tool_call_history): + # Duplicate call with non-unique reasoning - block it + error_message = f"Duplicate tool call detected for '{tool_name}' with previously used reasoning: '{reasoning}'. Please provide unique justification." + print(f"Blocked duplicate tool call with reused reasoning: {tool_name}") + + if is_openai_model(model_name): + conversation.append({"role": "system", "content": f"ERROR: {error_message}"}) + else: + conversation.append({"role": "user", "content": f"SYSTEM:\nERROR: {error_message}"}) + + # Give on_thought a chance to see the error + self.out_conversation.value = conversation + if hasattr(self, 'on_thought') and self.on_thought: + SubGraphExecutor(self.on_thought).do(ctx) + + return min(stress_level + 0.1, 1.5) # Increase stress due to blocked duplicate + + else: + # Duplicate call with unique reasoning - allow it + print(f"Allowing duplicate tool call '{tool_name}' with unique reasoning: '{reasoning}'") + + # Record this tool call in history (before execution in case of errors) + tool_call_record = { + 'tool_name': tool_name, + 'normalized_args': normalize_tool_args(parsed_args), + 'reasoning': reasoning + } + self.tool_call_history.append(tool_call_record) + tool_result = None error_message = None From 82f011b7573158d3f57538aac4b4815b12393648 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:13:37 +0000 Subject: [PATCH 3/4] Fix whitespace handling in reasoning and add AgentLearn support Co-authored-by: wmeddie <122480+wmeddie@users.noreply.github.com> --- agent_components.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/agent_components.py b/agent_components.py index 9ab34d6..ff4b962 100644 --- a/agent_components.py +++ b/agent_components.py @@ -1040,9 +1040,11 @@ def is_reasoning_unique(reasoning, tool_history): if reasoning is None or reasoning.strip() == "": return False - used_reasoning = [call.get('reasoning') for call in tool_history - if call.get('reasoning') is not None] - return reasoning not in used_reasoning + # Normalize reasoning by stripping whitespace for comparison + normalized_reasoning = reasoning.strip() + used_reasoning = [call.get('reasoning', '').strip() for call in tool_history + if call.get('reasoning') is not None and call.get('reasoning').strip() != ""] + return normalized_reasoning not in used_reasoning # --- End Duplicate Tool Call Prevention Functions --- @@ -1239,7 +1241,7 @@ def handle_tool_use(self, ctx, agent, conversation, content, standard_toolbelt, tool_call_record = { 'tool_name': tool_name, 'normalized_args': normalize_tool_args(parsed_args), - 'reasoning': reasoning + 'reasoning': reasoning.strip() if reasoning else None } self.tool_call_history.append(tool_call_record) @@ -1489,8 +1491,15 @@ class AgentLearn(Component): out_conversation: OutArg[list] last_response: OutArg[str] + + def __init__(self): + super().__init__() + # Track tool calls within this AgentLearn instance to prevent duplicates + self.tool_call_history = [] def execute(self, ctx) -> None: + # Reset tool call history for this execution + self.tool_call_history = [] agent = ctx['agent_' + self.agent_name.value] model_name = agent['agent_model'] From 40034137f8701a7a5ab8b51da2a94b6db94bdf1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:15:02 +0000 Subject: [PATCH 4/4] Add comprehensive documentation for duplicate tool call prevention Co-authored-by: wmeddie <122480+wmeddie@users.noreply.github.com> --- DUPLICATE_PREVENTION.md | 89 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 DUPLICATE_PREVENTION.md diff --git a/DUPLICATE_PREVENTION.md b/DUPLICATE_PREVENTION.md new file mode 100644 index 0000000..0d64720 --- /dev/null +++ b/DUPLICATE_PREVENTION.md @@ -0,0 +1,89 @@ +# Duplicate Tool Call Prevention + +This feature prevents agents from calling the same tool multiple times in a row with identical inputs, which is a common failure mode in frontier models like GPT-5 and Gemini-Flash-2.5. + +## How It Works + +The system tracks tool calls within each `AgentRun` execution and blocks duplicates based on: +- Tool name +- Normalized arguments (excluding special parameters) + +## Special Parameters + +### `_duplicate_reasoning` +Allows agents to justify why they need to call the same tool again with the same arguments. + +**JSON Format:** +```json +{ + "query": "search term", + "_duplicate_reasoning": "trying with broader context" +} +``` + +**XML Format:** +```xml +search term +<_duplicate_reasoning>trying with broader context +``` + +### `_nonce` +Reserved for future use. Currently ignored in duplicate detection but preserved in tool arguments. + +## Behavior + +### ✅ Allowed Cases +1. **First call** - Any tool call is allowed initially +2. **Different tool** - Calling a different tool is always allowed +3. **Different arguments** - Same tool with different parameters is allowed +4. **Unique reasoning** - Duplicate call with unique `_duplicate_reasoning` + +### ❌ Blocked Cases +1. **Exact duplicate** - Same tool + same arguments without reasoning +2. **With nonce only** - Same tool + same arguments + `_nonce` but no reasoning +3. **Reused reasoning** - Same tool + same arguments + previously used reasoning + +## Examples + +```python +# First call - allowed +{"query": "project timeline"} + +# Exact duplicate - blocked +{"query": "project timeline"} +# Result: ERROR: Duplicate tool call detected... + +# With unique reasoning - allowed +{"query": "project timeline", "_duplicate_reasoning": "searching with broader context"} + +# Reused reasoning - blocked +{"query": "project timeline", "_duplicate_reasoning": "searching with broader context"} +# Result: ERROR: Duplicate tool call detected for 'lookup_memory' with previously used reasoning... + +# Different arguments - allowed +{"query": "project milestones"} +``` + +## Error Messages + +When duplicates are blocked, clear error messages guide the agent: + +- **No reasoning:** "Duplicate tool call detected for 'TOOL_NAME' with same arguments. To retry this tool, provide '_duplicate_reasoning' parameter with unique justification." + +- **Reused reasoning:** "Duplicate tool call detected for 'TOOL_NAME' with previously used reasoning: 'REASONING'. Please provide unique justification." + +## Implementation Details + +- Tracking scope: Within single `AgentRun` execution (resets each run) +- Argument normalization: Removes `_nonce` and `_duplicate_reasoning` for comparison +- Reasoning comparison: Whitespace normalized to prevent trivial bypasses +- Stress level: Increases when duplicates are blocked to encourage different behavior +- Format support: Both JSON and XML argument formats +- Components: Works with both `AgentRun` and `AgentLearn` + +## Benefits + +🎯 **Prevents infinite loops** - Stops frontier models from getting stuck +🧠 **Encourages thoughtful usage** - Agents must justify repeat calls +🔄 **Allows justified retries** - Supports legitimate retry scenarios +📝 **Provides clear feedback** - Error messages guide better behavior \ No newline at end of file