fix(subagent): preserve reasoning fields across tool turns

Share assistant message construction between the main agent and subagents, and add a regression test to keep reasoning_content and thinking_blocks in follow-up tool rounds.
This commit is contained in:
Re-bin
2026-03-11 03:47:24 +00:00
parent 1d611f9bf3
commit ddccf25bb1
4 changed files with 74 additions and 23 deletions

View File

@@ -10,7 +10,7 @@ from typing import Any
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
from nanobot.utils.helpers import detect_image_mime from nanobot.utils.helpers import build_assistant_message, detect_image_mime
class ContextBuilder: class ContextBuilder:
@@ -182,12 +182,10 @@ Reply directly with text for conversations. Only use the 'message' tool to send
thinking_blocks: list[dict] | None = None, thinking_blocks: list[dict] | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Add an assistant message to the message list.""" """Add an assistant message to the message list."""
msg: dict[str, Any] = {"role": "assistant", "content": content} messages.append(build_assistant_message(
if tool_calls: content,
msg["tool_calls"] = tool_calls tool_calls=tool_calls,
if reasoning_content is not None: reasoning_content=reasoning_content,
msg["reasoning_content"] = reasoning_content thinking_blocks=thinking_blocks,
if thinking_blocks: ))
msg["thinking_blocks"] = thinking_blocks
messages.append(msg)
return messages return messages

View File

@@ -16,6 +16,7 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig from nanobot.config.schema import ExecToolConfig
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.utils.helpers import build_assistant_message
class SubagentManager: class SubagentManager:
@@ -133,7 +134,6 @@ class SubagentManager:
) )
if response.has_tool_calls: if response.has_tool_calls:
# Add assistant message with tool calls
tool_call_dicts = [ tool_call_dicts = [
{ {
"id": tc.id, "id": tc.id,
@@ -145,19 +145,12 @@ class SubagentManager:
} }
for tc in response.tool_calls for tc in response.tool_calls
] ]
assistant_msg: dict[str, Any] = { messages.append(build_assistant_message(
"role": "assistant", response.content or "",
"content": response.content or "", tool_calls=tool_call_dicts,
"tool_calls": tool_call_dicts, reasoning_content=response.reasoning_content,
} thinking_blocks=response.thinking_blocks,
# Preserve reasoning_content for providers that require it ))
# (e.g. Deepseek Reasoner mandates this field on every
# assistant message when thinking mode is active).
if response.reasoning_content is not None:
assistant_msg["reasoning_content"] = response.reasoning_content
if response.thinking_blocks:
assistant_msg["thinking_blocks"] = response.thinking_blocks
messages.append(assistant_msg)
# Execute tools # Execute tools
for tool_call in response.tool_calls: for tool_call in response.tool_calls:

View File

@@ -72,6 +72,23 @@ def split_message(content: str, max_len: int = 2000) -> list[str]:
return chunks return chunks
def build_assistant_message(
content: str | None,
tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None,
thinking_blocks: list[dict] | None = None,
) -> dict[str, Any]:
"""Build a provider-safe assistant message with optional reasoning fields."""
msg: dict[str, Any] = {"role": "assistant", "content": content}
if tool_calls:
msg["tool_calls"] = tool_calls
if reasoning_content is not None:
msg["reasoning_content"] = reasoning_content
if thinking_blocks:
msg["thinking_blocks"] = thinking_blocks
return msg
def estimate_prompt_tokens( def estimate_prompt_tokens(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None, tools: list[dict[str, Any]] | None = None,

View File

@@ -165,3 +165,46 @@ class TestSubagentCancellation:
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus) mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus)
assert await mgr.cancel_by_session("nonexistent") == 0 assert await mgr.cancel_by_session("nonexistent") == 0
@pytest.mark.asyncio
async def test_subagent_preserves_reasoning_fields_in_tool_turn(self, monkeypatch, tmp_path):
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
captured_second_call: list[dict] = []
call_count = {"n": 0}
async def scripted_chat_with_retry(*, messages, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
return LLMResponse(
content="thinking",
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
reasoning_content="hidden reasoning",
thinking_blocks=[{"type": "thinking", "thinking": "step"}],
)
captured_second_call[:] = messages
return LLMResponse(content="done", tool_calls=[])
provider.chat_with_retry = scripted_chat_with_retry
mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus)
async def fake_execute(self, name, arguments):
return "tool result"
monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
assistant_messages = [
msg for msg in captured_second_call
if msg.get("role") == "assistant" and msg.get("tool_calls")
]
assert len(assistant_messages) == 1
assert assistant_messages[0]["reasoning_content"] == "hidden reasoning"
assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}]