From 2836064c4709dddd5bebf3f7e1a8685ead70f629 Mon Sep 17 00:00:00 2001 From: Carmen Iniesta Date: Tue, 1 Sep 2026 16:04:58 +0100 Subject: [PATCH 1/2] fix tool choice --- agents/smart/src/hyperforge_smart/agent.py | 2 ++ agents/smart/tests/test_smart.py | 29 +++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/agents/smart/src/hyperforge_smart/agent.py b/agents/smart/src/hyperforge_smart/agent.py index 6f4243fa..fc26fe97 100644 --- a/agents/smart/src/hyperforge_smart/agent.py +++ b/agents/smart/src/hyperforge_smart/agent.py @@ -21,6 +21,7 @@ ChatModel, Message, Tool, + ToolChoiceAuto, UserPrompt, ) from pydantic import BaseModel, ConfigDict, Field @@ -478,6 +479,7 @@ async def choose_tools( generative_model=model.model_id, reasoning=build_reasoning(model), tools=tools, + tool_choice=ToolChoiceAuto(), user_prompt=UserPrompt( prompt=f"{system}\n\nChoose the best tool or tools for the task. Call task_complete when you have enough information." ), diff --git a/agents/smart/tests/test_smart.py b/agents/smart/tests/test_smart.py index 5a207913..9f5b3a28 100644 --- a/agents/smart/tests/test_smart.py +++ b/agents/smart/tests/test_smart.py @@ -15,7 +15,7 @@ from hyperforge.minimal_fixtures import cassette_nua_key from hyperforge.models import Context, HistoryQuestionAnswer, Rules from hyperforge.pubsub import UserToAgentInteraction -from nuclia.lib.nua_responses import Author, Message +from nuclia.lib.nua_responses import Author, Message, ToolChoiceAuto from hyperforge_smart.agent import RegisteredAgent, SmartAgent from hyperforge_smart.config import SmartAgentConfig @@ -97,6 +97,33 @@ } +@pytest.mark.asyncio +async def test_choose_tools_uses_auto_tool_choice(): + smart_agent = SmartAgent( + config=SmartAgentConfig.model_validate( + { + "id": "smart-test", + "module": "smart", + "title": "Smart Agent", + "planning_mode": "reactive", + } + ) + ) + captured_items = [] + + async def execute_raw(item, tracking=None): + captured_items.append(item) + return SimpleNamespace(), 0.0, 0.0 + + await smart_agent.choose_tools( + manager=SimpleNamespace(execute_raw=execute_raw), + messages=[], + tools=[], + ) + + assert isinstance(captured_items[0].tool_choice, ToolChoiceAuto) + + @pytest.mark.asyncio @pytest.mark.vcr(ignore_localhost=True) @pytest.mark.parametrize( From 2f176905f65a8641cb3aec4866e9a7197688d7b2 Mon Sep 17 00:00:00 2001 From: Carmen Iniesta Date: Tue, 1 Sep 2026 16:09:17 +0100 Subject: [PATCH 2/2] mcp fix --- agents/mcp/src/hyperforge_mcp/agent.py | 24 +++++++-- agents/mcp/tests/test_mcp.py | 68 +++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/agents/mcp/src/hyperforge_mcp/agent.py b/agents/mcp/src/hyperforge_mcp/agent.py index 0e7ae3a3..bcb3c006 100644 --- a/agents/mcp/src/hyperforge_mcp/agent.py +++ b/agents/mcp/src/hyperforge_mcp/agent.py @@ -30,7 +30,15 @@ from mcp.shared.context import RequestContext from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder -from nuclia.lib.nua_responses import Author, ChatModel, Image, Message, Tool, UserPrompt +from nuclia.lib.nua_responses import ( + Author, + ChatModel, + Image, + Message, + Tool, + ToolChoiceAuto, + UserPrompt, +) from nuclia_models.predict.generative_responses import GenerativeFullResponse from pydantic import FileUrl @@ -375,6 +383,7 @@ async def choose_tool( generative_model=self.config.tool_choice_model.model_id, reasoning=build_reasoning(self.config.tool_choice_model), tools=tools, + tool_choice=ToolChoiceAuto(), user_prompt=UserPrompt( prompt="Choose the best tool or tools for the task, select task_complete if no more tools are needed according to the user request and previous interactions" ), @@ -950,14 +959,15 @@ async def mcp_interaction( total_input_tokens += input_tokens total_output_tokens += output_tokens - for tool_name, tool_arguments in iterate_tools_resp(resp): + tool_calls = list(iterate_tools_resp(resp)) + for tool_name, tool_arguments in tool_calls: await self.process_tool( memory, tool_name, tool_arguments, context, messages, images ) - if self.config.work_chain is False: + if self.config.work_chain is False or not tool_calls: logger.debug("Exiting loop on tool") - return input_tokens, output_tokens + return total_input_tokens, total_output_tokens count = 0 finished = False @@ -972,7 +982,11 @@ async def mcp_interaction( ) total_input_tokens += input_tokens total_output_tokens += output_tokens - for tool_name, tool_arguments in iterate_tools_resp(resp): + tool_calls = list(iterate_tools_resp(resp)) + if not tool_calls: + logger.debug("Exiting loop because no tool was selected") + break + for tool_name, tool_arguments in tool_calls: if tool_name == "task_complete": logger.debug("Exiting loop on task_complete tool") finished = True diff --git a/agents/mcp/tests/test_mcp.py b/agents/mcp/tests/test_mcp.py index 2fc8f553..ee39c8e6 100644 --- a/agents/mcp/tests/test_mcp.py +++ b/agents/mcp/tests/test_mcp.py @@ -1,13 +1,16 @@ import os from copy import deepcopy -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch import pytest from hyperforge.engine import main as arag_main from hyperforge.interaction import AragAnswer from hyperforge.minimal_fixtures import cassette_nua_key +from nuclia.lib.nua_responses import Tool, ToolChoiceAuto -from hyperforge_mcp.agent import _tool_parameters +from hyperforge_mcp.agent import MCPAgent, _tool_parameters +from hyperforge_mcp.config import MCPAgentConfig from .mcp_server import run from .mcp_server_no_prompt import run_mcp_server_no_prompt @@ -43,6 +46,67 @@ async def test_tool_parameters_preserve_array_item_schema(): } +async def test_choose_tool_uses_auto_tool_choice(): + mcp_agent = MCPAgent( + MCPAgentConfig.model_validate( + { + "id": "mcp-test", + "module": "mcp", + "source": "mcphttp-01", + } + ) + ) + captured_items = [] + + async def execute_raw(item, tracking=None): + captured_items.append(item) + return SimpleNamespace(tools=None), 0.0, 0.0 + + await mcp_agent.choose_tool( + manager=SimpleNamespace(execute_raw=execute_raw), + images=[], + messages=[], + extra_tools=[ + Tool(name="task_complete", description="", parameters={"type": "object"}) + ], + ) + + assert isinstance(captured_items[0].tool_choice, ToolChoiceAuto) + + +async def test_mcp_interaction_stops_when_no_tool_is_selected(): + mcp_agent = MCPAgent( + MCPAgentConfig.model_validate( + { + "id": "mcp-test", + "module": "mcp", + "source": "mcphttp-01", + "work_chain": True, + "max_turns": 5, + } + ) + ) + choose_tool = AsyncMock(return_value=(SimpleNamespace(tools=None), 1.0, 2.0)) + + with ( + patch.object( + MCPAgent, + "get_tool_selection_prompt", + AsyncMock(return_value=([], [])), + ), + patch.object(MCPAgent, "choose_tool", choose_tool), + ): + tokens = await mcp_agent.mcp_interaction( + memory=SimpleNamespace(get_tracking_info=lambda: None), + manager=SimpleNamespace(), + question="No tool is needed", + context=SimpleNamespace(), + ) + + assert tokens == (1.0, 2.0) + choose_tool.assert_awaited_once() + + CONFIG = { "drivers": [ {