From 67f4dbf23ffba5bf8f7f5197ff8eba11f75993da Mon Sep 17 00:00:00 2001 From: Daniel Peng <97350516+original4422@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:12:47 +0800 Subject: [PATCH] fix(llm): preserve reasoning content across tool turns (#1034) --- src/llm/history_adapters.py | 2 + src/llm/tool_loop.py | 3 + tests/llm/test_history_adapters.py | 48 ++++++++ tests/llm/test_tool_loop_reasoning_content.py | 104 ++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 tests/llm/test_tool_loop_reasoning_content.py diff --git a/src/llm/history_adapters.py b/src/llm/history_adapters.py index 02d2ea05..8e3b837e 100644 --- a/src/llm/history_adapters.py +++ b/src/llm/history_adapters.py @@ -121,6 +121,8 @@ class OpenAIHistoryAdapter: } if result.reasoning_details: message["reasoning_details"] = result.reasoning_details + elif result.thinking_content: + message["reasoning_content"] = result.thinking_content return message def format_tool_results( diff --git a/src/llm/tool_loop.py b/src/llm/tool_loop.py index 0feec1d8..783d4965 100644 --- a/src/llm/tool_loop.py +++ b/src/llm/tool_loop.py @@ -212,6 +212,7 @@ def format_assistant_tool_message( tool_calls: list[dict[str, Any]], thinking_blocks: list[dict[str, Any]] | None = None, reasoning_details: list[dict[str, Any]] | None = None, + thinking_content: str | None = None, ) -> dict[str, Any]: """Format an assistant message with tool calls in provider-native shape.""" from .backend import CompletionResult as BackendCompletionResult @@ -229,6 +230,7 @@ def format_assistant_tool_message( ) for tool_call in tool_calls ], + thinking_content=thinking_content, thinking_blocks=thinking_blocks or [], reasoning_details=reasoning_details or [], ) @@ -573,6 +575,7 @@ async def execute_tool_loop( response.tool_calls_made, response.thinking_blocks, response.reasoning_details, + response.thinking_content, ) conversation_messages.append(assistant_message) diff --git a/tests/llm/test_history_adapters.py b/tests/llm/test_history_adapters.py index 6881df6a..a992398d 100644 --- a/tests/llm/test_history_adapters.py +++ b/tests/llm/test_history_adapters.py @@ -1,3 +1,5 @@ +import pytest + from src.llm.backend import CompletionResult, ToolCallResult from src.llm.history_adapters import ( AnthropicHistoryAdapter, @@ -65,3 +67,49 @@ def test_openai_history_adapter_preserves_reasoning_details() -> None: assert message["role"] == "assistant" assert message["reasoning_details"] == [{"type": "reasoning", "content": "step 1"}] assert message["tool_calls"][0]["function"]["name"] == "search" + + +def test_openai_history_adapter_preserves_thinking_content() -> None: + adapter = OpenAIHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + thinking_content="step 1", + tool_calls=[ + ToolCallResult(id="tool_1", name="search", input={"query": "honcho"}) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["reasoning_content"] == "step 1" + assert "reasoning_details" not in message + + +def test_openai_history_adapter_prefers_reasoning_details() -> None: + adapter = OpenAIHistoryAdapter() + reasoning_details = [{"type": "reasoning", "content": "step 1"}] + result = CompletionResult( + content="Calling a tool", + thinking_content="duplicate step 1", + reasoning_details=reasoning_details, + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["reasoning_details"] == reasoning_details + assert "reasoning_content" not in message + + +@pytest.mark.parametrize("thinking_content", [None, ""]) +def test_openai_history_adapter_omits_empty_thinking_content( + thinking_content: str | None, +) -> None: + adapter = OpenAIHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + thinking_content=thinking_content, + ) + + message = adapter.format_assistant_tool_message(result) + + assert "reasoning_content" not in message diff --git a/tests/llm/test_tool_loop_reasoning_content.py b/tests/llm/test_tool_loop_reasoning_content.py new file mode 100644 index 00000000..cd4ec91b --- /dev/null +++ b/tests/llm/test_tool_loop_reasoning_content.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, cast +from unittest.mock import patch + +import pytest + +from src.config import ModelConfig +from src.llm import tool_loop +from src.llm.runtime import AttemptPlan +from src.llm.tool_loop import execute_tool_loop +from src.llm.types import HonchoLLMCallResponse, ProviderClient + + +def _make_plan() -> AttemptPlan: + return AttemptPlan( + provider="openai", + model="deepseek-v4-pro", + client=cast(ProviderClient, object()), + thinking_budget_tokens=None, + reasoning_effort=None, + selected_config=ModelConfig( + model="deepseek-v4-pro", + transport="openai", + ), + attempt=1, + retry_attempts=1, + is_fallback=False, + ) + + +@pytest.mark.asyncio +async def test_tool_loop_replays_reasoning_content_on_continuation() -> None: + calls: list[list[dict[str, Any]]] = [] + responses = iter( + [ + HonchoLLMCallResponse( + content="", + output_tokens=5, + finish_reasons=["tool_calls"], + tool_calls_made=[ + { + "id": "call_1", + "name": "search", + "input": {"query": "honcho"}, + } + ], + thinking_content="DeepSeek reasoning", + ), + HonchoLLMCallResponse( + content="done", + output_tokens=3, + finish_reasons=["stop"], + tool_calls_made=[], + ), + ] + ) + + async def fake_call(*_args: Any, **kwargs: Any) -> HonchoLLMCallResponse[Any]: + calls.append(deepcopy(kwargs["messages"])) + return next(responses) + + async def execute_search(_name: str, _input: dict[str, Any]) -> str: + return "result" + + with patch.object(tool_loop, "honcho_llm_call_inner", new=fake_call): + result = await execute_tool_loop( + prompt="hi", + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + tools=[ + { + "name": "search", + "description": "Search", + "input_schema": {"type": "object"}, + } + ], + tool_choice="auto", + tool_executor=execute_search, + max_tool_iterations=5, + response_model=None, + json_mode=False, + temperature=None, + stop_seqs=None, + verbosity=None, + enable_retry=False, + retry_attempts=1, + max_input_tokens=None, + get_attempt_plan=_make_plan, + before_retry_callback=lambda _retry_state: None, + stream_final=False, + telemetry=None, + ) + + assert isinstance(result, HonchoLLMCallResponse) + assert len(calls) == 2 + assert calls[1][1]["reasoning_content"] == "DeepSeek reasoning" + assert calls[1][1]["tool_calls"][0]["function"]["name"] == "search" + assert calls[1][2] == { + "role": "tool", + "tool_call_id": "call_1", + "content": "result", + }