From 626012e92fcbe3921e252827185b0aeb0775c788 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 1 Aug 2026 01:05:26 +0800 Subject: [PATCH] core/agent: unify provider finish_reason mapping --- src/plyngent/agent/finish_reason.py | 33 ++++++++++++++++++++++++++ src/plyngent/agent/messages_bridge.py | 12 +++------- src/plyngent/agent/responses_bridge.py | 10 ++++---- tests/test_agent/test_finish_reason.py | 32 +++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 src/plyngent/agent/finish_reason.py create mode 100644 tests/test_agent/test_finish_reason.py diff --git a/src/plyngent/agent/finish_reason.py b/src/plyngent/agent/finish_reason.py new file mode 100644 index 0000000..ab8c9c3 --- /dev/null +++ b/src/plyngent/agent/finish_reason.py @@ -0,0 +1,33 @@ +"""Canonical chat-completions ``finish_reason`` vocabulary for provider bridges. + +Provider statuses/stops differ (Responses ``status``/``incomplete_details``, +Anthropic ``stop_reason``, DeepSeek reasoning, …); the agent loop and tool +execution only understand the chat-completions vocabulary. This module is the +single mapping point between raw provider reasons and that vocabulary. +""" + +from __future__ import annotations + +_LENGTH_REASONS = frozenset({"max_tokens", "max_output_tokens", "length"}) +_CONTENT_FILTER_REASONS = frozenset({"content_filter", "refusal"}) +_TOOL_REASONS = frozenset({"tool_use", "tool_calls"}) + + +def chat_finish_reason(reason: str | None, *, has_tool_calls: bool) -> str: + """Map a raw provider reason onto the chat ``finish_reason`` vocabulary. + + Canonical values: ``stop``, ``length``, ``content_filter``, ``tool_calls``. + Provider-specific stop indicators (``end_turn``, ``stop_sequence``, + ``completed``, ``None``, …) collapse to ``stop``. + """ + raw = (reason or "").lower() or None + if raw in _LENGTH_REASONS: + return "length" + if raw in _CONTENT_FILTER_REASONS: + return "content_filter" + if raw in _TOOL_REASONS or has_tool_calls: + return "tool_calls" + return "stop" + + +__all__ = ["chat_finish_reason"] diff --git a/src/plyngent/agent/messages_bridge.py b/src/plyngent/agent/messages_bridge.py index b315465..a6f50c6 100644 --- a/src/plyngent/agent/messages_bridge.py +++ b/src/plyngent/agent/messages_bridge.py @@ -228,15 +228,9 @@ def anthropic_stop_to_finish_reason( has_tool_calls: bool, ) -> str: """Map Anthropic ``stop_reason`` to a chat-style finish_reason.""" - reason = (stop_reason or "").lower() or None - if reason == "max_tokens": - return "length" - if reason in {"refusal", "content_filter"}: - return "content_filter" - if reason == "tool_use" or has_tool_calls: - return "tool_calls" - # end_turn, stop_sequence, None, … - return "stop" + from .finish_reason import chat_finish_reason + + return chat_finish_reason(stop_reason, has_tool_calls=has_tool_calls) def anthropic_usage_to_dict(usage: AnthropicUsage | dict[str, Any] | None) -> dict[str, Any] | None: diff --git a/src/plyngent/agent/responses_bridge.py b/src/plyngent/agent/responses_bridge.py index e7b52d7..471635a 100644 --- a/src/plyngent/agent/responses_bridge.py +++ b/src/plyngent/agent/responses_bridge.py @@ -161,20 +161,20 @@ def responses_status_to_finish_reason( has_tool_calls: bool, ) -> str: """Map Responses ``status`` to a chat-style finish_reason for the agent loop.""" + from .finish_reason import chat_finish_reason + status = response.status status_s = status if isinstance(status, str) else None if status_s == "incomplete": details = response.incomplete_details if details is not UNSET and details is not None: raw_reason = details.reason - if raw_reason is not UNSET and raw_reason == "content_filter": - return "content_filter" + if raw_reason is not UNSET: + return chat_finish_reason(raw_reason, has_tool_calls=False) return "length" if status_s in {"failed", "cancelled"}: return status_s - if has_tool_calls: - return "tool_calls" - return "stop" + return chat_finish_reason(status_s, has_tool_calls=has_tool_calls) def response_to_chat_completion(response: Response) -> ChatCompletionResponse: diff --git a/tests/test_agent/test_finish_reason.py b/tests/test_agent/test_finish_reason.py new file mode 100644 index 0000000..98a93e6 --- /dev/null +++ b/tests/test_agent/test_finish_reason.py @@ -0,0 +1,32 @@ +"""Tests for the canonical finish_reason vocabulary shared by provider bridges.""" + +from __future__ import annotations + +import pytest + +from plyngent.agent.finish_reason import chat_finish_reason + + +@pytest.mark.parametrize( + ("raw", "has_tool_calls", "expected"), + [ + (None, False, "stop"), + (None, True, "tool_calls"), + ("", False, "stop"), + ("end_turn", False, "stop"), + ("stop_sequence", False, "stop"), + ("completed", False, "stop"), + ("stop", False, "stop"), + ("max_tokens", False, "length"), + ("max_output_tokens", False, "length"), + ("length", False, "length"), + ("content_filter", False, "content_filter"), + ("refusal", False, "content_filter"), + ("tool_use", False, "tool_calls"), + ("tool_calls", False, "tool_calls"), + ("Tool_Use", False, "tool_calls"), + ("MAX_TOKENS", False, "length"), + ], +) +def test_chat_finish_reason(raw: str | None, has_tool_calls: bool, expected: str) -> None: + assert chat_finish_reason(raw, has_tool_calls=has_tool_calls) == expected