From 2ffeb9295bdb4a5ef308498f60f45b2448ab48d2 Mon Sep 17 00:00:00 2001 From: lailoo Date: Wed, 11 Mar 2026 00:47:09 +0800 Subject: [PATCH 1/2] fix(subagent): preserve reasoning_content in assistant messages Subagent's _run_subagent() was dropping reasoning_content and thinking_blocks when building assistant messages for the conversation history. Providers like Deepseek Reasoner require reasoning_content on every assistant message when thinking mode is active, causing a 400 BadRequestError on the second LLM round-trip. Align with the main AgentLoop which already preserves these fields via ContextBuilder.add_assistant_message(). Closes #1834 --- nanobot/agent/subagent.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index f9eda1f..308e67d 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -145,11 +145,19 @@ class SubagentManager: } for tc in response.tool_calls ] - messages.append({ + assistant_msg: dict[str, Any] = { "role": "assistant", "content": response.content or "", "tool_calls": tool_call_dicts, - }) + } + # 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 for tool_call in response.tool_calls: From ddccf25bb1be8529d453d2344eea21bd593021c2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Mar 2026 03:47:24 +0000 Subject: [PATCH 2/2] 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. --- nanobot/agent/context.py | 16 +++++++-------- nanobot/agent/subagent.py | 21 +++++++------------ nanobot/utils/helpers.py | 17 ++++++++++++++++ tests/test_task_cancel.py | 43 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 23 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 2c648eb..e47fcb8 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -10,7 +10,7 @@ from typing import Any from nanobot.agent.memory import MemoryStore 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: @@ -182,12 +182,10 @@ Reply directly with text for conversations. Only use the 'message' tool to send thinking_blocks: list[dict] | None = None, ) -> list[dict[str, Any]]: """Add an assistant message to the message list.""" - 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 - messages.append(msg) + messages.append(build_assistant_message( + content, + tool_calls=tool_calls, + reasoning_content=reasoning_content, + thinking_blocks=thinking_blocks, + )) return messages diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 308e67d..eff0b4f 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -16,6 +16,7 @@ from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.config.schema import ExecToolConfig from nanobot.providers.base import LLMProvider +from nanobot.utils.helpers import build_assistant_message class SubagentManager: @@ -133,7 +134,6 @@ class SubagentManager: ) if response.has_tool_calls: - # Add assistant message with tool calls tool_call_dicts = [ { "id": tc.id, @@ -145,19 +145,12 @@ class SubagentManager: } for tc in response.tool_calls ] - assistant_msg: dict[str, Any] = { - "role": "assistant", - "content": response.content or "", - "tool_calls": tool_call_dicts, - } - # 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) + messages.append(build_assistant_message( + response.content or "", + tool_calls=tool_call_dicts, + reasoning_content=response.reasoning_content, + thinking_blocks=response.thinking_blocks, + )) # Execute tools for tool_call in response.tool_calls: diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 9242ba6..6d2c670 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -72,6 +72,23 @@ def split_message(content: str, max_len: int = 2000) -> list[str]: 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( messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py index 27a2d73..62ab2cc 100644 --- a/tests/test_task_cancel.py +++ b/tests/test_task_cancel.py @@ -165,3 +165,46 @@ class TestSubagentCancellation: provider.get_default_model.return_value = "test-model" mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus) 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"}]