Skip to content

Commit 4cefc1c

Browse files
Zain Dana Harperclaude
andcommitted
Keep ToolCall hashable when output/inputs hold unhashable objects (#2815)
ToolCorrectnessMetric places ToolCall instances into a set during component-level evaluation. ToolCall.__hash__ routes input_parameters and output through _make_hashable, whose final branch returned arbitrary objects unchanged, assuming they were primitive hashable types. When the output (or a value nested in input_parameters) is an unhashable object, such as a LangChain ToolMessage or a pydantic model that defines __eq__ without __hash__, hashing raised `TypeError: unhashable type`, breaking evals_iterator() on agent traces. Fall back to a stable repr() for any value that is not hashable. Primitives and already-hashable objects are returned unchanged. Adds a regression test covering an unhashable object both as the output and nested in input_parameters. Closes #2815 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8ebfa33 commit 4cefc1c

2 files changed

Lines changed: 47 additions & 1 deletion

File tree

deepeval/test_case/llm_test_case.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,16 @@ def _make_hashable(obj):
229229
# Handle frozenset that might contain unhashable elements
230230
return frozenset(_make_hashable(item) for item in obj)
231231
else:
232-
# For primitive hashable types (str, int, float, bool, etc.)
232+
# Primitive hashable types (str, int, float, bool, etc.) pass through
233+
# unchanged. Some objects reach here while being unhashable, e.g. a
234+
# LangChain ToolMessage or a pydantic model that defines __eq__ without
235+
# __hash__. Returning those as-is makes ToolCall.__hash__ raise
236+
# ``TypeError: unhashable type`` (#2815), so fall back to a stable
237+
# string representation for anything that is not hashable.
238+
try:
239+
hash(obj)
240+
except TypeError:
241+
return repr(obj)
233242
return obj
234243

235244

tests/test_core/test_test_case/test_single_turn.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,43 @@ def test_tool_call_hashing_with_complex_types(self):
450450
hash_value = hash(tool_call)
451451
assert isinstance(hash_value, int)
452452

453+
def test_tool_call_hashing_with_unhashable_types(self):
454+
"""Regression test for #2815.
455+
456+
ToolCall must stay hashable even when its output (or a value nested in
457+
input_parameters) is an arbitrary unhashable object, such as a
458+
LangChain ToolMessage or a pydantic model that defines __eq__ without
459+
__hash__. ToolCorrectnessMetric puts ToolCall instances into a set, so a
460+
crash here breaks component-level evaluation of agent traces.
461+
"""
462+
463+
class _Unhashable:
464+
# Mirrors objects that define __eq__ without __hash__, which Python
465+
# then makes unhashable.
466+
__hash__ = None
467+
468+
def __init__(self, value):
469+
self.value = value
470+
471+
def __repr__(self):
472+
return f"_Unhashable({self.value!r})"
473+
474+
# Sanity check: the helper object really is unhashable.
475+
with pytest.raises(TypeError):
476+
hash(_Unhashable("x"))
477+
478+
# Unhashable object as the output.
479+
tool_output = ToolCall(name="tool", output=_Unhashable("x"))
480+
assert isinstance(hash(tool_output), int)
481+
assert len({tool_output}) == 1 # usable in a set
482+
483+
# Unhashable object nested inside input_parameters.
484+
tool_input = ToolCall(
485+
name="tool", input_parameters={"arg": _Unhashable("y")}
486+
)
487+
assert isinstance(hash(tool_input), int)
488+
assert len({tool_input}) == 1
489+
453490
def test_tool_call_repr(self):
454491
tool_call = ToolCall(
455492
name="test_tool",

0 commit comments

Comments
 (0)