Skip to content

Commit 179b683

Browse files
committed
fix: correct _interrupted state management in async API
- _setup_run() now resets _interrupted=False — the canonical place for per-run resets, shared by run()/arun()/run_stream() - Removed try/except from arun(): _setup_run() already handles the reset - Removed try/except from aresume()/areject(): clearing _interrupted on a stream crash was wrong — the LangGraph checkpointer still holds the pending interrupt, so _interrupted=True is correct and lets the user retry - Renamed _post_stream_result() param final_state -> last_values to avoid confusion with LangGraph's StateSnapshot - Deduplicated _interrupted_agent() test helper into make_interrupted_agent() - Updated test comment for test_arun_propagates_stream_error - Added test_aresume_allows_retry_after_stream_error - Added test_areject_allows_retry_after_stream_error
1 parent 4977ceb commit 179b683

2 files changed

Lines changed: 97 additions & 75 deletions

File tree

BaseAgent/base_agent.py

Lines changed: 40 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,11 +1179,14 @@ def _select_skills_for_prompt(self, prompt: str) -> None:
11791179
def _setup_run(self, prompt: str, thread_id: str | None) -> tuple[dict, dict]:
11801180
"""Reset per-run counters and build the LangGraph inputs/config dicts.
11811181
1182-
Shared by ``run()``, ``arun()``, and ``run_stream()``. Does *not*
1183-
reset ``self.log`` — callers that maintain a log do so explicitly.
1182+
Shared by ``run()``, ``arun()``, and ``run_stream()``. Resets
1183+
``_interrupted`` because any prior interrupt is irrelevant to a new run.
1184+
Does *not* reset ``self.log`` — ``resume()``/``reject()`` append to the
1185+
existing log rather than starting fresh, so they manage it themselves.
11841186
"""
11851187
self.critic_count = 0
11861188
self.user_task = prompt
1189+
self._interrupted = False
11871190
# Reset per-run counters; mark the start index in usage_metrics for
11881191
# per-run cost budget checks (existing metrics are preserved for
11891192
# lifetime cost tracking via agent._usage_metrics)
@@ -1204,15 +1207,15 @@ def _setup_run(self, prompt: str, thread_id: str | None) -> tuple[dict, dict]:
12041207
self._run_config = config
12051208
return inputs, config
12061209

1207-
def _post_stream_result(self, graph_state, final_state, last_msg):
1210+
def _post_stream_result(self, graph_state, last_values, last_msg):
12081211
"""Set conversation state, detect interrupt, and return ``(log, content)``.
12091212
12101213
Shared by ``run()``, ``arun()``, ``resume()``, ``aresume()``,
12111214
``reject()``, and ``areject()``. The caller fetches ``graph_state``
12121215
via ``get_state()`` (sync) or ``await aget_state()`` (async) and
12131216
passes it in, keeping this helper synchronous.
12141217
"""
1215-
self._conversation_state = final_state
1218+
self._conversation_state = last_values
12161219
for task in (graph_state.tasks or []):
12171220
if hasattr(task, "interrupts") and task.interrupts:
12181221
self._interrupted = True
@@ -1242,13 +1245,13 @@ def run(self, prompt: str, thread_id: str | None = None):
12421245
inputs, config = self._setup_run(prompt, thread_id)
12431246
self.log = []
12441247
last_msg = None
1245-
final_state = None
1248+
last_values = None
12461249
for state in self.app.stream(inputs, stream_mode="values", config=config):
12471250
last_msg = state["input"][-1]
12481251
self.log.append(pretty_print(last_msg))
1249-
final_state = state
1252+
last_values = state
12501253
graph_state = self.app.get_state(config)
1251-
return self._post_stream_result(graph_state, final_state, last_msg)
1254+
return self._post_stream_result(graph_state, last_values, last_msg)
12521255

12531256
def resume(self):
12541257
"""Approve the pending code block and continue execution.
@@ -1268,13 +1271,13 @@ def resume(self):
12681271

12691272
config = self._run_config
12701273
last_msg = None
1271-
final_state = None
1274+
last_values = None
12721275
for state in self.app.stream(Command(resume=True), stream_mode="values", config=config):
12731276
last_msg = state["input"][-1]
12741277
self.log.append(pretty_print(last_msg))
1275-
final_state = state
1278+
last_values = state
12761279
graph_state = self.app.get_state(config)
1277-
return self._post_stream_result(graph_state, final_state, last_msg)
1280+
return self._post_stream_result(graph_state, last_values, last_msg)
12781281

12791282
def reject(self, feedback: str = "User rejected this code. Try a different approach."):
12801283
"""Reject the pending code block and provide feedback to the agent.
@@ -1298,17 +1301,17 @@ def reject(self, feedback: str = "User rejected this code. Try a different appro
12981301

12991302
config = self._run_config
13001303
last_msg = None
1301-
final_state = None
1304+
last_values = None
13021305
for state in self.app.stream(
13031306
Command(resume={"approved": False, "feedback": feedback}),
13041307
stream_mode="values",
13051308
config=config,
13061309
):
13071310
last_msg = state["input"][-1]
13081311
self.log.append(pretty_print(last_msg))
1309-
final_state = state
1312+
last_values = state
13101313
graph_state = self.app.get_state(config)
1311-
return self._post_stream_result(graph_state, final_state, last_msg)
1314+
return self._post_stream_result(graph_state, last_values, last_msg)
13121315

13131316
# ------------------------------------------------------------------
13141317
# Async public API
@@ -1332,17 +1335,13 @@ async def arun(self, prompt: str, thread_id: str | None = None):
13321335
inputs, config = self._setup_run(prompt, thread_id)
13331336
self.log = []
13341337
last_msg = None
1335-
final_state = None
1336-
try:
1337-
async for state in self.app.astream(inputs, stream_mode="values", config=config):
1338-
last_msg = state["input"][-1]
1339-
self.log.append(pretty_print(last_msg))
1340-
final_state = state
1341-
except Exception:
1342-
self._interrupted = False # prevent stale interrupt state on error
1343-
raise
1338+
last_values = None
1339+
async for state in self.app.astream(inputs, stream_mode="values", config=config):
1340+
last_msg = state["input"][-1]
1341+
self.log.append(pretty_print(last_msg))
1342+
last_values = state
13441343
graph_state = await self.app.aget_state(config)
1345-
return self._post_stream_result(graph_state, final_state, last_msg)
1344+
return self._post_stream_result(graph_state, last_values, last_msg)
13461345

13471346
async def aresume(self):
13481347
"""Async equivalent of :meth:`resume`.
@@ -1358,19 +1357,15 @@ async def aresume(self):
13581357

13591358
config = self._run_config
13601359
last_msg = None
1361-
final_state = None
1362-
try:
1363-
async for state in self.app.astream(
1364-
Command(resume=True), stream_mode="values", config=config
1365-
):
1366-
last_msg = state["input"][-1]
1367-
self.log.append(pretty_print(last_msg))
1368-
final_state = state
1369-
except Exception:
1370-
self._interrupted = False
1371-
raise
1360+
last_values = None
1361+
async for state in self.app.astream(
1362+
Command(resume=True), stream_mode="values", config=config
1363+
):
1364+
last_msg = state["input"][-1]
1365+
self.log.append(pretty_print(last_msg))
1366+
last_values = state
13721367
graph_state = await self.app.aget_state(config)
1373-
return self._post_stream_result(graph_state, final_state, last_msg)
1368+
return self._post_stream_result(graph_state, last_values, last_msg)
13741369

13751370
async def areject(self, feedback: str = "User rejected this code. Try a different approach."):
13761371
"""Async equivalent of :meth:`reject`.
@@ -1389,21 +1384,17 @@ async def areject(self, feedback: str = "User rejected this code. Try a differen
13891384

13901385
config = self._run_config
13911386
last_msg = None
1392-
final_state = None
1393-
try:
1394-
async for state in self.app.astream(
1395-
Command(resume={"approved": False, "feedback": feedback}),
1396-
stream_mode="values",
1397-
config=config,
1398-
):
1399-
last_msg = state["input"][-1]
1400-
self.log.append(pretty_print(last_msg))
1401-
final_state = state
1402-
except Exception:
1403-
self._interrupted = False
1404-
raise
1387+
last_values = None
1388+
async for state in self.app.astream(
1389+
Command(resume={"approved": False, "feedback": feedback}),
1390+
stream_mode="values",
1391+
config=config,
1392+
):
1393+
last_msg = state["input"][-1]
1394+
self.log.append(pretty_print(last_msg))
1395+
last_values = state
14051396
graph_state = await self.app.aget_state(config)
1406-
return self._post_stream_result(graph_state, final_state, last_msg)
1397+
return self._post_stream_result(graph_state, last_values, last_msg)
14071398

14081399
async def run_stream(
14091400
self,

BaseAgent/tests/test_async_api.py

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ def make_agent():
3636
return agent
3737

3838

39+
def make_interrupted_agent():
40+
"""Return a BaseAgent in an interrupted state, ready for aresume()/areject()."""
41+
agent = make_agent()
42+
agent.app = MagicMock()
43+
agent.app.astream = MagicMock(return_value=_async_iter([_make_state()]))
44+
agent.app.aget_state = AsyncMock(return_value=_no_interrupt_graph_state())
45+
agent._interrupted = True
46+
agent._run_config = {"configurable": {"thread_id": "t1"}}
47+
agent.log = [] # normally set by arun(); set here for isolation
48+
return agent
49+
50+
3951
def _make_state(content: str = "<solution>done</solution>"):
4052
"""One LangGraph state snapshot with a single AIMessage."""
4153
return {"input": [AIMessage(content=content)], "next_step": "end"}
@@ -144,7 +156,8 @@ async def _failing_stream(*args, **kwargs):
144156
with pytest.raises(RuntimeError, match="stream exploded"):
145157
asyncio.run(agent.arun("test task"))
146158

147-
# stale interrupt must be cleared on error
159+
# _setup_run() cleared _interrupted before the stream started, so it
160+
# stays False even though the stream raised before _post_stream_result ran
148161
assert agent._interrupted is False
149162

150163
def test_arun_calls_astream_not_stream(self):
@@ -167,18 +180,8 @@ def test_arun_calls_astream_not_stream(self):
167180

168181
@pytest.mark.unit
169182
class TestAresume:
170-
def _interrupted_agent(self):
171-
agent = make_agent()
172-
agent.app = MagicMock()
173-
agent.app.astream = MagicMock(return_value=_async_iter([_make_state()]))
174-
agent.app.aget_state = AsyncMock(return_value=_no_interrupt_graph_state())
175-
agent._interrupted = True
176-
agent._run_config = {"configurable": {"thread_id": "t1"}}
177-
agent.log = [] # normally set by arun(); set here for isolation
178-
return agent
179-
180183
def test_aresume_returns_result(self):
181-
agent = self._interrupted_agent()
184+
agent = make_interrupted_agent()
182185

183186
log, content = asyncio.run(agent.aresume())
184187

@@ -194,14 +197,33 @@ def test_aresume_raises_if_not_interrupted(self):
194197
asyncio.run(agent.aresume())
195198

196199
def test_aresume_calls_astream_not_stream(self):
197-
agent = self._interrupted_agent()
200+
agent = make_interrupted_agent()
198201
agent.app.stream = MagicMock()
199202

200203
asyncio.run(agent.aresume())
201204

202205
agent.app.astream.assert_called_once()
203206
agent.app.stream.assert_not_called()
204207

208+
def test_aresume_allows_retry_after_stream_error(self):
209+
"""A stream crash in aresume() must not clear _interrupted.
210+
211+
The LangGraph checkpointer still holds the pending interrupt, so the
212+
user should be able to call aresume() again to retry.
213+
"""
214+
agent = make_interrupted_agent()
215+
216+
async def _failing_stream(*args, **kwargs):
217+
yield _make_state()
218+
raise RuntimeError("transient error")
219+
220+
agent.app.astream = MagicMock(return_value=_failing_stream())
221+
222+
with pytest.raises(RuntimeError, match="transient error"):
223+
asyncio.run(agent.aresume())
224+
225+
assert agent._interrupted is True # still retryable
226+
205227

206228
# ---------------------------------------------------------------------------
207229
# areject() tests
@@ -210,18 +232,8 @@ def test_aresume_calls_astream_not_stream(self):
210232

211233
@pytest.mark.unit
212234
class TestAreject:
213-
def _interrupted_agent(self):
214-
agent = make_agent()
215-
agent.app = MagicMock()
216-
agent.app.astream = MagicMock(return_value=_async_iter([_make_state()]))
217-
agent.app.aget_state = AsyncMock(return_value=_no_interrupt_graph_state())
218-
agent._interrupted = True
219-
agent._run_config = {"configurable": {"thread_id": "t1"}}
220-
agent.log = [] # normally set by arun(); set here for isolation
221-
return agent
222-
223235
def test_areject_returns_result(self):
224-
agent = self._interrupted_agent()
236+
agent = make_interrupted_agent()
225237

226238
log, content = asyncio.run(agent.areject("try again"))
227239

@@ -236,7 +248,7 @@ def test_areject_raises_if_not_interrupted(self):
236248
asyncio.run(agent.areject())
237249

238250
def test_areject_passes_correct_command(self):
239-
agent = self._interrupted_agent()
251+
agent = make_interrupted_agent()
240252

241253
asyncio.run(agent.areject("do it differently"))
242254

@@ -246,14 +258,33 @@ def test_areject_passes_correct_command(self):
246258
assert command.resume == {"approved": False, "feedback": "do it differently"}
247259

248260
def test_areject_calls_astream_not_stream(self):
249-
agent = self._interrupted_agent()
261+
agent = make_interrupted_agent()
250262
agent.app.stream = MagicMock()
251263

252264
asyncio.run(agent.areject("nope"))
253265

254266
agent.app.astream.assert_called_once()
255267
agent.app.stream.assert_not_called()
256268

269+
def test_areject_allows_retry_after_stream_error(self):
270+
"""A stream crash in areject() must not clear _interrupted.
271+
272+
The LangGraph checkpointer still holds the pending interrupt, so the
273+
user should be able to call areject() again to retry.
274+
"""
275+
agent = make_interrupted_agent()
276+
277+
async def _failing_stream(*args, **kwargs):
278+
yield _make_state()
279+
raise RuntimeError("transient error")
280+
281+
agent.app.astream = MagicMock(return_value=_failing_stream())
282+
283+
with pytest.raises(RuntimeError, match="transient error"):
284+
asyncio.run(agent.areject("nope"))
285+
286+
assert agent._interrupted is True # still retryable
287+
257288

258289
# ---------------------------------------------------------------------------
259290
# Async generator helper

0 commit comments

Comments
 (0)