Skip to content

Commit dc4b402

Browse files
CopilotTomeHirata
andcommitted
Fix ContextWindowExceededError after 3 retries in react loop
When _call_with_potential_trajectory_truncation exhausts all retry attempts, it now raises a clear ValueError instead of returning None. This prevents the AttributeError: 'NoneType' object has no attribute 'next_thought' that occurred when accessing properties on the None return value. The ValueError is caught in forward/aforward and causes the loop to break gracefully, allowing the extract phase to proceed with whatever trajectory was collected. Added tests for both sync and async versions of this scenario. Co-authored-by: TomeHirata <[email protected]>
1 parent 2cc00aa commit dc4b402

File tree

2 files changed

+82
-2
lines changed

2 files changed

+82
-2
lines changed

dspy/predict/react.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,16 @@ def _call_with_potential_trajectory_truncation(self, module, trajectory, **input
152152
)
153153
except ContextWindowExceededError:
154154
logger.warning("Trajectory exceeded the context window, truncating the oldest tool call information.")
155-
trajectory = self.truncate_trajectory(trajectory)
155+
try:
156+
trajectory = self.truncate_trajectory(trajectory)
157+
except ValueError:
158+
# Cannot truncate further, re-raise as a clear error
159+
raise ValueError(
160+
"The context window was exceeded and the trajectory could not be truncated further."
161+
)
162+
raise ValueError(
163+
"The context window was exceeded after 3 attempts to truncate the trajectory."
164+
)
156165

157166
async def _async_call_with_potential_trajectory_truncation(self, module, trajectory, **input_args):
158167
for _ in range(3):
@@ -163,7 +172,16 @@ async def _async_call_with_potential_trajectory_truncation(self, module, traject
163172
)
164173
except ContextWindowExceededError:
165174
logger.warning("Trajectory exceeded the context window, truncating the oldest tool call information.")
166-
trajectory = self.truncate_trajectory(trajectory)
175+
try:
176+
trajectory = self.truncate_trajectory(trajectory)
177+
except ValueError:
178+
# Cannot truncate further, re-raise as a clear error
179+
raise ValueError(
180+
"The context window was exceeded and the trajectory could not be truncated further."
181+
)
182+
raise ValueError(
183+
"The context window was exceeded after 3 attempts to truncate the trajectory."
184+
)
167185

168186
def truncate_trajectory(self, trajectory):
169187
"""Truncates the trajectory so that it fits in the context window.

tests/predict/test_react.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,68 @@ def mock_react(**kwargs):
204204
assert result.output_text == "Final output"
205205

206206

207+
def test_context_window_exceeded_after_retries():
208+
"""Test that context window errors are handled gracefully after exhausting retry attempts.
209+
210+
This tests the fix for the bug where returning None after exhausting retries
211+
caused "'NoneType' object has no attribute 'next_thought'" error.
212+
213+
The fix raises a ValueError instead, which is caught in forward() and causes
214+
the loop to break gracefully.
215+
"""
216+
217+
def echo(text: str) -> str:
218+
return f"Echoed: {text}"
219+
220+
react = dspy.ReAct("input_text -> output_text", tools=[echo])
221+
222+
# Always raise context window exceeded - simulating case where prompt is too large
223+
# even on the very first call with empty trajectory
224+
def mock_react(**kwargs):
225+
raise litellm.ContextWindowExceededError("Context window exceeded", "dummy_model", "dummy_provider")
226+
227+
react.react = mock_react
228+
react.extract = lambda **kwargs: dspy.Prediction(output_text="Fallback output")
229+
230+
# Call forward - should handle the error gracefully by logging and breaking the loop
231+
# This should NOT raise AttributeError: 'NoneType' object has no attribute 'next_thought'
232+
result = react(input_text="test input")
233+
234+
# The trajectory should be empty since the first call failed
235+
assert result.trajectory == {}
236+
# Extract should still be called and produce output
237+
assert result.output_text == "Fallback output"
238+
239+
240+
@pytest.mark.asyncio
241+
async def test_async_context_window_exceeded_after_retries():
242+
"""Test that context window errors are handled gracefully after exhausting retry attempts in async mode."""
243+
244+
async def echo(text: str) -> str:
245+
return f"Echoed: {text}"
246+
247+
react = dspy.ReAct("input_text -> output_text", tools=[echo])
248+
249+
# Always raise context window exceeded
250+
async def mock_react(**kwargs):
251+
raise litellm.ContextWindowExceededError("Context window exceeded", "dummy_model", "dummy_provider")
252+
253+
async def mock_extract(**kwargs):
254+
return dspy.Prediction(output_text="Fallback output")
255+
256+
react.react.acall = mock_react
257+
react.extract.acall = mock_extract
258+
259+
# Call forward - should handle the error gracefully
260+
# This should NOT raise AttributeError: 'NoneType' object has no attribute 'next_thought'
261+
result = await react.acall(input_text="test input")
262+
263+
# The trajectory should be empty since the first call failed
264+
assert result.trajectory == {}
265+
# Extract should still be called and produce output
266+
assert result.output_text == "Fallback output"
267+
268+
207269
def test_error_retry():
208270
# --- a tiny tool that always fails -------------------------------------
209271
def foo(a, b):

0 commit comments

Comments
 (0)