diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index c3f7615..cd4d88f 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -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), diff --git a/src/plyngent/agent/responses_bridge.py b/src/plyngent/agent/responses_bridge.py index 0ce76aa..abe497f 100644 --- a/src/plyngent/agent/responses_bridge.py +++ b/src/plyngent/agent/responses_bridge.py @@ -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", diff --git a/src/plyngent/agent/responses_client.py b/src/plyngent/agent/responses_client.py index 4d7b08a..324eded 100644 --- a/src/plyngent/agent/responses_client.py +++ b/src/plyngent/agent/responses_client.py @@ -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( diff --git a/tests/test_agent/test_loop.py b/tests/test_agent/test_loop.py index 4ab9133..1462384 100644 --- a/tests/test_agent/test_loop.py +++ b/tests/test_agent/test_loop.py @@ -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 if tool_calls is not UNSET and tool_calls: deltas: list[StreamToolCallDelta] = [] @@ -153,6 +175,7 @@ def _response( message: AssistantChatMessage, *, usage: dict[str, int] | None = None, + finish_reason: str | None = "stop", ) -> ChatCompletionResponse: return ChatCompletionResponse( id="1", @@ -164,7 +187,7 @@ def _response( index=0, message=message, logprobs={}, - finish_reason="stop", + finish_reason=finish_reason, ) ], system_fingerprint="", @@ -825,3 +848,118 @@ async def test_tool_result_char_budget() -> None: assert len(tool_msgs) == 1 assert tool_msgs[0].content.startswith("x" * 20) 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) diff --git a/tests/test_agent/test_responses_bridge.py b/tests/test_agent/test_responses_bridge.py index cb3e280..f3a2ee9 100644 --- a/tests/test_agent/test_responses_bridge.py +++ b/tests/test_agent/test_responses_bridge.py @@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import ( chat_param_to_responses_kwargs, response_to_assistant_message, response_to_chat_completion, + responses_status_to_finish_reason, tool_items_to_response_tools, ) 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 isinstance(completion.usage, dict) 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: