Skip to content

Commit 0dd312c

Browse files
Handle sessions out of limit (#338)
* handle sessions out of limit * fix stream proxy crash on client disconnect
1 parent 0a37f75 commit 0dd312c

4 files changed

Lines changed: 246 additions & 82 deletions

File tree

lagent/adapters/proxy.py

Lines changed: 150 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,11 @@ async def _handle_request(self, request: web.Request) -> web.Response:
167167
proxy=self.http_proxy,
168168
) as resp:
169169
if is_stream:
170-
# Stream response: collect chunks, forward as-is
170+
# Stream response: collect chunks, forward as-is.
171+
# If the downstream client disconnects mid-stream (e.g. an
172+
# AsyncAPIClient bails out on a finish_reason=='error' chunk
173+
# after the prompt overflowed the session), keep draining
174+
# the upstream so the trace is still recorded in full.
171175
response_chunks = []
172176
response = web.StreamResponse(
173177
status=resp.status,
@@ -178,10 +182,19 @@ async def _handle_request(self, request: web.Request) -> web.Response:
178182
},
179183
)
180184
await response.prepare(request)
185+
client_alive = True
181186
async for chunk in resp.content.iter_any():
182187
response_chunks.append(chunk)
183-
await response.write(chunk)
184-
await response.write_eof()
188+
if client_alive:
189+
try:
190+
await response.write(chunk)
191+
except (ConnectionError, aiohttp.ClientConnectionResetError):
192+
client_alive = False
193+
if client_alive:
194+
try:
195+
await response.write_eof()
196+
except (ConnectionError, aiohttp.ClientConnectionResetError):
197+
pass
185198
raw_response = b''.join(response_chunks)
186199
else:
187200
raw_response = await resp.read()
@@ -206,15 +219,15 @@ async def _handle_request(self, request: web.Request) -> web.Response:
206219
except (json.JSONDecodeError, UnicodeDecodeError):
207220
pass
208221

209-
# Additional fallback for Anthropic HTTP API error responses wrapped in proper JSON
222+
# Upstream returned an error body — forward it to the client
223+
# untouched (so the agent sees the real error), but treat
224+
# response as invalid so we don't record garbage.
210225
if response_data and response_data.get('type') == 'error':
211-
logger.error(f"Received error from provider: {response_data}")
212-
raise RuntimeError(f"Provider returned error: {response_data}")
213-
214-
# Additional fallback for OpenAI HTTP API error responses
215-
if response_data and 'error' in response_data and isinstance(response_data['error'], dict):
216-
logger.error(f"Received error from provider: {response_data}")
217-
raise RuntimeError(f"Provider returned error: {response_data}")
226+
logger.warning(f"Anthropic-format error from upstream: {response_data}")
227+
response_data = None
228+
elif response_data and isinstance(response_data.get('error'), dict):
229+
logger.warning(f"OpenAI-format error from upstream: {response_data}")
230+
response_data = None
218231

219232
# 7. Record
220233
has_messages = isinstance(request_data, dict) and 'messages' in request_data
@@ -290,6 +303,15 @@ async def _handle_request(self, request: web.Request) -> web.Response:
290303
messages.extend(response_items)
291304
elif assistant_msg:
292305
messages.append(assistant_msg)
306+
else:
307+
# Response invalid (parser returned None, error body, no
308+
# assistant content, etc.). Mirror model.py: drop the
309+
# whole input+output rather than recording an orphaned
310+
# user-only turn.
311+
logger.debug(
312+
f"Skipping record for session {self.session_id}: no valid response"
313+
)
314+
return response
293315

294316
# Convention: function_call.arguments stored as dict (not JSON
295317
# string). Applied to all items uniformly — including input items
@@ -321,17 +343,29 @@ async def _handle_request(self, request: web.Request) -> web.Response:
321343
def _parse_stream_response(raw: bytes) -> Optional[dict]:
322344
"""Parse SSE stream response to reconstruct the complete message.
323345
324-
Supports both Anthropic and OpenAI streaming formats.
346+
Supports both Anthropic and OpenAI streaming formats. Returns
347+
``None`` if the stream is invalid (malformed SSE JSON, no
348+
events, or format-specific failure modes detected by the
349+
per-format parsers).
325350
"""
326351
text = raw.decode('utf-8', errors='replace')
327-
events = []
352+
events: list = []
353+
saw_done = False
354+
328355
for line in text.split('\n'):
329356
line = line.strip()
330-
if line.startswith('data: ') and line != 'data: [DONE]':
331-
try:
332-
events.append(json.loads(line[6:]))
333-
except json.JSONDecodeError:
334-
pass
357+
if not line.startswith('data: '):
358+
continue
359+
if line == 'data: [DONE]':
360+
saw_done = True
361+
continue
362+
try:
363+
events.append(json.loads(line[6:]))
364+
except json.JSONDecodeError:
365+
# Mirrors model.py: malformed SSE JSON invalidates the
366+
# whole stream rather than getting silently skipped.
367+
logger.warning(f"Invalid SSE JSON in stream: {line[6:][:200]}")
368+
return None
335369

336370
if not events:
337371
return None
@@ -343,49 +377,68 @@ def _parse_stream_response(raw: bytes) -> Optional[dict]:
343377
if evt_type.startswith('response.') or first.get('object') == 'response':
344378
return SessionClient._parse_responses_stream(events)
345379
if 'choices' in first or first.get('object') == 'chat.completion.chunk':
346-
return SessionClient._parse_openai_stream(events)
380+
return SessionClient._parse_openai_stream(events, saw_done=saw_done)
347381
return SessionClient._parse_anthropic_stream(events)
348382

349383
@staticmethod
350-
def _parse_openai_stream(events: list) -> Optional[dict]:
351-
"""Reconstruct OpenAI chat completion from stream chunks."""
352-
message = {
353-
'choices': [{'message': {'role': 'assistant', 'content': ''}}],
384+
def _parse_openai_stream(events: list, saw_done: bool = False) -> Optional[dict]:
385+
"""Reconstruct OpenAI chat completion from stream chunks.
386+
387+
Validation mirrors ``AsyncAPIClient.chat`` in ``model.py``:
388+
returns ``None`` if any of these signals an invalid stream:
389+
390+
- mid-stream error event (``error`` field, ``type=='error'``,
391+
``object=='error'``)
392+
- ``finish_reason == 'error'`` (except input-length errors,
393+
which carry useful trailing content and are kept)
394+
- no ``choices`` ever observed (``saw_choice``)
395+
- no ``data: [DONE]`` terminator (``saw_done``)
396+
- no terminal ``finish_reason`` on choices[0]
397+
"""
398+
message: Dict = {
399+
'choices': [{'message': {'role': 'assistant', 'content': ''}, 'finish_reason': None}],
354400
}
355-
content_parts = []
356-
reasoning_content_parts = []
357-
tool_calls_map: Dict[int, dict] = {} # index → {id, type, function}
358-
usage = {}
401+
content_parts: List[str] = []
402+
reasoning_content_parts: List[str] = []
403+
tool_calls_map: Dict[int, dict] = {}
404+
function_call_data: Optional[dict] = None
405+
usage: dict = {}
406+
saw_choice = False
359407

360408
for event in events:
409+
# In-stream error event — invalidate the whole trace.
410+
if (
411+
event.get('error') is not None
412+
or event.get('type') == 'error'
413+
or event.get('object') == 'error'
414+
):
415+
logger.warning(f"OpenAI stream error event: {event}")
416+
return None
417+
361418
if event.get('id') and 'id' not in message:
362419
message['id'] = event['id']
363420
if event.get('model'):
364421
message['model'] = event['model']
365422

366423
choices = event.get('choices', [])
424+
if choices:
425+
saw_choice = True
426+
367427
for choice in choices:
368428
delta = choice.get('delta', {})
369429

370-
# Content
371430
if delta.get('content'):
372431
content_parts.append(delta['content'])
373-
374-
# Reasoning Content
375432
if delta.get('reasoning_content'):
376433
reasoning_content_parts.append(delta['reasoning_content'])
377434

378-
# Tool calls
379435
for tc_delta in delta.get('tool_calls') or []:
380436
idx = tc_delta.get('index', 0)
381437
if idx not in tool_calls_map:
382438
tool_calls_map[idx] = {
383439
'id': tc_delta.get('id', ''),
384440
'type': tc_delta.get('type', 'function'),
385-
'function': {
386-
'name': '',
387-
'arguments': '',
388-
},
441+
'function': {'name': '', 'arguments': ''},
389442
}
390443
tc = tool_calls_map[idx]
391444
fn = tc_delta.get('function', {})
@@ -396,18 +449,46 @@ def _parse_openai_stream(events: list) -> Optional[dict]:
396449
if tc_delta.get('id'):
397450
tc['id'] = tc_delta['id']
398451

452+
fc_delta = delta.get('function_call')
453+
if fc_delta:
454+
if function_call_data is None:
455+
function_call_data = {'name': '', 'arguments': ''}
456+
if fc_delta.get('name'):
457+
function_call_data['name'] += fc_delta['name']
458+
if fc_delta.get('arguments'):
459+
function_call_data['arguments'] += fc_delta['arguments']
460+
399461
if choice.get('finish_reason'):
400462
message['choices'][0]['finish_reason'] = choice['finish_reason']
463+
if choice['finish_reason'] == 'error':
464+
logger.warning(f"OpenAI finish_reason=error: {event}")
465+
return None
401466

402467
if event.get('usage'):
403468
usage = event['usage']
404469

470+
# Stream completeness checks (mirror model.py).
471+
if not saw_choice:
472+
logger.warning("OpenAI stream ended without any choices")
473+
return None
474+
if not saw_done:
475+
logger.warning("OpenAI stream ended without [DONE] terminator")
476+
return None
477+
if not message['choices'][0].get('finish_reason'):
478+
logger.warning("OpenAI stream ended without terminal finish_reason")
479+
return None
480+
405481
msg = message['choices'][0]['message']
406482
msg['content'] = ''.join(content_parts)
407483
if reasoning_content_parts:
408484
msg['reasoning_content'] = ''.join(reasoning_content_parts)
409485
if tool_calls_map:
410-
msg['tool_calls'] = [tool_calls_map[i] for i in sorted(tool_calls_map)]
486+
# Keep arguments as-string here (matching the non-stream raw
487+
# API shape). _handle_request does the single point of
488+
# JSON-deserialization for both stream and non-stream paths.
489+
msg['tool_calls'] = [tool_calls_map[idx] for idx in sorted(tool_calls_map)]
490+
if function_call_data:
491+
msg['function_call'] = function_call_data
411492
if usage:
412493
message['usage'] = usage
413494
return message
@@ -416,15 +497,21 @@ def _parse_openai_stream(events: list) -> Optional[dict]:
416497
def _parse_responses_stream(events: list) -> Optional[dict]:
417498
"""Reconstruct an OpenAI Responses API result from stream events.
418499
419-
Prefers the terminal ``response.completed`` / ``response.failed`` /
420-
``response.incomplete`` event, which carries the full response object.
421-
Falls back to incremental reconstruction from delta events when no
422-
terminal event was seen (e.g. truncated stream).
500+
Prefers the terminal ``response.completed`` event (carries the
501+
full response object). ``response.failed`` / ``response.incomplete``
502+
and any ``error`` event invalidate the trace — returns ``None``
503+
so the caller skips recording.
423504
"""
424-
# Fast path: terminal events embed the full response object
425-
for event in reversed(events):
505+
# Reject explicit failure / incomplete terminals.
506+
for event in events:
426507
evt_type = event.get('type', '')
427-
if evt_type in ('response.completed', 'response.failed', 'response.incomplete'):
508+
if evt_type in ('response.failed', 'response.incomplete', 'error'):
509+
logger.warning(f"Responses stream terminal failure: {evt_type}")
510+
return None
511+
512+
# Fast path: completed event embeds the full response object
513+
for event in reversed(events):
514+
if event.get('type') == 'response.completed':
428515
resp = event.get('response')
429516
if resp is not None:
430517
return resp
@@ -543,14 +630,23 @@ def _parse_responses_stream(events: list) -> Optional[dict]:
543630

544631
@staticmethod
545632
def _parse_anthropic_stream(events: list) -> Optional[dict]:
546-
"""Reconstruct Anthropic message from stream events."""
547-
message = {}
548-
content_blocks = []
549-
current_block = {}
633+
"""Reconstruct Anthropic message from stream events.
634+
635+
Returns ``None`` if an ``error`` event is observed mid-stream
636+
or no ``message_stop`` is seen.
637+
"""
638+
message: Dict = {}
639+
content_blocks: List[dict] = []
640+
current_block: dict = {}
641+
saw_message_stop = False
550642

551643
for event in events:
552644
event_type = event.get('type', '')
553645

646+
if event_type == 'error':
647+
logger.warning(f"Anthropic stream error event: {event}")
648+
return None
649+
554650
if event_type == 'message_start':
555651
# Initial message metadata
556652
msg = event.get('message', {})
@@ -610,6 +706,13 @@ def _parse_anthropic_stream(events: list) -> Optional[dict]:
610706
else:
611707
message['usage'][k] = v
612708

709+
elif event_type == 'message_stop':
710+
saw_message_stop = True
711+
712+
if not saw_message_stop:
713+
logger.warning("Anthropic stream ended without message_stop")
714+
return None
715+
613716
# Assemble final message
614717
message['content'] = content_blocks
615718
return message

lagent/agents/fc_agent.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,7 @@ async def forward(self, env_message: AgentMessage, **kwargs):
5252
self.max_turn is None or current_turn < self.max_turn
5353
):
5454
policy_message = await self.policy_agent(env_message, **kwargs)
55-
if policy_message.stream_state == AgentStatusCode.SERVER_ERR:
56-
raise ValueError("Rollout response error: state is neither completed nor aborted!")
57-
if policy_message.stream_state == AgentStatusCode.SESSION_OUT_OF_LIMIT:
55+
if policy_message.finish_reason == 'error':
5856
for _ in range(2): # remove the last two messages
5957
self.policy_agent.memory.delete(-1)
6058
return AgentMessage(

0 commit comments

Comments
 (0)