mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/agent: classify empty/truncated/missing stream terminals as errors
This commit is contained in:
@@ -114,6 +114,66 @@ async def _execute_tool_calls(
|
|||||||
yield ToolResultEvent(message=tool_msg)
|
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(
|
async def _non_stream_round(
|
||||||
client: ChatClient,
|
client: ChatClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
@@ -122,7 +182,14 @@ async def _non_stream_round(
|
|||||||
if not response.choices:
|
if not response.choices:
|
||||||
msg = "chat completion response contained no choices"
|
msg = "chat completion response contained no choices"
|
||||||
raise RuntimeError(msg)
|
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
|
reasoning = assistant.reasoning_content
|
||||||
if isinstance(reasoning, str) and reasoning:
|
if isinstance(reasoning, str) and reasoning:
|
||||||
yield ReasoningDeltaEvent(content=reasoning)
|
yield ReasoningDeltaEvent(content=reasoning)
|
||||||
@@ -154,6 +221,8 @@ async def _stream_round(
|
|||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
tool_deltas: list[StreamToolCallDelta] = []
|
tool_deltas: list[StreamToolCallDelta] = []
|
||||||
last_api_usage: object = UNSET
|
last_api_usage: object = UNSET
|
||||||
|
finish_reason: str | None = None
|
||||||
|
saw_terminal = False
|
||||||
|
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
parsed = token_usage_from_api(chunk.usage)
|
parsed = token_usage_from_api(chunk.usage)
|
||||||
@@ -162,6 +231,10 @@ async def _stream_round(
|
|||||||
if not chunk.choices:
|
if not chunk.choices:
|
||||||
continue
|
continue
|
||||||
choice = chunk.choices[0]
|
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
|
delta = choice.delta
|
||||||
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
||||||
reasoning_parts.append(delta.reasoning_content)
|
reasoning_parts.append(delta.reasoning_content)
|
||||||
@@ -185,6 +258,16 @@ async def _stream_round(
|
|||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
reasoning_content=full_reasoning or UNSET,
|
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 AssistantMessageEvent(message=assistant)
|
||||||
yield UsageEvent(
|
yield UsageEvent(
|
||||||
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
||||||
|
|||||||
@@ -158,10 +158,36 @@ def _reasoning_summary_text(response: Response) -> str:
|
|||||||
return "".join(parts)
|
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:
|
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
||||||
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
|
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
|
||||||
assistant = response_to_assistant_message(response)
|
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
|
usage = response.usage if response.usage is not UNSET else UNSET
|
||||||
created = int(response.created_at)
|
created = int(response.created_at)
|
||||||
return ChatCompletionResponse(
|
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:
|
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
|
||||||
return ChatCompletionChunk(
|
return ChatCompletionChunk(
|
||||||
id="resp-stream",
|
id="resp-stream",
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ from msgspec import UNSET
|
|||||||
|
|
||||||
from plyngent.agent.responses_bridge import (
|
from plyngent.agent.responses_bridge import (
|
||||||
chat_param_to_responses_kwargs,
|
chat_param_to_responses_kwargs,
|
||||||
|
finish_reason_chunk,
|
||||||
reasoning_delta_chunk,
|
reasoning_delta_chunk,
|
||||||
|
response_to_assistant_message,
|
||||||
response_to_chat_completion,
|
response_to_chat_completion,
|
||||||
|
responses_status_to_finish_reason,
|
||||||
text_delta_chunk,
|
text_delta_chunk,
|
||||||
tool_call_chunks_from_response,
|
tool_call_chunks_from_response,
|
||||||
usage_chunk_from_response,
|
usage_chunk_from_response,
|
||||||
@@ -109,12 +112,20 @@ class ResponsesChatClient:
|
|||||||
except TypeError, ValueError, msgspec.ValidationError:
|
except TypeError, ValueError, msgspec.ValidationError:
|
||||||
final = None
|
final = None
|
||||||
|
|
||||||
if final is not None:
|
if final is None:
|
||||||
for chunk in tool_call_chunks_from_response(final, model=model):
|
# Stream ended without response.completed — signal missing terminal.
|
||||||
yield chunk
|
# Loop will treat empty + no finish_reason as a glitch.
|
||||||
usage = usage_chunk_from_response(final, model=model)
|
return
|
||||||
if usage is not None:
|
|
||||||
yield usage
|
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(
|
def wrap_openai_for_agent(
|
||||||
|
|||||||
@@ -61,6 +61,28 @@ def _chunks_from_response(response: ChatCompletionResponse) -> list[ChatCompleti
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Emit terminal finish_reason when content was streamed without one.
|
||||||
|
fr = response.choices[0].finish_reason
|
||||||
|
if (
|
||||||
|
fr
|
||||||
|
and (isinstance(message.content, str) and message.content)
|
||||||
|
and (message.tool_calls is UNSET or not message.tool_calls)
|
||||||
|
):
|
||||||
|
chunks.append(
|
||||||
|
ChatCompletionChunk(
|
||||||
|
id=response.id,
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=response.created,
|
||||||
|
model=response.model,
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(),
|
||||||
|
finish_reason=fr,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
tool_calls = message.tool_calls
|
tool_calls = message.tool_calls
|
||||||
if tool_calls is not UNSET and tool_calls:
|
if tool_calls is not UNSET and tool_calls:
|
||||||
deltas: list[StreamToolCallDelta] = []
|
deltas: list[StreamToolCallDelta] = []
|
||||||
@@ -153,6 +175,7 @@ def _response(
|
|||||||
message: AssistantChatMessage,
|
message: AssistantChatMessage,
|
||||||
*,
|
*,
|
||||||
usage: dict[str, int] | None = None,
|
usage: dict[str, int] | None = None,
|
||||||
|
finish_reason: str | None = "stop",
|
||||||
) -> ChatCompletionResponse:
|
) -> ChatCompletionResponse:
|
||||||
return ChatCompletionResponse(
|
return ChatCompletionResponse(
|
||||||
id="1",
|
id="1",
|
||||||
@@ -164,7 +187,7 @@ def _response(
|
|||||||
index=0,
|
index=0,
|
||||||
message=message,
|
message=message,
|
||||||
logprobs={},
|
logprobs={},
|
||||||
finish_reason="stop",
|
finish_reason=finish_reason,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
system_fingerprint="",
|
system_fingerprint="",
|
||||||
@@ -825,3 +848,118 @@ async def test_tool_result_char_budget() -> None:
|
|||||||
assert len(tool_msgs) == 1
|
assert len(tool_msgs) == 1
|
||||||
assert tool_msgs[0].content.startswith("x" * 20)
|
assert tool_msgs[0].content.startswith("x" * 20)
|
||||||
assert "truncated" in tool_msgs[0].content
|
assert "truncated" in tool_msgs[0].content
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_completion_raises() -> None:
|
||||||
|
client = ScriptedClient([_response(AssistantChatMessage(content=None))])
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="empty model completion"):
|
||||||
|
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_length_finish_raises() -> None:
|
||||||
|
client = ScriptedClient(
|
||||||
|
[
|
||||||
|
_response(
|
||||||
|
AssistantChatMessage(content="partial"),
|
||||||
|
finish_reason="length",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="truncated"):
|
||||||
|
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_content_filter_finish_raises() -> None:
|
||||||
|
client = ScriptedClient(
|
||||||
|
[
|
||||||
|
_response(
|
||||||
|
AssistantChatMessage(content=""),
|
||||||
|
finish_reason="content_filter",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="content filter"):
|
||||||
|
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_missing_terminal_empty_raises() -> None:
|
||||||
|
class EmptyStreamClient:
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||||
|
) -> ChatCompletionResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||||
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
|
del param
|
||||||
|
if not stream:
|
||||||
|
return _response(AssistantChatMessage(content="x"))
|
||||||
|
|
||||||
|
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||||
|
# Usage-only style chunk with no finish_reason and no content.
|
||||||
|
yield ChatCompletionChunk(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=0,
|
||||||
|
model="t",
|
||||||
|
choices=[],
|
||||||
|
usage={"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks()
|
||||||
|
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="stream ended without a terminal"):
|
||||||
|
_ = [e async for e in run_chat_loop(EmptyStreamClient(), messages, model="m", stream=True)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_payload_without_finish_reason_ok() -> None:
|
||||||
|
"""Some providers omit finish_reason; non-empty text still counts as terminal."""
|
||||||
|
|
||||||
|
class PayloadNoFinishClient:
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||||
|
) -> ChatCompletionResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||||
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
|
del param
|
||||||
|
if not stream:
|
||||||
|
return _response(AssistantChatMessage(content="ok"))
|
||||||
|
|
||||||
|
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||||
|
yield ChatCompletionChunk(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=0,
|
||||||
|
model="t",
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(content="ok"),
|
||||||
|
finish_reason=None,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks()
|
||||||
|
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
events = [e async for e in run_chat_loop(PayloadNoFinishClient(), messages, model="m", stream=True)]
|
||||||
|
assert any(isinstance(e, TextDeltaEvent) and e.content == "ok" for e in events)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import (
|
|||||||
chat_param_to_responses_kwargs,
|
chat_param_to_responses_kwargs,
|
||||||
response_to_assistant_message,
|
response_to_assistant_message,
|
||||||
response_to_chat_completion,
|
response_to_chat_completion,
|
||||||
|
responses_status_to_finish_reason,
|
||||||
tool_items_to_response_tools,
|
tool_items_to_response_tools,
|
||||||
)
|
)
|
||||||
from plyngent.agent.responses_client import ResponsesChatClient
|
from plyngent.agent.responses_client import ResponsesChatClient
|
||||||
@@ -100,6 +101,31 @@ def test_response_to_assistant_with_tools() -> None:
|
|||||||
assert completion.choices[0].message.content == "done"
|
assert completion.choices[0].message.content == "done"
|
||||||
assert isinstance(completion.usage, dict)
|
assert isinstance(completion.usage, dict)
|
||||||
assert completion.usage["input_tokens"] == 10
|
assert completion.usage["input_tokens"] == 10
|
||||||
|
assert completion.choices[0].finish_reason == "tool_calls"
|
||||||
|
|
||||||
|
|
||||||
|
def test_responses_status_to_finish_reason_incomplete() -> None:
|
||||||
|
body = {
|
||||||
|
"id": "resp_i",
|
||||||
|
"object": "response",
|
||||||
|
"created_at": 1,
|
||||||
|
"model": "gpt-test",
|
||||||
|
"status": "incomplete",
|
||||||
|
"incomplete_details": {"reason": "max_output_tokens"},
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"id": "msg_1",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"status": "incomplete",
|
||||||
|
"content": [{"type": "output_text", "text": "partial", "annotations": []}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
resp = msgspec.convert(body, Response)
|
||||||
|
assert responses_status_to_finish_reason(resp, has_tool_calls=False) == "length"
|
||||||
|
completion = response_to_chat_completion(resp)
|
||||||
|
assert completion.choices[0].finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
def test_chat_param_to_responses_kwargs() -> None:
|
def test_chat_param_to_responses_kwargs() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user