Skip to content

Commit de78dfd

Browse files
authored
fix: sanitize NaN/Infinity values in LangGraph state before JSON serialization (CopilotKit#3851)
## Summary - Add sanitization pass over LangGraph agent state to replace NaN and Infinity with null before JSON serialization - Prevents `ValueError: Out of range float values are not JSON compliant` in Python SDK Closes CopilotKit#1955 --- *Split from CopilotKit#3847*
2 parents fe365d3 + 6d9efa0 commit de78dfd

2 files changed

Lines changed: 229 additions & 3 deletions

File tree

sdk-python/copilotkit/langgraph_agent.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import uuid
44
import json
5+
import math
56
from typing import Optional, List, Callable, Any, cast, Union, TypedDict, Literal
67

78
from langgraph.graph.state import CompiledStateGraph
@@ -47,6 +48,20 @@ def _serialize_state(state):
4748

4849
logger = get_logger(__name__)
4950

51+
52+
def _sanitize_for_json(obj):
53+
"""Replace NaN and Infinity float values with None for valid JSON serialization."""
54+
if isinstance(obj, float):
55+
if math.isnan(obj) or math.isinf(obj):
56+
return None
57+
return obj
58+
if isinstance(obj, dict):
59+
return {k: _sanitize_for_json(v) for k, v in obj.items()}
60+
if isinstance(obj, (list, tuple)):
61+
return [_sanitize_for_json(item) for item in obj]
62+
return obj
63+
64+
5065
class CopilotKitConfig(TypedDict):
5166
"""
5267
CopilotKit config for LangGraphAgent
@@ -507,7 +522,7 @@ async def _stream_events( # pylint: disable=too-many-locals
507522
active=not exiting_node
508523
) + "\n"
509524

510-
yield langchain_dumps(event) + "\n"
525+
yield langchain_dumps(_sanitize_for_json(event)) + "\n"
511526
except Exception as error:
512527
# Emit error information through streaming protocol before terminating
513528
# This preserves the semantic error details that would otherwise be lost
@@ -619,7 +634,7 @@ def _emit_state_sync_event(
619634
"agent_name": self.name,
620635
"node_name": node_name,
621636
"active": active,
622-
"state": state,
637+
"state": _sanitize_for_json(state),
623638
"running": running,
624639
"role": "assistant"
625640
})
@@ -663,7 +678,7 @@ async def get_state(
663678
return {
664679
"threadId": thread_id,
665680
"threadExists": True,
666-
"state": state_copy,
681+
"state": _sanitize_for_json(state_copy),
667682
"messages": messages
668683
}
669684

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Tests for _sanitize_for_json in langgraph_agent.py.
2+
3+
Covers the NaN/Infinity sanitization fix for issue #1955:
4+
- Valid floats, ints, strings, booleans, None pass through unchanged
5+
- NaN and +/-Infinity are replaced with None
6+
- Sanitization recurses into dicts, lists, and tuples
7+
- Nested structures with mixed valid/invalid values are handled correctly
8+
"""
9+
10+
import math
11+
12+
from copilotkit.langgraph_agent import _sanitize_for_json
13+
14+
15+
class TestSanitizePassthrough:
16+
"""Values that should pass through unchanged."""
17+
18+
def test_regular_float(self):
19+
assert _sanitize_for_json(3.14) == 3.14
20+
21+
def test_zero_float(self):
22+
assert _sanitize_for_json(0.0) == 0.0
23+
24+
def test_negative_float(self):
25+
assert _sanitize_for_json(-1.5) == -1.5
26+
27+
def test_integer(self):
28+
assert _sanitize_for_json(42) == 42
29+
30+
def test_string(self):
31+
assert _sanitize_for_json("hello") == "hello"
32+
33+
def test_boolean_true(self):
34+
assert _sanitize_for_json(True) is True
35+
36+
def test_boolean_false(self):
37+
assert _sanitize_for_json(False) is False
38+
39+
def test_none(self):
40+
assert _sanitize_for_json(None) is None
41+
42+
def test_empty_dict(self):
43+
assert _sanitize_for_json({}) == {}
44+
45+
def test_empty_list(self):
46+
assert _sanitize_for_json([]) == []
47+
48+
def test_dict_with_valid_values(self):
49+
data = {"a": 1, "b": "two", "c": 3.0, "d": None, "e": True}
50+
assert _sanitize_for_json(data) == data
51+
52+
def test_list_with_valid_values(self):
53+
data = [1, "two", 3.0, None, True]
54+
assert _sanitize_for_json(data) == data
55+
56+
57+
class TestSanitizeNanInfinity:
58+
"""NaN and Infinity values must be replaced with None."""
59+
60+
def test_nan_becomes_none(self):
61+
assert _sanitize_for_json(float("nan")) is None
62+
63+
def test_positive_infinity_becomes_none(self):
64+
assert _sanitize_for_json(float("inf")) is None
65+
66+
def test_negative_infinity_becomes_none(self):
67+
assert _sanitize_for_json(float("-inf")) is None
68+
69+
def test_math_nan_becomes_none(self):
70+
assert _sanitize_for_json(math.nan) is None
71+
72+
def test_math_inf_becomes_none(self):
73+
assert _sanitize_for_json(math.inf) is None
74+
75+
76+
class TestSanitizeNestedStructures:
77+
"""Sanitization must recurse into nested dicts and lists."""
78+
79+
def test_nan_in_dict_value(self):
80+
result = _sanitize_for_json({"score": float("nan")})
81+
assert result == {"score": None}
82+
83+
def test_inf_in_dict_value(self):
84+
result = _sanitize_for_json({"score": float("inf")})
85+
assert result == {"score": None}
86+
87+
def test_nan_in_list(self):
88+
result = _sanitize_for_json([1.0, float("nan"), 3.0])
89+
assert result == [1.0, None, 3.0]
90+
91+
def test_nan_in_tuple(self):
92+
result = _sanitize_for_json((1.0, float("nan"), 3.0))
93+
assert result == [1.0, None, 3.0]
94+
95+
def test_deeply_nested_nan(self):
96+
data = {
97+
"outer": {
98+
"inner": {
99+
"values": [1.0, float("nan"), {"deep": float("inf")}]
100+
}
101+
}
102+
}
103+
result = _sanitize_for_json(data)
104+
assert result == {
105+
"outer": {
106+
"inner": {
107+
"values": [1.0, None, {"deep": None}]
108+
}
109+
}
110+
}
111+
112+
def test_mixed_valid_and_invalid_in_dict(self):
113+
data = {
114+
"valid_int": 42,
115+
"valid_str": "hello",
116+
"nan_val": float("nan"),
117+
"inf_val": float("inf"),
118+
"neg_inf_val": float("-inf"),
119+
"valid_float": 3.14,
120+
"nested": {"also_nan": float("nan"), "ok": True},
121+
}
122+
result = _sanitize_for_json(data)
123+
assert result == {
124+
"valid_int": 42,
125+
"valid_str": "hello",
126+
"nan_val": None,
127+
"inf_val": None,
128+
"neg_inf_val": None,
129+
"valid_float": 3.14,
130+
"nested": {"also_nan": None, "ok": True},
131+
}
132+
133+
134+
class TestSanitizeEventLikeStructures:
135+
"""Simulate real LangGraph event structures to ensure they're sanitized.
136+
137+
These tests verify that the kind of data flowing through the event stream
138+
(line 507 in langgraph_agent.py) is properly sanitized before JSON serialization.
139+
Without sanitization, langchain_dumps() would raise ValueError for NaN/Infinity.
140+
"""
141+
142+
def test_event_with_nan_in_data_output(self):
143+
"""Simulates an on_chain_end event where output state contains NaN."""
144+
event = {
145+
"event": "on_chain_end",
146+
"name": "agent_node",
147+
"run_id": "abc-123",
148+
"metadata": {},
149+
"data": {
150+
"output": {
151+
"score": float("nan"),
152+
"result": "success",
153+
"confidence": float("inf"),
154+
}
155+
},
156+
}
157+
result = _sanitize_for_json(event)
158+
assert result["data"]["output"]["score"] is None
159+
assert result["data"]["output"]["confidence"] is None
160+
assert result["data"]["output"]["result"] == "success"
161+
assert result["event"] == "on_chain_end"
162+
163+
def test_event_with_nan_in_streaming_chunk(self):
164+
"""Simulates a streaming event where a chunk contains NaN values."""
165+
event = {
166+
"event": "on_chat_model_stream",
167+
"name": "model",
168+
"run_id": "def-456",
169+
"metadata": {"copilotkit:emit-intermediate-state": True},
170+
"data": {
171+
"chunk": {
172+
"content": "",
173+
"tool_call_chunks": [
174+
{
175+
"args": '{"temperature": NaN}',
176+
"score": float("nan"),
177+
}
178+
],
179+
}
180+
},
181+
}
182+
result = _sanitize_for_json(event)
183+
assert result["data"]["chunk"]["tool_call_chunks"][0]["score"] is None
184+
# String "NaN" inside args is NOT a float, so it stays as-is
185+
assert result["data"]["chunk"]["tool_call_chunks"][0]["args"] == '{"temperature": NaN}'
186+
187+
def test_event_with_no_problematic_values_unchanged(self):
188+
"""Normal events with no NaN/Infinity should pass through with same structure."""
189+
event = {
190+
"event": "on_chain_start",
191+
"name": "agent_node",
192+
"run_id": "ghi-789",
193+
"metadata": {"key": "value"},
194+
"data": {"input": {"query": "hello", "count": 5}},
195+
}
196+
result = _sanitize_for_json(event)
197+
assert result == event
198+
199+
def test_state_sync_payload_with_nan(self):
200+
"""Simulates the state dict passed to _emit_state_sync_event."""
201+
state = {
202+
"messages": [],
203+
"score": float("nan"),
204+
"embedding": [0.1, float("inf"), 0.3, float("-inf")],
205+
"metadata": {"loss": float("nan"), "accuracy": 0.95},
206+
}
207+
result = _sanitize_for_json(state)
208+
assert result["score"] is None
209+
assert result["embedding"] == [0.1, None, 0.3, None]
210+
assert result["metadata"]["loss"] is None
211+
assert result["metadata"]["accuracy"] == 0.95

0 commit comments

Comments
 (0)