hud-python 0.4.1__py3-none-any.whl → 0.4.3__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.
Potentially problematic release.
This version of hud-python might be problematic. Click here for more details.
- hud/__init__.py +22 -22
- hud/agents/__init__.py +13 -15
- hud/agents/base.py +599 -599
- hud/agents/claude.py +373 -373
- hud/agents/langchain.py +261 -250
- hud/agents/misc/__init__.py +7 -7
- hud/agents/misc/response_agent.py +82 -80
- hud/agents/openai.py +352 -352
- hud/agents/openai_chat_generic.py +154 -154
- hud/agents/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -742
- hud/agents/tests/test_claude.py +324 -324
- hud/agents/tests/test_client.py +363 -363
- hud/agents/tests/test_openai.py +237 -237
- hud/cli/__init__.py +617 -617
- hud/cli/__main__.py +8 -8
- hud/cli/analyze.py +371 -371
- hud/cli/analyze_metadata.py +230 -230
- hud/cli/build.py +498 -427
- hud/cli/clone.py +185 -185
- hud/cli/cursor.py +92 -92
- hud/cli/debug.py +392 -392
- hud/cli/docker_utils.py +83 -83
- hud/cli/init.py +280 -281
- hud/cli/interactive.py +353 -353
- hud/cli/mcp_server.py +764 -756
- hud/cli/pull.py +330 -336
- hud/cli/push.py +404 -370
- hud/cli/remote_runner.py +311 -311
- hud/cli/runner.py +160 -160
- hud/cli/tests/__init__.py +3 -3
- hud/cli/tests/test_analyze.py +284 -284
- hud/cli/tests/test_cli_init.py +265 -265
- hud/cli/tests/test_cli_main.py +27 -27
- hud/cli/tests/test_clone.py +142 -142
- hud/cli/tests/test_cursor.py +253 -253
- hud/cli/tests/test_debug.py +453 -453
- hud/cli/tests/test_mcp_server.py +139 -139
- hud/cli/tests/test_utils.py +388 -388
- hud/cli/utils.py +263 -263
- hud/clients/README.md +143 -143
- hud/clients/__init__.py +16 -16
- hud/clients/base.py +378 -379
- hud/clients/fastmcp.py +222 -222
- hud/clients/mcp_use.py +298 -278
- hud/clients/tests/__init__.py +1 -1
- hud/clients/tests/test_client_integration.py +111 -111
- hud/clients/tests/test_fastmcp.py +342 -342
- hud/clients/tests/test_protocol.py +188 -188
- hud/clients/utils/__init__.py +1 -1
- hud/clients/utils/retry_transport.py +160 -160
- hud/datasets.py +327 -322
- hud/misc/__init__.py +1 -1
- hud/misc/claude_plays_pokemon.py +292 -292
- hud/otel/__init__.py +35 -35
- hud/otel/collector.py +142 -142
- hud/otel/config.py +164 -164
- hud/otel/context.py +536 -536
- hud/otel/exporters.py +366 -366
- hud/otel/instrumentation.py +97 -97
- hud/otel/processors.py +118 -118
- hud/otel/tests/__init__.py +1 -1
- hud/otel/tests/test_processors.py +197 -197
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -114
- hud/server/helper/__init__.py +5 -5
- hud/server/low_level.py +132 -132
- hud/server/server.py +170 -166
- hud/server/tests/__init__.py +3 -3
- hud/settings.py +73 -73
- hud/shared/__init__.py +5 -5
- hud/shared/exceptions.py +180 -180
- hud/shared/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -157
- hud/shared/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -25
- hud/telemetry/instrument.py +379 -379
- hud/telemetry/job.py +309 -309
- hud/telemetry/replay.py +74 -74
- hud/telemetry/trace.py +83 -83
- hud/tools/__init__.py +33 -33
- hud/tools/base.py +365 -365
- hud/tools/bash.py +161 -161
- hud/tools/computer/__init__.py +15 -15
- hud/tools/computer/anthropic.py +437 -437
- hud/tools/computer/hud.py +376 -376
- hud/tools/computer/openai.py +295 -295
- hud/tools/computer/settings.py +82 -82
- hud/tools/edit.py +314 -314
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -539
- hud/tools/executors/pyautogui.py +621 -621
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -511
- hud/tools/playwright.py +412 -412
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -282
- hud/tools/tests/test_bash.py +158 -158
- hud/tools/tests/test_bash_extended.py +197 -197
- hud/tools/tests/test_computer.py +425 -425
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -259
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -145
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -72
- hud/tools/utils.py +50 -50
- hud/types.py +136 -136
- hud/utils/__init__.py +10 -10
- hud/utils/async_utils.py +65 -65
- hud/utils/design.py +236 -168
- hud/utils/mcp.py +55 -55
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -173
- hud/utils/tests/test_init.py +17 -17
- hud/utils/tests/test_progress.py +261 -261
- hud/utils/tests/test_telemetry.py +82 -82
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
- hud_python-0.4.3.dist-info/RECORD +131 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
- hud/agents/art.py +0 -101
- hud_python-0.4.1.dist-info/RECORD +0 -132
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/tools/base.py
CHANGED
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from abc import ABC, abstractmethod
|
|
4
|
-
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
-
|
|
6
|
-
from fastmcp import FastMCP
|
|
7
|
-
|
|
8
|
-
from hud.tools.types import ContentBlock, EvaluationResult
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from collections.abc import Callable
|
|
12
|
-
|
|
13
|
-
from fastmcp.tools import FunctionTool
|
|
14
|
-
from fastmcp.tools.tool import Tool, ToolResult
|
|
15
|
-
|
|
16
|
-
# Basic result types for tools
|
|
17
|
-
BaseResult = list[ContentBlock] | EvaluationResult
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class BaseTool(ABC):
|
|
21
|
-
"""
|
|
22
|
-
Base helper class for all MCP tools to constrain their output.
|
|
23
|
-
|
|
24
|
-
USAGE:
|
|
25
|
-
All tools should inherit from this class and implement the __call__ method.
|
|
26
|
-
Tools are registered with FastMCP using add_tool.
|
|
27
|
-
|
|
28
|
-
FORMAT:
|
|
29
|
-
Tools that return messages should return a list[ContentBlock].
|
|
30
|
-
Tools that return miscallaneous content should return a pydantic model such as EvaluationResult.
|
|
31
|
-
Both of these types of tools are processed via structuredContent.
|
|
32
|
-
Any other type of tool will not be processed well by the client.
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
def __init__(
|
|
36
|
-
self,
|
|
37
|
-
env: Any = None,
|
|
38
|
-
name: str | None = None,
|
|
39
|
-
title: str | None = None,
|
|
40
|
-
description: str | None = None,
|
|
41
|
-
) -> None:
|
|
42
|
-
"""Initialize the tool.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
env: Optional, often stateful, context object that the tool operates on. Could be:
|
|
46
|
-
- A game instance (e.g., Chess Board)
|
|
47
|
-
- An executor (e.g., PyAutoGUIExecutor for computer control)
|
|
48
|
-
- A browser/page instance (e.g., Playwright Page)
|
|
49
|
-
- Any stateful resource the tool needs to interact with
|
|
50
|
-
name: Tool name for MCP registration (auto-generated from class name if not provided)
|
|
51
|
-
title: Human-readable display name for the tool (auto-generated from class name)
|
|
52
|
-
description: Tool description (auto-generated from docstring if not provided)
|
|
53
|
-
"""
|
|
54
|
-
self.env = env
|
|
55
|
-
self.name = name or self.__class__.__name__.lower().replace("tool", "")
|
|
56
|
-
self.title = title or self.__class__.__name__.replace("Tool", "").replace("_", " ").title()
|
|
57
|
-
self.description = description or (self.__doc__.strip() if self.__doc__ else None)
|
|
58
|
-
|
|
59
|
-
# Expose attributes FastMCP expects when registering an instance directly
|
|
60
|
-
self.__name__ = self.name # FastMCP uses fn.__name__ if name param omitted
|
|
61
|
-
if self.description:
|
|
62
|
-
self.__doc__ = self.description
|
|
63
|
-
|
|
64
|
-
@abstractmethod
|
|
65
|
-
async def __call__(self, **kwargs: Any) -> ToolResult:
|
|
66
|
-
"""Execute the tool. Often uses the context to perform an action.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
**kwargs: Tool-specific arguments
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
List of ContentBlock (TextContent, ImageContent, etc.) with the tool's output
|
|
73
|
-
"""
|
|
74
|
-
raise NotImplementedError("Subclasses must implement __call__")
|
|
75
|
-
|
|
76
|
-
def register(self, server: FastMCP, **meta: Any) -> BaseTool:
|
|
77
|
-
"""Register this tool on a FastMCP server and return self for chaining."""
|
|
78
|
-
server.add_tool(self.mcp, **meta)
|
|
79
|
-
return self
|
|
80
|
-
|
|
81
|
-
@property
|
|
82
|
-
def mcp(self) -> FunctionTool:
|
|
83
|
-
"""Get this tool as a FastMCP FunctionTool (cached).
|
|
84
|
-
|
|
85
|
-
This allows clean registration:
|
|
86
|
-
server.add_tool(my_tool.mcp)
|
|
87
|
-
"""
|
|
88
|
-
if not hasattr(self, "_mcp_tool"):
|
|
89
|
-
from fastmcp.tools import FunctionTool
|
|
90
|
-
|
|
91
|
-
self._mcp_tool = FunctionTool.from_function(
|
|
92
|
-
self,
|
|
93
|
-
name=self.name,
|
|
94
|
-
title=self.title,
|
|
95
|
-
description=self.description,
|
|
96
|
-
)
|
|
97
|
-
return self._mcp_tool
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# Prefix for internal tool names
|
|
101
|
-
_INTERNAL_PREFIX = "int_"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class BaseHub(FastMCP):
|
|
105
|
-
"""A composition-friendly FastMCP server that holds an internal tool dispatcher."""
|
|
106
|
-
|
|
107
|
-
env: Any
|
|
108
|
-
|
|
109
|
-
def __init__(
|
|
110
|
-
self,
|
|
111
|
-
name: str,
|
|
112
|
-
*,
|
|
113
|
-
env: Any | None = None,
|
|
114
|
-
title: str | None = None,
|
|
115
|
-
description: str | None = None,
|
|
116
|
-
) -> None:
|
|
117
|
-
"""Create a new BaseHub.
|
|
118
|
-
|
|
119
|
-
Parameters
|
|
120
|
-
----------
|
|
121
|
-
name:
|
|
122
|
-
Public name. Also becomes the *dispatcher tool* name.
|
|
123
|
-
env:
|
|
124
|
-
Optional long-lived environment object. Stored on the server
|
|
125
|
-
instance (``layer.env``) and therefore available to every request
|
|
126
|
-
via ``ctx.fastmcp.env``.
|
|
127
|
-
"""
|
|
128
|
-
|
|
129
|
-
# Naming scheme for hidden objects
|
|
130
|
-
self._prefix_fn: Callable[[str], str] = lambda n: f"{_INTERNAL_PREFIX}{n}"
|
|
131
|
-
|
|
132
|
-
super().__init__(name=name)
|
|
133
|
-
|
|
134
|
-
if env is not None:
|
|
135
|
-
self.env = env
|
|
136
|
-
|
|
137
|
-
dispatcher_title = title or f"{name.title()} Dispatcher"
|
|
138
|
-
dispatcher_desc = description or f"Call internal '{name}' functions"
|
|
139
|
-
|
|
140
|
-
# Register dispatcher manually with FunctionTool
|
|
141
|
-
async def _dispatch( # noqa: ANN202
|
|
142
|
-
name: str,
|
|
143
|
-
arguments: dict | str | None = None,
|
|
144
|
-
ctx=None, # noqa: ANN001
|
|
145
|
-
):
|
|
146
|
-
"""Gateway to hidden tools.
|
|
147
|
-
|
|
148
|
-
Parameters
|
|
149
|
-
----------
|
|
150
|
-
name : str
|
|
151
|
-
Internal function name *without* prefix.
|
|
152
|
-
arguments : dict | str | None
|
|
153
|
-
Arguments forwarded to the internal tool. Can be dict or JSON string.
|
|
154
|
-
ctx : Context
|
|
155
|
-
Injected by FastMCP; can be the custom subclass.
|
|
156
|
-
"""
|
|
157
|
-
|
|
158
|
-
# Handle JSON string inputs
|
|
159
|
-
if isinstance(arguments, str):
|
|
160
|
-
import json
|
|
161
|
-
|
|
162
|
-
try:
|
|
163
|
-
arguments = json.loads(arguments)
|
|
164
|
-
except json.JSONDecodeError:
|
|
165
|
-
# If it's not valid JSON, treat as empty dict
|
|
166
|
-
arguments = {}
|
|
167
|
-
|
|
168
|
-
# Use the tool manager to call internal tools
|
|
169
|
-
return await self._tool_manager.call_tool(self._prefix_fn(name), arguments or {}) # type: ignore
|
|
170
|
-
|
|
171
|
-
from fastmcp.tools.tool import FunctionTool
|
|
172
|
-
|
|
173
|
-
dispatcher_tool = FunctionTool.from_function(
|
|
174
|
-
_dispatch,
|
|
175
|
-
name=name,
|
|
176
|
-
title=dispatcher_title,
|
|
177
|
-
description=dispatcher_desc,
|
|
178
|
-
tags=set(),
|
|
179
|
-
)
|
|
180
|
-
self._tool_manager.add_tool(dispatcher_tool)
|
|
181
|
-
|
|
182
|
-
# Expose list of internal functions via read-only resource
|
|
183
|
-
async def _functions_catalogue() -> list[str]:
|
|
184
|
-
# List all internal function names without prefix
|
|
185
|
-
return [
|
|
186
|
-
key.removeprefix(_INTERNAL_PREFIX)
|
|
187
|
-
for key in self._tool_manager._tools
|
|
188
|
-
if key.startswith(_INTERNAL_PREFIX)
|
|
189
|
-
]
|
|
190
|
-
|
|
191
|
-
from fastmcp.resources import Resource
|
|
192
|
-
|
|
193
|
-
catalogue_resource = Resource.from_function(
|
|
194
|
-
_functions_catalogue,
|
|
195
|
-
uri=f"file:///{name}/functions",
|
|
196
|
-
name=f"{name} Functions Catalogue",
|
|
197
|
-
description=f"List of internal functions available in {name}",
|
|
198
|
-
mime_type="application/json",
|
|
199
|
-
tags=set(),
|
|
200
|
-
)
|
|
201
|
-
self._resource_manager.add_resource(catalogue_resource)
|
|
202
|
-
|
|
203
|
-
def tool(self, name_or_fn: Any = None, /, **kwargs: Any) -> Callable[..., Any]:
|
|
204
|
-
"""Register an *internal* tool (hidden from clients)."""
|
|
205
|
-
# Handle when decorator's partial calls us back with the function
|
|
206
|
-
if callable(name_or_fn):
|
|
207
|
-
# This only happens in phase 2 of decorator application
|
|
208
|
-
# The name was already prefixed in phase 1, just pass through
|
|
209
|
-
result = super().tool(name_or_fn, **kwargs)
|
|
210
|
-
|
|
211
|
-
# Update dispatcher description after registering tool
|
|
212
|
-
self._update_dispatcher_description()
|
|
213
|
-
|
|
214
|
-
return cast("Callable[..., Any]", result)
|
|
215
|
-
|
|
216
|
-
# Handle the name from either positional or keyword argument
|
|
217
|
-
if isinstance(name_or_fn, str):
|
|
218
|
-
# Called as @hub.tool("name")
|
|
219
|
-
name = name_or_fn
|
|
220
|
-
elif name_or_fn is None and "name" in kwargs:
|
|
221
|
-
# Called as @hub.tool(name="name")
|
|
222
|
-
name = kwargs.pop("name")
|
|
223
|
-
else:
|
|
224
|
-
# Called as @hub.tool or @hub.tool()
|
|
225
|
-
name = None
|
|
226
|
-
|
|
227
|
-
new_name = self._prefix_fn(name) if name is not None else None
|
|
228
|
-
tags = kwargs.pop("tags", None) or set()
|
|
229
|
-
|
|
230
|
-
# Pass through correctly to parent
|
|
231
|
-
if new_name is not None:
|
|
232
|
-
return super().tool(new_name, **kwargs, tags=tags)
|
|
233
|
-
else:
|
|
234
|
-
return super().tool(**kwargs, tags=tags)
|
|
235
|
-
|
|
236
|
-
def _update_dispatcher_description(self) -> None:
|
|
237
|
-
"""Update the dispatcher tool's description and schema with available tools."""
|
|
238
|
-
# Get list of internal tools with their details
|
|
239
|
-
internal_tools = []
|
|
240
|
-
for key, tool in self._tool_manager._tools.items():
|
|
241
|
-
if key.startswith(_INTERNAL_PREFIX):
|
|
242
|
-
tool_name = key.removeprefix(_INTERNAL_PREFIX)
|
|
243
|
-
internal_tools.append((tool_name, tool))
|
|
244
|
-
|
|
245
|
-
if internal_tools:
|
|
246
|
-
# Update the dispatcher tool's description
|
|
247
|
-
dispatcher_name = self.name
|
|
248
|
-
if dispatcher_name in self._tool_manager._tools:
|
|
249
|
-
dispatcher_tool = self._tool_manager._tools[dispatcher_name]
|
|
250
|
-
|
|
251
|
-
# Build detailed description
|
|
252
|
-
desc_lines = [f"Call internal '{self.name}' functions. Available tools:"]
|
|
253
|
-
desc_lines.append("") # Empty line for readability
|
|
254
|
-
|
|
255
|
-
# Build tool schemas for oneOf
|
|
256
|
-
tool_schemas = []
|
|
257
|
-
|
|
258
|
-
for tool_name, tool in sorted(internal_tools):
|
|
259
|
-
# Add tool name and description
|
|
260
|
-
tool_desc = tool.description or "No description"
|
|
261
|
-
desc_lines.append(f"• Name: {tool_name} ({tool_desc})")
|
|
262
|
-
|
|
263
|
-
# Build schema for this specific tool call
|
|
264
|
-
tool_schema = {
|
|
265
|
-
"type": "object",
|
|
266
|
-
"properties": {
|
|
267
|
-
"name": {
|
|
268
|
-
"type": "string",
|
|
269
|
-
"const": tool_name,
|
|
270
|
-
"description": f"Must be '{tool_name}'",
|
|
271
|
-
},
|
|
272
|
-
"arguments": tool.parameters
|
|
273
|
-
if hasattr(tool, "parameters") and tool.parameters
|
|
274
|
-
else {"type": "object"},
|
|
275
|
-
},
|
|
276
|
-
"required": ["name", "arguments"],
|
|
277
|
-
"additionalProperties": False,
|
|
278
|
-
}
|
|
279
|
-
tool_schemas.append(tool_schema)
|
|
280
|
-
|
|
281
|
-
# Add parameters from the tool's parameters field (JSON schema)
|
|
282
|
-
if hasattr(tool, "parameters") and tool.parameters:
|
|
283
|
-
schema = tool.parameters
|
|
284
|
-
if isinstance(schema, dict) and "properties" in schema:
|
|
285
|
-
params = []
|
|
286
|
-
required = schema.get("required", [])
|
|
287
|
-
for prop_name, prop_info in schema["properties"].items():
|
|
288
|
-
prop_type = prop_info.get("type", "any")
|
|
289
|
-
# Check for more detailed type info
|
|
290
|
-
if "anyOf" in prop_info:
|
|
291
|
-
types = [
|
|
292
|
-
t.get("type", "unknown")
|
|
293
|
-
for t in prop_info["anyOf"]
|
|
294
|
-
if isinstance(t, dict)
|
|
295
|
-
]
|
|
296
|
-
prop_type = " | ".join(types) if types else "any"
|
|
297
|
-
|
|
298
|
-
param_str = f"{prop_name} ({prop_type})"
|
|
299
|
-
if prop_name not in required:
|
|
300
|
-
param_str += " (optional)"
|
|
301
|
-
params.append(param_str)
|
|
302
|
-
|
|
303
|
-
if params:
|
|
304
|
-
desc_lines.append(f" Arguments: {', '.join(params)}")
|
|
305
|
-
else:
|
|
306
|
-
desc_lines.append(" Arguments: none")
|
|
307
|
-
else:
|
|
308
|
-
desc_lines.append(" Arguments: none")
|
|
309
|
-
|
|
310
|
-
desc_lines.append("") # Empty line between tools
|
|
311
|
-
|
|
312
|
-
dispatcher_tool.description = "\n".join(desc_lines).strip()
|
|
313
|
-
|
|
314
|
-
# Update the input schema to better document available tools
|
|
315
|
-
# Build examples of tool calls
|
|
316
|
-
examples = []
|
|
317
|
-
for tool_name, tool in sorted(internal_tools)[:3]: # Show first 3 as examples
|
|
318
|
-
if hasattr(tool, "parameters") and tool.parameters:
|
|
319
|
-
schema = tool.parameters
|
|
320
|
-
if isinstance(schema, dict) and "properties" in schema:
|
|
321
|
-
example_args = {}
|
|
322
|
-
for prop_name, prop_info in schema["properties"].items():
|
|
323
|
-
# Generate example value based on type
|
|
324
|
-
prop_type = prop_info.get("type", "any")
|
|
325
|
-
if prop_type == "string":
|
|
326
|
-
example_args[prop_name] = f"<{prop_name}>"
|
|
327
|
-
elif prop_type == "integer" or prop_type == "number":
|
|
328
|
-
example_args[prop_name] = 0
|
|
329
|
-
elif prop_type == "boolean":
|
|
330
|
-
example_args[prop_name] = True
|
|
331
|
-
else:
|
|
332
|
-
example_args[prop_name] = None
|
|
333
|
-
examples.append({"name": tool_name, "arguments": example_args})
|
|
334
|
-
else:
|
|
335
|
-
examples.append({"name": tool_name, "arguments": {}})
|
|
336
|
-
|
|
337
|
-
# Enhanced schema with better documentation
|
|
338
|
-
dispatcher_tool.parameters = {
|
|
339
|
-
"type": "object",
|
|
340
|
-
"properties": {
|
|
341
|
-
"name": {
|
|
342
|
-
"type": "string",
|
|
343
|
-
"description": f"Name of the internal tool to call. Must be one of: {', '.join(t[0] for t in sorted(internal_tools))}", # noqa: E501
|
|
344
|
-
"enum": [t[0] for t in sorted(internal_tools)],
|
|
345
|
-
},
|
|
346
|
-
"arguments": {
|
|
347
|
-
"type": "object",
|
|
348
|
-
"description": "Arguments to pass to the internal tool. See description for details on each tool's parameters.", # noqa: E501
|
|
349
|
-
},
|
|
350
|
-
},
|
|
351
|
-
"required": ["name", "arguments"],
|
|
352
|
-
"examples": examples if examples else None,
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
# Override _list_tools to hide internal tools when mounted
|
|
356
|
-
async def _list_tools(self) -> list[Tool]:
|
|
357
|
-
"""Override _list_tools to hide internal tools when mounted."""
|
|
358
|
-
return [
|
|
359
|
-
tool
|
|
360
|
-
for key, tool in self._tool_manager._tools.items()
|
|
361
|
-
if not key.startswith(_INTERNAL_PREFIX)
|
|
362
|
-
]
|
|
363
|
-
|
|
364
|
-
resource = FastMCP.resource
|
|
365
|
-
prompt = FastMCP.prompt
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
+
|
|
6
|
+
from fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from hud.tools.types import ContentBlock, EvaluationResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from fastmcp.tools import FunctionTool
|
|
14
|
+
from fastmcp.tools.tool import Tool, ToolResult
|
|
15
|
+
|
|
16
|
+
# Basic result types for tools
|
|
17
|
+
BaseResult = list[ContentBlock] | EvaluationResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseTool(ABC):
|
|
21
|
+
"""
|
|
22
|
+
Base helper class for all MCP tools to constrain their output.
|
|
23
|
+
|
|
24
|
+
USAGE:
|
|
25
|
+
All tools should inherit from this class and implement the __call__ method.
|
|
26
|
+
Tools are registered with FastMCP using add_tool.
|
|
27
|
+
|
|
28
|
+
FORMAT:
|
|
29
|
+
Tools that return messages should return a list[ContentBlock].
|
|
30
|
+
Tools that return miscallaneous content should return a pydantic model such as EvaluationResult.
|
|
31
|
+
Both of these types of tools are processed via structuredContent.
|
|
32
|
+
Any other type of tool will not be processed well by the client.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
env: Any = None,
|
|
38
|
+
name: str | None = None,
|
|
39
|
+
title: str | None = None,
|
|
40
|
+
description: str | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Initialize the tool.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
env: Optional, often stateful, context object that the tool operates on. Could be:
|
|
46
|
+
- A game instance (e.g., Chess Board)
|
|
47
|
+
- An executor (e.g., PyAutoGUIExecutor for computer control)
|
|
48
|
+
- A browser/page instance (e.g., Playwright Page)
|
|
49
|
+
- Any stateful resource the tool needs to interact with
|
|
50
|
+
name: Tool name for MCP registration (auto-generated from class name if not provided)
|
|
51
|
+
title: Human-readable display name for the tool (auto-generated from class name)
|
|
52
|
+
description: Tool description (auto-generated from docstring if not provided)
|
|
53
|
+
"""
|
|
54
|
+
self.env = env
|
|
55
|
+
self.name = name or self.__class__.__name__.lower().replace("tool", "")
|
|
56
|
+
self.title = title or self.__class__.__name__.replace("Tool", "").replace("_", " ").title()
|
|
57
|
+
self.description = description or (self.__doc__.strip() if self.__doc__ else None)
|
|
58
|
+
|
|
59
|
+
# Expose attributes FastMCP expects when registering an instance directly
|
|
60
|
+
self.__name__ = self.name # FastMCP uses fn.__name__ if name param omitted
|
|
61
|
+
if self.description:
|
|
62
|
+
self.__doc__ = self.description
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def __call__(self, **kwargs: Any) -> ToolResult:
|
|
66
|
+
"""Execute the tool. Often uses the context to perform an action.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
**kwargs: Tool-specific arguments
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List of ContentBlock (TextContent, ImageContent, etc.) with the tool's output
|
|
73
|
+
"""
|
|
74
|
+
raise NotImplementedError("Subclasses must implement __call__")
|
|
75
|
+
|
|
76
|
+
def register(self, server: FastMCP, **meta: Any) -> BaseTool:
|
|
77
|
+
"""Register this tool on a FastMCP server and return self for chaining."""
|
|
78
|
+
server.add_tool(self.mcp, **meta)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def mcp(self) -> FunctionTool:
|
|
83
|
+
"""Get this tool as a FastMCP FunctionTool (cached).
|
|
84
|
+
|
|
85
|
+
This allows clean registration:
|
|
86
|
+
server.add_tool(my_tool.mcp)
|
|
87
|
+
"""
|
|
88
|
+
if not hasattr(self, "_mcp_tool"):
|
|
89
|
+
from fastmcp.tools import FunctionTool
|
|
90
|
+
|
|
91
|
+
self._mcp_tool = FunctionTool.from_function(
|
|
92
|
+
self,
|
|
93
|
+
name=self.name,
|
|
94
|
+
title=self.title,
|
|
95
|
+
description=self.description,
|
|
96
|
+
)
|
|
97
|
+
return self._mcp_tool
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Prefix for internal tool names
|
|
101
|
+
_INTERNAL_PREFIX = "int_"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BaseHub(FastMCP):
|
|
105
|
+
"""A composition-friendly FastMCP server that holds an internal tool dispatcher."""
|
|
106
|
+
|
|
107
|
+
env: Any
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
name: str,
|
|
112
|
+
*,
|
|
113
|
+
env: Any | None = None,
|
|
114
|
+
title: str | None = None,
|
|
115
|
+
description: str | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Create a new BaseHub.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
name:
|
|
122
|
+
Public name. Also becomes the *dispatcher tool* name.
|
|
123
|
+
env:
|
|
124
|
+
Optional long-lived environment object. Stored on the server
|
|
125
|
+
instance (``layer.env``) and therefore available to every request
|
|
126
|
+
via ``ctx.fastmcp.env``.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Naming scheme for hidden objects
|
|
130
|
+
self._prefix_fn: Callable[[str], str] = lambda n: f"{_INTERNAL_PREFIX}{n}"
|
|
131
|
+
|
|
132
|
+
super().__init__(name=name)
|
|
133
|
+
|
|
134
|
+
if env is not None:
|
|
135
|
+
self.env = env
|
|
136
|
+
|
|
137
|
+
dispatcher_title = title or f"{name.title()} Dispatcher"
|
|
138
|
+
dispatcher_desc = description or f"Call internal '{name}' functions"
|
|
139
|
+
|
|
140
|
+
# Register dispatcher manually with FunctionTool
|
|
141
|
+
async def _dispatch( # noqa: ANN202
|
|
142
|
+
name: str,
|
|
143
|
+
arguments: dict | str | None = None,
|
|
144
|
+
ctx=None, # noqa: ANN001
|
|
145
|
+
):
|
|
146
|
+
"""Gateway to hidden tools.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
name : str
|
|
151
|
+
Internal function name *without* prefix.
|
|
152
|
+
arguments : dict | str | None
|
|
153
|
+
Arguments forwarded to the internal tool. Can be dict or JSON string.
|
|
154
|
+
ctx : Context
|
|
155
|
+
Injected by FastMCP; can be the custom subclass.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
# Handle JSON string inputs
|
|
159
|
+
if isinstance(arguments, str):
|
|
160
|
+
import json
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
arguments = json.loads(arguments)
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
# If it's not valid JSON, treat as empty dict
|
|
166
|
+
arguments = {}
|
|
167
|
+
|
|
168
|
+
# Use the tool manager to call internal tools
|
|
169
|
+
return await self._tool_manager.call_tool(self._prefix_fn(name), arguments or {}) # type: ignore
|
|
170
|
+
|
|
171
|
+
from fastmcp.tools.tool import FunctionTool
|
|
172
|
+
|
|
173
|
+
dispatcher_tool = FunctionTool.from_function(
|
|
174
|
+
_dispatch,
|
|
175
|
+
name=name,
|
|
176
|
+
title=dispatcher_title,
|
|
177
|
+
description=dispatcher_desc,
|
|
178
|
+
tags=set(),
|
|
179
|
+
)
|
|
180
|
+
self._tool_manager.add_tool(dispatcher_tool)
|
|
181
|
+
|
|
182
|
+
# Expose list of internal functions via read-only resource
|
|
183
|
+
async def _functions_catalogue() -> list[str]:
|
|
184
|
+
# List all internal function names without prefix
|
|
185
|
+
return [
|
|
186
|
+
key.removeprefix(_INTERNAL_PREFIX)
|
|
187
|
+
for key in self._tool_manager._tools
|
|
188
|
+
if key.startswith(_INTERNAL_PREFIX)
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
from fastmcp.resources import Resource
|
|
192
|
+
|
|
193
|
+
catalogue_resource = Resource.from_function(
|
|
194
|
+
_functions_catalogue,
|
|
195
|
+
uri=f"file:///{name}/functions",
|
|
196
|
+
name=f"{name} Functions Catalogue",
|
|
197
|
+
description=f"List of internal functions available in {name}",
|
|
198
|
+
mime_type="application/json",
|
|
199
|
+
tags=set(),
|
|
200
|
+
)
|
|
201
|
+
self._resource_manager.add_resource(catalogue_resource)
|
|
202
|
+
|
|
203
|
+
def tool(self, name_or_fn: Any = None, /, **kwargs: Any) -> Callable[..., Any]:
|
|
204
|
+
"""Register an *internal* tool (hidden from clients)."""
|
|
205
|
+
# Handle when decorator's partial calls us back with the function
|
|
206
|
+
if callable(name_or_fn):
|
|
207
|
+
# This only happens in phase 2 of decorator application
|
|
208
|
+
# The name was already prefixed in phase 1, just pass through
|
|
209
|
+
result = super().tool(name_or_fn, **kwargs)
|
|
210
|
+
|
|
211
|
+
# Update dispatcher description after registering tool
|
|
212
|
+
self._update_dispatcher_description()
|
|
213
|
+
|
|
214
|
+
return cast("Callable[..., Any]", result)
|
|
215
|
+
|
|
216
|
+
# Handle the name from either positional or keyword argument
|
|
217
|
+
if isinstance(name_or_fn, str):
|
|
218
|
+
# Called as @hub.tool("name")
|
|
219
|
+
name = name_or_fn
|
|
220
|
+
elif name_or_fn is None and "name" in kwargs:
|
|
221
|
+
# Called as @hub.tool(name="name")
|
|
222
|
+
name = kwargs.pop("name")
|
|
223
|
+
else:
|
|
224
|
+
# Called as @hub.tool or @hub.tool()
|
|
225
|
+
name = None
|
|
226
|
+
|
|
227
|
+
new_name = self._prefix_fn(name) if name is not None else None
|
|
228
|
+
tags = kwargs.pop("tags", None) or set()
|
|
229
|
+
|
|
230
|
+
# Pass through correctly to parent
|
|
231
|
+
if new_name is not None:
|
|
232
|
+
return super().tool(new_name, **kwargs, tags=tags)
|
|
233
|
+
else:
|
|
234
|
+
return super().tool(**kwargs, tags=tags)
|
|
235
|
+
|
|
236
|
+
def _update_dispatcher_description(self) -> None:
|
|
237
|
+
"""Update the dispatcher tool's description and schema with available tools."""
|
|
238
|
+
# Get list of internal tools with their details
|
|
239
|
+
internal_tools = []
|
|
240
|
+
for key, tool in self._tool_manager._tools.items():
|
|
241
|
+
if key.startswith(_INTERNAL_PREFIX):
|
|
242
|
+
tool_name = key.removeprefix(_INTERNAL_PREFIX)
|
|
243
|
+
internal_tools.append((tool_name, tool))
|
|
244
|
+
|
|
245
|
+
if internal_tools:
|
|
246
|
+
# Update the dispatcher tool's description
|
|
247
|
+
dispatcher_name = self.name
|
|
248
|
+
if dispatcher_name in self._tool_manager._tools:
|
|
249
|
+
dispatcher_tool = self._tool_manager._tools[dispatcher_name]
|
|
250
|
+
|
|
251
|
+
# Build detailed description
|
|
252
|
+
desc_lines = [f"Call internal '{self.name}' functions. Available tools:"]
|
|
253
|
+
desc_lines.append("") # Empty line for readability
|
|
254
|
+
|
|
255
|
+
# Build tool schemas for oneOf
|
|
256
|
+
tool_schemas = []
|
|
257
|
+
|
|
258
|
+
for tool_name, tool in sorted(internal_tools):
|
|
259
|
+
# Add tool name and description
|
|
260
|
+
tool_desc = tool.description or "No description"
|
|
261
|
+
desc_lines.append(f"• Name: {tool_name} ({tool_desc})")
|
|
262
|
+
|
|
263
|
+
# Build schema for this specific tool call
|
|
264
|
+
tool_schema = {
|
|
265
|
+
"type": "object",
|
|
266
|
+
"properties": {
|
|
267
|
+
"name": {
|
|
268
|
+
"type": "string",
|
|
269
|
+
"const": tool_name,
|
|
270
|
+
"description": f"Must be '{tool_name}'",
|
|
271
|
+
},
|
|
272
|
+
"arguments": tool.parameters
|
|
273
|
+
if hasattr(tool, "parameters") and tool.parameters
|
|
274
|
+
else {"type": "object"},
|
|
275
|
+
},
|
|
276
|
+
"required": ["name", "arguments"],
|
|
277
|
+
"additionalProperties": False,
|
|
278
|
+
}
|
|
279
|
+
tool_schemas.append(tool_schema)
|
|
280
|
+
|
|
281
|
+
# Add parameters from the tool's parameters field (JSON schema)
|
|
282
|
+
if hasattr(tool, "parameters") and tool.parameters:
|
|
283
|
+
schema = tool.parameters
|
|
284
|
+
if isinstance(schema, dict) and "properties" in schema:
|
|
285
|
+
params = []
|
|
286
|
+
required = schema.get("required", [])
|
|
287
|
+
for prop_name, prop_info in schema["properties"].items():
|
|
288
|
+
prop_type = prop_info.get("type", "any")
|
|
289
|
+
# Check for more detailed type info
|
|
290
|
+
if "anyOf" in prop_info:
|
|
291
|
+
types = [
|
|
292
|
+
t.get("type", "unknown")
|
|
293
|
+
for t in prop_info["anyOf"]
|
|
294
|
+
if isinstance(t, dict)
|
|
295
|
+
]
|
|
296
|
+
prop_type = " | ".join(types) if types else "any"
|
|
297
|
+
|
|
298
|
+
param_str = f"{prop_name} ({prop_type})"
|
|
299
|
+
if prop_name not in required:
|
|
300
|
+
param_str += " (optional)"
|
|
301
|
+
params.append(param_str)
|
|
302
|
+
|
|
303
|
+
if params:
|
|
304
|
+
desc_lines.append(f" Arguments: {', '.join(params)}")
|
|
305
|
+
else:
|
|
306
|
+
desc_lines.append(" Arguments: none")
|
|
307
|
+
else:
|
|
308
|
+
desc_lines.append(" Arguments: none")
|
|
309
|
+
|
|
310
|
+
desc_lines.append("") # Empty line between tools
|
|
311
|
+
|
|
312
|
+
dispatcher_tool.description = "\n".join(desc_lines).strip()
|
|
313
|
+
|
|
314
|
+
# Update the input schema to better document available tools
|
|
315
|
+
# Build examples of tool calls
|
|
316
|
+
examples = []
|
|
317
|
+
for tool_name, tool in sorted(internal_tools)[:3]: # Show first 3 as examples
|
|
318
|
+
if hasattr(tool, "parameters") and tool.parameters:
|
|
319
|
+
schema = tool.parameters
|
|
320
|
+
if isinstance(schema, dict) and "properties" in schema:
|
|
321
|
+
example_args = {}
|
|
322
|
+
for prop_name, prop_info in schema["properties"].items():
|
|
323
|
+
# Generate example value based on type
|
|
324
|
+
prop_type = prop_info.get("type", "any")
|
|
325
|
+
if prop_type == "string":
|
|
326
|
+
example_args[prop_name] = f"<{prop_name}>"
|
|
327
|
+
elif prop_type == "integer" or prop_type == "number":
|
|
328
|
+
example_args[prop_name] = 0
|
|
329
|
+
elif prop_type == "boolean":
|
|
330
|
+
example_args[prop_name] = True
|
|
331
|
+
else:
|
|
332
|
+
example_args[prop_name] = None
|
|
333
|
+
examples.append({"name": tool_name, "arguments": example_args})
|
|
334
|
+
else:
|
|
335
|
+
examples.append({"name": tool_name, "arguments": {}})
|
|
336
|
+
|
|
337
|
+
# Enhanced schema with better documentation
|
|
338
|
+
dispatcher_tool.parameters = {
|
|
339
|
+
"type": "object",
|
|
340
|
+
"properties": {
|
|
341
|
+
"name": {
|
|
342
|
+
"type": "string",
|
|
343
|
+
"description": f"Name of the internal tool to call. Must be one of: {', '.join(t[0] for t in sorted(internal_tools))}", # noqa: E501
|
|
344
|
+
"enum": [t[0] for t in sorted(internal_tools)],
|
|
345
|
+
},
|
|
346
|
+
"arguments": {
|
|
347
|
+
"type": "object",
|
|
348
|
+
"description": "Arguments to pass to the internal tool. See description for details on each tool's parameters.", # noqa: E501
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
"required": ["name", "arguments"],
|
|
352
|
+
"examples": examples if examples else None,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# Override _list_tools to hide internal tools when mounted
|
|
356
|
+
async def _list_tools(self) -> list[Tool]:
|
|
357
|
+
"""Override _list_tools to hide internal tools when mounted."""
|
|
358
|
+
return [
|
|
359
|
+
tool
|
|
360
|
+
for key, tool in self._tool_manager._tools.items()
|
|
361
|
+
if not key.startswith(_INTERNAL_PREFIX)
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
resource = FastMCP.resource
|
|
365
|
+
prompt = FastMCP.prompt
|