fix(llm): preserve reasoning content across tool turns (#1034)

This commit is contained in:
Daniel Peng 2026-08-20 23:12:47 +08:00 committed by GitHub
parent 4797489281
commit 67f4dbf23f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 157 additions and 0 deletions

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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",
}