From fb8ef88d9feaf766bce253015740172249e8eb81 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 6 Oct 2026 21:07:23 +0800 Subject: [PATCH] core/agent: send the resolved reasoning effort with each request --- src/plyngent/agent/chat.py | 6 ++ src/plyngent/agent/loop.py | 10 ++- src/plyngent/agent/messages_bridge.py | 78 +++++++++++++++++++++- src/plyngent/agent/messages_dispatch.py | 9 ++- src/plyngent/agent/responses_bridge.py | 3 + tests/test_agent/test_loop.py | 14 ++++ tests/test_agent/test_messages_bridge.py | 56 ++++++++++++++++ tests/test_agent/test_messages_dispatch.py | 43 ++++++++++++ tests/test_agent/test_responses_bridge.py | 12 ++++ 9 files changed, 225 insertions(+), 6 deletions(-) diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 10c67cc..32eb5f1 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -287,6 +287,8 @@ class ChatAgent: session_id: int | None max_rounds: int temperature: float | None + # Resolved thinking strength ("" = provider default); see config.reasoning. + reasoning_effort: str on_limit: LimitContinueHook | None stream: bool system_prompt: str | None @@ -318,6 +320,7 @@ class ChatAgent: session_id: int | None = None, max_rounds: int = DEFAULT_MAX_ROUNDS, temperature: float | None = None, + reasoning_effort: str = "", messages: Sequence[AnyChatMessage] | None = None, on_limit: LimitContinueHook | None = None, stream: bool = True, @@ -340,6 +343,7 @@ class ChatAgent: self.session_id = session_id self.max_rounds = max_rounds self.temperature = temperature + self.reasoning_effort = reasoning_effort self.on_limit = on_limit self.stream = stream self.system_prompt = system_prompt @@ -597,6 +601,7 @@ class ChatAgent: tools=self.tools, max_rounds=self.max_rounds, temperature=self.temperature, + reasoning_effort=self.reasoning_effort, on_limit=self.on_limit, stream=self.stream, max_tool_result_chars=self.max_tool_result_chars, @@ -740,6 +745,7 @@ class ChatAgent: session_id=None, max_rounds=rounds, temperature=self.temperature, + reasoning_effort=self.reasoning_effort, on_limit=self.on_limit, stream=self.stream, system_prompt=self.system_prompt if system_prompt is None else system_prompt, diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index c7f1d09..4e98dc4 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -57,7 +57,7 @@ if TYPE_CHECKING: from plyngent.lmproto.anthropic.client import AnthropicClient from plyngent.lmproto.openai.client import OpenAIClient - from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem + from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem, ReasoningEffort from .todo_nag import TodoNagStrategy from .todo_stack import TodoStack @@ -418,6 +418,7 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop tools: ToolRegistry | None = None, max_rounds: int = DEFAULT_MAX_ROUNDS, temperature: float | None = None, + reasoning_effort: str = "", on_limit: LimitContinueHook | None = None, stream: bool = True, max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS, @@ -438,6 +439,10 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop Multiple tool calls in one round run in parallel when ``parallel_tools``. Request payloads may shrink older tool results when over ``max_context_tokens``. + *reasoning_effort* is the resolved thinking strength (``""`` = send nothing, + the provider's own default); each API surface maps it onto its own wire field + (see ``config.reasoning``). + *provider_tools* are hosted/provider-side tools (OpenAI Responses only, e.g. ``web_search``) merged into the request as opaque dicts; never executed by the :class:`ToolRegistry`. Ignored by non-Responses clients. @@ -482,6 +487,9 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop model=model, temperature=temperature if temperature is not None else UNSET, tools=list(tool_items) if tool_items is not None else UNSET, + # "max" is DeepSeek-only; config.reasoning already clamped it away + # for every other surface, so this cast does not hide a mistake. + reasoning_effort=cast("ReasoningEffort", reasoning_effort) if reasoning_effort else UNSET, ) pre_len = len(messages) diff --git a/src/plyngent/agent/messages_bridge.py b/src/plyngent/agent/messages_bridge.py index ebbefb0..ccbda74 100644 --- a/src/plyngent/agent/messages_bridge.py +++ b/src/plyngent/agent/messages_bridge.py @@ -19,6 +19,7 @@ from plyngent.lmproto.anthropic.model import ( AnthropicMessagesParam, AnthropicResponseText, AnthropicTextContent, + AnthropicThinkingConfig, AnthropicToolChoice, AnthropicToolDefinition, AnthropicToolResultContent, @@ -273,16 +274,87 @@ def _max_tokens_from_param(param: ChatCompletionsParam) -> int: return 8192 -def chat_param_to_anthropic_param(param: ChatCompletionsParam) -> AnthropicMessagesParam: - """Build :class:`AnthropicMessagesParam` from a chat completions param.""" +# Effort level → Anthropic ``thinking`` budget (an effort has no wire form there). +# Unknown levels fall back to "medium"; ``none`` is handled separately (disabled). +_ANTHROPIC_THINKING_BUDGETS: dict[str, int] = { + "minimal": 1024, + "low": 4096, + "medium": 8192, + "high": 16384, + "xhigh": 32768, + "max": 32768, +} +# Anthropic rejects ``budget_tokens < 1024`` and ``budget_tokens >= max_tokens``. +_ANTHROPIC_MIN_THINKING_BUDGET = 1024 +# Headroom left for the answer when a thinking budget has to raise max_tokens. +_ANTHROPIC_MIN_ANSWER_TOKENS = 1024 + + +def _thinking_from_param( + param: ChatCompletionsParam, + *, + thinking_budget_tokens: int, +) -> AnthropicThinkingConfig | Unset: + """Build the ``thinking`` block for *param* (UNSET = send nothing). + + ``reasoning_effort = "none"`` disables thinking; any other level enables it + with the explicit *thinking_budget_tokens*, else with the budget that level + maps onto. + """ + effort = param.reasoning_effort + if effort is UNSET: + return UNSET + if effort == "none": + return AnthropicThinkingConfig(type="disabled") + fallback = _ANTHROPIC_THINKING_BUDGETS["medium"] + budget = thinking_budget_tokens or _ANTHROPIC_THINKING_BUDGETS.get(str(effort), fallback) + return AnthropicThinkingConfig( + type="enabled", + budget_tokens=max(_ANTHROPIC_MIN_THINKING_BUDGET, int(budget)), + ) + + +def _thinking_and_max_tokens( + param: ChatCompletionsParam, + *, + thinking_budget_tokens: int, +) -> tuple[int, AnthropicThinkingConfig | Unset]: + """Resolve the ``thinking`` block and the ``max_tokens`` that has to fit it. + + Anthropic rejects ``budget_tokens >= max_tokens``; rather than silently + shrink a requested reasoning budget, raise ``max_tokens`` so the answer still + has room. + """ + max_tokens = _max_tokens_from_param(param) + thinking = _thinking_from_param(param, thinking_budget_tokens=thinking_budget_tokens) + if thinking is not UNSET and thinking.budget_tokens is not UNSET: + budget = int(thinking.budget_tokens) + if budget >= max_tokens: + max_tokens = budget + _ANTHROPIC_MIN_ANSWER_TOKENS + return max_tokens, thinking + + +def chat_param_to_anthropic_param( + param: ChatCompletionsParam, + *, + thinking_budget_tokens: int = 0, +) -> AnthropicMessagesParam: + """Build :class:`AnthropicMessagesParam` from a chat completions param. + + *thinking_budget_tokens* is the configured explicit ``thinking`` budget + (0 = derive it from the param's ``reasoning_effort``). + """ system, messages = chat_messages_to_anthropic(param.messages) tools = tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None) + max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens) kwargs: dict[str, Any] = { "model": param.model, - "max_tokens": _max_tokens_from_param(param), + "max_tokens": max_tokens, "messages": messages, } + if thinking is not UNSET: + kwargs["thinking"] = thinking if system: kwargs["system"] = system if tools: diff --git a/src/plyngent/agent/messages_dispatch.py b/src/plyngent/agent/messages_dispatch.py index 2dbfc08..da0ca94 100644 --- a/src/plyngent/agent/messages_dispatch.py +++ b/src/plyngent/agent/messages_dispatch.py @@ -44,6 +44,11 @@ if TYPE_CHECKING: ) +def _thinking_budget(client: AnthropicClient) -> int: + """Configured explicit ``thinking`` budget on *client* (0 = derive from effort).""" + return max(0, int(getattr(client, "thinking_budget_tokens", 0))) + + class _ToolBlockState: """Tracks an in-flight tool_use content block during SSE streaming.""" @@ -219,7 +224,7 @@ async def _stream_as_chat_chunks( ``message_stop``. Errors raise ``RuntimeError`` so the agent loop can surface them as retryable failures. """ - create = chat_param_to_anthropic_param(param) + create = chat_param_to_anthropic_param(param, thinking_budget_tokens=_thinking_budget(client)) stream = await client.messages(create, stream=True) state = _StreamState(param.model) @@ -249,7 +254,7 @@ async def dispatch_messages( stream: bool = False, ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: """Run one Anthropic Messages turn and return a chat-completions-shaped result.""" - create = chat_param_to_anthropic_param(param) + create = chat_param_to_anthropic_param(param, thinking_budget_tokens=_thinking_budget(client)) if stream: return _stream_as_chat_chunks(client, param) response = await client.messages(create, stream=False) diff --git a/src/plyngent/agent/responses_bridge.py b/src/plyngent/agent/responses_bridge.py index 2d3accb..bd6b316 100644 --- a/src/plyngent/agent/responses_bridge.py +++ b/src/plyngent/agent/responses_bridge.py @@ -16,6 +16,7 @@ from plyngent.lmproto.openai.model import ( ResponseEasyInputMessage, ResponseFunctionTool, ResponseFunctionToolCallOutput, + ResponseReasoningConfig, response_function_calls, response_output_text, ) @@ -336,6 +337,8 @@ def chat_param_to_responses_kwargs( kwargs["max_output_tokens"] = param.max_tokens if param.parallel_tool_calls is not UNSET: kwargs["parallel_tool_calls"] = param.parallel_tool_calls + if param.reasoning_effort is not UNSET: + kwargs["reasoning"] = ResponseReasoningConfig(effort=param.reasoning_effort) if param.tool_choice is not UNSET and isinstance(param.tool_choice, str): kwargs["tool_choice"] = param.tool_choice return kwargs diff --git a/tests/test_agent/test_loop.py b/tests/test_agent/test_loop.py index 5e19447..5376d8d 100644 --- a/tests/test_agent/test_loop.py +++ b/tests/test_agent/test_loop.py @@ -211,6 +211,20 @@ async def test_run_chat_loop_text_only() -> None: assert len(client.calls) == 1 +async def test_run_chat_loop_carries_reasoning_effort() -> None: + client = ScriptedClient([_response(AssistantChatMessage(content="hello"))]) + messages: list[AnyChatMessage] = [UserChatMessage(content="hi")] + _ = [e async for e in run_chat_loop(client, messages, model="m", stream=False, reasoning_effort="high")] + assert client.calls[0].reasoning_effort == "high" + + +async def test_run_chat_loop_omits_unset_reasoning_effort() -> None: + client = ScriptedClient([_response(AssistantChatMessage(content="hello"))]) + messages: list[AnyChatMessage] = [UserChatMessage(content="hi")] + _ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)] + assert client.calls[0].reasoning_effort is UNSET + + async def test_non_stream_emits_usage() -> None: client = ScriptedClient( [ diff --git a/tests/test_agent/test_messages_bridge.py b/tests/test_agent/test_messages_bridge.py index 0b76c8f..63e0e8a 100644 --- a/tests/test_agent/test_messages_bridge.py +++ b/tests/test_agent/test_messages_bridge.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from msgspec import UNSET from plyngent.agent.messages_bridge import ( @@ -29,6 +31,7 @@ from plyngent.lmproto.openai_compatible.model import ( AssistantFunctionToolCall, ChatCompletionsParam, DeveloperChatMessage, + ReasoningEffort, SystemChatMessage, ToolChatMessage, ToolFunction, @@ -36,6 +39,9 @@ from plyngent.lmproto.openai_compatible.model import ( UserChatMessage, ) +if TYPE_CHECKING: + from plyngent.typedef import Unset + def test_tool_items_to_anthropic_tools() -> None: items = [ @@ -198,3 +204,53 @@ def test_chat_param_to_anthropic_param() -> None: assert body.tools is not UNSET assert len(body.tools) == 1 assert body.temperature == 0.2 + # No effort configured → no thinking block, provider default applies. + assert body.thinking is UNSET + + +def _effort_param(effort: ReasoningEffort | Unset = UNSET) -> ChatCompletionsParam: + return ChatCompletionsParam( + model="claude-test", + messages=[UserChatMessage(content="hi")], + reasoning_effort=effort, + ) + + +def test_chat_param_to_anthropic_param_thinking_from_effort() -> None: + body = chat_param_to_anthropic_param(_effort_param("medium")) + assert body.thinking is not UNSET + assert body.thinking.type == "enabled" + assert body.thinking.budget_tokens == 8192 + # A budget equal to max_tokens is invalid for Anthropic, so the default + # output limit grows to leave room after it. + assert body.max_tokens == 8192 + 1024 + + +def test_chat_param_to_anthropic_param_thinking_raises_max_tokens() -> None: + # Anthropic requires budget_tokens < max_tokens; the answer keeps room after + # the reasoning budget instead of the budget being shrunk to fit. + body = chat_param_to_anthropic_param(_effort_param("high")) + assert body.thinking is not UNSET + assert body.thinking.budget_tokens == 16384 + assert body.max_tokens == 16384 + 1024 + + +def test_chat_param_to_anthropic_param_explicit_thinking_budget() -> None: + body = chat_param_to_anthropic_param(_effort_param("minimal"), thinking_budget_tokens=20000) + assert body.thinking is not UNSET + assert body.thinking.budget_tokens == 20000 + assert body.max_tokens == 20000 + 1024 + + +def test_chat_param_to_anthropic_param_thinking_budget_floor() -> None: + body = chat_param_to_anthropic_param(_effort_param("minimal"), thinking_budget_tokens=100) + assert body.thinking is not UNSET + assert body.thinking.budget_tokens == 1024 + assert body.max_tokens == 8192 + + +def test_chat_param_to_anthropic_param_thinking_disabled() -> None: + body = chat_param_to_anthropic_param(_effort_param("none")) + assert body.thinking is not UNSET + assert body.thinking.type == "disabled" + assert body.thinking.budget_tokens is UNSET diff --git a/tests/test_agent/test_messages_dispatch.py b/tests/test_agent/test_messages_dispatch.py index 0a8d398..457dfff 100644 --- a/tests/test_agent/test_messages_dispatch.py +++ b/tests/test_agent/test_messages_dispatch.py @@ -71,6 +71,8 @@ class ScriptedMessagesClient: kind: str = "messages" calls: list[AnthropicMessagesParam] + # Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort). + thinking_budget_tokens: int _non_stream: list[AnthropicMessageResponse] _stream_events: list[list[AnthropicStreamEvent]] @@ -81,6 +83,7 @@ class ScriptedMessagesClient: stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None, ) -> None: self.calls = [] + self.thinking_budget_tokens = 0 self._non_stream = list(non_stream or ()) self._stream_events = [list(events) for events in (stream_events or ())] @@ -186,6 +189,46 @@ async def test_run_chat_loop_messages_kind_text() -> None: assert any(isinstance(e, UsageEvent) and e.usage.prompt_tokens == 10 for e in events) assert isinstance(messages[-1], AssistantChatMessage) assert messages[-1].content == "done" + # No thinking configured → the request carries no thinking block. + assert client.calls[0].thinking is UNSET + + +async def test_run_chat_loop_passes_reasoning_effort_and_client_thinking_budget() -> None: + client = ScriptedMessagesClient(non_stream=[_message_response(text="done")]) + client.thinking_budget_tokens = 12000 + events = [ + event + async for event in run_chat_loop( + cast("Any", client), + [UserChatMessage(content="go")], + model="claude-test", + stream=False, + reasoning_effort="high", + ) + ] + assert any(isinstance(e, TextDeltaEvent) for e in events) + thinking = client.calls[0].thinking + assert thinking is not UNSET + assert thinking.type == "enabled" + # The client's explicit budget wins over the level's derived one. + assert thinking.budget_tokens == 12000 + + +async def test_run_chat_loop_reasoning_effort_none_disables_thinking() -> None: + client = ScriptedMessagesClient(non_stream=[_message_response(text="done")]) + _ = [ + event + async for event in run_chat_loop( + cast("Any", client), + [UserChatMessage(content="go")], + model="claude-test", + stream=False, + reasoning_effort="none", + ) + ] + thinking = client.calls[0].thinking + assert thinking is not UNSET + assert thinking.type == "disabled" async def test_run_chat_loop_messages_kind_tool_round() -> None: diff --git a/tests/test_agent/test_responses_bridge.py b/tests/test_agent/test_responses_bridge.py index 9bde384..28ca056 100644 --- a/tests/test_agent/test_responses_bridge.py +++ b/tests/test_agent/test_responses_bridge.py @@ -235,6 +235,18 @@ def test_chat_param_to_responses_kwargs() -> None: assert kwargs["store"] is False assert kwargs["temperature"] == 0.2 assert len(kwargs["tools"]) == 1 + # No effort configured → no reasoning block. + assert "reasoning" not in kwargs + + +def test_chat_param_to_responses_kwargs_reasoning_effort() -> None: + param = ChatCompletionsParam( + model="gpt-test", + messages=[UserChatMessage(content="hi")], + reasoning_effort="xhigh", + ) + kwargs = chat_param_to_responses_kwargs(param) + assert kwargs["reasoning"].effort == "xhigh" def test_provider_tools_merged_after_local_functions() -> None: