hud-python 0.5.1__py3-none-any.whl → 0.5.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hud/__init__.py +1 -1
- hud/agents/__init__.py +65 -6
- hud/agents/base.py +33 -15
- hud/agents/claude.py +60 -31
- hud/agents/gateway.py +42 -0
- hud/agents/gemini.py +15 -26
- hud/agents/gemini_cua.py +6 -17
- hud/agents/misc/response_agent.py +7 -0
- hud/agents/openai.py +16 -29
- hud/agents/openai_chat.py +3 -19
- hud/agents/operator.py +5 -17
- hud/agents/resolver.py +70 -0
- hud/agents/tests/test_claude.py +2 -4
- hud/agents/tests/test_openai.py +2 -1
- hud/agents/tests/test_resolver.py +192 -0
- hud/agents/types.py +148 -0
- hud/cli/__init__.py +34 -3
- hud/cli/build.py +37 -5
- hud/cli/dev.py +11 -2
- hud/cli/eval.py +51 -39
- hud/cli/flows/init.py +1 -1
- hud/cli/pull.py +1 -1
- hud/cli/push.py +9 -2
- hud/cli/tests/test_build.py +2 -2
- hud/cli/tests/test_push.py +1 -1
- hud/cli/utils/metadata.py +1 -1
- hud/cli/utils/tests/test_metadata.py +1 -1
- hud/clients/mcp_use.py +6 -1
- hud/datasets/loader.py +17 -18
- hud/datasets/runner.py +16 -10
- hud/datasets/tests/test_loader.py +15 -15
- hud/environment/__init__.py +5 -3
- hud/environment/connection.py +58 -6
- hud/environment/connectors/mcp_config.py +29 -1
- hud/environment/environment.py +218 -77
- hud/environment/router.py +175 -24
- hud/environment/scenarios.py +313 -186
- hud/environment/tests/test_connectors.py +10 -23
- hud/environment/tests/test_environment.py +432 -0
- hud/environment/tests/test_local_connectors.py +81 -40
- hud/environment/tests/test_scenarios.py +820 -14
- hud/eval/context.py +63 -10
- hud/eval/instrument.py +4 -2
- hud/eval/manager.py +79 -12
- hud/eval/task.py +36 -4
- hud/eval/tests/test_eval.py +1 -1
- hud/eval/tests/test_task.py +147 -1
- hud/eval/types.py +2 -0
- hud/eval/utils.py +14 -3
- hud/patches/mcp_patches.py +178 -21
- hud/telemetry/instrument.py +8 -1
- hud/telemetry/tests/test_eval_telemetry.py +8 -8
- hud/tools/__init__.py +2 -0
- hud/tools/agent.py +223 -0
- hud/tools/computer/__init__.py +34 -5
- hud/tools/shell.py +3 -3
- hud/tools/tests/test_agent_tool.py +355 -0
- hud/types.py +62 -34
- hud/utils/hud_console.py +30 -17
- hud/utils/strict_schema.py +1 -1
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/METADATA +2 -2
- {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/RECORD +67 -61
- {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/WHEEL +0 -0
- {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/entry_points.txt +0 -0
- {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
|
-
from unittest.mock import
|
|
6
|
+
from unittest.mock import patch
|
|
7
7
|
|
|
8
8
|
from hud.environment.connection import ConnectionType, Connector
|
|
9
9
|
|
|
@@ -180,39 +180,26 @@ class TestRemoteConnectorMixin:
|
|
|
180
180
|
conn = env._connections["example"]
|
|
181
181
|
assert conn._auth == "Bearer my-token"
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"""connect_hub fetches mcp_config from API."""
|
|
183
|
+
def test_connect_hub_creates_connection(self) -> None:
|
|
184
|
+
"""connect_hub creates connection with correct config."""
|
|
186
185
|
from hud.environment.connectors.remote import RemoteConnectorMixin
|
|
187
186
|
|
|
188
187
|
class TestEnv(RemoteConnectorMixin):
|
|
189
188
|
def __init__(self) -> None:
|
|
190
189
|
self._connections: dict[str, Connector] = {}
|
|
190
|
+
self._hub_config: dict[str, Any] | None = None
|
|
191
191
|
|
|
192
192
|
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
193
193
|
pass
|
|
194
194
|
|
|
195
|
-
# Mock httpx response
|
|
196
|
-
mock_response = MagicMock()
|
|
197
|
-
mock_response.json.return_value = {
|
|
198
|
-
"mcp_config": {
|
|
199
|
-
"browser": {"url": "https://mcp.hud.ai/browser"},
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
mock_response.raise_for_status = MagicMock()
|
|
203
|
-
|
|
204
|
-
mock_client = MagicMock()
|
|
205
|
-
mock_client.get.return_value = mock_response
|
|
206
|
-
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
207
|
-
mock_client.__exit__ = MagicMock(return_value=None)
|
|
208
|
-
mock_httpx_cls.return_value = mock_client
|
|
209
|
-
|
|
210
195
|
env = TestEnv()
|
|
211
196
|
with patch("hud.settings.settings") as mock_settings:
|
|
212
|
-
mock_settings.
|
|
213
|
-
mock_settings.
|
|
197
|
+
mock_settings.hud_mcp_url = "https://mcp.hud.ai"
|
|
198
|
+
mock_settings.client_timeout = 300 # Used in connect_mcp for sse_read_timeout
|
|
214
199
|
|
|
215
|
-
env.connect_hub("
|
|
200
|
+
env.connect_hub("browser")
|
|
216
201
|
|
|
217
|
-
# connect_hub creates a connection named "hud" (
|
|
202
|
+
# connect_hub creates a connection named "hud" (from mcp_config key)
|
|
218
203
|
assert "hud" in env._connections
|
|
204
|
+
# Verify hub config is stored for serialization
|
|
205
|
+
assert env._hub_config == {"name": "browser"}
|
|
@@ -159,3 +159,435 @@ class TestEnvironmentSetupEvaluate:
|
|
|
159
159
|
)
|
|
160
160
|
|
|
161
161
|
assert len(env._setup_calls) == 2
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestEnvironmentMCPProtocol:
|
|
165
|
+
"""Tests for MCP protocol overrides - Environment._env_list_tools and _env_call_tool.
|
|
166
|
+
|
|
167
|
+
These test that Environment properly exposes connector tools via MCP handlers.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_env_list_tools_includes_local_tools(self) -> None:
|
|
172
|
+
"""_env_list_tools returns local tools after routing is built."""
|
|
173
|
+
from hud.environment import Environment
|
|
174
|
+
|
|
175
|
+
env = Environment("test")
|
|
176
|
+
|
|
177
|
+
@env.tool()
|
|
178
|
+
def my_tool(x: int) -> int:
|
|
179
|
+
"""A test tool."""
|
|
180
|
+
return x * 2
|
|
181
|
+
|
|
182
|
+
# Build routing (simulates what __aenter__ does)
|
|
183
|
+
await env._build_routing()
|
|
184
|
+
|
|
185
|
+
# Call the handler that MCP will call
|
|
186
|
+
tools = await env._env_list_tools()
|
|
187
|
+
|
|
188
|
+
assert len(tools) == 1
|
|
189
|
+
assert tools[0].name == "my_tool"
|
|
190
|
+
|
|
191
|
+
@pytest.mark.asyncio
|
|
192
|
+
async def test_env_list_tools_includes_connector_tools(self) -> None:
|
|
193
|
+
"""_env_list_tools returns tools from connectors (the key feature)."""
|
|
194
|
+
import mcp.types as mcp_types
|
|
195
|
+
|
|
196
|
+
from hud.environment import Environment
|
|
197
|
+
|
|
198
|
+
env = Environment("test")
|
|
199
|
+
|
|
200
|
+
# Create a mock connector with cached tools
|
|
201
|
+
mock_tools = [
|
|
202
|
+
mcp_types.Tool(
|
|
203
|
+
name="remote_tool",
|
|
204
|
+
description="A remote tool",
|
|
205
|
+
inputSchema={"type": "object"},
|
|
206
|
+
)
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
class MockConnector:
|
|
210
|
+
is_connected = True
|
|
211
|
+
_tools_cache = mock_tools
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def cached_tools(self) -> list[mcp_types.Tool]:
|
|
215
|
+
return self._tools_cache
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def cached_prompts(self) -> list[mcp_types.Prompt]:
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def cached_resources(self) -> list[mcp_types.Resource]:
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
async def connect(self) -> None:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
async def disconnect(self) -> None:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
async def list_tools(self) -> list[mcp_types.Tool]:
|
|
232
|
+
return self._tools_cache
|
|
233
|
+
|
|
234
|
+
# Add the mock connector
|
|
235
|
+
env._connections["mock"] = MockConnector() # type: ignore
|
|
236
|
+
|
|
237
|
+
# Build routing
|
|
238
|
+
await env._build_routing()
|
|
239
|
+
|
|
240
|
+
# Call the handler that MCP will call
|
|
241
|
+
tools = await env._env_list_tools()
|
|
242
|
+
|
|
243
|
+
# Should include the remote tool
|
|
244
|
+
tool_names = [t.name for t in tools]
|
|
245
|
+
assert "remote_tool" in tool_names
|
|
246
|
+
|
|
247
|
+
@pytest.mark.asyncio
|
|
248
|
+
async def test_env_call_tool_routes_to_local(self) -> None:
|
|
249
|
+
"""_env_call_tool routes local tool calls correctly."""
|
|
250
|
+
from hud.environment import Environment
|
|
251
|
+
|
|
252
|
+
env = Environment("test")
|
|
253
|
+
called_with: list[int] = []
|
|
254
|
+
|
|
255
|
+
@env.tool()
|
|
256
|
+
def my_tool(x: int) -> str:
|
|
257
|
+
"""A test tool."""
|
|
258
|
+
called_with.append(x)
|
|
259
|
+
return f"result: {x}"
|
|
260
|
+
|
|
261
|
+
# Build routing
|
|
262
|
+
await env._build_routing()
|
|
263
|
+
|
|
264
|
+
# Call the handler that MCP will call
|
|
265
|
+
result = await env._env_call_tool("my_tool", {"x": 42})
|
|
266
|
+
|
|
267
|
+
assert called_with == [42]
|
|
268
|
+
assert len(result) == 1
|
|
269
|
+
|
|
270
|
+
@pytest.mark.asyncio
|
|
271
|
+
async def test_env_call_tool_routes_to_connector(self) -> None:
|
|
272
|
+
"""_env_call_tool routes connector tool calls correctly."""
|
|
273
|
+
from unittest.mock import AsyncMock
|
|
274
|
+
|
|
275
|
+
import mcp.types as mcp_types
|
|
276
|
+
|
|
277
|
+
from hud.environment import Environment
|
|
278
|
+
from hud.types import MCPToolResult
|
|
279
|
+
|
|
280
|
+
env = Environment("test")
|
|
281
|
+
|
|
282
|
+
# Create a mock connector
|
|
283
|
+
mock_tools = [
|
|
284
|
+
mcp_types.Tool(
|
|
285
|
+
name="remote_tool",
|
|
286
|
+
description="A remote tool",
|
|
287
|
+
inputSchema={"type": "object"},
|
|
288
|
+
)
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
class MockConnector:
|
|
292
|
+
is_connected = True
|
|
293
|
+
_tools_cache = mock_tools
|
|
294
|
+
call_tool = AsyncMock(
|
|
295
|
+
return_value=MCPToolResult(
|
|
296
|
+
content=[mcp_types.TextContent(type="text", text="remote result")],
|
|
297
|
+
isError=False,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def cached_tools(self) -> list[mcp_types.Tool]:
|
|
303
|
+
return self._tools_cache
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def cached_prompts(self) -> list[mcp_types.Prompt]:
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def cached_resources(self) -> list[mcp_types.Resource]:
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
async def connect(self) -> None:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
async def disconnect(self) -> None:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
async def list_tools(self) -> list[mcp_types.Tool]:
|
|
320
|
+
return self._tools_cache
|
|
321
|
+
|
|
322
|
+
mock_conn = MockConnector()
|
|
323
|
+
env._connections["mock"] = mock_conn # type: ignore
|
|
324
|
+
|
|
325
|
+
# Build routing
|
|
326
|
+
await env._build_routing()
|
|
327
|
+
|
|
328
|
+
# Call the handler that MCP will call
|
|
329
|
+
result = await env._env_call_tool("remote_tool", {"arg": "value"})
|
|
330
|
+
|
|
331
|
+
# Verify the connector was called
|
|
332
|
+
mock_conn.call_tool.assert_called_once_with("remote_tool", {"arg": "value"})
|
|
333
|
+
assert len(result) == 1
|
|
334
|
+
|
|
335
|
+
def test_setup_handlers_registers_custom_handlers(self) -> None:
|
|
336
|
+
"""Verify _setup_handlers registers our _env_list_tools and _env_call_tool."""
|
|
337
|
+
from hud.environment import Environment
|
|
338
|
+
|
|
339
|
+
env = Environment("test")
|
|
340
|
+
|
|
341
|
+
# Verify the custom handlers exist
|
|
342
|
+
assert hasattr(env, "_env_list_tools")
|
|
343
|
+
assert hasattr(env, "_env_call_tool")
|
|
344
|
+
assert callable(env._env_list_tools)
|
|
345
|
+
assert callable(env._env_call_tool)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class TestEnvironmentToolFiltering:
|
|
349
|
+
"""Tests for agent-level tool filtering with wildcard support (v4 backwards compat)."""
|
|
350
|
+
|
|
351
|
+
@pytest.mark.asyncio
|
|
352
|
+
async def test_as_tools_no_filter(self) -> None:
|
|
353
|
+
"""as_tools returns all tools when no filter is set."""
|
|
354
|
+
from hud.environment import Environment
|
|
355
|
+
|
|
356
|
+
env = Environment("test")
|
|
357
|
+
|
|
358
|
+
@env.tool()
|
|
359
|
+
def tool_a() -> str:
|
|
360
|
+
"""Tool A."""
|
|
361
|
+
return "a"
|
|
362
|
+
|
|
363
|
+
@env.tool()
|
|
364
|
+
def tool_b() -> str:
|
|
365
|
+
"""Tool B."""
|
|
366
|
+
return "b"
|
|
367
|
+
|
|
368
|
+
await env._build_routing()
|
|
369
|
+
|
|
370
|
+
tools = env.as_tools()
|
|
371
|
+
tool_names = [t.name for t in tools]
|
|
372
|
+
|
|
373
|
+
assert "tool_a" in tool_names
|
|
374
|
+
assert "tool_b" in tool_names
|
|
375
|
+
|
|
376
|
+
@pytest.mark.asyncio
|
|
377
|
+
async def test_as_tools_exact_include(self) -> None:
|
|
378
|
+
"""as_tools filters with exact include list."""
|
|
379
|
+
from hud.environment import Environment
|
|
380
|
+
|
|
381
|
+
env = Environment("test")
|
|
382
|
+
|
|
383
|
+
@env.tool()
|
|
384
|
+
def tool_a() -> str:
|
|
385
|
+
"""Tool A."""
|
|
386
|
+
return "a"
|
|
387
|
+
|
|
388
|
+
@env.tool()
|
|
389
|
+
def tool_b() -> str:
|
|
390
|
+
"""Tool B."""
|
|
391
|
+
return "b"
|
|
392
|
+
|
|
393
|
+
env._agent_include = ["tool_a"]
|
|
394
|
+
await env._build_routing()
|
|
395
|
+
|
|
396
|
+
tools = env.as_tools()
|
|
397
|
+
tool_names = [t.name for t in tools]
|
|
398
|
+
|
|
399
|
+
assert "tool_a" in tool_names
|
|
400
|
+
assert "tool_b" not in tool_names
|
|
401
|
+
|
|
402
|
+
@pytest.mark.asyncio
|
|
403
|
+
async def test_as_tools_exact_exclude(self) -> None:
|
|
404
|
+
"""as_tools filters with exact exclude list."""
|
|
405
|
+
from hud.environment import Environment
|
|
406
|
+
|
|
407
|
+
env = Environment("test")
|
|
408
|
+
|
|
409
|
+
@env.tool()
|
|
410
|
+
def tool_a() -> str:
|
|
411
|
+
"""Tool A."""
|
|
412
|
+
return "a"
|
|
413
|
+
|
|
414
|
+
@env.tool()
|
|
415
|
+
def tool_b() -> str:
|
|
416
|
+
"""Tool B."""
|
|
417
|
+
return "b"
|
|
418
|
+
|
|
419
|
+
env._agent_exclude = ["tool_a"]
|
|
420
|
+
await env._build_routing()
|
|
421
|
+
|
|
422
|
+
tools = env.as_tools()
|
|
423
|
+
tool_names = [t.name for t in tools]
|
|
424
|
+
|
|
425
|
+
assert "tool_a" not in tool_names
|
|
426
|
+
assert "tool_b" in tool_names
|
|
427
|
+
|
|
428
|
+
@pytest.mark.asyncio
|
|
429
|
+
async def test_as_tools_wildcard_exclude_prefix(self) -> None:
|
|
430
|
+
"""as_tools filters with wildcard prefix pattern (e.g., 'setup_*')."""
|
|
431
|
+
from hud.environment import Environment
|
|
432
|
+
|
|
433
|
+
env = Environment("test")
|
|
434
|
+
|
|
435
|
+
@env.tool()
|
|
436
|
+
def setup_database() -> str:
|
|
437
|
+
"""Setup tool."""
|
|
438
|
+
return "setup"
|
|
439
|
+
|
|
440
|
+
@env.tool()
|
|
441
|
+
def setup_user() -> str:
|
|
442
|
+
"""Another setup tool."""
|
|
443
|
+
return "setup"
|
|
444
|
+
|
|
445
|
+
@env.tool()
|
|
446
|
+
def run_query() -> str:
|
|
447
|
+
"""Regular tool."""
|
|
448
|
+
return "query"
|
|
449
|
+
|
|
450
|
+
env._agent_exclude = ["setup_*"]
|
|
451
|
+
await env._build_routing()
|
|
452
|
+
|
|
453
|
+
tools = env.as_tools()
|
|
454
|
+
tool_names = [t.name for t in tools]
|
|
455
|
+
|
|
456
|
+
assert "setup_database" not in tool_names
|
|
457
|
+
assert "setup_user" not in tool_names
|
|
458
|
+
assert "run_query" in tool_names
|
|
459
|
+
|
|
460
|
+
@pytest.mark.asyncio
|
|
461
|
+
async def test_as_tools_wildcard_exclude_contains(self) -> None:
|
|
462
|
+
"""as_tools filters with wildcard contains pattern (e.g., '*setup*')."""
|
|
463
|
+
from hud.environment import Environment
|
|
464
|
+
|
|
465
|
+
env = Environment("test")
|
|
466
|
+
|
|
467
|
+
@env.tool()
|
|
468
|
+
def hud_setup() -> str:
|
|
469
|
+
"""Contains setup."""
|
|
470
|
+
return "setup"
|
|
471
|
+
|
|
472
|
+
@env.tool()
|
|
473
|
+
def setup_env() -> str:
|
|
474
|
+
"""Starts with setup."""
|
|
475
|
+
return "setup"
|
|
476
|
+
|
|
477
|
+
@env.tool()
|
|
478
|
+
def my_setup_tool() -> str:
|
|
479
|
+
"""Contains setup in middle."""
|
|
480
|
+
return "setup"
|
|
481
|
+
|
|
482
|
+
@env.tool()
|
|
483
|
+
def run_query() -> str:
|
|
484
|
+
"""No setup in name."""
|
|
485
|
+
return "query"
|
|
486
|
+
|
|
487
|
+
env._agent_exclude = ["*setup*"]
|
|
488
|
+
await env._build_routing()
|
|
489
|
+
|
|
490
|
+
tools = env.as_tools()
|
|
491
|
+
tool_names = [t.name for t in tools]
|
|
492
|
+
|
|
493
|
+
assert "hud_setup" not in tool_names
|
|
494
|
+
assert "setup_env" not in tool_names
|
|
495
|
+
assert "my_setup_tool" not in tool_names
|
|
496
|
+
assert "run_query" in tool_names
|
|
497
|
+
|
|
498
|
+
@pytest.mark.asyncio
|
|
499
|
+
async def test_as_tools_multiple_wildcard_patterns(self) -> None:
|
|
500
|
+
"""as_tools filters with multiple wildcard patterns."""
|
|
501
|
+
from hud.environment import Environment
|
|
502
|
+
|
|
503
|
+
env = Environment("test")
|
|
504
|
+
|
|
505
|
+
@env.tool()
|
|
506
|
+
def setup_db() -> str:
|
|
507
|
+
"""Setup tool."""
|
|
508
|
+
return "setup"
|
|
509
|
+
|
|
510
|
+
@env.tool()
|
|
511
|
+
def evaluate_result() -> str:
|
|
512
|
+
"""Evaluate tool."""
|
|
513
|
+
return "evaluate"
|
|
514
|
+
|
|
515
|
+
@env.tool()
|
|
516
|
+
def checkout_branch() -> str:
|
|
517
|
+
"""Checkout tool."""
|
|
518
|
+
return "checkout"
|
|
519
|
+
|
|
520
|
+
@env.tool()
|
|
521
|
+
def run_query() -> str:
|
|
522
|
+
"""Regular tool."""
|
|
523
|
+
return "query"
|
|
524
|
+
|
|
525
|
+
env._agent_exclude = ["*setup*", "*evaluate*", "checkout_branch"]
|
|
526
|
+
await env._build_routing()
|
|
527
|
+
|
|
528
|
+
tools = env.as_tools()
|
|
529
|
+
tool_names = [t.name for t in tools]
|
|
530
|
+
|
|
531
|
+
assert "setup_db" not in tool_names
|
|
532
|
+
assert "evaluate_result" not in tool_names
|
|
533
|
+
assert "checkout_branch" not in tool_names
|
|
534
|
+
assert "run_query" in tool_names
|
|
535
|
+
|
|
536
|
+
@pytest.mark.asyncio
|
|
537
|
+
async def test_as_tools_wildcard_include_all(self) -> None:
|
|
538
|
+
"""as_tools with ['*'] include pattern matches all tools."""
|
|
539
|
+
from hud.environment import Environment
|
|
540
|
+
|
|
541
|
+
env = Environment("test")
|
|
542
|
+
|
|
543
|
+
@env.tool()
|
|
544
|
+
def tool_a() -> str:
|
|
545
|
+
"""Tool A."""
|
|
546
|
+
return "a"
|
|
547
|
+
|
|
548
|
+
@env.tool()
|
|
549
|
+
def tool_b() -> str:
|
|
550
|
+
"""Tool B."""
|
|
551
|
+
return "b"
|
|
552
|
+
|
|
553
|
+
env._agent_include = ["*"]
|
|
554
|
+
await env._build_routing()
|
|
555
|
+
|
|
556
|
+
tools = env.as_tools()
|
|
557
|
+
tool_names = [t.name for t in tools]
|
|
558
|
+
|
|
559
|
+
assert "tool_a" in tool_names
|
|
560
|
+
assert "tool_b" in tool_names
|
|
561
|
+
|
|
562
|
+
@pytest.mark.asyncio
|
|
563
|
+
async def test_as_tools_include_and_exclude_combined(self) -> None:
|
|
564
|
+
"""as_tools applies both include and exclude filters."""
|
|
565
|
+
from hud.environment import Environment
|
|
566
|
+
|
|
567
|
+
env = Environment("test")
|
|
568
|
+
|
|
569
|
+
@env.tool()
|
|
570
|
+
def browser_navigate() -> str:
|
|
571
|
+
"""Browser tool."""
|
|
572
|
+
return "nav"
|
|
573
|
+
|
|
574
|
+
@env.tool()
|
|
575
|
+
def browser_setup() -> str:
|
|
576
|
+
"""Browser setup - should be excluded."""
|
|
577
|
+
return "setup"
|
|
578
|
+
|
|
579
|
+
@env.tool()
|
|
580
|
+
def file_read() -> str:
|
|
581
|
+
"""File tool - not included."""
|
|
582
|
+
return "read"
|
|
583
|
+
|
|
584
|
+
env._agent_include = ["browser_*"]
|
|
585
|
+
env._agent_exclude = ["*setup*"]
|
|
586
|
+
await env._build_routing()
|
|
587
|
+
|
|
588
|
+
tools = env.as_tools()
|
|
589
|
+
tool_names = [t.name for t in tools]
|
|
590
|
+
|
|
591
|
+
assert "browser_navigate" in tool_names
|
|
592
|
+
assert "browser_setup" not in tool_names # Excluded by *setup*
|
|
593
|
+
assert "file_read" not in tool_names # Not included by browser_*
|
|
@@ -11,13 +11,10 @@ from hud.environment.connection import ConnectionType, Connector
|
|
|
11
11
|
class TestConnectImage:
|
|
12
12
|
"""Tests for LocalConnectorMixin.connect_image."""
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
def test_connect_image_creates_local_connection(self, mock_docker_cmd: MagicMock) -> None:
|
|
14
|
+
def test_connect_image_creates_local_connection(self) -> None:
|
|
16
15
|
"""connect_image creates LOCAL connection with docker command."""
|
|
17
16
|
from hud.environment.connectors.local import LocalConnectorMixin
|
|
18
17
|
|
|
19
|
-
mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
|
|
20
|
-
|
|
21
18
|
class TestEnv(LocalConnectorMixin):
|
|
22
19
|
def __init__(self) -> None:
|
|
23
20
|
self._connections: dict[str, Connector] = {}
|
|
@@ -25,21 +22,32 @@ class TestConnectImage:
|
|
|
25
22
|
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
26
23
|
pass
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
25
|
+
# Mock the import that happens inside connect_image
|
|
26
|
+
mock_docker_utils = MagicMock()
|
|
27
|
+
mock_docker_utils.create_docker_run_command.return_value = [
|
|
28
|
+
"docker",
|
|
29
|
+
"run",
|
|
30
|
+
"-i",
|
|
31
|
+
"--rm",
|
|
32
|
+
"mcp/fetch",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
with patch.dict(
|
|
36
|
+
"sys.modules",
|
|
37
|
+
{"hud.cli.utils.docker": mock_docker_utils},
|
|
38
|
+
):
|
|
39
|
+
env = TestEnv()
|
|
40
|
+
env.connect_image("mcp/fetch")
|
|
41
|
+
|
|
42
|
+
assert "mcp/fetch" in env._connections
|
|
43
|
+
conn = env._connections["mcp/fetch"]
|
|
44
|
+
assert conn.connection_type == ConnectionType.LOCAL
|
|
45
|
+
mock_docker_utils.create_docker_run_command.assert_called_once()
|
|
46
|
+
|
|
47
|
+
def test_connect_image_with_alias(self) -> None:
|
|
38
48
|
"""connect_image uses alias for connection name."""
|
|
39
49
|
from hud.environment.connectors.local import LocalConnectorMixin
|
|
40
50
|
|
|
41
|
-
mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
|
|
42
|
-
|
|
43
51
|
class TestEnv(LocalConnectorMixin):
|
|
44
52
|
def __init__(self) -> None:
|
|
45
53
|
self._connections: dict[str, Connector] = {}
|
|
@@ -47,19 +55,29 @@ class TestConnectImage:
|
|
|
47
55
|
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
48
56
|
pass
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
mock_docker_utils = MagicMock()
|
|
59
|
+
mock_docker_utils.create_docker_run_command.return_value = [
|
|
60
|
+
"docker",
|
|
61
|
+
"run",
|
|
62
|
+
"-i",
|
|
63
|
+
"--rm",
|
|
64
|
+
"mcp/fetch",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
with patch.dict(
|
|
68
|
+
"sys.modules",
|
|
69
|
+
{"hud.cli.utils.docker": mock_docker_utils},
|
|
70
|
+
):
|
|
71
|
+
env = TestEnv()
|
|
72
|
+
env.connect_image("mcp/fetch", alias="fetcher")
|
|
73
|
+
|
|
74
|
+
assert "fetcher" in env._connections
|
|
75
|
+
assert "mcp/fetch" not in env._connections
|
|
76
|
+
|
|
77
|
+
def test_connect_image_with_prefix(self) -> None:
|
|
58
78
|
"""connect_image passes prefix to config."""
|
|
59
79
|
from hud.environment.connectors.local import LocalConnectorMixin
|
|
60
80
|
|
|
61
|
-
mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
|
|
62
|
-
|
|
63
81
|
class TestEnv(LocalConnectorMixin):
|
|
64
82
|
def __init__(self) -> None:
|
|
65
83
|
self._connections: dict[str, Connector] = {}
|
|
@@ -67,19 +85,29 @@ class TestConnectImage:
|
|
|
67
85
|
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
68
86
|
pass
|
|
69
87
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
mock_docker_utils = MagicMock()
|
|
89
|
+
mock_docker_utils.create_docker_run_command.return_value = [
|
|
90
|
+
"docker",
|
|
91
|
+
"run",
|
|
92
|
+
"-i",
|
|
93
|
+
"--rm",
|
|
94
|
+
"mcp/fetch",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
with patch.dict(
|
|
98
|
+
"sys.modules",
|
|
99
|
+
{"hud.cli.utils.docker": mock_docker_utils},
|
|
100
|
+
):
|
|
101
|
+
env = TestEnv()
|
|
102
|
+
env.connect_image("mcp/fetch", prefix="fetch")
|
|
103
|
+
|
|
104
|
+
conn = env._connections["mcp/fetch"]
|
|
105
|
+
assert conn.config.prefix == "fetch"
|
|
106
|
+
|
|
107
|
+
def test_connect_image_returns_self(self) -> None:
|
|
78
108
|
"""connect_image returns self for chaining."""
|
|
79
109
|
from hud.environment.connectors.local import LocalConnectorMixin
|
|
80
110
|
|
|
81
|
-
mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
|
|
82
|
-
|
|
83
111
|
class TestEnv(LocalConnectorMixin):
|
|
84
112
|
def __init__(self) -> None:
|
|
85
113
|
self._connections: dict[str, Connector] = {}
|
|
@@ -87,10 +115,23 @@ class TestConnectImage:
|
|
|
87
115
|
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
88
116
|
pass
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
mock_docker_utils = MagicMock()
|
|
119
|
+
mock_docker_utils.create_docker_run_command.return_value = [
|
|
120
|
+
"docker",
|
|
121
|
+
"run",
|
|
122
|
+
"-i",
|
|
123
|
+
"--rm",
|
|
124
|
+
"mcp/fetch",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
with patch.dict(
|
|
128
|
+
"sys.modules",
|
|
129
|
+
{"hud.cli.utils.docker": mock_docker_utils},
|
|
130
|
+
):
|
|
131
|
+
env = TestEnv()
|
|
132
|
+
result = env.connect_image("mcp/fetch")
|
|
133
|
+
|
|
134
|
+
assert result is env
|
|
94
135
|
|
|
95
136
|
|
|
96
137
|
class TestConnectServer:
|