Skip to content

Commit d3d3acc

Browse files
authored
Default Python tool to direct provider (#1467)
Signed-off-by: tamohannes <hovhannes.tamoyan@gmail.com>
1 parent 69fbc9a commit d3d3acc

2 files changed

Lines changed: 46 additions & 151 deletions

File tree

nemo_skills/mcp/servers/python_tool.py

Lines changed: 18 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626

2727
from nemo_skills.code_execution.sandbox import get_sandbox
2828
from nemo_skills.mcp.tool_manager import Tool
29-
from nemo_skills.mcp.tool_providers import MCPClientTool
3029
from nemo_skills.mcp.utils import add_config_args, load_mcp_config
3130

3231
logger = logging.getLogger(__name__)
@@ -110,74 +109,24 @@ def main():
110109
# ==============================
111110

112111

113-
class PythonTool(MCPClientTool):
114-
def __init__(self, exec_timeout_s: float = DEFAULT_EXEC_TIMEOUT_S) -> None:
115-
super().__init__()
116-
# Defaults for stdio Python MCP using explicit client class
117-
self.apply_config_updates(
118-
{
119-
"client": "nemo_skills.mcp.clients.MCPStdioClient",
120-
"client_params": {
121-
"command": "python",
122-
"args": ["-m", "nemo_skills.mcp.servers.python_tool"],
123-
},
124-
# hide args from schemas and sanitize at runtime
125-
"hide_args": {"stateful_python_code_exec": ["session_id", "timeout"]},
126-
# use explicit Hydra connector built from full context by default
127-
"init_hook": "hydra",
128-
# execution-specific default
129-
"exec_timeout_s": exec_timeout_s,
130-
}
131-
)
132-
self.requests_to_sessions = defaultdict(lambda: None)
133-
134-
def _description(self) -> str:
135-
return get_python_tool_description(self._config.get("exec_timeout_s", DEFAULT_EXEC_TIMEOUT_S))
136-
137-
async def list_tools(self) -> List[Dict[str, Any]]:
138-
tools = await super().list_tools()
139-
return [
140-
{**tool, "description": self._description()} if tool.get("name") == "stateful_python_code_exec" else tool
141-
for tool in tools
142-
]
143-
144-
async def execute(self, tool_name: str, arguments: Dict[str, Any], extra_args: Dict[str, Any] | None = None):
145-
# Ensure timeout is sent via extra_args (post-sanitize), not in main arguments
146-
arguments = dict(arguments)
147-
# TODO: error handling?
148-
request_id = extra_args.pop("request_id")
149-
merged_extra = dict(extra_args or {})
150-
merged_extra.setdefault("timeout", self._config.get("exec_timeout_s", DEFAULT_EXEC_TIMEOUT_S))
151-
merged_extra["session_id"] = self.requests_to_sessions[request_id]
152-
result = await self._client.call_tool(tool=tool_name, args=arguments, extra_args=merged_extra)
153-
self.requests_to_sessions[request_id] = result["session_id"]
154-
output = f"{result['output_dict']['stdout']}{result['output_dict']['stderr']}"
155-
if output.endswith("\n"): # there is always a trailing newline, removing it
156-
output = output[:-1]
157-
return output
158-
159-
async def shutdown(self) -> None:
160-
return None
161-
162-
163112
class DirectPythonTool(Tool):
164113
"""Python code execution tool that calls the sandbox directly, bypassing MCP.
165114
166-
This is a drop-in replacement for PythonTool that eliminates the MCP protocol
167-
overhead (subprocess spawning, MCP session initialization, JSON-RPC serialization)
168-
by calling sandbox.execute_code() directly via HTTP.
115+
This is the in-process implementation used by PythonTool. It eliminates the
116+
MCP protocol overhead (subprocess spawning, MCP session initialization,
117+
JSON-RPC serialization) by calling sandbox.execute_code() directly via HTTP.
169118
170-
Shared config keys with PythonTool (so switching is just changing the module spec):
119+
Config keys:
171120
- hide_args: controls which args are stripped from schemas and sanitized at runtime
172121
- exec_timeout_s: default execution timeout
173122
174123
Usage:
175-
tool_modules=["nemo_skills.mcp.servers.python_tool::DirectPythonTool"]
124+
tool_modules=["nemo_skills.mcp.servers.python_tool::PythonTool"]
176125
"""
177126

178127
def __init__(self, exec_timeout_s: float = DEFAULT_EXEC_TIMEOUT_S) -> None:
179128
self._config: Dict[str, Any] = {
180-
# Same keys/defaults as PythonTool (minus MCP-specific: client, client_params, init_hook)
129+
# MCP-specific keys (client, client_params, init_hook) are intentionally absent here
181130
"hide_args": {"stateful_python_code_exec": ["session_id", "timeout"]},
182131
"exec_timeout_s": exec_timeout_s,
183132
"sandbox": {},
@@ -299,5 +248,17 @@ async def cleanup_request(self, request_id: str) -> None:
299248
self.requests_to_sessions.pop(request_id, None)
300249

301250

251+
class PythonTool(DirectPythonTool):
252+
"""Default Python tool implementation.
253+
254+
Uses the direct in-process provider (DirectPythonTool) instead of stdio MCP
255+
transport so generation jobs do not depend on a subprocess teardown path.
256+
Kept as a subclass for backward compatibility with existing
257+
``tool_modules=[...python_tool::PythonTool]`` references.
258+
"""
259+
260+
pass
261+
262+
302263
if __name__ == "__main__":
303264
main()

tests/test_mcp_clients.py

Lines changed: 28 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ async def get_items(count: int):
637637

638638

639639
# ==============================
640-
# Comparison tests: MCP PythonTool vs DirectPythonTool
640+
# DirectPythonTool functional tests
641641
# ==============================
642642

643643

@@ -815,74 +815,50 @@ async def test_direct_python_tool_cleanup_request_deletes_session():
815815

816816

817817
@pytest.mark.asyncio
818-
async def test_mcp_vs_direct_python_tool_parity():
819-
"""MCP-based PythonTool and DirectPythonTool produce identical results for the same tool calls."""
820-
from nemo_skills.mcp.servers.python_tool import DirectPythonTool, PythonTool
818+
async def test_direct_python_tool_session_sequence():
819+
"""DirectPythonTool runs a multi-step stateful session and computes correct results."""
820+
from nemo_skills.mcp.servers.python_tool import DirectPythonTool
821821

822822
sandbox_context = {"sandbox": {"sandbox_type": "local"}}
823823

824-
# Set up DirectPythonTool
825824
direct = DirectPythonTool()
826825
direct.configure(context=sandbox_context)
827826

828-
# Set up MCP PythonTool
829-
mcp_tool = PythonTool()
830-
mcp_tool.configure(context=sandbox_context)
831-
832-
# Verify both expose the same tool name
833-
direct_tools = await direct.list_tools()
834-
mcp_tools = await mcp_tool.list_tools()
835-
assert direct_tools[0]["name"] == mcp_tools[0]["name"] == "stateful_python_code_exec"
827+
assert (await direct.list_tools())[0]["name"] == "stateful_python_code_exec"
836828

837-
# Define a sequence of tool calls that exercises session persistence
829+
# A sequence of tool calls that exercises session persistence
838830
tool_calls = [
839-
{"code": "import math", "request_id": "parity"},
840-
{"code": "result = math.factorial(10)", "request_id": "parity"},
841-
{"code": "print(result)", "request_id": "parity"},
842-
{"code": "x = [i**2 for i in range(5)]", "request_id": "parity"},
843-
{"code": "print(sum(x))", "request_id": "parity"},
831+
{"code": "import math", "request_id": "seq"},
832+
{"code": "result = math.factorial(10)", "request_id": "seq"},
833+
{"code": "print(result)", "request_id": "seq"},
834+
{"code": "x = [i**2 for i in range(5)]", "request_id": "seq"},
835+
{"code": "print(sum(x))", "request_id": "seq"},
844836
]
845837

846-
direct_results = await _run_tool_sequence(direct, tool_calls)
847-
mcp_results = await _run_tool_sequence(mcp_tool, tool_calls)
848-
849-
for i, (d, m) in enumerate(zip(direct_results, mcp_results)):
850-
assert d == m, f"Mismatch at step {i}: direct={d!r}, mcp={m!r}"
838+
results = await _run_tool_sequence(direct, tool_calls)
851839

852840
# Verify the actual computed values are correct
853-
assert direct_results[2] == "3628800" # 10!
854-
assert direct_results[4] == "30" # 0 + 1 + 4 + 9 + 16
841+
assert results[2] == "3628800" # 10!
842+
assert results[4] == "30" # 0 + 1 + 4 + 9 + 16
855843

856844
await direct.shutdown()
857-
await mcp_tool.shutdown()
858845

859846

860847
@pytest.mark.asyncio
861-
async def test_mcp_vs_direct_error_parity():
862-
"""Both implementations handle errors the same way."""
863-
from nemo_skills.mcp.servers.python_tool import DirectPythonTool, PythonTool
848+
async def test_direct_python_tool_surfaces_exceptions():
849+
"""DirectPythonTool surfaces Python exceptions in the returned output."""
850+
from nemo_skills.mcp.servers.python_tool import DirectPythonTool
864851

865852
sandbox_context = {"sandbox": {"sandbox_type": "local"}}
866853

867854
direct = DirectPythonTool()
868855
direct.configure(context=sandbox_context)
869856

870-
mcp_tool = PythonTool()
871-
mcp_tool.configure(context=sandbox_context)
872-
873-
tool_calls = [
874-
{"code": "1 / 0", "request_id": "err"},
875-
]
876-
877-
direct_results = await _run_tool_sequence(direct, tool_calls)
878-
mcp_results = await _run_tool_sequence(mcp_tool, tool_calls)
857+
results = await _run_tool_sequence(direct, [{"code": "1 / 0", "request_id": "err"}])
879858

880-
# Both should contain ZeroDivisionError
881-
assert "ZeroDivisionError" in direct_results[0]
882-
assert "ZeroDivisionError" in mcp_results[0]
859+
assert "ZeroDivisionError" in results[0]
883860

884861
await direct.shutdown()
885-
await mcp_tool.shutdown()
886862

887863

888864
# ==============================
@@ -938,24 +914,21 @@ def test_python_tools_accept_exec_timeout_argument():
938914
assert DirectPythonTool(exec_timeout_s=42).default_config()["exec_timeout_s"] == 42
939915

940916

917+
def test_python_tool_defaults_to_direct_provider():
918+
from nemo_skills.mcp.servers.python_tool import DirectPythonTool, PythonTool
919+
920+
# PythonTool is now the in-process direct provider (no stdio MCP transport).
921+
assert isinstance(PythonTool(), DirectPythonTool)
922+
923+
941924
@pytest.mark.asyncio
942925
async def test_python_tool_description_uses_exec_timeout_override():
943926
from nemo_skills.mcp.servers.python_tool import PythonTool
944927

945-
class FakeClient:
946-
async def list_tools(self):
947-
return [
948-
{
949-
"name": "stateful_python_code_exec",
950-
"description": "stale server description",
951-
"input_schema": {"type": "object", "properties": {"code": {"type": "string"}}},
952-
}
953-
]
954-
955928
tool = PythonTool()
956929
tool.configure(
957-
overrides={"client": FakeClient, "client_params": {}, "exec_timeout_s": 37, "init_hook": None},
958-
context={},
930+
overrides={"exec_timeout_s": 37},
931+
context={"sandbox": {"sandbox_type": "local"}},
959932
)
960933

961934
tools = await tool.list_tools()
@@ -964,45 +937,6 @@ async def list_tools(self):
964937
assert "10.0 seconds" not in tools[0]["description"]
965938

966939

967-
@pytest.mark.asyncio
968-
async def test_python_tool_uses_exec_timeout_argument():
969-
from nemo_skills.mcp.servers.python_tool import PythonTool
970-
971-
class FakeClient:
972-
def __init__(self):
973-
self.extra_args = None
974-
975-
async def list_tools(self):
976-
return [
977-
{
978-
"name": "stateful_python_code_exec",
979-
"description": "stale server description",
980-
"input_schema": {"type": "object", "properties": {"code": {"type": "string"}}},
981-
}
982-
]
983-
984-
async def call_tool(self, tool, args, extra_args=None):
985-
self.extra_args = extra_args
986-
return {"session_id": "session", "output_dict": {"stdout": "done\n", "stderr": ""}}
987-
988-
fake_client = FakeClient()
989-
tool = PythonTool(exec_timeout_s=42)
990-
tool._client = fake_client
991-
992-
result = await tool.execute(
993-
"stateful_python_code_exec",
994-
{"code": "print('done')"},
995-
extra_args={"request_id": "timeout-arg"},
996-
)
997-
998-
assert result == "done"
999-
assert fake_client.extra_args["timeout"] == 42
1000-
1001-
tools = await tool.list_tools()
1002-
assert "42.0 seconds" in tools[0]["description"]
1003-
assert "10.0 seconds" not in tools[0]["description"]
1004-
1005-
1006940
@pytest.mark.asyncio
1007941
async def test_direct_python_tool_uses_exec_timeout_argument():
1008942
from nemo_skills.mcp.servers.python_tool import DirectPythonTool

0 commit comments

Comments
 (0)