fix(llm): preserve reasoning content across tool turns (#1034)
This commit is contained in:
parent
4797489281
commit
67f4dbf23f
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
Loading…
Reference in New Issue