core/agent: classify empty/truncated/missing stream terminals as errors

This commit is contained in:
2026-07-18 00:56:45 +08:00
parent e04fda4bc6
commit 65d248064e
5 changed files with 315 additions and 9 deletions
+84 -1
View File
@@ -114,6 +114,66 @@ async def _execute_tool_calls(
yield ToolResultEvent(message=tool_msg)
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
"""True if the model produced text, reasoning, or tool calls."""
if assistant.tool_calls is not UNSET and assistant.tool_calls:
return True
if isinstance(assistant.content, str) and assistant.content.strip():
return True
reasoning = assistant.reasoning_content
return bool(isinstance(reasoning, str) and reasoning.strip())
def _finish_reason_value(finish: object) -> str | None:
if finish is UNSET or finish is None:
return None
if isinstance(finish, str) and finish.strip():
return finish.strip()
return None
def _validate_assistant_terminal(
assistant: AssistantChatMessage,
*,
finish_reason: str | None,
stream_terminal: bool,
) -> None:
"""Raise when the round is not a usable agent stop.
Distinguishes empty generation, truncated/filtered stops, and missing
stream terminals (network/client glitch) from a normal stop/tool_calls end.
"""
reason = (finish_reason or "").lower() or None
if reason in {"length", "content_filter"}:
label = "truncated (max tokens)" if reason == "length" else "content filter"
detail = ""
if isinstance(assistant.content, str) and assistant.content.strip():
detail = f"; partial text kept ({len(assistant.content)} chars)"
msg = f"model stopped early: {label}{detail}"
raise RuntimeError(msg)
if reason in {"failed", "incomplete", "cancelled"}:
msg = f"model response status: {reason}"
raise RuntimeError(msg)
if _assistant_has_payload(assistant):
return
if not stream_terminal and reason is None:
msg = (
"stream ended without a terminal signal (no finish_reason / response.completed) and empty assistant output"
)
raise RuntimeError(msg)
if reason in {"stop", "tool_calls", "function_call"} or reason is None:
msg = "empty model completion (no text, reasoning, or tool calls)"
raise RuntimeError(msg)
msg = f"empty model completion (finish_reason={reason})"
raise RuntimeError(msg)
async def _non_stream_round(
client: ChatClient,
param: ChatCompletionsParam,
@@ -122,7 +182,14 @@ async def _non_stream_round(
if not response.choices:
msg = "chat completion response contained no choices"
raise RuntimeError(msg)
assistant = response.choices[0].message
choice = response.choices[0]
assistant = choice.message
finish = _finish_reason_value(choice.finish_reason)
_validate_assistant_terminal(
assistant,
finish_reason=finish,
stream_terminal=True,
)
reasoning = assistant.reasoning_content
if isinstance(reasoning, str) and reasoning:
yield ReasoningDeltaEvent(content=reasoning)
@@ -154,6 +221,8 @@ async def _stream_round(
reasoning_parts: list[str] = []
tool_deltas: list[StreamToolCallDelta] = []
last_api_usage: object = UNSET
finish_reason: str | None = None
saw_terminal = False
async for chunk in stream:
parsed = token_usage_from_api(chunk.usage)
@@ -162,6 +231,10 @@ async def _stream_round(
if not chunk.choices:
continue
choice = chunk.choices[0]
fr = _finish_reason_value(choice.finish_reason)
if fr is not None:
finish_reason = fr
saw_terminal = True
delta = choice.delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts.append(delta.reasoning_content)
@@ -185,6 +258,16 @@ async def _stream_round(
tool_calls=tool_calls,
reasoning_content=full_reasoning or UNSET,
)
# Usage-only final chunks leave choices empty; a non-empty usage after
# content still is not a finish_reason. Prefer explicit finish_reason.
# If we got payload but no finish_reason, treat as terminal (some providers
# omit it); if empty and no finish_reason, flag as missing terminal.
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
_validate_assistant_terminal(
assistant,
finish_reason=finish_reason,
stream_terminal=stream_terminal,
)
yield AssistantMessageEvent(message=assistant)
yield UsageEvent(
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
+49 -1
View File
@@ -158,10 +158,36 @@ def _reasoning_summary_text(response: Response) -> str:
return "".join(parts)
def responses_status_to_finish_reason(
response: Response,
*,
has_tool_calls: bool,
) -> str:
"""Map Responses ``status`` to a chat-style finish_reason for the agent loop."""
status = response.status
status_s = status if isinstance(status, str) else None
if status_s == "incomplete":
details = response.incomplete_details
reason = None
if details is not UNSET and details is not None:
raw_reason = details.reason
if raw_reason is not UNSET and isinstance(raw_reason, str):
reason = raw_reason
if reason == "content_filter":
return "content_filter"
return "length"
if status_s in {"failed", "cancelled"}:
return status_s
if has_tool_calls:
return "tool_calls"
return "stop"
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
assistant = response_to_assistant_message(response)
finish: str | None = "tool_calls" if assistant.tool_calls is not UNSET else "stop"
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(response, has_tool_calls=has_tools)
usage = response.usage if response.usage is not UNSET else UNSET
created = int(response.created_at)
return ChatCompletionResponse(
@@ -180,6 +206,28 @@ def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
)
def finish_reason_chunk(
*,
model: str,
finish_reason: str,
created: int = 0,
) -> ChatCompletionChunk:
"""Terminal stream chunk carrying only ``finish_reason`` (no delta text)."""
return ChatCompletionChunk(
id="resp-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(),
finish_reason=cast("Any", finish_reason),
)
],
)
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="resp-stream",
+17 -6
View File
@@ -8,8 +8,11 @@ from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
finish_reason_chunk,
reasoning_delta_chunk,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
text_delta_chunk,
tool_call_chunks_from_response,
usage_chunk_from_response,
@@ -109,12 +112,20 @@ class ResponsesChatClient:
except TypeError, ValueError, msgspec.ValidationError:
final = None
if final is not None:
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
if final is None:
# Stream ended without response.completed — signal missing terminal.
# Loop will treat empty + no finish_reason as a glitch.
return
assistant = response_to_assistant_message(final)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
def wrap_openai_for_agent(