Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions DUPLICATE_PREVENTION.md
Original file line number Diff line number Diff line change
@@ -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
<query>search term</query>
<_duplicate_reasoning>trying with broader context</_duplicate_reasoning>
```

### `_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
<tool name="lookup_memory">{"query": "project timeline"}</tool>

# Exact duplicate - blocked
<tool name="lookup_memory">{"query": "project timeline"}</tool>
# Result: ERROR: Duplicate tool call detected...

# With unique reasoning - allowed
<tool name="lookup_memory">{"query": "project timeline", "_duplicate_reasoning": "searching with broader context"}</tool>

# Reused reasoning - blocked
<tool name="lookup_memory">{"query": "project timeline", "_duplicate_reasoning": "searching with broader context"}</tool>
# Result: ERROR: Duplicate tool call detected for 'lookup_memory' with previously used reasoning...

# Different arguments - allowed
<tool name="lookup_memory">{"query": "project milestones"}</tool>
```

## 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
107 changes: 107 additions & 0 deletions agent_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,47 @@ 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

# 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 ---


@xai_component
class AgentRun(Component):
"""Run the agent with the given conversation.
Expand All @@ -1032,8 +1073,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:
Expand Down Expand Up @@ -1145,6 +1193,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.strip() if reasoning else None
}
self.tool_call_history.append(tool_call_record)

tool_result = None
error_message = None

Expand Down Expand Up @@ -1391,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']
Expand Down