Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions agents/mcp/src/hyperforge_mcp/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
),
Expand Down Expand Up @@ -951,14 +960,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
Expand All @@ -973,7 +983,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
Expand Down
68 changes: 66 additions & 2 deletions agents/mcp/tests/test_mcp.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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": [
{
Expand Down
2 changes: 2 additions & 0 deletions agents/smart/src/hyperforge_smart/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ChatModel,
Message,
Tool,
ToolChoiceAuto,
UserPrompt,
)
from pydantic import BaseModel, ConfigDict, Field
Expand Down Expand Up @@ -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."
),
Expand Down
29 changes: 28 additions & 1 deletion agents/smart/tests/test_smart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading