From 8fc4189edd23dde055232cc07ea14d1d525e44ee Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sun, 16 Aug 2026 11:10:57 +0800 Subject: [PATCH] fix(agent): harden canonical tool call deduplication --- run_agent.py | 15 +++++++++--- tests/run_agent/test_agent_guardrails.py | 31 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/run_agent.py b/run_agent.py index 8326517472..4f95762d8b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4879,19 +4879,28 @@ class AIAgent: Valid JSON arguments are canonicalized so equivalent objects do not evade deduplication merely because their keys or whitespace differ. - Malformed arguments retain their raw representation rather than being - repaired here. Only the first occurrence of each unique pair is kept. + Duplicate object keys use the parser's last-value-wins semantics, + matching downstream argument parsing. Malformed or excessively nested + arguments retain their raw representation rather than being repaired + here. Only the first occurrence of each unique pair is kept. Returns the original list if no duplicates were found. """ + seen_raw: set = set() seen: set = set() unique: list = [] for tc in tool_calls: + raw_key = (tc.function.name, tc.function.arguments) + if raw_key in seen_raw: + logger.warning("Removed duplicate tool call: %s", tc.function.name) + continue + seen_raw.add(raw_key) + arguments = tc.function.arguments try: arguments = json.dumps( json.loads(arguments), separators=(",", ":"), sort_keys=True ) - except (TypeError, ValueError): + except (RecursionError, TypeError, ValueError): pass key = (tc.function.name, arguments) if key not in seen: diff --git a/tests/run_agent/test_agent_guardrails.py b/tests/run_agent/test_agent_guardrails.py index e94363089f..ec5d97b207 100644 --- a/tests/run_agent/test_agent_guardrails.py +++ b/tests/run_agent/test_agent_guardrails.py @@ -7,6 +7,7 @@ Covers three static methods on AIAgent (inspired by PR #1321 — @alireza78a): - _uniquify_tool_call_ids() — Phase 2c: duplicate-id repair (lossless pairing) """ +import json import types import pytest @@ -212,6 +213,36 @@ class TestDeduplicateToolCalls: assert out == [first, distinct] + def test_recursion_error_uses_raw_string_for_deduplication(self, monkeypatch): + def raise_recursion_error(_arguments): + raise RecursionError("maximum recursion depth exceeded") + + monkeypatch.setattr("run_agent.json.loads", raise_recursion_error) + first = make_tc("terminal", '{"command":"one"}') + duplicate = make_tc("terminal", '{"command":"one"}') + + out = AIAgent._deduplicate_tool_calls([first, duplicate]) + + assert out == [first] + + def test_byte_identical_duplicate_is_parsed_only_once(self, monkeypatch): + original_loads = json.loads + calls = 0 + + def track_loads(arguments): + nonlocal calls + calls += 1 + return original_loads(arguments) + + monkeypatch.setattr("run_agent.json.loads", track_loads) + first = make_tc("terminal", '{"command":"one"}') + duplicate = make_tc("terminal", '{"command":"one"}') + + out = AIAgent._deduplicate_tool_calls([first, duplicate]) + + assert out == [first] + assert calls == 1 +